2026-06-11 13:46:01 +08:00
|
|
|
import { describe, expect, expectTypeOf, it } from 'vitest'
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
import { Context } from 'cordis'
|
2026-07-28 13:55:59 +08:00
|
|
|
import { createUserMessage, CallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
2026-07-09 15:25:18 +08:00
|
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
2026-07-11 21:37:38 +08:00
|
|
|
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
|
2026-06-11 13:46:01 +08:00
|
|
|
import ToolRegistry, {
|
2026-07-21 03:08:35 +08:00
|
|
|
defineContentToolFixture, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
2026-07-19 23:38:54 +08:00
|
|
|
TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH,
|
2026-07-21 01:11:55 +08:00
|
|
|
type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision,
|
2026-07-22 21:31:16 +08:00
|
|
|
type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken,
|
2026-06-11 13:46:01 +08:00
|
|
|
} from '@deepseek-ai/dsh-tools'
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const testToolSignal = new AbortController().signal
|
|
|
|
|
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
async function setup() {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
const echoTool = defineTool({
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
name: 'echo',
|
|
|
|
|
description: 'echo arguments back',
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
parameters: { text: { type: 'string' } },
|
2026-07-21 03:08:35 +08:00
|
|
|
output: {
|
|
|
|
|
schema: { type: 'string' },
|
|
|
|
|
render: (_args, value) => [{ type: 'text', text: value }],
|
|
|
|
|
},
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
async execute(args) {
|
2026-07-21 03:08:35 +08:00
|
|
|
return args.text ?? ''
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
},
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
})
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
|
|
|
|
describe('ToolRegistry', () => {
|
|
|
|
|
it('registers tools, exposes schemas, and feeds the system-prompt assembly', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
|
|
|
|
|
expect(ctx.tools.schemas()).toEqual([{
|
|
|
|
|
name: 'echo',
|
|
|
|
|
description: 'echo arguments back',
|
|
|
|
|
parameters: { type: 'object', properties: { text: { type: 'string' } } },
|
|
|
|
|
}])
|
2026-06-11 13:46:01 +08:00
|
|
|
// schemas() result must not leak execute — ToolSchema deliberately has no
|
|
|
|
|
// 'execute' key, so widen through unknown to probe for the absent property
|
|
|
|
|
expect((ctx.tools.schemas()[0] as unknown as Record<string, unknown>).execute).toBeUndefined()
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
|
|
|
|
const assembly = await ctx.systemPrompt.assemble()
|
|
|
|
|
expect(assembly.tools.map(t => t.name)).toEqual(['echo'])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-23 02:53:43 +08:00
|
|
|
it('schemas() drops host callbacks — they must never reach the model', async () => {
|
2026-06-18 10:36:53 +08:00
|
|
|
const ctx = await setup()
|
2026-07-23 20:50:45 +08:00
|
|
|
// Tool definitions contain output, finalization, execution, and presentation
|
|
|
|
|
// callbacks. schemas() is an explicit allowlist so none can reach the model.
|
2026-07-21 03:08:35 +08:00
|
|
|
ctx.tools.register(defineContentToolFixture({
|
2026-06-18 10:36:53 +08:00
|
|
|
name: 'present',
|
|
|
|
|
description: 'has presenters',
|
|
|
|
|
parameters: { x: { type: 'string', required: true } },
|
|
|
|
|
async execute() { return [] },
|
2026-07-23 02:53:43 +08:00
|
|
|
finalizeContent: (_exec, result) => result.content,
|
2026-07-03 02:04:03 +08:00
|
|
|
presentCall: args => ({ card: 'generic', title: args.x }),
|
|
|
|
|
presentResult: (args, result) => ({ card: 'generic', title: args.x, content: result.content }),
|
2026-06-18 10:36:53 +08:00
|
|
|
}))
|
|
|
|
|
const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown>
|
|
|
|
|
expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
|
2026-07-23 02:53:43 +08:00
|
|
|
expect(schema.finalizeContent).toBeUndefined()
|
2026-06-18 10:36:53 +08:00
|
|
|
expect(schema.presentCall).toBeUndefined()
|
|
|
|
|
expect(schema.presentResult).toBeUndefined()
|
|
|
|
|
expect(schema.execute).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-08 14:06:24 +08:00
|
|
|
it('schemas() excludes timeoutMs — the budget must never reach the model', async () => {
|
|
|
|
|
const ctx = await setup()
|
2026-07-21 03:08:35 +08:00
|
|
|
ctx.tools.register(defineContentToolFixture({
|
2026-07-08 14:06:24 +08:00
|
|
|
name: 'budgeted', description: 'has a budget', parameters: {}, timeoutMs: 5_000,
|
|
|
|
|
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
|
|
|
|
|
}))
|
|
|
|
|
const schema = ctx.tools.schemas().find(s => s.name === 'budgeted')
|
|
|
|
|
expect(schema).toBeDefined()
|
|
|
|
|
expect('timeoutMs' in (schema as object)).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
it('executes a tool and returns its content', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
2026-07-21 03:08:35 +08:00
|
|
|
let observed: ToolExecutionResult | undefined
|
|
|
|
|
ctx.on('tools/result', (_exec, result) => { observed = result })
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: 'hi' })
|
|
|
|
|
expect(observed).toEqual(result)
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-21 03:08:35 +08:00
|
|
|
it('projects presentation metadata from the canonical value', async () => {
|
2026-07-03 17:12:00 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'meta-tool',
|
2026-07-21 03:08:35 +08:00
|
|
|
output: {
|
|
|
|
|
...echoTool.output,
|
|
|
|
|
presentationMeta: () => ({ diffs: [{ path: 'a', oldText: null, newText: 'x' }] }),
|
|
|
|
|
},
|
2026-07-03 17:12:00 +08:00
|
|
|
async execute() {
|
2026-07-21 03:08:35 +08:00
|
|
|
return 'ok'
|
2026-07-03 17:12:00 +08:00
|
|
|
},
|
|
|
|
|
})
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'meta-tool', arguments: {} })
|
2026-07-03 17:12:00 +08:00
|
|
|
expect(result).toEqual({
|
|
|
|
|
content: [{ type: 'text', text: 'ok' }],
|
|
|
|
|
isError: false,
|
|
|
|
|
meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] },
|
2026-07-21 03:08:35 +08:00
|
|
|
value: 'ok',
|
2026-07-03 17:12:00 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 03:08:35 +08:00
|
|
|
it('omits meta when no presentation projector is declared', async () => {
|
2026-07-03 17:12:00 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'no-meta-tool',
|
|
|
|
|
async execute() {
|
2026-07-21 03:08:35 +08:00
|
|
|
return 'ok'
|
2026-07-03 17:12:00 +08:00
|
|
|
},
|
|
|
|
|
})
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false, value: 'ok' })
|
2026-07-03 17:12:00 +08:00
|
|
|
expect('meta' in result).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 22:55:26 +08:00
|
|
|
it('normalizes a contract-violating non-cloneable result before final notification', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
let observedError: boolean | undefined
|
|
|
|
|
ctx.on('tools/result', (_exec, result) => { observedError = result.isError })
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'bad-meta',
|
2026-07-21 03:08:35 +08:00
|
|
|
output: {
|
|
|
|
|
...echoTool.output,
|
|
|
|
|
presentationMeta: () => (() => undefined) as unknown as JsonValue,
|
|
|
|
|
},
|
2026-07-11 22:55:26 +08:00
|
|
|
async execute() {
|
2026-07-21 03:08:35 +08:00
|
|
|
return 'ok'
|
2026-07-11 22:55:26 +08:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const result = await ctx.tools.execute({
|
2026-07-19 23:38:54 +08:00
|
|
|
signal: testToolSignal,
|
2026-07-11 22:55:26 +08:00
|
|
|
callId: CallId('bad-meta'), name: 'bad-meta', arguments: {},
|
|
|
|
|
})
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]?.type === 'text' && result.content[0].text).toContain('Error:')
|
2026-07-21 18:03:01 +08:00
|
|
|
expect(result.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } })
|
2026-07-11 22:55:26 +08:00
|
|
|
expect(observedError).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-23 03:15:15 +08:00
|
|
|
it('finalizes errors discovered while snapshotting non-content result fields', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
let finalizeCalls = 0
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'throwing-meta',
|
2026-07-23 20:50:45 +08:00
|
|
|
output: {
|
|
|
|
|
...echoTool.output,
|
|
|
|
|
presentationMeta() {
|
|
|
|
|
const meta = {}
|
|
|
|
|
Object.defineProperty(meta, 'value', {
|
|
|
|
|
enumerable: true,
|
|
|
|
|
get() { throw new Error('snapshot failed: '.repeat(100)) },
|
|
|
|
|
})
|
|
|
|
|
return meta
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-07-23 03:15:15 +08:00
|
|
|
finalizeContent(_exec, result) {
|
|
|
|
|
finalizeCalls += 1
|
|
|
|
|
const block = result.content[0]
|
|
|
|
|
if (block?.type !== 'text') return undefined
|
|
|
|
|
return [{ type: 'text', text: block.text.slice(0, 32) }]
|
|
|
|
|
},
|
|
|
|
|
async execute() {
|
2026-07-23 20:50:45 +08:00
|
|
|
return 'body'
|
2026-07-23 03:15:15 +08:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const result = await ctx.tools.execute({
|
|
|
|
|
signal: testToolSignal,
|
|
|
|
|
callId: CallId('throwing-meta'), name: 'throwing-meta', arguments: {},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(result.isError).toBe(true)
|
2026-07-23 20:50:45 +08:00
|
|
|
const block = result.content[0]
|
|
|
|
|
expect(block?.type).toBe('text')
|
|
|
|
|
expect(block?.type === 'text' ? block.text : '').toMatch(/^Error: tool "throwing-meta"/)
|
|
|
|
|
expect(block?.type === 'text' ? block.text : '').toHaveLength(32)
|
2026-07-23 03:15:15 +08:00
|
|
|
expect(finalizeCalls).toBe(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('normalizes a throwing final content callback without invoking it again', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
let finalizeCalls = 0
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'throwing-finalizer',
|
|
|
|
|
finalizeContent() {
|
|
|
|
|
finalizeCalls += 1
|
|
|
|
|
throw new Error('finalizer violated its total contract')
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const result = await ctx.tools.execute({
|
|
|
|
|
signal: testToolSignal,
|
|
|
|
|
callId: CallId('throwing-finalizer'), name: 'throwing-finalizer', arguments: {},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: finalizer violated its total contract' }],
|
|
|
|
|
isError: true,
|
2026-07-23 20:50:45 +08:00
|
|
|
error: { message: 'finalizer violated its total contract' },
|
2026-07-23 03:15:15 +08:00
|
|
|
})
|
|
|
|
|
expect(finalizeCalls).toBe(1)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 03:08:35 +08:00
|
|
|
it('requires every raw registration to declare its canonical output', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const missingOutput = {
|
|
|
|
|
name: 'legacy-content-tool',
|
|
|
|
|
description: 'missing output',
|
|
|
|
|
parameters: {},
|
|
|
|
|
execute: async () => [{ type: 'text', text: 'legacy' }],
|
|
|
|
|
} as unknown as ToolDefinition
|
|
|
|
|
|
|
|
|
|
expect(() => ctx.tools.register(missingOutput))
|
|
|
|
|
.toThrow('must declare output { schema, render, presentationMeta? }')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects lossy and schema-mismatched body values before post-execute', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(defineTool({
|
|
|
|
|
name: 'lossy-output',
|
|
|
|
|
description: 'lossy',
|
|
|
|
|
parameters: {},
|
|
|
|
|
output: { schema: { type: 'json' }, render: () => [] },
|
|
|
|
|
execute: async () => (() => undefined) as unknown as JsonValue,
|
|
|
|
|
}))
|
|
|
|
|
ctx.tools.register(defineTool({
|
|
|
|
|
name: 'wrong-output',
|
|
|
|
|
description: 'wrong schema',
|
|
|
|
|
parameters: {},
|
|
|
|
|
output: { schema: { type: 'string' }, render: () => [] },
|
|
|
|
|
execute: async () => 42 as unknown as string,
|
|
|
|
|
}))
|
|
|
|
|
|
2026-07-21 23:39:03 +08:00
|
|
|
const lossy = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('lossy'), name: 'lossy-output', arguments: {} })
|
|
|
|
|
const mismatch = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('mismatch'), name: 'wrong-output', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(lossy.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } })
|
|
|
|
|
expect(lossy.content[0]?.type === 'text' ? lossy.content[0].text : '').toContain('not lossless JSON')
|
|
|
|
|
expect(mismatch.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } })
|
|
|
|
|
expect(mismatch.content[0]?.type === 'text' ? mismatch.content[0].text : '').toContain('"value" must be a string')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-22 02:02:46 +08:00
|
|
|
it('classifies a throwing body snapshot as invalid tool output', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const hostile = Object.defineProperty({}, 'value', {
|
|
|
|
|
enumerable: true,
|
|
|
|
|
get: () => { throw new Error('body snapshot getter exploded') },
|
|
|
|
|
})
|
|
|
|
|
ctx.tools.register(defineTool({
|
|
|
|
|
name: 'hostile-body',
|
|
|
|
|
description: 'hostile body',
|
|
|
|
|
parameters: {},
|
|
|
|
|
output: { schema: { type: 'json' }, render: () => [] },
|
|
|
|
|
execute: async () => hostile as JsonValue,
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const result = await ctx.tools.execute({
|
|
|
|
|
signal: testToolSignal,
|
|
|
|
|
callId: CallId('hostile-body'), name: 'hostile-body', arguments: {},
|
|
|
|
|
})
|
|
|
|
|
expect(result.error?.message).toContain('value snapshot failed: body snapshot getter exploded')
|
|
|
|
|
expect(result.error?.info).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 03:08:35 +08:00
|
|
|
it.each(['render', 'presentationMeta'] as const)('contains a throwing output.%s projector as one failed call', async (projector) => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(defineTool({
|
|
|
|
|
name: `throwing-${projector}`,
|
|
|
|
|
description: projector,
|
|
|
|
|
parameters: {},
|
|
|
|
|
output: {
|
|
|
|
|
schema: { type: 'string' },
|
|
|
|
|
render: () => {
|
|
|
|
|
if (projector === 'render') throw new Error('renderer exploded')
|
|
|
|
|
return [{ type: 'text', text: 'ok' }]
|
|
|
|
|
},
|
|
|
|
|
presentationMeta: () => {
|
|
|
|
|
if (projector === 'presentationMeta') throw new Error('metadata exploded')
|
|
|
|
|
return null
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
execute: async () => 'ok',
|
|
|
|
|
}))
|
|
|
|
|
|
2026-07-21 23:39:03 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId(projector), name: `throwing-${projector}`, arguments: {} })
|
2026-07-21 18:03:01 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.error?.message)
|
|
|
|
|
.toContain(projector === 'render' ? 'renderer exploded' : 'metadata exploded')
|
|
|
|
|
expect(result.error?.info).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect('value' in result).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 18:22:14 +08:00
|
|
|
it.each(['render', 'presentationMeta'] as const)('contains a throwing output.%s snapshot as one failed call', async (projector) => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const hostile = Object.defineProperty({}, 'value', {
|
|
|
|
|
enumerable: true,
|
|
|
|
|
get: () => { throw new Error('snapshot getter exploded') },
|
|
|
|
|
})
|
|
|
|
|
ctx.tools.register(defineTool({
|
|
|
|
|
name: `hostile-${projector}`,
|
|
|
|
|
description: projector,
|
|
|
|
|
parameters: {},
|
|
|
|
|
output: {
|
|
|
|
|
schema: { type: 'string' },
|
|
|
|
|
render: () => projector === 'render'
|
|
|
|
|
? hostile as unknown as ContentBlock[]
|
|
|
|
|
: [{ type: 'text', text: 'ok' }],
|
|
|
|
|
presentationMeta: () => projector === 'presentationMeta'
|
|
|
|
|
? hostile as unknown as JsonValue
|
|
|
|
|
: null,
|
|
|
|
|
},
|
|
|
|
|
execute: async () => 'ok',
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const result = await ctx.tools.execute({
|
2026-07-21 23:39:03 +08:00
|
|
|
signal: testToolSignal,
|
2026-07-21 18:22:14 +08:00
|
|
|
callId: CallId(`hostile-${projector}`), name: `hostile-${projector}`, arguments: {},
|
|
|
|
|
})
|
|
|
|
|
expect(result.error?.message).toContain('snapshot getter exploded')
|
|
|
|
|
expect(result.error?.info).toEqual({ name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 03:08:35 +08:00
|
|
|
it('keeps value/meta through content replacement and recomputes both projections after value replacement', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(defineTool({
|
|
|
|
|
name: 'projected',
|
|
|
|
|
description: 'projected',
|
|
|
|
|
parameters: {},
|
|
|
|
|
output: {
|
|
|
|
|
schema: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
additionalProperties: false,
|
|
|
|
|
properties: { text: { type: 'string', required: true } },
|
|
|
|
|
},
|
|
|
|
|
render: (_args, value) => [{ type: 'text', text: `render:${value.text}` }],
|
|
|
|
|
presentationMeta: (_args, value) => ({ projected: value.text }),
|
|
|
|
|
},
|
|
|
|
|
execute: async () => ({ text: 'body' }),
|
|
|
|
|
}))
|
|
|
|
|
let replacement: 'content' | 'value' = 'content'
|
|
|
|
|
ctx.on('tools/post-execute', async () => {
|
|
|
|
|
if (replacement === 'content') {
|
|
|
|
|
return { kind: 'accept', content: [{ type: 'text', text: 'policy content' }] }
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
kind: 'accept',
|
|
|
|
|
value: { text: 'policy value' },
|
2026-07-28 13:55:59 +08:00
|
|
|
additionalContexts: [createUserMessage({
|
|
|
|
|
content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' },
|
|
|
|
|
})],
|
2026-07-21 03:08:35 +08:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 23:39:03 +08:00
|
|
|
const content = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('content'), name: 'projected', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
replacement = 'value'
|
2026-07-21 23:39:03 +08:00
|
|
|
const value = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('value'), name: 'projected', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
|
|
|
|
|
expect(content).toEqual({
|
|
|
|
|
isError: false,
|
|
|
|
|
value: { text: 'body' },
|
|
|
|
|
content: [{ type: 'text', text: 'policy content' }],
|
|
|
|
|
meta: { projected: 'body' },
|
|
|
|
|
})
|
|
|
|
|
expect(value).toEqual({
|
|
|
|
|
isError: false,
|
|
|
|
|
value: { text: 'policy value' },
|
|
|
|
|
content: [{ type: 'text', text: 'render:policy value' }],
|
|
|
|
|
meta: { projected: 'policy value' },
|
2026-07-28 14:15:23 +08:00
|
|
|
additionalContexts: [{
|
|
|
|
|
id: expect.any(String) as unknown,
|
|
|
|
|
role: 'user',
|
|
|
|
|
content: [{ type: 'text', text: 'value context' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
|
|
|
}],
|
2026-07-21 03:08:35 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('fails a post-execute decision that replaces both projections or supplies an invalid value', async () => {
|
|
|
|
|
const both = await setup()
|
|
|
|
|
both.tools.register(echoTool)
|
|
|
|
|
both.on('tools/post-execute', async () => ({
|
|
|
|
|
kind: 'accept',
|
|
|
|
|
value: 'replacement',
|
|
|
|
|
content: [{ type: 'text', text: 'also replacement' }],
|
|
|
|
|
} as unknown as PostToolDecision))
|
2026-07-21 23:39:03 +08:00
|
|
|
const bothResult = await both.tools.execute({ signal: testToolSignal, callId: CallId('both'), name: 'echo', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(bothResult).toMatchObject({
|
|
|
|
|
isError: true,
|
|
|
|
|
error: { message: 'tools/post-execute accept decision cannot replace both value and content' },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const invalid = await setup()
|
|
|
|
|
invalid.tools.register(echoTool)
|
|
|
|
|
invalid.on('tools/post-execute', async () => ({ kind: 'accept', value: 1 }))
|
2026-07-21 23:39:03 +08:00
|
|
|
const invalidResult = await invalid.tools.execute({ signal: testToolSignal, callId: CallId('invalid'), name: 'echo', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(invalidResult.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } })
|
|
|
|
|
expect('value' in invalidResult).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('turns a post-execute block into a valueless failure', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
ctx.on('tools/post-execute', async () => ({
|
|
|
|
|
kind: 'block',
|
|
|
|
|
feedback: [{ type: 'text', text: 'blocked by policy' }],
|
|
|
|
|
}))
|
|
|
|
|
|
2026-07-21 23:39:03 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('block'), name: 'echo', arguments: { text: 'secret' } })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result).toEqual({
|
|
|
|
|
isError: true,
|
|
|
|
|
error: { message: 'blocked by policy' },
|
|
|
|
|
content: [{ type: 'text', text: 'blocked by policy' }],
|
|
|
|
|
})
|
|
|
|
|
expect('value' in result).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('replaces a canonical value without manufacturing additional context', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' }))
|
|
|
|
|
|
2026-07-21 23:39:03 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('replace-value'), name: 'echo', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result).toEqual({
|
|
|
|
|
isError: false,
|
|
|
|
|
value: 'replacement',
|
|
|
|
|
content: [{ type: 'text', text: 'replacement' }],
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
[[], 'tool result blocked by post-execute policy'],
|
|
|
|
|
[[{ type: 'reasoning', text: 'private rationale' }], '[reasoning content]'],
|
|
|
|
|
] as const)('derives a stable failure message from non-text or empty block feedback', async (feedback, message) => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
ctx.on('tools/post-execute', async () => ({ kind: 'block', feedback: [...feedback] }))
|
|
|
|
|
|
2026-07-21 23:39:03 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('block-message'), name: 'echo', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result.error?.message).toBe(message)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('contains a non-JSON post-execute failure projection as a safe final error', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
ctx.on('tools/post-execute', async () => ({
|
|
|
|
|
kind: 'block',
|
|
|
|
|
feedback: [{ type: 'text', text: 'blocked', invalid: () => undefined } as never],
|
|
|
|
|
}))
|
|
|
|
|
|
2026-07-21 23:39:03 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('invalid-block'), name: 'echo', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result).toMatchObject({
|
|
|
|
|
isError: true,
|
|
|
|
|
error: { message: 'tool result must be losslessly JSON-serializable' },
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects value replacement on a failed dispatch', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'throw-before-replace',
|
|
|
|
|
async execute() { throw new Error('body failed') },
|
|
|
|
|
})
|
|
|
|
|
ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' }))
|
|
|
|
|
|
|
|
|
|
const result = await ctx.tools.execute({
|
2026-07-21 23:39:03 +08:00
|
|
|
signal: testToolSignal,
|
2026-07-21 03:08:35 +08:00
|
|
|
callId: CallId('failed-replace'), name: 'throw-before-replace', arguments: {},
|
|
|
|
|
})
|
|
|
|
|
expect(result.error?.message).toBe('tools/post-execute cannot replace the value of a failed result')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('fails value replacement when the owning tool disappears before post-policy resolves', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const dispose = ctx.tools.register(echoTool)
|
|
|
|
|
ctx.on('tools/post-execute', async () => {
|
|
|
|
|
dispose()
|
|
|
|
|
return { kind: 'accept', value: 'replacement' }
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 23:39:03 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('post-disposed'), name: 'echo', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result.error).toEqual({
|
|
|
|
|
message: 'unknown tool "echo"',
|
|
|
|
|
info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' },
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('normalizes wrapper-authored failure metadata and contexts', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
ctx.on('tools/execute', async () => ({
|
|
|
|
|
isError: true,
|
|
|
|
|
error: { message: 'wrapped failure' },
|
|
|
|
|
content: [{ type: 'text', text: 'wrapper content' }],
|
|
|
|
|
meta: { wrapped: true },
|
2026-07-28 13:55:59 +08:00
|
|
|
additionalContexts: [createUserMessage({
|
|
|
|
|
content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' },
|
|
|
|
|
})],
|
2026-07-21 03:08:35 +08:00
|
|
|
}))
|
|
|
|
|
|
2026-07-21 23:39:03 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('wrapper-failure'), name: 'echo', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result).toEqual({
|
|
|
|
|
isError: true,
|
|
|
|
|
error: { message: 'wrapped failure' },
|
|
|
|
|
content: [{ type: 'text', text: 'wrapper content' }],
|
|
|
|
|
meta: { wrapped: true },
|
2026-07-28 14:15:23 +08:00
|
|
|
additionalContexts: [{
|
|
|
|
|
id: expect.any(String) as unknown,
|
|
|
|
|
role: 'user',
|
|
|
|
|
content: [{ type: 'text', text: 'wrapper context' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
|
|
|
}],
|
2026-07-21 03:08:35 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('fails wrapper-authored success normalization when the owning tool disappears', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const dispose = ctx.tools.register(echoTool)
|
|
|
|
|
ctx.on('tools/execute', async () => {
|
|
|
|
|
dispose()
|
|
|
|
|
return { isError: false, value: 'replacement', content: [] }
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 23:39:03 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('wrapper-disposed'), name: 'echo', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result.error).toEqual({
|
|
|
|
|
message: 'unknown tool "echo"',
|
|
|
|
|
info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' },
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('suppresses presentation metadata only for nested composite dispatches', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'meta-suppression',
|
|
|
|
|
output: { ...echoTool.output, presentationMeta: () => ({ card: true }) },
|
|
|
|
|
})
|
2026-07-21 23:39:03 +08:00
|
|
|
const direct = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('direct'), name: 'meta-suppression', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
const nested = await ctx.tools.execute({
|
2026-07-21 23:39:03 +08:00
|
|
|
signal: testToolSignal,
|
2026-07-21 03:08:35 +08:00
|
|
|
callId: CallId('nested'),
|
|
|
|
|
name: 'meta-suppression',
|
|
|
|
|
arguments: {},
|
|
|
|
|
parent: Symbol('outer') as ToolExecutionToken,
|
|
|
|
|
})
|
|
|
|
|
expect(direct.meta).toEqual({ card: true })
|
|
|
|
|
expect(nested.meta).toBeUndefined()
|
|
|
|
|
expect(nested.isError ? undefined : nested.value).toBe('')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-26 21:24:16 +08:00
|
|
|
it('carries a nested conclusion on the nested result for its composite to forward', async () => {
|
2026-07-26 20:50:02 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'terminal-nested',
|
|
|
|
|
async execute(_args, exec) {
|
|
|
|
|
exec.concludeTurn()
|
2026-07-26 21:24:16 +08:00
|
|
|
return 'terminal'
|
2026-07-26 20:50:02 +08:00
|
|
|
},
|
|
|
|
|
})
|
2026-07-26 21:24:16 +08:00
|
|
|
// A composite that forwards the marker from the nested result — the Code
|
|
|
|
|
// Mode dispatch shape. A recovering composite (nested failure swallowed)
|
|
|
|
|
// has no marker to forward: ToolExecutionFailure types concludesTurn as
|
|
|
|
|
// never, so only an authoritative nested success can conclude the run.
|
2026-07-26 20:50:02 +08:00
|
|
|
let call = 0
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'composite',
|
|
|
|
|
async execute(_args, exec) {
|
|
|
|
|
call += 1
|
|
|
|
|
const nested = await ctx.tools.execute({
|
|
|
|
|
signal: exec.signal, callId: CallId(`nested-${call}`), name: 'terminal-nested', arguments: {}, parent: exec.token,
|
|
|
|
|
})
|
2026-07-26 21:24:16 +08:00
|
|
|
if (nested.concludesTurn) exec.concludeTurn()
|
2026-07-26 20:50:02 +08:00
|
|
|
return nested.isError ? 'nested failed, composite recovered' : 'nested succeeded'
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-26 21:24:16 +08:00
|
|
|
// A policy converts the nested success into an error: the failed result
|
|
|
|
|
// carries no marker, so the recovering composite does not conclude.
|
2026-07-26 20:50:02 +08:00
|
|
|
const veto = ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
|
|
|
|
|
if (exec.name !== 'terminal-nested') return next()
|
|
|
|
|
return { kind: 'block', feedback: [{ type: 'text', text: 'nested success rejected' }] }
|
|
|
|
|
})
|
|
|
|
|
const recovered = await ctx.tools.execute({
|
|
|
|
|
signal: testToolSignal, callId: CallId('composite-vetoed'), name: 'composite', arguments: {},
|
|
|
|
|
})
|
|
|
|
|
expect(recovered.isError).toBe(false)
|
|
|
|
|
expect(recovered.concludesTurn).toBeUndefined()
|
|
|
|
|
veto()
|
|
|
|
|
|
2026-07-26 21:24:16 +08:00
|
|
|
// The same nested call succeeding carries the marker; the composite
|
|
|
|
|
// forwards it onto its own successful result.
|
2026-07-26 20:50:02 +08:00
|
|
|
const concluded = await ctx.tools.execute({
|
|
|
|
|
signal: testToolSignal, callId: CallId('composite-ok'), name: 'composite', arguments: {},
|
|
|
|
|
})
|
|
|
|
|
expect(concluded.isError).toBe(false)
|
|
|
|
|
expect(concluded.concludesTurn).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
it('returns isError results for unknown tools and throwing tools', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'boom',
|
|
|
|
|
async execute() {
|
|
|
|
|
throw new Error('exploded')
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'nope', arguments: {} })
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
expect(unknown.isError).toBe(true)
|
2026-06-14 10:43:23 +08:00
|
|
|
expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' })
|
|
|
|
|
// An unknown tool is a routable failure class, same as a tool-thrown one.
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(unknown.error).toEqual({
|
|
|
|
|
message: 'unknown tool "nope"',
|
|
|
|
|
info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' },
|
|
|
|
|
})
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const thrown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'boom', arguments: {} })
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
expect(thrown.isError).toBe(true)
|
|
|
|
|
expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 22:55:26 +08:00
|
|
|
it('normalizes a hostile thrown value whose inspection and coercion both throw', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'hostile-throw',
|
|
|
|
|
async execute() {
|
|
|
|
|
throw new Proxy({}, {
|
|
|
|
|
getPrototypeOf: () => { throw new Error('prototype trap') },
|
|
|
|
|
has: () => { throw new Error('has trap') },
|
|
|
|
|
get: () => { throw new Error('get trap') },
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(ctx.tools.execute({
|
2026-07-19 23:38:54 +08:00
|
|
|
signal: testToolSignal,
|
2026-07-11 22:55:26 +08:00
|
|
|
callId: CallId('hostile'), name: 'hostile-throw', arguments: {},
|
|
|
|
|
})).resolves.toMatchObject({
|
|
|
|
|
isError: true,
|
|
|
|
|
content: [{ type: 'text', text: 'Error: <unprintable thrown value>' }],
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 02:54:30 +08:00
|
|
|
it('ToolNotFoundError carries a stable message and code', async () => {
|
2026-06-14 10:43:23 +08:00
|
|
|
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
|
|
|
|
|
const err = new ToolNotFoundError('ghost')
|
|
|
|
|
expect(err).toBeInstanceOf(HarnessError)
|
|
|
|
|
expect(err.name).toBe('ToolNotFoundError')
|
|
|
|
|
expect(err.code).toBe('UNKNOWN_TOOL')
|
|
|
|
|
expect(err.message).toBe('unknown tool "ghost"')
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-30 17:11:18 +08:00
|
|
|
it('lets a tools/pre-execute listener deny a call (permission pattern)', async () => {
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
2026-07-21 03:08:35 +08:00
|
|
|
let postSawFrozen = false
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
2026-06-30 17:11:18 +08:00
|
|
|
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
|
|
|
|
if (exec.name === 'echo') return { kind: 'deny', reason: 'denied by policy' }
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
return next()
|
|
|
|
|
})
|
2026-07-21 03:08:35 +08:00
|
|
|
ctx.on('tools/post-execute', async (_exec, result, next) => {
|
|
|
|
|
postSawFrozen = Object.isFrozen(result)
|
|
|
|
|
expect(Reflect.set(result, 'content', [])).toBe(false)
|
|
|
|
|
return next()
|
|
|
|
|
})
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
expect(result.isError).toBe(true)
|
2026-06-30 17:11:18 +08:00
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(postSawFrozen).toBe(true)
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-09 15:25:18 +08:00
|
|
|
it('an ask decision degrades to deny when no approval seam is mounted', async () => {
|
2026-06-30 17:11:18 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
|
|
|
|
|
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> =>
|
|
|
|
|
({ kind: 'ask', reason: 'needs approval' }))
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
2026-06-30 17:11:18 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: needs approval' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('an ask decision with no reason degrades to deny with a default message', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
|
|
|
|
|
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
2026-06-30 17:11:18 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-09 15:25:18 +08:00
|
|
|
describe('ask routing through ctx.approval', () => {
|
|
|
|
|
/**
|
|
|
|
|
* A minimal Agent stand-in — the approval seam reaches
|
|
|
|
|
* `agent.session.append` and folds `.events`; the seeded open turn
|
|
|
|
|
* satisfies request()'s enclosure precondition.
|
|
|
|
|
*/
|
|
|
|
|
function fakeAgent(): Agent {
|
|
|
|
|
return {
|
|
|
|
|
session: { events: [{ type: 'turn/start' }], append: () => ({}) },
|
|
|
|
|
} as unknown as Agent
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function approvalSetup() {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
await ctx.plugin(ApprovalService)
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('dispatches the tool when the answerer grants allowed-once, forwarding the ask fields', async () => {
|
|
|
|
|
const ctx = await approvalSetup()
|
|
|
|
|
const agent = fakeAgent()
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const seen: ApprovalRequest[] = []
|
|
|
|
|
ctx.on('approval/request', (req) => {
|
|
|
|
|
seen.push(req)
|
|
|
|
|
return Promise.resolve<ApprovalOutcome>('allowed-once')
|
|
|
|
|
})
|
|
|
|
|
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> =>
|
|
|
|
|
({ kind: 'ask', reason: 'hook wants a human' }))
|
|
|
|
|
|
|
|
|
|
const result = await ctx.tools.execute({
|
|
|
|
|
callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' }, agent, signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(result).toMatchObject({ isError: false, content: [{ type: 'text', text: 'hi' }] })
|
|
|
|
|
expect(seen).toHaveLength(1)
|
|
|
|
|
expect(seen[0]).toMatchObject({ agent, toolName: 'echo', callId: 'c1', reason: 'hook wants a human' })
|
|
|
|
|
expect(seen[0]?.signal).toBe(controller.signal)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('denies with the user-rejection reason on rejected', async () => {
|
|
|
|
|
const ctx = await approvalSetup()
|
|
|
|
|
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
|
|
|
|
|
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
|
2026-07-09 15:25:18 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: the user rejected tool "echo"' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('denies with the cancellation reason on cancelled', async () => {
|
|
|
|
|
const ctx = await approvalSetup()
|
|
|
|
|
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('cancelled'))
|
|
|
|
|
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
|
2026-07-09 15:25:18 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: approval for tool "echo" was cancelled' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
it('returns ABORTED_BEFORE_DISPATCH when caller cancellation overtakes approval', async () => {
|
|
|
|
|
const ctx = await approvalSetup()
|
|
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
|
|
const release = Promise.withResolvers<ApprovalOutcome>()
|
|
|
|
|
let dispatched = 0
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'approval-probe',
|
|
|
|
|
async execute() { dispatched += 1; return [] },
|
|
|
|
|
})
|
|
|
|
|
ctx.on('approval/request', () => {
|
|
|
|
|
entered.resolve(undefined)
|
|
|
|
|
return release.promise
|
|
|
|
|
})
|
|
|
|
|
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('approval-cancelled'),
|
|
|
|
|
name: 'approval-probe',
|
|
|
|
|
arguments: {},
|
|
|
|
|
agent: fakeAgent(),
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await entered.promise
|
|
|
|
|
controller.abort('caller cancelled approval')
|
|
|
|
|
release.resolve('allowed-once')
|
|
|
|
|
|
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
2026-07-19 23:38:54 +08:00
|
|
|
})
|
|
|
|
|
expect(dispatched).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-09 15:25:18 +08:00
|
|
|
it('denies with the no-channel reason when the seam is mounted but nobody answers', async () => {
|
|
|
|
|
const ctx = await approvalSetup()
|
|
|
|
|
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
|
2026-07-09 15:25:18 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but no approval channel is available' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('denies an agent-less execution without asking — nothing to route or audit through', async () => {
|
|
|
|
|
const ctx = await approvalSetup()
|
|
|
|
|
let asked = false
|
|
|
|
|
ctx.on('approval/request', () => {
|
|
|
|
|
asked = true
|
|
|
|
|
return Promise.resolve<ApprovalOutcome>('allowed-once')
|
|
|
|
|
})
|
|
|
|
|
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} })
|
2026-07-09 15:25:18 +08:00
|
|
|
expect(asked).toBe(false)
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but the call has no agent to route it through' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('turns a rogue outcome from a NON-conforming approval stand-in into an isError result', async () => {
|
|
|
|
|
// ApprovalService normalizes rogue answers itself; this pins the
|
|
|
|
|
// registry's own exhaustiveness backstop by shadowing the service with a
|
|
|
|
|
// stand-in that violates the outcome contract.
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as ApprovalService)
|
|
|
|
|
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
|
2026-07-09 15:25:18 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
const text = result.content[0]?.type === 'text' ? result.content[0].text : ''
|
|
|
|
|
expect(text).toContain('unreachable')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-30 17:11:18 +08:00
|
|
|
it('a tools/post-execute listener can replace the result content (accept) ', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
|
|
|
|
|
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
|
|
|
|
|
({ kind: 'accept', content: [{ type: 'text', text: 'rewritten' }] }))
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
2026-06-30 17:11:18 +08:00
|
|
|
expect(result.isError).toBe(false)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'rewritten' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a tools/post-execute block turns the call into an isError with corrective feedback', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
|
|
|
|
|
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
|
|
|
|
|
({ kind: 'block', feedback: [{ type: 'text', text: 'output rejected: try again' }] }))
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
2026-06-30 17:11:18 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-23 02:53:43 +08:00
|
|
|
it('runs the snapshotted final content transform after outer pipeline normalization', async () => {
|
|
|
|
|
const ctx = await setup()
|
2026-07-23 20:50:45 +08:00
|
|
|
const dispose = ctx.tools.register(defineContentToolFixture({
|
2026-07-23 02:53:43 +08:00
|
|
|
name: 'bounded',
|
|
|
|
|
description: 'bounded result',
|
|
|
|
|
parameters: {},
|
|
|
|
|
async execute() { return [{ type: 'text', text: 'body' }] },
|
|
|
|
|
finalizeContent(exec, result) {
|
|
|
|
|
expect(exec.name).toBe('bounded')
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
return [{ type: 'text', text: 'bounded failure' }]
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
ctx.on('tools/pre-execute', async () => {
|
|
|
|
|
dispose()
|
|
|
|
|
throw new HarnessError('policy failed', 'POLICY_FAILED')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('bounded'), name: 'bounded', arguments: {} })
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
content: [{ type: 'text', text: 'bounded failure' }],
|
|
|
|
|
isError: true,
|
2026-07-23 20:50:45 +08:00
|
|
|
error: {
|
|
|
|
|
message: 'policy failed',
|
|
|
|
|
info: { name: 'HarnessError', code: 'POLICY_FAILED' },
|
|
|
|
|
},
|
2026-07-23 02:53:43 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-13 13:56:45 +08:00
|
|
|
it('a block decision can ALSO attach additionalContexts', async () => {
|
2026-06-30 17:11:18 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
|
|
|
|
|
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
|
|
|
|
|
({
|
|
|
|
|
kind: 'block',
|
|
|
|
|
feedback: [{ type: 'text', text: 'rejected' }],
|
2026-07-28 13:55:59 +08:00
|
|
|
additionalContexts: [createUserMessage({
|
|
|
|
|
content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' },
|
|
|
|
|
})],
|
2026-06-30 17:11:18 +08:00
|
|
|
}))
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
2026-06-30 17:11:18 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'rejected' })
|
2026-07-13 13:56:45 +08:00
|
|
|
expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }])
|
2026-06-30 17:11:18 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-13 13:56:45 +08:00
|
|
|
it('post-execute additionalContexts ride on the result for the loop to buffer', async () => {
|
2026-06-30 17:11:18 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
|
|
|
|
|
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
|
2026-07-28 13:55:59 +08:00
|
|
|
({ kind: 'accept', additionalContexts: [createUserMessage({
|
|
|
|
|
content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' },
|
|
|
|
|
})] }))
|
2026-06-30 17:11:18 +08:00
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
2026-07-13 13:56:45 +08:00
|
|
|
expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('preserves tool-deferred, execute-wrapper, and post-execute contexts in order', async () => {
|
|
|
|
|
const ctx = await setup()
|
2026-07-21 03:08:35 +08:00
|
|
|
ctx.tools.register(defineContentToolFixture({
|
2026-07-13 13:56:45 +08:00
|
|
|
name: 'composite',
|
|
|
|
|
description: 'composite',
|
|
|
|
|
parameters: {},
|
|
|
|
|
async execute(_args, exec) {
|
2026-07-28 13:55:59 +08:00
|
|
|
exec.deferContext(createUserMessage({
|
|
|
|
|
content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' },
|
|
|
|
|
}))
|
|
|
|
|
exec.deferContext(createUserMessage({
|
|
|
|
|
content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' },
|
|
|
|
|
}))
|
2026-07-13 13:56:45 +08:00
|
|
|
return [{ type: 'text', text: 'done' }]
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
ctx.on('tools/execute', async (_exec, next) => {
|
|
|
|
|
const result = await next()
|
|
|
|
|
return {
|
|
|
|
|
...result,
|
|
|
|
|
additionalContexts: [
|
|
|
|
|
...result.additionalContexts ?? [],
|
2026-07-28 13:55:59 +08:00
|
|
|
createUserMessage({
|
|
|
|
|
content: [{ type: 'text', text: 'wrapper' }], source: { kind: 'plugin', plugin: 'wrapper' },
|
|
|
|
|
}),
|
2026-07-13 13:56:45 +08:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
|
|
|
|
|
const downstream = await next()
|
|
|
|
|
return {
|
|
|
|
|
...downstream,
|
|
|
|
|
additionalContexts: [
|
2026-07-28 13:55:59 +08:00
|
|
|
createUserMessage({
|
|
|
|
|
content: [{ type: 'text', text: 'post' }], source: { kind: 'plugin', plugin: 'post' },
|
|
|
|
|
}),
|
2026-07-13 13:56:45 +08:00
|
|
|
...downstream.additionalContexts ?? [],
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('composite'), name: 'composite', arguments: {} })
|
2026-07-13 13:56:45 +08:00
|
|
|
|
|
|
|
|
expect(result.additionalContexts?.map(context => context.source)).toEqual([
|
|
|
|
|
{ kind: 'plugin', plugin: 'nested-1' },
|
|
|
|
|
{ kind: 'plugin', plugin: 'nested-2' },
|
|
|
|
|
{ kind: 'plugin', plugin: 'wrapper' },
|
|
|
|
|
{ kind: 'plugin', plugin: 'post' },
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => {
|
|
|
|
|
const ctx = await setup()
|
2026-07-21 03:08:35 +08:00
|
|
|
ctx.tools.register(defineContentToolFixture({
|
2026-07-13 13:56:45 +08:00
|
|
|
name: 'failing-composite',
|
|
|
|
|
description: 'failing composite',
|
|
|
|
|
parameters: {},
|
|
|
|
|
async execute(_args, exec) {
|
2026-07-28 13:55:59 +08:00
|
|
|
exec.deferContext(createUserMessage({
|
|
|
|
|
content: [{ type: 'text', text: 'nested' }], source: { kind: 'plugin', plugin: 'nested' },
|
|
|
|
|
}))
|
2026-07-13 13:56:45 +08:00
|
|
|
throw new Error('outer failure')
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const failed = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('failed'), name: 'failing-composite', arguments: {} })
|
2026-07-13 13:56:45 +08:00
|
|
|
expect(failed.isError).toBe(true)
|
|
|
|
|
expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'nested' }])
|
|
|
|
|
|
|
|
|
|
ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
|
|
|
|
|
kind: 'block',
|
|
|
|
|
feedback: [{ type: 'text', text: 'blocked' }],
|
2026-07-28 13:55:59 +08:00
|
|
|
additionalContexts: [createUserMessage({
|
|
|
|
|
content: [{ type: 'text', text: 'block-only' }], source: { kind: 'plugin', plugin: 'blocker' },
|
|
|
|
|
})],
|
2026-07-13 13:56:45 +08:00
|
|
|
}))
|
2026-07-19 23:38:54 +08:00
|
|
|
const blocked = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('blocked'), name: 'failing-composite', arguments: {} })
|
2026-07-13 13:56:45 +08:00
|
|
|
expect(blocked.isError).toBe(true)
|
|
|
|
|
expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'blocker' }])
|
2026-06-30 17:11:18 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
|
|
|
|
|
const order: string[] = []
|
2026-06-30 17:11:18 +08:00
|
|
|
ctx.on('tools/pre-execute', async (_exec, next) => {
|
|
|
|
|
order.push('pre:before')
|
|
|
|
|
const decision = await next()
|
|
|
|
|
order.push('pre:after')
|
|
|
|
|
return decision
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
2026-06-30 17:11:18 +08:00
|
|
|
ctx.on('tools/post-execute', async (_exec, _result, next) => {
|
|
|
|
|
order.push('post:before')
|
|
|
|
|
const decision = await next()
|
|
|
|
|
order.push('post:after')
|
|
|
|
|
return decision
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } })
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
expect(result.isError).toBe(false)
|
2026-06-30 17:11:18 +08:00
|
|
|
// pre runs fully (gate) before dispatch, then post runs over the result.
|
|
|
|
|
expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after'])
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-08 10:06:07 +08:00
|
|
|
it('runs tools/execute after an allowed pre-execute, around dispatch, and before post-execute', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const order: string[] = []
|
2026-07-21 03:08:35 +08:00
|
|
|
ctx.tools.register(defineContentToolFixture({
|
2026-07-08 10:06:07 +08:00
|
|
|
name: 'traced',
|
|
|
|
|
description: 'echo',
|
|
|
|
|
parameters: { text: { type: 'string' } },
|
|
|
|
|
async execute(args) {
|
|
|
|
|
order.push('dispatch')
|
|
|
|
|
return [{ type: 'text' as const, text: args.text ?? '' }]
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
ctx.on('tools/pre-execute', async (_exec, next) => { order.push('pre'); return next() })
|
2026-07-19 23:38:54 +08:00
|
|
|
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
2026-07-08 10:06:07 +08:00
|
|
|
order.push('execute:before')
|
|
|
|
|
const result = await next()
|
|
|
|
|
order.push('execute:after')
|
|
|
|
|
return result
|
|
|
|
|
})
|
|
|
|
|
ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: [{ type: 'text', text: 'hi' }] })
|
2026-07-08 10:06:07 +08:00
|
|
|
// The around seam wraps dispatch; pre gates before it, post runs over its result.
|
|
|
|
|
expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 18:05:54 +08:00
|
|
|
it('skips dispatch when caller cancellation arrives while pre-execute awaits', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
let dispatched = 0
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'must-not-run',
|
|
|
|
|
async execute() { dispatched += 1; return [] },
|
|
|
|
|
})
|
|
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
|
|
const release = Promise.withResolvers<undefined>()
|
|
|
|
|
ctx.on('tools/pre-execute', async (_exec, next) => {
|
|
|
|
|
entered.resolve(undefined)
|
|
|
|
|
await release.promise
|
|
|
|
|
return await next()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('cancelled-in-pre'), name: 'must-not-run', arguments: {}, signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
await entered.promise
|
|
|
|
|
controller.abort('cancelled in policy')
|
|
|
|
|
release.resolve(undefined)
|
|
|
|
|
|
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
2026-07-19 23:38:54 +08:00
|
|
|
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
|
|
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
2026-07-19 23:38:54 +08:00
|
|
|
})
|
|
|
|
|
expect(dispatched).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('preserves a pre-execute denial that settles after cancellation', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
let dispatched = 0
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'denied-after-cancel',
|
|
|
|
|
async execute() { dispatched += 1; return [] },
|
|
|
|
|
})
|
|
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
|
|
const release = Promise.withResolvers<undefined>()
|
|
|
|
|
ctx.on('tools/pre-execute', async () => {
|
|
|
|
|
entered.resolve(undefined)
|
|
|
|
|
await release.promise
|
|
|
|
|
return { kind: 'deny', reason: 'policy denied the call' }
|
|
|
|
|
})
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('denied-after-cancel'), name: 'denied-after-cancel', arguments: {}, signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await entered.promise
|
|
|
|
|
controller.abort('cancelled while policy decided')
|
|
|
|
|
release.resolve(undefined)
|
|
|
|
|
|
|
|
|
|
await expect(pending).resolves.toEqual({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: policy denied the call' }],
|
2026-07-19 18:05:54 +08:00
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { message: 'policy denied the call' },
|
2026-07-19 18:05:54 +08:00
|
|
|
})
|
|
|
|
|
expect(dispatched).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
it('preserves an async pre-execute failure that settles after cancellation', async () => {
|
2026-07-19 18:05:54 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
let dispatched = 0
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'must-not-run',
|
|
|
|
|
async execute() { dispatched += 1; return [] },
|
|
|
|
|
})
|
|
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
|
|
const release = Promise.withResolvers<undefined>()
|
|
|
|
|
ctx.on('tools/pre-execute', async () => {
|
|
|
|
|
entered.resolve(undefined)
|
|
|
|
|
await release.promise
|
|
|
|
|
throw new Error('gate interrupted')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('cancelled-pre-error'), name: 'must-not-run', arguments: {}, signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
await entered.promise
|
|
|
|
|
controller.abort('cancelled in policy')
|
|
|
|
|
release.resolve(undefined)
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
await expect(pending).resolves.toEqual({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: gate interrupted' }],
|
2026-07-19 18:05:54 +08:00
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { message: 'gate interrupted' },
|
2026-07-19 18:05:54 +08:00
|
|
|
})
|
|
|
|
|
expect(dispatched).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rechecks caller cancellation after an async around-dispatch wrapper delegates', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
let dispatched = 0
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'must-not-run',
|
|
|
|
|
async execute() { dispatched += 1; return [] },
|
|
|
|
|
})
|
|
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
|
|
const release = Promise.withResolvers<undefined>()
|
|
|
|
|
const replacement = new AbortController()
|
|
|
|
|
ctx.on('tools/execute', async (exec, next) => {
|
|
|
|
|
const upstream = exec.signal
|
|
|
|
|
exec.signal = replacement.signal
|
|
|
|
|
try {
|
|
|
|
|
entered.resolve(undefined)
|
|
|
|
|
await release.promise
|
|
|
|
|
return await next()
|
|
|
|
|
} finally {
|
2026-07-19 23:38:54 +08:00
|
|
|
exec.signal = upstream
|
2026-07-19 18:05:54 +08:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('cancelled-in-around'), name: 'must-not-run', arguments: {}, signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
await entered.promise
|
|
|
|
|
controller.abort('cancelled in wrapper')
|
|
|
|
|
release.resolve(undefined)
|
|
|
|
|
|
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
2026-07-19 18:05:54 +08:00
|
|
|
})
|
|
|
|
|
expect(dispatched).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('skips dispatch when an around wrapper supplies an already-aborted signal', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
let dispatched = 0
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'must-not-run',
|
|
|
|
|
async execute() { dispatched += 1; return [] },
|
|
|
|
|
})
|
|
|
|
|
const replacement = AbortSignal.abort('wrapper cancelled')
|
|
|
|
|
ctx.on('tools/execute', async (exec, next) => {
|
|
|
|
|
const upstream = exec.signal
|
|
|
|
|
exec.signal = replacement
|
|
|
|
|
try {
|
|
|
|
|
return await next()
|
|
|
|
|
} finally {
|
2026-07-19 23:38:54 +08:00
|
|
|
exec.signal = upstream
|
2026-07-19 18:05:54 +08:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const result = await ctx.tools.execute({
|
|
|
|
|
callId: CallId('cancelled-wrapper'), name: 'must-not-run', arguments: {}, signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 23:39:03 +08:00
|
|
|
expect(result.error).toEqual({
|
|
|
|
|
message: 'tool call aborted before dispatch',
|
|
|
|
|
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
|
|
|
|
})
|
2026-07-19 23:38:54 +08:00
|
|
|
expect(dispatched).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses ABORTED_BEFORE_DISPATCH when cancellation overtakes a wrapper short-circuit', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
let dispatched = 0
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'short-circuited',
|
|
|
|
|
async execute() { dispatched += 1; return [] },
|
|
|
|
|
})
|
|
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
|
|
const release = Promise.withResolvers<undefined>()
|
|
|
|
|
ctx.on('tools/execute', async () => {
|
|
|
|
|
entered.resolve(undefined)
|
|
|
|
|
await release.promise
|
|
|
|
|
return {
|
2026-07-21 23:39:03 +08:00
|
|
|
value: 'wrapper success',
|
2026-07-19 23:38:54 +08:00
|
|
|
content: [{ type: 'text', text: 'wrapper success' }],
|
|
|
|
|
isError: false,
|
2026-07-28 13:55:59 +08:00
|
|
|
additionalContexts: [createUserMessage({
|
2026-07-19 23:38:54 +08:00
|
|
|
content: [{ type: 'text', text: 'wrapper context' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'wrapper' },
|
2026-07-28 13:55:59 +08:00
|
|
|
})],
|
2026-07-19 23:38:54 +08:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('cancelled-short-circuit'),
|
|
|
|
|
name: 'short-circuited',
|
|
|
|
|
arguments: {},
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await entered.promise
|
|
|
|
|
controller.abort('cancelled while wrapper waited')
|
|
|
|
|
release.resolve(undefined)
|
|
|
|
|
|
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
|
|
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
2026-07-19 23:38:54 +08:00
|
|
|
additionalContexts: [{ source: { kind: 'plugin', plugin: 'wrapper' } }],
|
|
|
|
|
})
|
2026-07-19 18:05:54 +08:00
|
|
|
expect(dispatched).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('replaces a late wrapper success with ABORTED and preserves deferred contexts', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'completed-before-wrapper',
|
|
|
|
|
async execute(_args, exec) {
|
2026-07-28 13:55:59 +08:00
|
|
|
exec.deferContext(createUserMessage({
|
2026-07-19 18:05:54 +08:00
|
|
|
content: [{ type: 'text', text: 'completed child work' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'child' },
|
2026-07-28 13:55:59 +08:00
|
|
|
}))
|
2026-07-21 23:39:03 +08:00
|
|
|
return 'body complete'
|
2026-07-19 18:05:54 +08:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
|
|
const release = Promise.withResolvers<undefined>()
|
|
|
|
|
ctx.on('tools/execute', async (_exec, next) => {
|
|
|
|
|
const result = await next()
|
|
|
|
|
entered.resolve(undefined)
|
|
|
|
|
await release.promise
|
|
|
|
|
return result
|
|
|
|
|
})
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('cancelled-after-body'), name: 'completed-before-wrapper', arguments: {}, signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
await entered.promise
|
|
|
|
|
controller.abort('cancelled while wrapper settled')
|
|
|
|
|
release.resolve(undefined)
|
|
|
|
|
|
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: tool call aborted' }],
|
|
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { info: { name: 'AbortError', code: TOOL_ABORTED } },
|
2026-07-19 18:05:54 +08:00
|
|
|
additionalContexts: [{ source: { kind: 'plugin', plugin: 'child' } }],
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 21:13:41 +08:00
|
|
|
it('replaces a late post-execute success with ABORTED and preserves contexts', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'completed-before-post',
|
|
|
|
|
async execute(_args, exec) {
|
2026-07-28 13:55:59 +08:00
|
|
|
exec.deferContext(createUserMessage({
|
2026-07-19 21:13:41 +08:00
|
|
|
content: [{ type: 'text', text: 'completed child work' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'child' },
|
2026-07-28 13:55:59 +08:00
|
|
|
}))
|
2026-07-21 23:39:03 +08:00
|
|
|
return 'body complete'
|
2026-07-19 21:13:41 +08:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
|
|
const release = Promise.withResolvers<undefined>()
|
|
|
|
|
ctx.on('tools/post-execute', async (_exec, _result, next) => {
|
|
|
|
|
const decision = await next()
|
|
|
|
|
entered.resolve(undefined)
|
|
|
|
|
await release.promise
|
|
|
|
|
return {
|
|
|
|
|
...decision,
|
2026-07-28 13:55:59 +08:00
|
|
|
additionalContexts: [createUserMessage({
|
2026-07-19 21:13:41 +08:00
|
|
|
content: [{ type: 'text', text: 'post context' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'post' },
|
2026-07-28 13:55:59 +08:00
|
|
|
})],
|
2026-07-19 21:13:41 +08:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('cancelled-in-post'), name: 'completed-before-post', arguments: {}, signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
await entered.promise
|
|
|
|
|
controller.abort('cancelled while post policy waits')
|
|
|
|
|
release.resolve(undefined)
|
|
|
|
|
|
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: tool call aborted' }],
|
|
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { info: { name: 'AbortError', code: TOOL_ABORTED } },
|
2026-07-19 21:13:41 +08:00
|
|
|
additionalContexts: [
|
|
|
|
|
{ source: { kind: 'plugin', plugin: 'child' } },
|
|
|
|
|
{ source: { kind: 'plugin', plugin: 'post' } },
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
it('preserves an around-dispatch failure that settles after cancellation', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
let dispatched = 0
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'wrapper-failure',
|
|
|
|
|
async execute() { dispatched += 1; return [] },
|
|
|
|
|
})
|
|
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
|
|
const release = Promise.withResolvers<undefined>()
|
|
|
|
|
ctx.on('tools/execute', async () => {
|
|
|
|
|
entered.resolve(undefined)
|
|
|
|
|
await release.promise
|
|
|
|
|
throw new HarnessError('wrapper failed', 'WRAPPER_FAILURE')
|
|
|
|
|
})
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('wrapper-failure'), name: 'wrapper-failure', arguments: {}, signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await entered.promise
|
|
|
|
|
controller.abort('cancelled while wrapper failed')
|
|
|
|
|
release.resolve(undefined)
|
|
|
|
|
|
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: wrapper failed' }],
|
|
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { info: { name: 'HarnessError', code: 'WRAPPER_FAILURE' } },
|
2026-07-19 23:38:54 +08:00
|
|
|
})
|
|
|
|
|
expect(dispatched).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('preserves a tool-owned failure after the body observes cancellation', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'tool-failure',
|
|
|
|
|
execute(_args, exec) {
|
|
|
|
|
entered.resolve(undefined)
|
2026-07-21 23:39:03 +08:00
|
|
|
return new Promise<never>((_resolve, reject) => {
|
2026-07-19 23:38:54 +08:00
|
|
|
exec.signal.addEventListener('abort', () => {
|
|
|
|
|
reject(new HarnessError('tool failed', 'TOOL_FAILURE'))
|
|
|
|
|
}, { once: true })
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('tool-failure'), name: 'tool-failure', arguments: {}, signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await entered.promise
|
|
|
|
|
controller.abort('cancelled running body')
|
|
|
|
|
|
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: tool failed' }],
|
|
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { info: { name: 'HarnessError', code: 'TOOL_FAILURE' } },
|
2026-07-19 23:38:54 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('preserves a post-policy failure that settles after cancellation', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
|
|
const release = Promise.withResolvers<undefined>()
|
|
|
|
|
ctx.on('tools/post-execute', async () => {
|
|
|
|
|
entered.resolve(undefined)
|
|
|
|
|
await release.promise
|
|
|
|
|
throw new HarnessError('post-policy failed', 'POST_FAILURE')
|
|
|
|
|
})
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('post-failure'), name: 'echo', arguments: {}, signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await entered.promise
|
|
|
|
|
controller.abort('cancelled while post-policy failed')
|
|
|
|
|
release.resolve(undefined)
|
|
|
|
|
|
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: post-policy failed' }],
|
|
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { info: { name: 'HarnessError', code: 'POST_FAILURE' } },
|
2026-07-19 23:38:54 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 18:05:54 +08:00
|
|
|
it('fuses caller cancellation back into a wrapper replacement for the running body', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const entered = Promise.withResolvers<undefined>()
|
|
|
|
|
const replacement = new AbortController()
|
|
|
|
|
let bodySignal: AbortSignal | undefined
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'cooperative',
|
|
|
|
|
execute(_args, exec) {
|
|
|
|
|
bodySignal = exec.signal
|
|
|
|
|
entered.resolve(undefined)
|
2026-07-21 23:39:03 +08:00
|
|
|
if (exec.signal.aborted) return Promise.resolve('stopped')
|
|
|
|
|
return new Promise<string>((resolve) => {
|
|
|
|
|
exec.signal.addEventListener('abort', () => { resolve('stopped') }, { once: true })
|
2026-07-19 18:05:54 +08:00
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
ctx.on('tools/execute', async (exec, next) => {
|
|
|
|
|
const upstream = exec.signal
|
|
|
|
|
exec.signal = replacement.signal
|
|
|
|
|
try {
|
|
|
|
|
return await next()
|
|
|
|
|
} finally {
|
2026-07-19 23:38:54 +08:00
|
|
|
exec.signal = upstream
|
2026-07-19 18:05:54 +08:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('cancelled-body'), name: 'cooperative', arguments: {}, signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
await entered.promise
|
|
|
|
|
expect(bodySignal).not.toBe(controller.signal)
|
|
|
|
|
expect(bodySignal).not.toBe(replacement.signal)
|
|
|
|
|
controller.abort('cancel running body')
|
|
|
|
|
|
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { info: { name: 'AbortError', code: TOOL_ABORTED } },
|
2026-07-19 18:05:54 +08:00
|
|
|
})
|
|
|
|
|
expect(bodySignal?.aborted).toBe(true)
|
|
|
|
|
expect(replacement.signal.aborted).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
it('restores the required caller signal after around dispatch', async () => {
|
2026-07-19 18:05:54 +08:00
|
|
|
const ctx = await setup()
|
2026-07-19 23:38:54 +08:00
|
|
|
let postSignal: AbortSignal | undefined
|
2026-07-19 18:05:54 +08:00
|
|
|
ctx.on('tools/execute', async (exec, next) => {
|
|
|
|
|
const upstream = exec.signal
|
2026-07-19 23:38:54 +08:00
|
|
|
exec.signal = new AbortController().signal
|
2026-07-19 18:05:54 +08:00
|
|
|
try {
|
|
|
|
|
return await next()
|
|
|
|
|
} finally {
|
2026-07-19 23:38:54 +08:00
|
|
|
exec.signal = upstream
|
2026-07-19 18:05:54 +08:00
|
|
|
}
|
|
|
|
|
})
|
2026-07-19 23:38:54 +08:00
|
|
|
ctx.on('tools/post-execute', async (exec, _result, next) => {
|
|
|
|
|
postSignal = exec.signal
|
|
|
|
|
return next()
|
|
|
|
|
})
|
2026-07-19 18:05:54 +08:00
|
|
|
const controller = new AbortController()
|
|
|
|
|
|
|
|
|
|
await ctx.tools.execute({
|
2026-07-19 23:38:54 +08:00
|
|
|
callId: CallId('restored-signal'), name: 'echo', arguments: {}, signal: controller.signal,
|
2026-07-19 18:05:54 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
expect(postSignal).toBe(controller.signal)
|
2026-07-19 18:05:54 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('waits for an uncooperative started body before returning ABORTED', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const entered = Promise.withResolvers<undefined>()
|
2026-07-21 23:39:03 +08:00
|
|
|
const release = Promise.withResolvers<string>()
|
2026-07-19 18:05:54 +08:00
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'uncooperative',
|
|
|
|
|
execute(_args, exec) {
|
2026-07-28 13:55:59 +08:00
|
|
|
exec.deferContext(createUserMessage({
|
2026-07-19 18:05:54 +08:00
|
|
|
content: [{ type: 'text', text: 'nested outcome' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'nested' },
|
2026-07-28 13:55:59 +08:00
|
|
|
}))
|
2026-07-19 18:05:54 +08:00
|
|
|
entered.resolve(undefined)
|
|
|
|
|
return release.promise
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = ctx.tools.execute({
|
|
|
|
|
callId: CallId('drain-body'), name: 'uncooperative', arguments: {}, signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
await entered.promise
|
|
|
|
|
controller.abort('must still drain')
|
|
|
|
|
|
|
|
|
|
const state = await Promise.race([
|
|
|
|
|
pending.then(() => 'settled' as const),
|
|
|
|
|
Promise.resolve('pending' as const),
|
|
|
|
|
])
|
|
|
|
|
expect(state).toBe('pending')
|
2026-07-21 23:39:03 +08:00
|
|
|
release.resolve('settled')
|
2026-07-19 18:05:54 +08:00
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { info: { name: 'AbortError', code: TOOL_ABORTED } },
|
2026-07-19 18:05:54 +08:00
|
|
|
additionalContexts: [{ source: { kind: 'plugin', plugin: 'nested' } }],
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
it('materializes a pre-aborted call and publishes one result without entering pipeline phases', async () => {
|
2026-07-19 18:05:54 +08:00
|
|
|
const ctx = await setup()
|
2026-07-19 23:38:54 +08:00
|
|
|
const phases = { pre: 0, around: 0, body: 0, post: 0, result: 0 }
|
|
|
|
|
const callerArguments = { nested: { value: 1 } }
|
|
|
|
|
const callerSignal = AbortSignal.abort('already cancelled')
|
|
|
|
|
let argumentReads = 0
|
|
|
|
|
let observedArguments: unknown
|
|
|
|
|
let observedExecution: object | undefined
|
|
|
|
|
let observedToken: symbol | undefined
|
|
|
|
|
let observedSignal: AbortSignal | undefined
|
|
|
|
|
let observedResult: ToolExecutionResult | undefined
|
2026-07-19 18:05:54 +08:00
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'domain-abort',
|
2026-07-19 23:38:54 +08:00
|
|
|
async execute() { phases.body += 1; return [] },
|
|
|
|
|
})
|
|
|
|
|
ctx.on('tools/pre-execute', async (_exec, next) => { phases.pre += 1; return next() })
|
|
|
|
|
ctx.on('tools/execute', async (_exec, next) => { phases.around += 1; return next() })
|
|
|
|
|
ctx.on('tools/post-execute', async (_exec, _result, next) => { phases.post += 1; return next() })
|
|
|
|
|
ctx.on('tools/result', (exec, result) => {
|
|
|
|
|
phases.result += 1
|
|
|
|
|
observedExecution = exec
|
|
|
|
|
observedArguments = exec.arguments
|
|
|
|
|
observedToken = exec.token
|
|
|
|
|
observedSignal = exec.signal
|
|
|
|
|
observedResult = result
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const result = await ctx.tools.execute({
|
|
|
|
|
callId: CallId('pre-aborted'),
|
|
|
|
|
name: 'domain-abort',
|
|
|
|
|
get arguments() { argumentReads += 1; return callerArguments },
|
|
|
|
|
signal: callerSignal,
|
2026-07-19 18:05:54 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
expect(argumentReads).toBe(1)
|
|
|
|
|
expect(phases).toEqual({ pre: 0, around: 0, body: 0, post: 0, result: 1 })
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
|
|
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: {
|
|
|
|
|
message: 'tool call aborted before dispatch',
|
|
|
|
|
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
|
|
|
|
},
|
2026-07-19 23:38:54 +08:00
|
|
|
})
|
|
|
|
|
expect(observedResult).toBe(result)
|
|
|
|
|
expect(Object.isFrozen(observedExecution)).toBe(true)
|
|
|
|
|
expect(typeof observedToken).toBe('symbol')
|
|
|
|
|
expect(observedSignal).toBe(callerSignal)
|
|
|
|
|
expect(Object.isFrozen(result)).toBe(true)
|
|
|
|
|
expect(observedArguments).not.toBe(callerArguments)
|
|
|
|
|
expect(Object.isFrozen(observedArguments)).toBe(true)
|
|
|
|
|
expect(Object.isFrozen((observedArguments as { nested: object }).nested)).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('lets argument materialization failure win over a pre-aborted signal', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
let observed = 0
|
|
|
|
|
ctx.on('tools/result', () => { observed += 1 })
|
|
|
|
|
|
2026-07-19 18:05:54 +08:00
|
|
|
const result = await ctx.tools.execute({
|
2026-07-19 23:38:54 +08:00
|
|
|
callId: CallId('invalid-pre-aborted'),
|
|
|
|
|
name: 'missing',
|
|
|
|
|
arguments: { invalid: () => undefined },
|
|
|
|
|
signal: AbortSignal.abort('already cancelled'),
|
2026-07-19 18:05:54 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
expect(result).toEqual({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable' }],
|
|
|
|
|
isError: true,
|
2026-07-21 23:39:03 +08:00
|
|
|
error: { message: 'tool execution arguments must be losslessly JSON-serializable' },
|
2026-07-19 23:38:54 +08:00
|
|
|
})
|
|
|
|
|
expect(observed).toBe(1)
|
2026-07-19 18:05:54 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-08 10:06:07 +08:00
|
|
|
it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
|
|
|
|
|
let entered = false
|
|
|
|
|
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'deny', reason: 'nope' }))
|
2026-07-19 23:38:54 +08:00
|
|
|
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
2026-07-09 13:34:13 +08:00
|
|
|
entered = true
|
|
|
|
|
return next()
|
|
|
|
|
})
|
2026-07-08 10:06:07 +08:00
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
2026-07-08 10:06:07 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: nope' })
|
|
|
|
|
expect(entered).toBe(false) // a denied call never enters the around-dispatch seam
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a thrown tool is normalized to an isError result BEFORE a tools/execute listener sees next()', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'boom',
|
|
|
|
|
async execute() { throw new HarnessError('kaboom', 'BOOM') },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
let seen: { isError: boolean; error?: unknown } | undefined
|
2026-07-19 23:38:54 +08:00
|
|
|
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
2026-07-08 10:06:07 +08:00
|
|
|
const result = await next()
|
|
|
|
|
// The base next() IS dispatch-with-normalization: the wrapper sees the
|
|
|
|
|
// normalized isError result, never a raw throw from the tool body.
|
|
|
|
|
seen = { isError: result.isError, error: result.error }
|
|
|
|
|
return result
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'boom', arguments: {} })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(seen).toEqual({
|
|
|
|
|
isError: true,
|
|
|
|
|
error: { message: 'kaboom', info: { name: 'HarnessError', code: 'BOOM' } },
|
|
|
|
|
})
|
2026-07-08 10:06:07 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 03:08:35 +08:00
|
|
|
it('freezes core dispatch outcomes before around and post listeners can observe them', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
const mutationAttempts: boolean[] = []
|
|
|
|
|
ctx.on('tools/execute', async (_exec, next) => {
|
|
|
|
|
const result = await next()
|
|
|
|
|
mutationAttempts.push(Reflect.set(result, 'value', 'around mutation'))
|
|
|
|
|
return result
|
|
|
|
|
})
|
|
|
|
|
ctx.on('tools/post-execute', async (_exec, result, next) => {
|
|
|
|
|
mutationAttempts.push(Reflect.set(result, 'value', 'post mutation'))
|
|
|
|
|
return next()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const result = await ctx.tools.execute({
|
2026-07-21 23:39:03 +08:00
|
|
|
signal: testToolSignal,
|
2026-07-21 03:08:35 +08:00
|
|
|
callId: CallId('frozen-canonical'), name: 'echo', arguments: { text: 'original' },
|
|
|
|
|
})
|
|
|
|
|
expect(mutationAttempts).toEqual([false, false])
|
|
|
|
|
expect(result.isError ? undefined : result.value).toBe('original')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-08 10:06:07 +08:00
|
|
|
it('a thrown tool normalized inside tools/execute still reaches post-execute', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'boom',
|
|
|
|
|
async execute() { throw new Error('exploded') },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
let postSaw: boolean | undefined
|
2026-07-19 23:38:54 +08:00
|
|
|
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => next())
|
2026-07-08 10:06:07 +08:00
|
|
|
ctx.on('tools/post-execute', async (_exec, result, next) => {
|
|
|
|
|
postSaw = result.isError
|
|
|
|
|
return next()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'boom', arguments: {} })
|
2026-07-08 10:06:07 +08:00
|
|
|
expect(postSaw).toBe(true) // the normalized isError still flows through post-execute
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: exploded' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 18:05:54 +08:00
|
|
|
it('re-fuses the caller signal with an around-dispatch replacement for the body', async () => {
|
2026-07-08 10:06:07 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
let seenSignal: AbortSignal | undefined
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'signal-probe',
|
|
|
|
|
async execute(_args, exec) {
|
|
|
|
|
seenSignal = exec.signal
|
2026-07-21 03:08:35 +08:00
|
|
|
return 'ok'
|
2026-07-08 10:06:07 +08:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const upstream = new AbortController().signal
|
|
|
|
|
const replacement = new AbortController().signal
|
2026-07-19 23:38:54 +08:00
|
|
|
ctx.on('tools/execute', async (exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
2026-07-08 10:06:07 +08:00
|
|
|
expect(exec.signal).toBe(upstream)
|
|
|
|
|
// Cordis next() ignores passed arguments, so a wrapper mutates exec in
|
|
|
|
|
// place (the documented "mutate the shared object, then delegate" idiom).
|
|
|
|
|
exec.signal = replacement
|
|
|
|
|
return next()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await ctx.tools.execute({ callId: CallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream })
|
2026-07-19 18:05:54 +08:00
|
|
|
expect(seenSignal).toBeDefined()
|
|
|
|
|
expect(seenSignal).not.toBe(upstream)
|
|
|
|
|
expect(seenSignal).not.toBe(replacement)
|
2026-07-08 10:06:07 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a tools/execute listener can short-circuit dispatch by returning a result without next()', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
let dispatched = false
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'never-runs',
|
2026-07-21 03:08:35 +08:00
|
|
|
async execute() { dispatched = true; return 'unreachable' },
|
2026-07-08 10:06:07 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
|
2026-07-21 03:08:35 +08:00
|
|
|
({ content: [{ type: 'text', text: 'ignored authored content' }], isError: false, value: 'short-circuited' }))
|
2026-07-08 10:06:07 +08:00
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'never-runs', arguments: {} })
|
2026-07-08 10:06:07 +08:00
|
|
|
expect(dispatched).toBe(false) // returning without next() skips core dispatch
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-23 00:39:55 +08:00
|
|
|
it('revalidates a cached canonical result returned from a different dispatch', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({ ...echoTool, name: 'string-output', async execute() { return 'cached' } })
|
|
|
|
|
let objectBodyRan = false
|
|
|
|
|
ctx.tools.register(defineTool({
|
|
|
|
|
name: 'object-output',
|
|
|
|
|
description: 'Return one closed object.',
|
|
|
|
|
parameters: {},
|
|
|
|
|
output: {
|
|
|
|
|
schema: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { ok: { type: 'boolean', required: true } },
|
|
|
|
|
additionalProperties: false,
|
|
|
|
|
},
|
|
|
|
|
render: (_args, value) => [{ type: 'text', text: String(value.ok) }],
|
|
|
|
|
},
|
|
|
|
|
execute() {
|
|
|
|
|
objectBodyRan = true
|
|
|
|
|
return Promise.resolve({ ok: true })
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
let cached: ToolExecutionResult | undefined
|
|
|
|
|
ctx.on('tools/execute', async (exec, next) => {
|
|
|
|
|
if (exec.name === 'string-output') {
|
|
|
|
|
cached = await next()
|
|
|
|
|
return cached
|
|
|
|
|
}
|
|
|
|
|
if (exec.name === 'object-output') {
|
|
|
|
|
if (cached === undefined) throw new Error('expected the first dispatch result')
|
|
|
|
|
return cached
|
|
|
|
|
}
|
|
|
|
|
return next()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const first = await ctx.tools.execute({
|
|
|
|
|
signal: testToolSignal, callId: CallId('cached-first'), name: 'string-output', arguments: {},
|
|
|
|
|
})
|
|
|
|
|
const second = await ctx.tools.execute({
|
|
|
|
|
signal: testToolSignal, callId: CallId('cached-second'), name: 'object-output', arguments: {},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(first.isError ? undefined : first.value).toBe('cached')
|
|
|
|
|
expect(objectBodyRan).toBe(false)
|
|
|
|
|
expect(second).toMatchObject({
|
|
|
|
|
isError: true,
|
|
|
|
|
error: { info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } },
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-13 14:37:32 +08:00
|
|
|
it('preserves additionalContexts supplied by an around-dispatch result', async () => {
|
2026-07-11 22:55:26 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
2026-07-14 02:54:30 +08:00
|
|
|
ctx.on('tools/execute', async () => ({
|
2026-07-11 22:55:26 +08:00
|
|
|
content: [{ type: 'text', text: 'short-circuited with context' }],
|
|
|
|
|
isError: false,
|
2026-07-21 03:08:35 +08:00
|
|
|
value: 'short-circuited with context',
|
2026-07-28 13:55:59 +08:00
|
|
|
additionalContexts: [createUserMessage({
|
2026-07-11 22:55:26 +08:00
|
|
|
content: [{ type: 'text', text: 'from around dispatch' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'test' },
|
2026-07-28 13:55:59 +08:00
|
|
|
})],
|
2026-07-11 22:55:26 +08:00
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const result = await ctx.tools.execute({
|
2026-07-19 23:38:54 +08:00
|
|
|
signal: testToolSignal,
|
2026-07-11 22:55:26 +08:00
|
|
|
callId: CallId('around-context'), name: 'echo', arguments: {},
|
|
|
|
|
})
|
2026-07-13 14:37:32 +08:00
|
|
|
expect(result.additionalContexts).toEqual([{
|
2026-07-28 14:15:23 +08:00
|
|
|
id: expect.any(String) as unknown,
|
|
|
|
|
role: 'user',
|
2026-07-11 22:55:26 +08:00
|
|
|
content: [{ type: 'text', text: 'from around dispatch' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'test' },
|
2026-07-13 14:37:32 +08:00
|
|
|
}])
|
2026-07-11 22:55:26 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-08 10:06:07 +08:00
|
|
|
it('returns an isError result when a tools/execute listener throws', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
ctx.on('tools/execute', async () => { throw new Error('wrapper broke') })
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
2026-07-08 10:06:07 +08:00
|
|
|
expect(result).toEqual({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: wrapper broke' }],
|
2026-07-21 03:08:35 +08:00
|
|
|
error: { message: 'wrapper broke' },
|
2026-07-08 10:06:07 +08:00
|
|
|
isError: true,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-30 17:11:18 +08:00
|
|
|
it('returns an isError result when a tools/pre-execute listener throws', async () => {
|
2026-06-17 21:26:12 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
2026-06-30 17:11:18 +08:00
|
|
|
ctx.on('tools/pre-execute', async () => {
|
2026-06-17 21:26:12 +08:00
|
|
|
throw new Error('permission hook broke')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
2026-06-17 21:26:12 +08:00
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: permission hook broke' }],
|
2026-07-21 03:08:35 +08:00
|
|
|
error: { message: 'permission hook broke' },
|
2026-06-17 21:26:12 +08:00
|
|
|
isError: true,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-30 17:11:18 +08:00
|
|
|
it('returns an isError result when a tools/post-execute listener throws', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
ctx.on('tools/post-execute', async () => {
|
|
|
|
|
throw new Error('post hook broke')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
2026-06-30 17:11:18 +08:00
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
content: [{ type: 'text', text: 'Error: post hook broke' }],
|
2026-07-21 03:08:35 +08:00
|
|
|
error: { message: 'post hook broke' },
|
2026-06-30 17:11:18 +08:00
|
|
|
isError: true,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('preserves structured error info when a tools/pre-execute listener throws HarnessError', async () => {
|
2026-06-17 21:31:54 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
2026-06-30 17:11:18 +08:00
|
|
|
ctx.on('tools/pre-execute', async () => {
|
2026-06-17 21:31:54 +08:00
|
|
|
throw new HarnessError('denied', 'DENIED')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
2026-06-17 21:31:54 +08:00
|
|
|
|
|
|
|
|
expect(result).toMatchObject({
|
|
|
|
|
isError: true,
|
2026-07-21 03:08:35 +08:00
|
|
|
error: { message: 'denied', info: { name: 'HarnessError', code: 'DENIED' } },
|
2026-06-17 21:31:54 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-17 21:26:12 +08:00
|
|
|
it('schemas() snapshots tool schemas instead of exposing registry objects', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
|
|
|
|
|
const first = ctx.tools.schemas()
|
|
|
|
|
const firstParameters = first[0]!.parameters as { properties: Record<string, unknown> }
|
|
|
|
|
firstParameters.properties['mutated'] = { type: 'string' }
|
|
|
|
|
first[0]!.description = 'mutated'
|
|
|
|
|
|
|
|
|
|
expect(ctx.tools.schemas()).toEqual([{
|
|
|
|
|
name: 'echo',
|
|
|
|
|
description: 'echo arguments back',
|
|
|
|
|
parameters: { type: 'object', properties: { text: { type: 'string' } } },
|
|
|
|
|
}])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-22 20:51:13 +08:00
|
|
|
it('schemas() snapshots deeply nested parameters without using structured-clone recursion', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const depth = 5_000
|
|
|
|
|
let nested: JsonSchemaNode = { type: 'string' }
|
|
|
|
|
for (let index = 0; index < depth; index++) nested = { oneOf: [nested, { type: 'null' }] }
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'deep-schema',
|
|
|
|
|
parameters: { type: 'object', properties: { nested } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const projected = ctx.tools.schemas()[0]!.parameters as JsonSchemaNode
|
|
|
|
|
|
|
|
|
|
let cursor = projected.properties!.nested!
|
|
|
|
|
let layers = 0
|
|
|
|
|
while (cursor.oneOf !== undefined) {
|
|
|
|
|
cursor = cursor.oneOf[0]!
|
|
|
|
|
layers++
|
|
|
|
|
}
|
|
|
|
|
expect(layers).toBe(depth)
|
|
|
|
|
expect(cursor).toEqual({ type: 'string' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects schema projection when a raw registration is not lossless JSON', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'lossy-schema',
|
|
|
|
|
parameters: { type: 'object', default: Number.NaN },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(() => ctx.tools.schemas())
|
|
|
|
|
.toThrow('tool "lossy-schema" parameters must be lossless JSON before schema projection')
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-12 22:39:01 +08:00
|
|
|
it('rejects a non-positive or non-finite registration timeout', async () => {
|
2026-07-12 03:51:55 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
expect(() => ctx.tools.register({ ...echoTool, name: 'zero-timeout', timeoutMs: 0 }))
|
|
|
|
|
.toThrow('timeoutMs must be a positive finite number')
|
2026-07-12 22:39:01 +08:00
|
|
|
expect(() => ctx.tools.register({ ...echoTool, name: 'infinite-timeout', timeoutMs: Number.POSITIVE_INFINITY }))
|
|
|
|
|
.toThrow('timeoutMs must be a positive finite number')
|
2026-07-11 22:55:26 +08:00
|
|
|
})
|
|
|
|
|
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
expect(() => ctx.tools.register(echoTool)).toThrow('already registered')
|
|
|
|
|
|
|
|
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
|
|
|
inner.tools.register({ ...echoTool, name: 'scoped' })
|
|
|
|
|
}, { inject: ['tools'] }))
|
|
|
|
|
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'scoped'])
|
|
|
|
|
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
|
|
|
|
|
})
|
Enforce 100% per-file test coverage on packages/*/src
vitest coverage (v8 provider) with per-file 100% thresholds for
statements, branches, functions, and lines. Scope: our runtime source
only — types-only files, vendor/ (upstream code), and examples/
(exercised by the demo smoke test) are excluded. yarn test:coverage
runs the gate.
59 tests added to close every gap: llm generate-waterfall and adapter
disposal; assembler edge protocol (duplicate block-start, stragglers
after block-end, id fallback, usage omission, invariant violation);
the whole Inbox surface incl. the wakeup-overwrite race; LoopAgent
disposed-state throws and double-stop idempotence; config-driven agent
creation; loop backstop catches (throwing turn-start/turn-end
listeners, non-Error throws, non-JSON tool arguments); system-prompt
dynamic sections and disposer paths; tools errorMessage fallbacks and
the full schema-DSL emission matrix. Genuinely unreachable defensive
guards carry /* v8 ignore */ comments with stated reasons rather than
deletion (132 tests total).
2026-06-11 14:58:36 +08:00
|
|
|
|
|
|
|
|
it('returns a callable disposer from register() that unregisters the tool', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
|
|
|
|
|
const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' })
|
|
|
|
|
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable'])
|
|
|
|
|
|
|
|
|
|
dispose()
|
|
|
|
|
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
|
|
|
|
|
})
|
fix: make the six registration methods atomic under a throwing change-listener (P1-1)
llm.registerAdapter, agents.register, sessions.create, systemPrompt.section,
systemPrompt.tools, and tools.register each mutated state, emitted a change
event, then returned the disposer. In Cordis a synchronous throw before the
effect returns its disposer leaves nothing for the fiber to collect, so a
throwing change-listener leaked the registry entry permanently — HMR/dispose
could not clean it, and the duplicate-name/already-exists check stayed wedged
until restart.
Convert each to the generator-effect pattern already proven in
AgentLoop.create: mutate state, `yield` the disposer that undoes it (collected
before the next step runs, so it is torn down if a later step throws), THEN
emit the change event. The existing duplicate-name throws are unchanged — they
fire before any mutation, so they correctly leak nothing. No public API change:
generator effects are still synchronous SyncEffects and register() keeps
returning its fire-and-forget disposer wrapper.
Tests: a listener-throw rollback test for all six methods — register with a
change-listener that throws, assert the call throws AND the registry is clean
(entry absent; a subsequent listener-free register of the same name succeeds
and contributes exactly once). For systemPrompt (no duplicate-name check) the
two tests assert assembly is clean. Verified each fails against the pre-fix
emit-before-return-disposer form.
2026-06-15 00:25:17 +08:00
|
|
|
|
|
|
|
|
it('rolls back the tool entry when a tools/change listener throws (P1-1)', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
|
|
|
|
|
let threw = false
|
|
|
|
|
ctx.on('tools/change', () => {
|
|
|
|
|
if (!threw) { threw = true; throw new Error('boom change listener') }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// The throwing emit must roll the entry back, not leak it.
|
|
|
|
|
expect(() => ctx.tools.register(echoTool)).toThrow('boom change listener')
|
|
|
|
|
expect(ctx.tools.get('echo')).toBeUndefined() // rolled back, not leaked
|
|
|
|
|
expect(ctx.tools.schemas()).toHaveLength(0)
|
|
|
|
|
|
|
|
|
|
// A subsequent listener-free register of the SAME name succeeds and is
|
|
|
|
|
// exposed exactly once (the duplicate-name check is not wedged).
|
|
|
|
|
const dispose = ctx.tools.register(echoTool)
|
|
|
|
|
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
|
|
|
|
|
dispose()
|
|
|
|
|
expect(ctx.tools.get('echo')).toBeUndefined()
|
|
|
|
|
})
|
refactor(core): every registry register-method returns the exact effect disposer
The exact-disposer fix (5fbac8be B1) repaired agents.register but left the
same wrapper (return () => void dispose()) at seven sibling sites:
tools.register, tools.restrict, systemPrompt.section/tools/variable,
agents.setFactory, and subagents.registerProvider. A wrapper makes correct
composite usage unrepresentable — the exact disposer cannot be recovered, so
a generator effect yielding it leaves the inner effect disposing as a
CONCURRENT SIBLING on owner unload, silently reproducing B1's ordering
corruption. The exact disposer serves both usages (composite-nestable AND
fire-and-forget callable); all seven now return it, typed
() => Promise<void> | void, with the convention pinned by a discriminating
test: an async-link composite probe that passes with the exact disposer and
observes the sibling unregistration firing mid-drain with a wrapper.
Re-auditing also surfaced that B1 itself SHIPPED a full-lint failure: it
changed register()'s return type without updating cross-file consumers
(agent.spec.ts dispose() statements, tool-bash's disposer list), which the
staged-scoped pre-commit lint never saw — pnpm run lint was red at HEAD.
Those three sites and this change's own fallout are fixed together: tests
now await disposers (stronger — they observe the full unwind), sync
paths void them, and the two annotation sites carry the honest union type.
agents.register's README line had drifted the same way (B1 updated the
JSDoc, not the README) — all seven README signatures now match; services
catalog regenerated.
2026-07-09 13:05:44 +08:00
|
|
|
|
|
|
|
|
it('register() returns the EXACT effect disposer: a composite yield nests the teardown in order', async () => {
|
2026-07-13 23:27:00 +08:00
|
|
|
// Registry methods return the exact Cordis effect disposer so a composite yield places
|
|
|
|
|
// unregistration at its LIFO position. A wrapper would create a concurrent sibling; this async
|
|
|
|
|
// probe yields during earlier teardown and would then observe the tool already removed.
|
refactor(core): every registry register-method returns the exact effect disposer
The exact-disposer fix (5fbac8be B1) repaired agents.register but left the
same wrapper (return () => void dispose()) at seven sibling sites:
tools.register, tools.restrict, systemPrompt.section/tools/variable,
agents.setFactory, and subagents.registerProvider. A wrapper makes correct
composite usage unrepresentable — the exact disposer cannot be recovered, so
a generator effect yielding it leaves the inner effect disposing as a
CONCURRENT SIBLING on owner unload, silently reproducing B1's ordering
corruption. The exact disposer serves both usages (composite-nestable AND
fire-and-forget callable); all seven now return it, typed
() => Promise<void> | void, with the convention pinned by a discriminating
test: an async-link composite probe that passes with the exact disposer and
observes the sibling unregistration firing mid-drain with a wrapper.
Re-auditing also surfaced that B1 itself SHIPPED a full-lint failure: it
changed register()'s return type without updating cross-file consumers
(agent.spec.ts dispose() statements, tool-bash's disposer list), which the
staged-scoped pre-commit lint never saw — pnpm run lint was red at HEAD.
Those three sites and this change's own fallout are fixed together: tests
now await disposers (stronger — they observe the full unwind), sync
paths void them, and the two annotation sites carry the honest union type.
agents.register's README line had drifted the same way (B1 updated the
JSDoc, not the README) — all seven README signatures now match; services
catalog regenerated.
2026-07-09 13:05:44 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
const order: string[] = []
|
|
|
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
|
|
|
inner.effect(function* () {
|
|
|
|
|
yield () => { order.push('disposed-last') }
|
|
|
|
|
yield inner.tools.register({ ...echoTool, name: 'nested' })
|
|
|
|
|
order.push('registered')
|
|
|
|
|
yield async () => {
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
|
|
order.push(inner.tools.get('nested') ? 'first: still registered' : 'first: already gone')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}, { inject: ['tools'] }))
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
expect(order).toEqual(['registered', 'first: still registered', 'disposed-last'])
|
|
|
|
|
expect(ctx.tools.get('nested')).toBeUndefined()
|
|
|
|
|
})
|
Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.
@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.
@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.
@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).
@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00
|
|
|
})
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
|
|
|
|
|
describe('defineTool / schema DSL', () => {
|
2026-07-21 01:11:55 +08:00
|
|
|
it('converts ParameterSchemaSpec to standard JSON Schema with required array', () => {
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
const spec = {
|
2026-06-11 13:46:01 +08:00
|
|
|
path: { type: 'string', required: true, description: 'Absolute path' },
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
offset: { type: 'number' },
|
|
|
|
|
limit: { type: 'number', description: 'Max lines' },
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
|
|
|
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
expect(jsonSchema).toEqual({
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
path: { type: 'string', description: 'Absolute path' },
|
|
|
|
|
offset: { type: 'number' },
|
|
|
|
|
limit: { type: 'number', description: 'Max lines' },
|
|
|
|
|
},
|
|
|
|
|
required: ['path'],
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('handles empty spec (no properties, no required)', () => {
|
2026-07-21 01:11:55 +08:00
|
|
|
expect(parameterSchemaSpecToJsonSchema({})).toEqual({
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
type: 'object',
|
|
|
|
|
properties: {},
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('handles nested object spec', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
config: {
|
2026-06-11 13:46:01 +08:00
|
|
|
type: 'object',
|
2026-07-21 01:11:55 +08:00
|
|
|
additionalProperties: true,
|
2026-06-11 13:46:01 +08:00
|
|
|
required: true,
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
properties: {
|
2026-06-11 13:46:01 +08:00
|
|
|
host: { type: 'string', required: true },
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
port: { type: 'number' },
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
|
|
|
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
expect(jsonSchema).toEqual({
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
config: {
|
|
|
|
|
type: 'object',
|
2026-07-21 01:11:55 +08:00
|
|
|
additionalProperties: true,
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
properties: {
|
|
|
|
|
host: { type: 'string' },
|
|
|
|
|
port: { type: 'number' },
|
|
|
|
|
},
|
|
|
|
|
required: ['host'],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
required: ['config'],
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('defineTool returns a valid ToolDefinition with typed execute', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const tool = defineTool({
|
|
|
|
|
name: 'typed-echo',
|
|
|
|
|
description: 'A typed echo tool',
|
|
|
|
|
parameters: {
|
|
|
|
|
text: { type: 'string', required: true },
|
|
|
|
|
uppercase: { type: 'boolean' },
|
|
|
|
|
},
|
2026-07-21 03:08:35 +08:00
|
|
|
output: {
|
|
|
|
|
schema: { type: 'string' },
|
|
|
|
|
render: (_args, value) => [{ type: 'text', text: value }],
|
|
|
|
|
},
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
async execute(args) {
|
|
|
|
|
// args is typed: { text: string; uppercase?: boolean }
|
|
|
|
|
const result = args.uppercase ? args.text.toUpperCase() : args.text
|
2026-07-21 03:08:35 +08:00
|
|
|
return result
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
ctx.tools.register(tool)
|
|
|
|
|
expect(ctx.tools.schemas()).toEqual([{
|
|
|
|
|
name: 'typed-echo',
|
|
|
|
|
description: 'A typed echo tool',
|
|
|
|
|
parameters: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
text: { type: 'string' },
|
|
|
|
|
uppercase: { type: 'boolean' },
|
|
|
|
|
},
|
|
|
|
|
required: ['text'],
|
|
|
|
|
},
|
|
|
|
|
}])
|
|
|
|
|
|
|
|
|
|
const result = await ctx.tools.execute({
|
2026-07-19 23:38:54 +08:00
|
|
|
signal: testToolSignal,
|
2026-06-11 15:17:56 +08:00
|
|
|
callId: CallId('c1'),
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
name: 'typed-echo',
|
|
|
|
|
arguments: { text: 'hello', uppercase: true },
|
|
|
|
|
})
|
|
|
|
|
expect(result.isError).toBe(false)
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result.isError ? undefined : result.value).toBe('HELLO')
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('type-level: InferArgs maps required properties to non-optional', () => {
|
|
|
|
|
// Compile-time check: if this compiles, InferArgs is correct.
|
|
|
|
|
// args.a is string (required), args.b is number|undefined (optional).
|
|
|
|
|
const tool = defineTool({
|
|
|
|
|
name: 'type-check',
|
|
|
|
|
description: '',
|
|
|
|
|
parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } },
|
2026-07-21 03:08:35 +08:00
|
|
|
output: { schema: { type: 'string' }, render: () => [] },
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
async execute(args) {
|
|
|
|
|
expect(typeof args.a).toBe('string')
|
|
|
|
|
void args
|
2026-07-21 03:08:35 +08:00
|
|
|
return args.a
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
void tool
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('registry round-trips a defineTool definition (register→schemas→execute)', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(defineTool({
|
|
|
|
|
name: 'roundtrip',
|
|
|
|
|
description: 'Round-trip test',
|
|
|
|
|
parameters: {
|
|
|
|
|
req: { type: 'string', required: true },
|
|
|
|
|
opt: { type: 'number', description: 'Optional number' },
|
|
|
|
|
},
|
2026-07-21 03:08:35 +08:00
|
|
|
output: {
|
|
|
|
|
schema: { type: 'string' },
|
|
|
|
|
render: (_args, value) => [{ type: 'text', text: value }],
|
|
|
|
|
},
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
async execute(args) {
|
2026-07-21 03:08:35 +08:00
|
|
|
return `${args.req}:${args.opt ?? 'none'}`
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Schema round-trip: schemas() returns standard JSON Schema
|
|
|
|
|
const schemas = ctx.tools.schemas()
|
|
|
|
|
expect(schemas).toHaveLength(1)
|
Enable maximum-strict TypeScript across our packages
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.
Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
2026-06-11 14:02:47 +08:00
|
|
|
expect(schemas[0]!.parameters).toEqual({
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
req: { type: 'string' },
|
|
|
|
|
opt: { type: 'number', description: 'Optional number' },
|
|
|
|
|
},
|
|
|
|
|
required: ['req'],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Execution round-trip
|
|
|
|
|
const result = await ctx.tools.execute({
|
2026-07-19 23:38:54 +08:00
|
|
|
signal: testToolSignal,
|
2026-06-11 15:17:56 +08:00
|
|
|
callId: CallId('c1'),
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
name: 'roundtrip',
|
|
|
|
|
arguments: { req: 'hello' },
|
|
|
|
|
})
|
|
|
|
|
expect(result.isError).toBe(false)
|
|
|
|
|
expect(result.content).toEqual([{ type: 'text', text: 'hello:none' }])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('still accepts raw JSON-Schema ToolDefinition directly (MCP interop)', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
name: 'raw-tool',
|
|
|
|
|
description: 'Raw JSON Schema tool (like an MCP adapter would register)',
|
|
|
|
|
parameters: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { path: { type: 'string' } },
|
|
|
|
|
required: ['path'],
|
|
|
|
|
},
|
2026-07-21 03:08:35 +08:00
|
|
|
output: {
|
|
|
|
|
schema: { type: 'string' },
|
|
|
|
|
render: (_args, value) => [{ type: 'text', text: value as string }],
|
|
|
|
|
},
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
async execute(args: unknown) {
|
|
|
|
|
const p = args as { path: string }
|
2026-07-21 03:08:35 +08:00
|
|
|
return p.path
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const schemas = ctx.tools.schemas()
|
Enable maximum-strict TypeScript across our packages
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.
Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
2026-06-11 14:02:47 +08:00
|
|
|
expect(schemas[0]!.parameters).toEqual({
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
type: 'object',
|
|
|
|
|
properties: { path: { type: 'string' } },
|
|
|
|
|
required: ['path'],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const result = await ctx.tools.execute({
|
2026-07-19 23:38:54 +08:00
|
|
|
signal: testToolSignal,
|
2026-06-11 15:17:56 +08:00
|
|
|
callId: CallId('c1'),
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
name: 'raw-tool',
|
|
|
|
|
arguments: { path: '/tmp' },
|
|
|
|
|
})
|
|
|
|
|
expect(result.isError).toBe(false)
|
|
|
|
|
expect(result.content).toEqual([{ type: 'text', text: '/tmp' }])
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-06-11 13:46:01 +08:00
|
|
|
|
Enforce 100% per-file test coverage on packages/*/src
vitest coverage (v8 provider) with per-file 100% thresholds for
statements, branches, functions, and lines. Scope: our runtime source
only — types-only files, vendor/ (upstream code), and examples/
(exercised by the demo smoke test) are excluded. yarn test:coverage
runs the gate.
59 tests added to close every gap: llm generate-waterfall and adapter
disposal; assembler edge protocol (duplicate block-start, stragglers
after block-end, id fallback, usage omission, invariant violation);
the whole Inbox surface incl. the wakeup-overwrite race; LoopAgent
disposed-state throws and double-stop idempotence; config-driven agent
creation; loop backstop catches (throwing turn-start/turn-end
listeners, non-Error throws, non-JSON tool arguments); system-prompt
dynamic sections and disposer paths; tools errorMessage fallbacks and
the full schema-DSL emission matrix. Genuinely unreachable defensive
guards carry /* v8 ignore */ comments with stated reasons rather than
deletion (132 tests total).
2026-06-11 14:58:36 +08:00
|
|
|
describe('schema DSL edge cases', () => {
|
|
|
|
|
it('emits enum values in JSON Schema property', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
color: { type: 'string', enum: ['red', 'green', 'blue'], description: 'Color choice' },
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
|
|
|
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
Enforce 100% per-file test coverage on packages/*/src
vitest coverage (v8 provider) with per-file 100% thresholds for
statements, branches, functions, and lines. Scope: our runtime source
only — types-only files, vendor/ (upstream code), and examples/
(exercised by the demo smoke test) are excluded. yarn test:coverage
runs the gate.
59 tests added to close every gap: llm generate-waterfall and adapter
disposal; assembler edge protocol (duplicate block-start, stragglers
after block-end, id fallback, usage omission, invariant violation);
the whole Inbox surface incl. the wakeup-overwrite race; LoopAgent
disposed-state throws and double-stop idempotence; config-driven agent
creation; loop backstop catches (throwing turn-start/turn-end
listeners, non-Error throws, non-JSON tool arguments); system-prompt
dynamic sections and disposer paths; tools errorMessage fallbacks and
the full schema-DSL emission matrix. Genuinely unreachable defensive
guards carry /* v8 ignore */ comments with stated reasons rather than
deletion (132 tests total).
2026-06-11 14:58:36 +08:00
|
|
|
expect(jsonSchema.properties['color']).toMatchObject({
|
|
|
|
|
type: 'string',
|
|
|
|
|
enum: ['red', 'green', 'blue'],
|
|
|
|
|
description: 'Color choice',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('emits default value in JSON Schema property', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
limit: { type: 'number', default: 25 },
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
|
|
|
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
Enforce 100% per-file test coverage on packages/*/src
vitest coverage (v8 provider) with per-file 100% thresholds for
statements, branches, functions, and lines. Scope: our runtime source
only — types-only files, vendor/ (upstream code), and examples/
(exercised by the demo smoke test) are excluded. yarn test:coverage
runs the gate.
59 tests added to close every gap: llm generate-waterfall and adapter
disposal; assembler edge protocol (duplicate block-start, stragglers
after block-end, id fallback, usage omission, invariant violation);
the whole Inbox surface incl. the wakeup-overwrite race; LoopAgent
disposed-state throws and double-stop idempotence; config-driven agent
creation; loop backstop catches (throwing turn-start/turn-end
listeners, non-Error throws, non-JSON tool arguments); system-prompt
dynamic sections and disposer paths; tools errorMessage fallbacks and
the full schema-DSL emission matrix. Genuinely unreachable defensive
guards carry /* v8 ignore */ comments with stated reasons rather than
deletion (132 tests total).
2026-06-11 14:58:36 +08:00
|
|
|
expect(jsonSchema.properties['limit']).toMatchObject({
|
|
|
|
|
type: 'number',
|
|
|
|
|
default: 25,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('handles array items without nested properties (plain type array)', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
tags: { type: 'array', items: { type: 'string' } },
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
|
|
|
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
Enforce 100% per-file test coverage on packages/*/src
vitest coverage (v8 provider) with per-file 100% thresholds for
statements, branches, functions, and lines. Scope: our runtime source
only — types-only files, vendor/ (upstream code), and examples/
(exercised by the demo smoke test) are excluded. yarn test:coverage
runs the gate.
59 tests added to close every gap: llm generate-waterfall and adapter
disposal; assembler edge protocol (duplicate block-start, stragglers
after block-end, id fallback, usage omission, invariant violation);
the whole Inbox surface incl. the wakeup-overwrite race; LoopAgent
disposed-state throws and double-stop idempotence; config-driven agent
creation; loop backstop catches (throwing turn-start/turn-end
listeners, non-Error throws, non-JSON tool arguments); system-prompt
dynamic sections and disposer paths; tools errorMessage fallbacks and
the full schema-DSL emission matrix. Genuinely unreachable defensive
guards carry /* v8 ignore */ comments with stated reasons rather than
deletion (132 tests total).
2026-06-11 14:58:36 +08:00
|
|
|
expect(jsonSchema.properties['tags']).toEqual({
|
|
|
|
|
type: 'array',
|
|
|
|
|
items: { type: 'string' },
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('handles enum and default together in one property', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
level: { type: 'string', enum: ['low', 'high'], default: 'low' },
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
|
|
|
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
Enforce 100% per-file test coverage on packages/*/src
vitest coverage (v8 provider) with per-file 100% thresholds for
statements, branches, functions, and lines. Scope: our runtime source
only — types-only files, vendor/ (upstream code), and examples/
(exercised by the demo smoke test) are excluded. yarn test:coverage
runs the gate.
59 tests added to close every gap: llm generate-waterfall and adapter
disposal; assembler edge protocol (duplicate block-start, stragglers
after block-end, id fallback, usage omission, invariant violation);
the whole Inbox surface incl. the wakeup-overwrite race; LoopAgent
disposed-state throws and double-stop idempotence; config-driven agent
creation; loop backstop catches (throwing turn-start/turn-end
listeners, non-Error throws, non-JSON tool arguments); system-prompt
dynamic sections and disposer paths; tools errorMessage fallbacks and
the full schema-DSL emission matrix. Genuinely unreachable defensive
guards carry /* v8 ignore */ comments with stated reasons rather than
deletion (132 tests total).
2026-06-11 14:58:36 +08:00
|
|
|
expect(jsonSchema.properties['level']).toMatchObject({
|
|
|
|
|
type: 'string',
|
|
|
|
|
enum: ['low', 'high'],
|
|
|
|
|
default: 'low',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('omits description, enum, default keys when not specified', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
bare: { type: 'string' },
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
|
|
|
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
Enforce 100% per-file test coverage on packages/*/src
vitest coverage (v8 provider) with per-file 100% thresholds for
statements, branches, functions, and lines. Scope: our runtime source
only — types-only files, vendor/ (upstream code), and examples/
(exercised by the demo smoke test) are excluded. yarn test:coverage
runs the gate.
59 tests added to close every gap: llm generate-waterfall and adapter
disposal; assembler edge protocol (duplicate block-start, stragglers
after block-end, id fallback, usage omission, invariant violation);
the whole Inbox surface incl. the wakeup-overwrite race; LoopAgent
disposed-state throws and double-stop idempotence; config-driven agent
creation; loop backstop catches (throwing turn-start/turn-end
listeners, non-Error throws, non-JSON tool arguments); system-prompt
dynamic sections and disposer paths; tools errorMessage fallbacks and
the full schema-DSL emission matrix. Genuinely unreachable defensive
guards carry /* v8 ignore */ comments with stated reasons rather than
deletion (132 tests total).
2026-06-11 14:58:36 +08:00
|
|
|
const prop = jsonSchema.properties['bare'] as Record<string, unknown>
|
|
|
|
|
expect(prop).toEqual({ type: 'string' })
|
|
|
|
|
expect('description' in prop).toBe(false)
|
|
|
|
|
expect('enum' in prop).toBe(false)
|
|
|
|
|
expect('default' in prop).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('handles array with no items (items omitted)', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
raw: { type: 'array' },
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
|
|
|
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
Enforce 100% per-file test coverage on packages/*/src
vitest coverage (v8 provider) with per-file 100% thresholds for
statements, branches, functions, and lines. Scope: our runtime source
only — types-only files, vendor/ (upstream code), and examples/
(exercised by the demo smoke test) are excluded. yarn test:coverage
runs the gate.
59 tests added to close every gap: llm generate-waterfall and adapter
disposal; assembler edge protocol (duplicate block-start, stragglers
after block-end, id fallback, usage omission, invariant violation);
the whole Inbox surface incl. the wakeup-overwrite race; LoopAgent
disposed-state throws and double-stop idempotence; config-driven agent
creation; loop backstop catches (throwing turn-start/turn-end
listeners, non-Error throws, non-JSON tool arguments); system-prompt
dynamic sections and disposer paths; tools errorMessage fallbacks and
the full schema-DSL emission matrix. Genuinely unreachable defensive
guards carry /* v8 ignore */ comments with stated reasons rather than
deletion (132 tests total).
2026-06-11 14:58:36 +08:00
|
|
|
expect(jsonSchema.properties['raw']).toEqual({
|
|
|
|
|
type: 'array',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('handles nested object with all-optional properties (no required array)', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
config: {
|
|
|
|
|
type: 'object',
|
2026-07-21 01:11:55 +08:00
|
|
|
additionalProperties: true,
|
Enforce 100% per-file test coverage on packages/*/src
vitest coverage (v8 provider) with per-file 100% thresholds for
statements, branches, functions, and lines. Scope: our runtime source
only — types-only files, vendor/ (upstream code), and examples/
(exercised by the demo smoke test) are excluded. yarn test:coverage
runs the gate.
59 tests added to close every gap: llm generate-waterfall and adapter
disposal; assembler edge protocol (duplicate block-start, stragglers
after block-end, id fallback, usage omission, invariant violation);
the whole Inbox surface incl. the wakeup-overwrite race; LoopAgent
disposed-state throws and double-stop idempotence; config-driven agent
creation; loop backstop catches (throwing turn-start/turn-end
listeners, non-Error throws, non-JSON tool arguments); system-prompt
dynamic sections and disposer paths; tools errorMessage fallbacks and
the full schema-DSL emission matrix. Genuinely unreachable defensive
guards carry /* v8 ignore */ comments with stated reasons rather than
deletion (132 tests total).
2026-06-11 14:58:36 +08:00
|
|
|
properties: {
|
|
|
|
|
host: { type: 'string' },
|
|
|
|
|
port: { type: 'number' },
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
|
|
|
|
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
|
Enforce 100% per-file test coverage on packages/*/src
vitest coverage (v8 provider) with per-file 100% thresholds for
statements, branches, functions, and lines. Scope: our runtime source
only — types-only files, vendor/ (upstream code), and examples/
(exercised by the demo smoke test) are excluded. yarn test:coverage
runs the gate.
59 tests added to close every gap: llm generate-waterfall and adapter
disposal; assembler edge protocol (duplicate block-start, stragglers
after block-end, id fallback, usage omission, invariant violation);
the whole Inbox surface incl. the wakeup-overwrite race; LoopAgent
disposed-state throws and double-stop idempotence; config-driven agent
creation; loop backstop catches (throwing turn-start/turn-end
listeners, non-Error throws, non-JSON tool arguments); system-prompt
dynamic sections and disposer paths; tools errorMessage fallbacks and
the full schema-DSL emission matrix. Genuinely unreachable defensive
guards carry /* v8 ignore */ comments with stated reasons rather than
deletion (132 tests total).
2026-06-11 14:58:36 +08:00
|
|
|
expect(jsonSchema.properties['config']).toMatchObject({
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
host: { type: 'string' },
|
|
|
|
|
port: { type: 'number' },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
const config = jsonSchema.properties['config'] as Record<string, unknown>
|
|
|
|
|
expect('required' in config).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-13 23:27:00 +08:00
|
|
|
describe('schema DSL optional and nested contracts', () => {
|
2026-06-11 13:46:01 +08:00
|
|
|
it('InferArgs makes non-required keys genuinely optional (omittable)', () => {
|
|
|
|
|
type Args = InferArgs<{
|
|
|
|
|
path: { type: 'string'; required: true }
|
|
|
|
|
limit: { type: 'number' }
|
|
|
|
|
}>
|
|
|
|
|
expectTypeOf<Args>().toEqualTypeOf<{ path: string; limit?: number }>()
|
|
|
|
|
const omitted: Args = { path: '/tmp' }
|
|
|
|
|
expect(omitted.limit).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('InferArgs recurses into array items, including arrays of objects', () => {
|
|
|
|
|
type Args = InferArgs<{
|
|
|
|
|
names: { type: 'array'; required: true; items: { type: 'string' } }
|
|
|
|
|
servers: {
|
|
|
|
|
type: 'array'
|
|
|
|
|
items: {
|
|
|
|
|
type: 'object'
|
2026-07-21 01:11:55 +08:00
|
|
|
additionalProperties: true
|
2026-06-11 13:46:01 +08:00
|
|
|
properties: {
|
|
|
|
|
host: { type: 'string'; required: true }
|
|
|
|
|
port: { type: 'number' }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}>
|
|
|
|
|
expectTypeOf<Args>().toEqualTypeOf<{
|
|
|
|
|
names: string[]
|
2026-07-21 01:11:55 +08:00
|
|
|
servers?: ({ host: string; port?: number } & Record<string, JsonValue>)[]
|
2026-06-11 13:46:01 +08:00
|
|
|
}>()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('runtime JSON Schema matches the array-of-objects inference', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
servers: {
|
|
|
|
|
type: 'array',
|
|
|
|
|
items: {
|
|
|
|
|
type: 'object',
|
2026-07-21 01:11:55 +08:00
|
|
|
additionalProperties: true,
|
2026-06-11 13:46:01 +08:00
|
|
|
properties: {
|
|
|
|
|
host: { type: 'string', required: true },
|
|
|
|
|
port: { type: 'number' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
|
|
|
|
expect(parameterSchemaSpecToJsonSchema(spec)).toEqual({
|
2026-06-11 13:46:01 +08:00
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
servers: {
|
|
|
|
|
type: 'array',
|
|
|
|
|
items: {
|
|
|
|
|
type: 'object',
|
2026-07-21 01:11:55 +08:00
|
|
|
additionalProperties: true,
|
2026-06-11 13:46:01 +08:00
|
|
|
properties: {
|
|
|
|
|
host: { type: 'string' },
|
|
|
|
|
port: { type: 'number' },
|
|
|
|
|
},
|
|
|
|
|
required: ['host'],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports messages from non-Error throws (throw { message })', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'object-thrower',
|
|
|
|
|
async execute() {
|
Add ESLint: typescript-eslint strict-type-checked + stylistic formatting
Flat config with two layers. Correctness (type-checked): the headline
rules for this codebase are no-floating-promises / no-misused-promises
(a lost promise in the agent loop is our primary bug class),
switch-exhaustiveness-check (we switch over merge-extensible unions
everywhere), no-unnecessary-condition, require-await, and
no-explicit-any. Style (@stylistic): 2-space, no semicolons, single
quotes, trailing commas, max-len 140 — the existing house style, now
enforced instead of drifting between agents. vendor/ is excluded
(vendored source keeps upstream style); tests relax the rules that
fight test ergonomics (non-null assertions after expects, async mock
signatures, non-Error throws).
Code adjusted to pass: registry disposers wrap ctx.effect's
promise-returning disposer behind a sync () => void (our public API),
BlockAssembler gains an invariant-checking mustGet instead of non-null
assertions, lastTurnNumber uses findLast, waterfall tails return
Promise.resolve instead of async-without-await arrows, and the two
deliberate suppressions (non-exhaustive derivation switch, unbound
execute pass-through) carry justification comments.
yarn lint / yarn lint:fix added.
2026-06-11 14:17:58 +08:00
|
|
|
// testing non-Error throws on purpose
|
2026-06-11 13:46:01 +08:00
|
|
|
throw { message: 'denied by object' }
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'object-thrower', arguments: {} })
|
2026-06-11 13:46:01 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' })
|
|
|
|
|
})
|
Enforce 100% per-file test coverage on packages/*/src
vitest coverage (v8 provider) with per-file 100% thresholds for
statements, branches, functions, and lines. Scope: our runtime source
only — types-only files, vendor/ (upstream code), and examples/
(exercised by the demo smoke test) are excluded. yarn test:coverage
runs the gate.
59 tests added to close every gap: llm generate-waterfall and adapter
disposal; assembler edge protocol (duplicate block-start, stragglers
after block-end, id fallback, usage omission, invariant violation);
the whole Inbox surface incl. the wakeup-overwrite race; LoopAgent
disposed-state throws and double-stop idempotence; config-driven agent
creation; loop backstop catches (throwing turn-start/turn-end
listeners, non-Error throws, non-JSON tool arguments); system-prompt
dynamic sections and disposer paths; tools errorMessage fallbacks and
the full schema-DSL emission matrix. Genuinely unreachable defensive
guards carry /* v8 ignore */ comments with stated reasons rather than
deletion (132 tests total).
2026-06-11 14:58:36 +08:00
|
|
|
|
|
|
|
|
it('reports messages from throws of non-objects (throw "string")', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'string-thrower',
|
|
|
|
|
async execute() {
|
|
|
|
|
// testing primitive throws on purpose
|
|
|
|
|
throw 'kaboom'
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'string-thrower', arguments: {} })
|
Enforce 100% per-file test coverage on packages/*/src
vitest coverage (v8 provider) with per-file 100% thresholds for
statements, branches, functions, and lines. Scope: our runtime source
only — types-only files, vendor/ (upstream code), and examples/
(exercised by the demo smoke test) are excluded. yarn test:coverage
runs the gate.
59 tests added to close every gap: llm generate-waterfall and adapter
disposal; assembler edge protocol (duplicate block-start, stragglers
after block-end, id fallback, usage omission, invariant violation);
the whole Inbox surface incl. the wakeup-overwrite race; LoopAgent
disposed-state throws and double-stop idempotence; config-driven agent
creation; loop backstop catches (throwing turn-start/turn-end
listeners, non-Error throws, non-JSON tool arguments); system-prompt
dynamic sections and disposer paths; tools errorMessage fallbacks and
the full schema-DSL emission matrix. Genuinely unreachable defensive
guards carry /* v8 ignore */ comments with stated reasons rather than
deletion (132 tests total).
2026-06-11 14:58:36 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports messages from throws of objects without message property', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'object-no-message',
|
|
|
|
|
async execute() {
|
|
|
|
|
// testing object throw without .message
|
|
|
|
|
throw { code: 500 }
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'object-no-message', arguments: {} })
|
Enforce 100% per-file test coverage on packages/*/src
vitest coverage (v8 provider) with per-file 100% thresholds for
statements, branches, functions, and lines. Scope: our runtime source
only — types-only files, vendor/ (upstream code), and examples/
(exercised by the demo smoke test) are excluded. yarn test:coverage
runs the gate.
59 tests added to close every gap: llm generate-waterfall and adapter
disposal; assembler edge protocol (duplicate block-start, stragglers
after block-end, id fallback, usage omission, invariant violation);
the whole Inbox surface incl. the wakeup-overwrite race; LoopAgent
disposed-state throws and double-stop idempotence; config-driven agent
creation; loop backstop catches (throwing turn-start/turn-end
listeners, non-Error throws, non-JSON tool arguments); system-prompt
dynamic sections and disposer paths; tools errorMessage fallbacks and
the full schema-DSL emission matrix. Genuinely unreachable defensive
guards carry /* v8 ignore */ comments with stated reasons rather than
deletion (132 tests total).
2026-06-11 14:58:36 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
const firstContent = result.content[0]!
|
|
|
|
|
expect(firstContent.type).toBe('text')
|
|
|
|
|
if (firstContent.type === 'text') {
|
|
|
|
|
expect(firstContent.text).toBe('Error: [object Object]')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('ToolRegistry.get', () => {
|
|
|
|
|
it('get() returns the registered tool definition', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register(echoTool)
|
|
|
|
|
const tool = ctx.tools.get('echo')
|
|
|
|
|
expect(tool).toBeDefined()
|
|
|
|
|
expect(tool!.name).toBe('echo')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('get() returns undefined for unknown tool names', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
expect(ctx.tools.get('nope')).toBeUndefined()
|
|
|
|
|
})
|
2026-06-11 13:46:01 +08:00
|
|
|
})
|
2026-06-13 23:00:42 +08:00
|
|
|
|
2026-07-19 22:50:49 +08:00
|
|
|
describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
|
2026-06-13 23:00:42 +08:00
|
|
|
it('returns [] for valid args and is total over malformed input', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
path: { type: 'string', required: true },
|
|
|
|
|
limit: { type: 'number' },
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
2026-06-13 23:00:42 +08:00
|
|
|
expect(validateArgs(spec, { path: '/tmp' })).toEqual([])
|
|
|
|
|
expect(validateArgs(spec, { path: '/tmp', limit: 5 })).toEqual([])
|
|
|
|
|
// never throws regardless of shape
|
|
|
|
|
expect(validateArgs(spec, null)).toHaveLength(1)
|
|
|
|
|
expect(validateArgs(spec, 'nope')).toHaveLength(1)
|
|
|
|
|
expect(validateArgs(spec, [])).toHaveLength(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('flags a missing required key and a required key present as undefined', () => {
|
2026-07-21 01:11:55 +08:00
|
|
|
const spec = { path: { type: 'string', required: true } } satisfies ParameterSchemaSpec
|
2026-06-13 23:00:42 +08:00
|
|
|
expect(validateArgs(spec, {})).toEqual(['missing required property "path"'])
|
|
|
|
|
expect(validateArgs(spec, { path: undefined })).toEqual(['missing required property "path"'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('allows extra keys (no additionalProperties:false) and omitted optionals', () => {
|
2026-07-21 01:11:55 +08:00
|
|
|
const spec = { path: { type: 'string', required: true } } satisfies ParameterSchemaSpec
|
2026-06-13 23:00:42 +08:00
|
|
|
expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not apply defaults (validation only)', () => {
|
2026-07-21 01:11:55 +08:00
|
|
|
const spec = { limit: { type: 'number', default: 25 } } satisfies ParameterSchemaSpec
|
2026-06-13 23:00:42 +08:00
|
|
|
// absent optional is valid, and validation does not synthesize the default
|
|
|
|
|
expect(validateArgs(spec, {})).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('type-checks primitives', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
s: { type: 'string' },
|
|
|
|
|
n: { type: 'number' },
|
|
|
|
|
b: { type: 'boolean' },
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
2026-06-13 23:00:42 +08:00
|
|
|
expect(validateArgs(spec, { s: 1 })).toEqual(['"s" must be a string'])
|
|
|
|
|
expect(validateArgs(spec, { n: 'x' })).toEqual(['"n" must be a number'])
|
|
|
|
|
expect(validateArgs(spec, { b: 'x' })).toEqual(['"b" must be a boolean'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('checks enum membership', () => {
|
2026-07-21 01:11:55 +08:00
|
|
|
const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies ParameterSchemaSpec
|
2026-06-13 23:00:42 +08:00
|
|
|
expect(validateArgs(spec, { color: 'red' })).toEqual([])
|
|
|
|
|
expect(validateArgs(spec, { color: 'blue' })).toEqual(['"color" must be one of ["red","green"]'])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 01:11:55 +08:00
|
|
|
it('enforces type-correct scalar enum declarations', () => {
|
|
|
|
|
const spec = { n: { type: 'number', enum: [1, 2] } } satisfies ParameterSchemaSpec
|
|
|
|
|
expect(validateArgs(spec, { n: 1 })).toEqual([])
|
|
|
|
|
expect(validateArgs(spec, { n: 3 })).toEqual(['"n" must be one of [1,2]'])
|
|
|
|
|
const invalid = { n: { type: 'number', enum: ['1', '2'] } } as unknown as ParameterSchemaSpec
|
|
|
|
|
expect(() => validateArgs(invalid, { n: 1 })).toThrow(JsonSchemaError)
|
2026-06-13 23:11:48 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-21 01:11:55 +08:00
|
|
|
it('rejects an unknown schema type at the author boundary', () => {
|
|
|
|
|
const spec = { x: { type: 'weird' } } as unknown as ParameterSchemaSpec
|
|
|
|
|
expect(() => validateArgs(spec, { x: 1 })).toThrow(JsonSchemaError)
|
2026-06-13 23:11:48 +08:00
|
|
|
})
|
|
|
|
|
|
2026-06-13 23:00:42 +08:00
|
|
|
it('recurses into nested objects (and an object without properties only type-checks)', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
config: {
|
|
|
|
|
type: 'object',
|
2026-07-21 01:11:55 +08:00
|
|
|
additionalProperties: true,
|
2026-06-13 23:00:42 +08:00
|
|
|
required: true,
|
|
|
|
|
properties: { host: { type: 'string', required: true }, port: { type: 'number' } },
|
|
|
|
|
},
|
2026-07-21 01:11:55 +08:00
|
|
|
bag: { type: 'object', additionalProperties: true },
|
|
|
|
|
} satisfies ParameterSchemaSpec
|
2026-06-13 23:00:42 +08:00
|
|
|
expect(validateArgs(spec, { config: { host: 'h' }, bag: { anything: true } })).toEqual([])
|
|
|
|
|
expect(validateArgs(spec, { config: { port: 9 }, bag: 5 })).toEqual([
|
|
|
|
|
'missing required property "config.host"',
|
|
|
|
|
'"bag" must be an object',
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('recurses into array items (and an array without items only type-checks)', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
tags: { type: 'array', items: { type: 'string' } },
|
|
|
|
|
raw: { type: 'array' },
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
2026-06-13 23:00:42 +08:00
|
|
|
expect(validateArgs(spec, { tags: ['a', 'b'], raw: [1, {}, 'x'] })).toEqual([])
|
|
|
|
|
expect(validateArgs(spec, { tags: ['a', 2] })).toEqual(['"tags[1]" must be a string'])
|
|
|
|
|
// a non-array value for an array-typed prop
|
|
|
|
|
expect(validateArgs(spec, { tags: 'nope' })).toEqual(['"tags" must be an array'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('validates arrays of objects element-wise', () => {
|
|
|
|
|
const spec = {
|
|
|
|
|
servers: {
|
|
|
|
|
type: 'array',
|
2026-07-21 01:11:55 +08:00
|
|
|
items: { type: 'object', additionalProperties: true, properties: { host: { type: 'string', required: true } } },
|
2026-06-13 23:00:42 +08:00
|
|
|
},
|
2026-07-21 01:11:55 +08:00
|
|
|
} satisfies ParameterSchemaSpec
|
2026-06-13 23:00:42 +08:00
|
|
|
expect(validateArgs(spec, { servers: [{ host: 'a' }, {}] })).toEqual([
|
|
|
|
|
'missing required property "servers[1].host"',
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 22:50:49 +08:00
|
|
|
describe('defineTool validation (the runtime-validation Agent Note, part 1)', () => {
|
2026-06-13 23:00:42 +08:00
|
|
|
it('returns an isError result with the violations when the model sends bad args', async () => {
|
|
|
|
|
const ctx = await setup()
|
2026-07-21 03:08:35 +08:00
|
|
|
ctx.tools.register(defineContentToolFixture({
|
2026-06-13 23:00:42 +08:00
|
|
|
name: 'reader',
|
|
|
|
|
description: 'reads a path',
|
|
|
|
|
parameters: { path: { type: 'string', required: true } },
|
|
|
|
|
async execute(args) {
|
|
|
|
|
return [{ type: 'text', text: args.path }]
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: {} })
|
2026-06-13 23:00:42 +08:00
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content[0]).toMatchObject({
|
|
|
|
|
text: 'Error: invalid arguments: missing required property "path"',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('runs execute normally when args are valid', async () => {
|
|
|
|
|
const ctx = await setup()
|
2026-07-21 03:08:35 +08:00
|
|
|
ctx.tools.register(defineContentToolFixture({
|
2026-06-13 23:00:42 +08:00
|
|
|
name: 'reader',
|
|
|
|
|
description: 'reads a path',
|
|
|
|
|
parameters: { path: { type: 'string', required: true } },
|
|
|
|
|
async execute(args) {
|
|
|
|
|
return [{ type: 'text', text: `read ${args.path}` }]
|
|
|
|
|
},
|
|
|
|
|
}))
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result).toEqual({
|
|
|
|
|
content: [{ type: 'text', text: 'read /x' }],
|
|
|
|
|
isError: false,
|
|
|
|
|
value: [{ type: 'text', text: 'read /x' }],
|
|
|
|
|
})
|
2026-06-13 23:00:42 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('ToolArgsError carries a stable code and the violation list', () => {
|
|
|
|
|
const err = new ToolArgsError(['missing required property "a"', '"b" must be a number'])
|
|
|
|
|
expect(err).toBeInstanceOf(Error)
|
|
|
|
|
expect(err.name).toBe('ToolArgsError')
|
|
|
|
|
expect(err.code).toBe('INVALID_ARGS')
|
|
|
|
|
expect(err.violations).toEqual(['missing required property "a"', '"b" must be a number'])
|
|
|
|
|
expect(err.message).toBe('invalid arguments: missing required property "a"; "b" must be a number')
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-14 01:07:28 +08:00
|
|
|
it('a schema-invalid call surfaces the structured error on the result', async () => {
|
|
|
|
|
const ctx = await setup()
|
2026-07-21 03:08:35 +08:00
|
|
|
ctx.tools.register(defineContentToolFixture({
|
2026-06-14 01:07:28 +08:00
|
|
|
name: 'reader',
|
|
|
|
|
description: 'reads a path',
|
|
|
|
|
parameters: { path: { type: 'string', required: true } },
|
|
|
|
|
async execute(args) {
|
|
|
|
|
return [{ type: 'text', text: args.path }]
|
|
|
|
|
},
|
|
|
|
|
}))
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: {} })
|
2026-06-14 01:07:28 +08:00
|
|
|
expect(result.isError).toBe(true)
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result.error).toEqual({
|
|
|
|
|
message: 'invalid arguments: missing required property "path"',
|
|
|
|
|
info: { name: 'ToolArgsError', code: 'INVALID_ARGS' },
|
|
|
|
|
})
|
2026-06-14 01:07:28 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a tool throwing a HarnessError surfaces its name and code', async () => {
|
|
|
|
|
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'coded',
|
|
|
|
|
async execute() {
|
|
|
|
|
throw new HarnessError('disk full', 'ENOSPC')
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'coded', arguments: {} })
|
2026-06-14 01:07:28 +08:00
|
|
|
expect(result.isError).toBe(true)
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result.error).toEqual({ message: 'disk full', info: { name: 'HarnessError', code: 'ENOSPC' } })
|
2026-06-14 01:07:28 +08:00
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: disk full' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 03:08:35 +08:00
|
|
|
it('a non-HarnessError throw retains only its message', async () => {
|
2026-06-14 01:07:28 +08:00
|
|
|
const ctx = await setup()
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
...echoTool,
|
|
|
|
|
name: 'plain',
|
|
|
|
|
async execute() {
|
|
|
|
|
throw new Error('just a message')
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'plain', arguments: {} })
|
2026-06-14 01:07:28 +08:00
|
|
|
expect(result.isError).toBe(true)
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(result.error).toEqual({ message: 'just a message' })
|
2026-06-14 01:07:28 +08:00
|
|
|
expect(result.content[0]).toMatchObject({ text: 'Error: just a message' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-13 23:00:42 +08:00
|
|
|
it('raw-registered tools are NOT validated by defineTool (MCP keeps its own)', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
// A raw ToolDefinition: no defineTool wrapping, so no validateArgs guard.
|
|
|
|
|
ctx.tools.register({
|
|
|
|
|
name: 'raw',
|
|
|
|
|
description: 'raw tool',
|
|
|
|
|
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
2026-07-21 03:08:35 +08:00
|
|
|
output: {
|
|
|
|
|
schema: { type: 'string' },
|
|
|
|
|
render: (_args, value) => [{ type: 'text', text: value as string }],
|
|
|
|
|
},
|
2026-06-13 23:00:42 +08:00
|
|
|
async execute(args: unknown) {
|
2026-07-21 03:08:35 +08:00
|
|
|
return typeof args
|
2026-06-13 23:00:42 +08:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
// Missing the "required" path — but raw tools validate their own input, so
|
|
|
|
|
// this reaches execute rather than being rejected by the harness.
|
2026-07-19 23:38:54 +08:00
|
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'raw', arguments: {} })
|
2026-06-13 23:00:42 +08:00
|
|
|
expect(result.isError).toBe(false)
|
|
|
|
|
})
|
2026-07-08 14:06:24 +08:00
|
|
|
|
|
|
|
|
it('attaches a positive-finite timeoutMs to the definition', () => {
|
2026-07-21 03:08:35 +08:00
|
|
|
const tool = defineContentToolFixture({
|
2026-07-08 14:06:24 +08:00
|
|
|
name: 'x', description: 'd', parameters: {}, timeoutMs: 30_000,
|
|
|
|
|
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
|
|
|
|
|
})
|
|
|
|
|
expect(tool.timeoutMs).toBe(30_000)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('omits timeoutMs when not declared', () => {
|
2026-07-21 03:08:35 +08:00
|
|
|
const tool = defineContentToolFixture({
|
2026-07-08 14:06:24 +08:00
|
|
|
name: 'x', description: 'd', parameters: {},
|
|
|
|
|
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
|
|
|
|
|
})
|
|
|
|
|
expect(tool.timeoutMs).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('throws when timeoutMs is zero or negative', () => {
|
2026-07-21 03:08:35 +08:00
|
|
|
const make = (ms: number) => defineContentToolFixture({
|
2026-07-08 14:06:24 +08:00
|
|
|
name: 'x', description: 'd', parameters: {}, timeoutMs: ms,
|
|
|
|
|
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
|
|
|
|
|
})
|
|
|
|
|
expect(() => make(0)).toThrow('timeoutMs must be a positive finite number')
|
|
|
|
|
expect(() => make(-5)).toThrow('positive finite number')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('throws when timeoutMs is non-finite', () => {
|
2026-07-21 03:08:35 +08:00
|
|
|
expect(() => defineContentToolFixture({
|
2026-07-08 14:06:24 +08:00
|
|
|
name: 'x', description: 'd', parameters: {}, timeoutMs: Infinity,
|
|
|
|
|
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
|
|
|
|
|
})).toThrow('positive finite number')
|
|
|
|
|
})
|
2026-06-13 23:00:42 +08:00
|
|
|
})
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
|
|
|
|
|
describe('defineTool presentation (presentCall / presentResult)', () => {
|
2026-07-21 21:37:11 +08:00
|
|
|
it('preserves inline enum and const literals in inferred arguments', () => {
|
|
|
|
|
defineTool({
|
|
|
|
|
name: 'literal-args',
|
|
|
|
|
description: 'literal arguments',
|
|
|
|
|
parameters: {
|
|
|
|
|
mode: { type: 'string', enum: ['read', 'write'], required: true },
|
|
|
|
|
attempt: { type: 'integer', const: 1 },
|
|
|
|
|
},
|
2026-07-21 21:44:00 +08:00
|
|
|
output: {
|
|
|
|
|
schema: { type: 'null' },
|
|
|
|
|
render: () => [],
|
|
|
|
|
},
|
2026-07-21 21:37:11 +08:00
|
|
|
async execute(args) {
|
|
|
|
|
expectTypeOf(args).toEqualTypeOf<{ mode: 'read' | 'write'; attempt?: 1 }>()
|
2026-07-21 21:44:00 +08:00
|
|
|
return null
|
2026-07-21 21:37:11 +08:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
|
2026-07-21 03:08:35 +08:00
|
|
|
const tool = defineContentToolFixture({
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
name: 'demo',
|
|
|
|
|
description: 'demo',
|
|
|
|
|
parameters: { path: { type: 'string', required: true }, n: { type: 'number' } },
|
|
|
|
|
async execute() { return [{ type: 'text', text: 'ok' }] },
|
|
|
|
|
presentCall(args) {
|
|
|
|
|
// args is typed { path: string; n?: number } — zero casts.
|
|
|
|
|
expectTypeOf(args).toEqualTypeOf<{ path: string; n?: number }>()
|
2026-07-03 02:04:03 +08:00
|
|
|
return { card: 'generic', title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
},
|
|
|
|
|
presentResult(args, result) {
|
2026-07-03 02:04:03 +08:00
|
|
|
return { card: 'generic', title: `Opened ${args.path}`, content: result.content }
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
},
|
|
|
|
|
})
|
2026-07-03 02:04:03 +08:00
|
|
|
expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ card: 'generic', title: 'Open /a', kind: 'read', rawInput: '/a' })
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
expect(tool.presentResult!({ path: '/a' }, { content: [{ type: 'text', text: 'x' }], isError: false }))
|
2026-07-03 02:04:03 +08:00
|
|
|
.toEqual({ card: 'generic', title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
|
2026-07-21 03:08:35 +08:00
|
|
|
const tool = defineContentToolFixture({
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
name: 'plain',
|
|
|
|
|
description: 'plain',
|
|
|
|
|
parameters: { x: { type: 'string', required: true } },
|
|
|
|
|
async execute() { return [] },
|
|
|
|
|
})
|
|
|
|
|
expect(typeof tool.presentCall).toBe('undefined')
|
|
|
|
|
expect(typeof tool.presentResult).toBe('undefined')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('presentCall/presentResult validate softly: malformed args return undefined, never throw (display runs on replay)', () => {
|
2026-07-21 03:08:35 +08:00
|
|
|
const tool = defineContentToolFixture({
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
name: 'demo',
|
|
|
|
|
description: 'demo',
|
|
|
|
|
parameters: { path: { type: 'string', required: true } },
|
|
|
|
|
async execute() { return [] },
|
2026-07-03 02:04:03 +08:00
|
|
|
presentCall: args => ({ card: 'generic', title: args.path }),
|
|
|
|
|
presentResult: (args, result) => ({ card: 'generic', title: args.path, content: result.content }),
|
feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.
dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).
dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.
dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.
Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
|
|
|
})
|
|
|
|
|
// Unlike execute (which throws ToolArgsError on a mismatch), the display
|
|
|
|
|
// methods soft-validate and fall back to undefined so a UI never crashes
|
|
|
|
|
// replaying an old/foreign log entry. The ToolDefinition methods take
|
|
|
|
|
// `unknown`, so malformed shapes pass without a cast.
|
|
|
|
|
expect(tool.presentCall?.({})).toBeUndefined()
|
|
|
|
|
expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
})
|