Merge branch 'codex/simp-unify-agent-session-id' into codex/simp-ui-identity-residue

# Conflicts:
#	docs/event-producer-consumer.md
#	docs/module-graph.md
This commit is contained in:
Tianyi Cui
2026-07-15 16:26:12 +08:00
305 changed files with 8816 additions and 2144 deletions

View File

@@ -34,7 +34,7 @@
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
"@deepseek-ai/dsh-tool-skill": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-timer": "workspace:^",
@@ -49,7 +49,7 @@
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"

View File

@@ -28,7 +28,7 @@
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -43,6 +43,6 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1044,8 +1044,7 @@ describe('tool result call identity', () => {
// A post-execute listener transforms the result (accept-with-replacement).
// The loop must still record the tool/result under the model's authoritative
// call.id (the loop ignores result.callId — which the registry always sets to
// exec.callId anyway — and uses call.id, the model-transcript id).
// call.id, which is the immutable identity carried by the execution input.
ctx.on('tools/post-execute', (exec, _result) => {
expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
@@ -1055,8 +1054,7 @@ describe('tool result call identity', () => {
send(agent, 'use tool')
await waitForIdle(ctx, agent)
// The logged tool/result.callId is the originating call.id, NOT the
// listener's wrong id.
// The logged tool/result.callId is the originating call.id.
const resultEvent = [...agent.session.events].find(e => e.type === 'tool/result')
expect(resultEvent?.type).toBe('tool/result')
if (resultEvent?.type === 'tool/result') {

View File

@@ -27,7 +27,7 @@
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
@@ -35,6 +35,6 @@
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -22,9 +22,9 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -25,12 +25,12 @@
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -41,6 +41,7 @@ declare module 'cordis' {
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
* receive only sessions entered through that agent's context.
* @param session - the session just entered and announced.
* @dshScopeScan unsupported
* @mode emit
*/
'session/created'(this: Scoped<Session>, session: Session): void
@@ -50,6 +51,7 @@ declare module 'cordis' {
* did not begin. Listener failures are logged and contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope.
* @param session - the session that is no longer live in the store.
* @dshScopeScan unsupported
* @mode emit
*/
'session/disposed'(this: Scoped<Session>, session: Session): void
@@ -61,6 +63,7 @@ declare module 'cordis' {
* receive only events from sessions entered through that agent's context.
* @param session - the session whose log grew.
* @param event - the appended event, exactly as recorded.
* @dshScopeScan unsupported
* @mode emit
*/
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
@@ -70,6 +73,7 @@ declare module 'cordis' {
* {@link SessionStore.flush}. Scope-filtered dispatch
* (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
* @param session - the session whose buffered events must reach durable storage.
* @dshScopeScan unsupported
* @mode parallel
*/
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void

View File

@@ -24,7 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -32,6 +32,6 @@
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -65,10 +65,6 @@ export interface PromptSection {
export interface AssembledSection {
/** The contributing section's unique name. */
name: string
// TODO(assembled-section-order): drop this output field; registry order has
// already sorted the array, and no production renderer/listener reads it.
/** The contributing section's order (sections arrive sorted ascending). */
order: number
/** The resolved (but not yet interpolated) section text. */
text: string
}
@@ -226,7 +222,7 @@ export class SystemPrompt extends Service {
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
private readonly toolOrder: string[] | undefined
constructor(ctx: Context, public config: Config) {
constructor(ctx: Context, config: Config) {
super(ctx, 'systemPrompt')
this.toolOrder = validateToolOrder(config.toolOrder)
// Keep harness-owned openers independent of the selected loop plugin.
@@ -403,12 +399,11 @@ export class SystemPrompt extends Service {
}
const assembly: PromptAssembly = {
sections: [...sectionByName.values()]
.sort((a, b) => a.order - b.order)
.map(section => ({
name: section.name,
order: section.order,
text: typeof section.text === 'function' ? section.text(context) : section.text,
}))
.sort((a, b) => a.order - b.order),
})),
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}

View File

@@ -139,7 +139,7 @@ describe('scoped assemble dispatch', () => {
scope.ctx.on('system-prompt/assemble', async (_assembly: PromptAssembly, context, next: () => Promise<PromptAssembly>) => {
shaped.push(context.scope)
const result = await next()
result.sections.push({ name: 'listener:extra', order: 999, text: 'listener text' })
result.sections.push({ name: 'listener:extra', text: 'listener text' })
return result
})

View File

@@ -21,9 +21,9 @@ describe('SystemPrompt', () => {
await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' })
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.map(s => [s.name, s.order])).toEqual([
['harness:identity', -100],
['deployment:persona', 0],
expect(assembly.sections.map(s => s.name)).toEqual([
'harness:identity',
'deployment:persona',
])
expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.`)
// The names are reserved by the plugin — one owner per section.
@@ -183,7 +183,7 @@ describe('SystemPrompt', () => {
const contexts: AssembleContext[] = []
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, context, next) => {
contexts.push(context)
assembly.sections.push({ name: 'from-a', order: 100, text: 'a' })
assembly.sections.push({ name: 'from-a', text: 'a' })
return next()
})
// Listener B (registered later, runs after A) sees A's contribution.
@@ -235,8 +235,8 @@ describe('SystemPrompt', () => {
it('filters out empty section text from renderPrompt', () => {
const result = renderPrompt({
sections: [
{ name: 'empty', order: 0, text: '' },
{ name: 'real', order: 1, text: 'content' },
{ name: 'empty', text: '' },
{ name: 'real', text: 'content' },
],
tools: [],
variables: {},
@@ -356,13 +356,13 @@ describe('SystemPrompt', () => {
})
it('names "(none)" when no variables are registered at all', () => {
expect(() => renderPrompt({ sections: [{ name: 's', order: 0, text: '{{x}}' }], tools: [], variables: {} }))
expect(() => renderPrompt({ sections: [{ name: 's', text: '{{x}}' }], tools: [], variables: {} }))
.toThrow('unknown prompt variable "{{x}}" in section "s"; registered variables: (none)')
})
it('throws when a referenced variable has no value for this assembly', () => {
expect(() => renderPrompt({
sections: [{ name: 'persona', order: 0, text: 'in {{cwd}}' }],
sections: [{ name: 'persona', text: 'in {{cwd}}' }],
tools: [],
variables: { cwd: undefined },
})).toThrow('prompt variable "{{cwd}}" has no value for this assembly (section "persona")')
@@ -370,7 +370,7 @@ describe('SystemPrompt', () => {
it('throws on a malformed complete reference, e.g. inner spaces', () => {
expect(() => renderPrompt({
sections: [{ name: 's', order: 0, text: 'on {{ model }}' }],
sections: [{ name: 's', text: 'on {{ model }}' }],
tools: [],
variables: { model: 'm' },
})).toThrow('malformed prompt variable reference "{{ model }}" in section "s"')
@@ -378,7 +378,7 @@ describe('SystemPrompt', () => {
it('leaves a lone {{ verbatim only when NO }} follows anywhere after it', () => {
const text = renderPrompt({
sections: [{ name: 's', order: 0, text: 'shell ${X:-{{fallback} stays' }],
sections: [{ name: 's', text: 'shell ${X:-{{fallback} stays' }],
tools: [],
variables: {},
})
@@ -390,7 +390,7 @@ describe('SystemPrompt', () => {
{ text: 'x {{a{b}} y {{model}}', label: 'nested brace inside a would-be group' },
])('throws on a mangled reference with a }} still following ($label)', ({ text }) => {
expect(() => renderPrompt({
sections: [{ name: 's', order: 0, text }],
sections: [{ name: 's', text }],
tools: [],
variables: { model: 'm' },
})).toThrow('malformed prompt variable reference at')
@@ -400,7 +400,7 @@ describe('SystemPrompt', () => {
// `in` would find Object.prototype.constructor and splice function
// source into the prompt; Object.hasOwn must reject it instead.
expect(() => renderPrompt({
sections: [{ name: 's', order: 0, text: 'on {{constructor}}' }],
sections: [{ name: 's', text: 'on {{constructor}}' }],
tools: [],
variables: { model: 'm' },
})).toThrow('unknown prompt variable "{{constructor}}"')
@@ -416,7 +416,7 @@ describe('SystemPrompt', () => {
it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => {
const text = renderPrompt({
sections: [{ name: 's', order: 0, text: 'v = {{model}}!' }],
sections: [{ name: 's', text: 'v = {{model}}!' }],
tools: [],
variables: { model: 'literal {{sneaky}} inside' },
})

View File

@@ -36,7 +36,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContext?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
- `PostToolDecision``{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
- `ToolGuard``(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.

View File

@@ -29,7 +29,7 @@
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -42,6 +42,6 @@
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -108,14 +108,13 @@ function renderValue(value: unknown): string {
/** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */
interface RunCodeMeta {
logs: CodeRunResult['logs']
dispatches: number
}
/** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */
function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
if (typeof meta !== 'object' || meta === null) return undefined
const m = meta as Record<string, unknown>
if (!Array.isArray(m.logs) || typeof m.dispatches !== 'number') return undefined
if (!Array.isArray(m.logs) || !m.logs.every(log => typeof log === 'string')) return undefined
return m as unknown as RunCodeMeta
}
@@ -251,12 +250,12 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
}
if (result.error) {
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.map(entry => entry.text).join('\n')}` : ''
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : ''
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
}
const rendered = renderValue(result.value)
const parts = [result.logs.map(entry => entry.text).join('\n'), rendered].filter(part => part.length > 0)
const meta: RunCodeMeta = { logs: result.logs, dispatches }
const parts = [result.logs.join('\n'), rendered].filter(part => part.length > 0)
const meta: RunCodeMeta = { logs: result.logs }
return {
content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }],
meta,
@@ -278,7 +277,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
presentResult: (_args, result) => {
const meta = asRunCodeMeta(result.meta)
if (!meta) return undefined
const output = meta.logs.map(entry => entry.text).join('\n')
const output = meta.logs.join('\n')
return {
card: 'generic',
...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {},

View File

@@ -220,7 +220,7 @@ export interface ToolErrorInfo {
* distinguish it from a tool body's own error.
*/
export class ToolNotFoundError extends HarnessError {
constructor(public readonly toolName: string) {
constructor(toolName: string) {
super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL')
this.name = 'ToolNotFoundError'
}
@@ -228,7 +228,6 @@ export class ToolNotFoundError extends HarnessError {
/** The outcome of one tool call. */
export interface ToolExecutionResult {
callId: CallId
content: ContentBlock[]
isError: boolean
/**
@@ -704,7 +703,7 @@ export class ToolRegistry extends Service {
}
} catch (error: unknown) {
execution = { ...base, arguments: undefined }
const result = this.materializeFinalResult(toolErrorResult(callId, error))
const result = this.materializeFinalResult(toolErrorResult(error))
this.notifyResult(execution, result)
return result
}
@@ -714,7 +713,7 @@ export class ToolRegistry extends Service {
} catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener, guard, or the
// waterfall machinery becomes an isError result, never a turn failure.
result = this.materializeFinalResult(toolErrorResult(execution.callId, error))
result = this.materializeFinalResult(toolErrorResult(error))
}
this.notifyResult(execution, result)
return result
@@ -739,7 +738,6 @@ export class ToolRegistry extends Service {
// Every non-grant, including a failed/unavailable approval request, takes
// the same deny path and still reaches post-policy plus result observers.
const denied: ToolExecutionResult = {
callId: exec.callId,
content: [{ type: 'text', text: `Error: ${denialReason}` }],
isError: true,
}
@@ -770,16 +768,12 @@ export class ToolRegistry extends Service {
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
return { content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
return toolErrorResult(exec.callId, error)
return toolErrorResult(error)
}
},
)
if (result.callId !== exec.callId) {
throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`)
}
return await this.postExecute(exec, result)
}
@@ -854,7 +848,6 @@ export class ToolRegistry extends Service {
const additionalContext = decision.additionalContext
if (decision.kind === 'block') {
return {
callId: result.callId,
content: decision.feedback,
isError: true,
...additionalContext ? { additionalContext } : {},
@@ -883,10 +876,9 @@ function createExecutionToken(): ToolExecutionToken {
return Symbol('dsh.tool.execution') as ToolExecutionToken
}
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
function toolErrorResult(error: unknown): ToolExecutionResult {
const info = errorInfo(error)
return {
callId,
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
isError: true,
...info ? { error: info } : {},

View File

@@ -327,7 +327,7 @@ describe('the run_code dispatch bridge', () => {
const tools = request.bindings[0]!.functions
const first = await tools.echo!({ value: 'one' })
const second = await tools.echo!({ value: 'two' })
return { logs: [{ source: 'console', level: 'log', text: `saw ${String(first)}` }], value: second }
return { logs: [`saw ${String(first)}`], value: second }
}
const result = await runCode(ctx, 'const …: string = …', { agent })
expect(result.isError).toBe(false)
@@ -338,7 +338,7 @@ describe('the run_code dispatch bridge', () => {
{ parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, resultSummary: 'echo:one' },
{ parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, resultSummary: 'echo:two' },
])
expect(result.meta).toEqual({ logs: [{ source: 'console', level: 'log', text: 'saw echo:one' }], dispatches: 2 })
expect(result.meta).toEqual({ logs: ['saw echo:one'] })
})
it('exposes only an opaque parent token to nested result observers', async () => {
@@ -502,7 +502,7 @@ describe('the run_code dispatch bridge', () => {
it('converts a failed run into a structured isError result carrying kind, message, and captured logs', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
runtime.behavior = () => Promise.resolve({
logs: [{ source: 'console', level: 'log', text: 'got this far' }],
logs: ['got this far'],
error: { kind: 'timeout', message: 'compute budget exhausted (300ms busy)' },
})
const result = await runCode(ctx, 'program')
@@ -626,7 +626,7 @@ describe('the run_code dispatch bridge', () => {
const view = tool.presentResult?.({ code: 'return 1' }, {
content: [{ type: 'text', text: 'model-facing' }],
isError: false,
meta: { logs: [{ source: 'console', level: 'log', text: 'printed' }], dispatches: 1 },
meta: { logs: ['printed'] },
})
// The result omits the title — an update replaces only provided fields,
// so the pending card's program title persists through completion.
@@ -635,9 +635,10 @@ describe('the run_code dispatch bridge', () => {
content: [{ type: 'text', text: 'printed' }],
})
// No captured output → no content either; everything pending persists.
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [], dispatches: 2 } }))
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [] } }))
.toEqual({ card: 'generic' })
// Replay with an unrecognizable meta falls back to the generic rendering.
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [{ text: 'legacy' }], dispatches: 1 } })).toBeUndefined()
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined()
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined()
})

View File

@@ -550,7 +550,6 @@ describe('scoped execution dispatch', () => {
expect(reads).toBe(1)
expect(result).toEqual({
callId: CallId('unstable-arguments'),
content: [{ type: 'text', text: 'ran:t' }],
isError: false,
})
@@ -566,10 +565,9 @@ describe('scoped execution dispatch', () => {
ctx.on('internal/dispatch', (mode, name) => {
if (name === 'tools/result') dispatchModes.push(mode)
})
ctx.on('tools/execute', async (exec, next) => {
ctx.on('tools/execute', async (_exec, next) => {
await next()
return {
callId: exec.callId,
content: [{ type: 'text', text: 'outer failure' }],
isError: true,
}

View File

@@ -80,7 +80,7 @@ describe('ToolRegistry', () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
})
it('threads a tool-attached meta (object return form) onto the result', async () => {
@@ -94,7 +94,6 @@ describe('ToolRegistry', () => {
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} })
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] },
@@ -111,7 +110,7 @@ describe('ToolRegistry', () => {
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
expect('meta' in result).toBe(false)
})
@@ -178,13 +177,12 @@ describe('ToolRegistry', () => {
})
})
it('ToolNotFoundError carries the tool name and a stable code', async () => {
it('ToolNotFoundError carries a stable message and code', async () => {
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.toolName).toBe('ghost')
expect(err.message).toBe('unknown tool "ghost"')
})
@@ -425,7 +423,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
// The around seam wraps dispatch; pre gates before it, post runs over its result.
expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
})
@@ -526,8 +524,8 @@ describe('ToolRegistry', () => {
async execute() { dispatched = true; return [] },
})
ctx.on('tools/execute', async (exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
({ callId: exec.callId, content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
({ content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
expect(dispatched).toBe(false) // returning without next() skips core dispatch
@@ -537,8 +535,7 @@ describe('ToolRegistry', () => {
it('preserves additionalContext supplied by an around-dispatch result', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async exec => ({
callId: exec.callId,
ctx.on('tools/execute', async () => ({
content: [{ type: 'text', text: 'short-circuited with context' }],
isError: false,
additionalContext: {
@@ -556,20 +553,6 @@ describe('ToolRegistry', () => {
})
})
it('normalizes a tools/execute result with the wrong call id', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => ({ callId: CallId('other'), content: [], isError: false }))
const result = await ctx.tools.execute({
callId: CallId('malformed-shape'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({
text: 'Error: tools/execute returned callId "other" for authoritative call "malformed-shape"',
})
})
it('returns an isError result when a tools/execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -577,7 +560,6 @@ describe('ToolRegistry', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: wrapper broke' }],
isError: true,
})
@@ -593,7 +575,6 @@ describe('ToolRegistry', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: permission hook broke' }],
isError: true,
})
@@ -609,7 +590,6 @@ describe('ToolRegistry', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: post hook broke' }],
isError: true,
})
@@ -625,7 +605,6 @@ describe('ToolRegistry', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toMatchObject({
callId: CallId('c1'),
isError: true,
error: { name: 'HarnessError', code: 'DENIED' },
})
@@ -1254,7 +1233,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
},
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'read /x' }], isError: false })
expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false })
})
it('ToolArgsError carries a stable code and the violation list', () => {