fix(tools): preserve canonical output boundaries
This commit is contained in:
@@ -151,7 +151,7 @@ describe('ToolResultPruneService session transaction', () => {
|
||||
text: 'x'.repeat(100),
|
||||
}], {
|
||||
isError: true,
|
||||
error: { message: 'exit 1', info: { name: 'ExitError', code: 'EXIT_1' } },
|
||||
error: { name: 'ExitError', code: 'EXIT_1' },
|
||||
meta: { diff: ['a', 'b'] },
|
||||
futureField: { nested: true },
|
||||
})
|
||||
@@ -180,7 +180,7 @@ describe('ToolResultPruneService session transaction', () => {
|
||||
step: 1,
|
||||
callId: CallId('one'),
|
||||
isError: true,
|
||||
error: { message: 'exit 1', info: { name: 'ExitError', code: 'EXIT_1' } },
|
||||
error: { name: 'ExitError', code: 'EXIT_1' },
|
||||
meta: { diff: ['a', 'b'] },
|
||||
futureField: { nested: true },
|
||||
},
|
||||
|
||||
@@ -1450,7 +1450,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionEventMap',
|
||||
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n message: string;\n info?: {\n name: string;\n code: string;\n };\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n content: Content /* …truncated — full shape in source */',
|
||||
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n /* …truncated — full shape in source */',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventReadRequest',
|
||||
|
||||
@@ -248,7 +248,7 @@ function appendToolResult(
|
||||
callId: block.id,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
...result.error?.info ? { error: result.error.info } : {},
|
||||
// The tool's private presentation payload (e.g. a result-time diff),
|
||||
// persisted so a UI bridge reproduces the card on replay.
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
|
||||
@@ -397,7 +397,7 @@ describe('Agent.cancel()', () => {
|
||||
expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
|
||||
callId: 'c1',
|
||||
isError: true,
|
||||
error: { info: { name: 'AbortError', code: 'ABORTED' } },
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
})
|
||||
|
||||
send(agent, 'continue safely')
|
||||
|
||||
@@ -259,7 +259,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
case 'assistant/message': order.push('assistant/message'); break
|
||||
case 'tool/call': order.push(`tool/call:${event.data.callId}`); break
|
||||
case 'tool/result': {
|
||||
const outcome = event.data.error?.info?.code === 'ABORTED' ? 'synthetic-aborted' : 'real'
|
||||
const outcome = event.data.error?.code === 'ABORTED' ? 'synthetic-aborted' : 'real'
|
||||
order.push(`tool/result:${event.data.callId}:${outcome}`)
|
||||
break
|
||||
}
|
||||
@@ -308,7 +308,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
expect(results[1]!.data).toMatchObject({
|
||||
callId: CallId('c2'),
|
||||
isError: true,
|
||||
error: { info: { name: 'AbortError', code: 'ABORTED' } },
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -479,18 +479,12 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
{
|
||||
callId: CallId('c1'),
|
||||
isError: true,
|
||||
error: {
|
||||
message: 'tool call skipped because the step was aborted before execution',
|
||||
info: { name: 'AbortError', code: 'ABORTED' },
|
||||
},
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
},
|
||||
{
|
||||
callId: CallId('c2'),
|
||||
isError: true,
|
||||
error: {
|
||||
message: 'tool call skipped because the step was aborted before execution',
|
||||
info: { name: 'AbortError', code: 'ABORTED' },
|
||||
},
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -523,7 +517,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1'), CallId('c2')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
|
||||
.toMatchObject({ callId: CallId('c2'), isError: true, error: { info: { name: 'AbortError', code: 'ABORTED' } } })
|
||||
.toMatchObject({ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
|
||||
})
|
||||
|
||||
it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => {
|
||||
@@ -555,7 +549,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => ({
|
||||
callId: e.data.callId,
|
||||
isError: e.data.isError,
|
||||
errorInfo: e.data.error?.info,
|
||||
errorInfo: e.data.error,
|
||||
})))
|
||||
.toEqual([
|
||||
{ callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: 'ABORTED' } },
|
||||
@@ -601,6 +595,6 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1'), CallId('c2'), CallId('c3')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
|
||||
.toMatchObject({ callId: CallId('c3'), isError: true, error: { info: { name: 'AbortError', code: 'ABORTED' } } })
|
||||
.toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,7 +58,7 @@ Durable values need one accepted representation, not a check followed by a secon
|
||||
|
||||
`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
|
||||
|
||||
`tool/result` persists the model-facing content, canonical failure detail, and optional presentation metadata. A tool's successful canonical `value` is deliberately execution-local and never enters the session event, so replay reconstructs the Native/model presentation but cannot recover intermediate programmatic values. This does not change `SESSION_FORMAT_VERSION`: the persisted projection remains authoritative.
|
||||
`tool/result` persists the model-facing content, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message. This preserves the existing event shape and does not change `SESSION_FORMAT_VERSION`.
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
|
||||
@@ -92,10 +92,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
callId,
|
||||
content: [{ type: 'text', text: 'Tool call interrupted by a crash; no result was recorded.' }],
|
||||
isError: true,
|
||||
error: {
|
||||
message: 'Tool call interrupted by a crash; no result was recorded.',
|
||||
info: { name: 'InterruptedError', code: 'interrupted' },
|
||||
},
|
||||
error: { name: 'InterruptedError', code: 'interrupted' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
...callSeq !== undefined ? { sourceEventSeqs: [callSeq] } : {},
|
||||
|
||||
@@ -243,12 +243,13 @@ export interface SessionEventMap {
|
||||
*/
|
||||
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
|
||||
/**
|
||||
* A completed tool call's model-facing result, canonical failure detail, and
|
||||
* optional tool-private `meta` presentation payload. `meta` is opaque to the
|
||||
* core (the producing tool owns its shape and reads it back in `presentResult`)
|
||||
* but MUST be JSON-serializable: `Session.append` runtime-validates all event
|
||||
* data with `isJsonValue`, so a non-serializable `meta` is rejected at the
|
||||
* source, and the durable log reproduces the identical card on replay. Absent
|
||||
* A completed tool call's model-facing result, optional internal failure
|
||||
* identity, and optional tool-private `meta` presentation payload. `meta` is
|
||||
* opaque to the core (the producing tool owns its shape and reads it back in
|
||||
* `presentResult`) but MUST be JSON-serializable: `Session.append`
|
||||
* runtime-validates all event data with `isJsonValue`, so a non-serializable
|
||||
* `meta` is rejected at the source, and the durable log reproduces the
|
||||
* identical card on replay. Absent
|
||||
* unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time
|
||||
* contextual diff here).
|
||||
*/
|
||||
@@ -258,7 +259,7 @@ export interface SessionEventMap {
|
||||
callId: CallId
|
||||
content: ContentBlock[]
|
||||
isError: boolean
|
||||
error?: { message: string; info?: { name: string; code: string } }
|
||||
error?: { name: string; code: string }
|
||||
meta?: JsonValue
|
||||
}
|
||||
/** Steering content injected between steps of a running turn. */
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('interruptedTurnClosers', () => {
|
||||
expect(closers.map(e => e.seq)).toEqual([3, 4, 5])
|
||||
const result = closers[0]!
|
||||
expect(result.type === 'tool/result' && result.data).toMatchObject({
|
||||
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { info: { code: 'interrupted' } },
|
||||
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: 'interrupted' },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -349,6 +349,25 @@ export class ToolOutputError extends HarnessError {
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert one projector exception into the canonical invalid-output failure. */
|
||||
function projectionError(toolName: string, projector: 'render' | 'presentationMeta', error: unknown): ToolOutputError {
|
||||
return new ToolOutputError(toolName, [`output.${projector} failed: ${errorMessage(error)}`])
|
||||
}
|
||||
|
||||
/** Snapshot one projector result before later durable-result materialization. */
|
||||
function snapshotProjection<T>(toolName: string, projector: 'render' | 'presentationMeta', candidate: T): T {
|
||||
try {
|
||||
const detached = snapshotJsonValue(candidate)
|
||||
if (detached === undefined) {
|
||||
throw new ToolOutputError(toolName, [`output.${projector} returned non-lossless JSON`])
|
||||
}
|
||||
return detached
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ToolOutputError) throw error
|
||||
throw projectionError(toolName, projector, error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Successful canonical tool execution, including its Native/model projection. */
|
||||
export interface ToolExecutionSuccess {
|
||||
readonly isError: false
|
||||
@@ -1156,10 +1175,23 @@ export class ToolRegistry extends Service {
|
||||
const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
|
||||
if (violations.length > 0) throw new ToolOutputError(tool.name, violations)
|
||||
const value = deepFreeze(detached as JsonValue)
|
||||
const content = tool.output.render(exec.arguments, value)
|
||||
const meta = exec.parent === undefined && tool.output.presentationMeta !== undefined
|
||||
? tool.output.presentationMeta(exec.arguments, value)
|
||||
: undefined
|
||||
let rendered: ContentBlock[]
|
||||
try {
|
||||
rendered = tool.output.render(exec.arguments, value)
|
||||
} catch (error: unknown) {
|
||||
throw projectionError(tool.name, 'render', error)
|
||||
}
|
||||
const content = snapshotProjection(tool.name, 'render', rendered)
|
||||
let meta: JsonValue | undefined
|
||||
if (exec.parent === undefined && tool.output.presentationMeta !== undefined) {
|
||||
let projected: JsonValue
|
||||
try {
|
||||
projected = tool.output.presentationMeta(exec.arguments, value)
|
||||
} catch (error: unknown) {
|
||||
throw projectionError(tool.name, 'presentationMeta', error)
|
||||
}
|
||||
meta = snapshotProjection(tool.name, 'presentationMeta', projected)
|
||||
}
|
||||
return this.markCanonical(this.materializeFinalResult({
|
||||
isError: false,
|
||||
value,
|
||||
|
||||
@@ -147,6 +147,7 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]?.type === 'text' && result.content[0].text).toContain('Error:')
|
||||
expect(result.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } })
|
||||
expect(observedError).toBe(true)
|
||||
})
|
||||
|
||||
@@ -209,10 +210,10 @@ describe('ToolRegistry', () => {
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId(projector), name: `throwing-${projector}`, arguments: {} })
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { message: projector === 'render' ? 'renderer exploded' : 'metadata exploded' },
|
||||
})
|
||||
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' })
|
||||
expect('value' in result).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ export async function buildWindow(
|
||||
appendToLineBuffer(chunk.slice(startPos, newlinePos))
|
||||
flushLine()
|
||||
startPos = newlinePos + 1
|
||||
if (acc.done) return finish(acc, request, displayPath)
|
||||
}
|
||||
appendToLineBuffer(chunk.slice(startPos))
|
||||
}
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"schemastery": "^3.18.0"
|
||||
"schemastery": "^3.18.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@modelcontextprotocol/server-everything": "^2026.7.4",
|
||||
"@modelcontextprotocol/server-filesystem": "^2026.7.4",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"zod": "^4.4.3"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { z } from 'zod'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
@@ -46,6 +48,35 @@ const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g
|
||||
/** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
|
||||
const HASH_LENGTH = 12
|
||||
|
||||
/** Raw result record: the bridge owns JSON-value validation after transport. */
|
||||
const RawCallToolResultSchema = z.record(z.string(), z.unknown())
|
||||
|
||||
/** List without mutating the SDK's per-page output-validator cache. */
|
||||
function listToolsUncached(client: Client, cursor?: string) {
|
||||
return client.request(
|
||||
{ method: 'tools/list', ...cursor === undefined ? {} : { params: { cursor } } },
|
||||
ListToolsResultSchema,
|
||||
)
|
||||
}
|
||||
|
||||
/** Call without the SDK pre-validating an output schema the bridge may not support. */
|
||||
function callToolUncached(
|
||||
client: Client,
|
||||
rawName: string,
|
||||
args: Record<string, unknown>,
|
||||
exec: ToolExecution,
|
||||
opts: ToolBridgeOptions,
|
||||
) {
|
||||
return client.request(
|
||||
{ method: 'tools/call', params: { name: rawName, arguments: args } },
|
||||
RawCallToolResultSchema,
|
||||
{
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
timeout: opts.toolCallTimeoutMs,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the model-facing public name for one MCP tool.
|
||||
*
|
||||
@@ -73,7 +104,7 @@ export function publicToolName(serverName: string, rawName: string): string {
|
||||
*
|
||||
* Two phases keep the swap safe:
|
||||
*
|
||||
* 1. Fetch: drain `client.listTools()` pagination and build the full next
|
||||
* 1. Fetch: drain uncached `tools/list` pagination and build the full next
|
||||
* generation of `ToolDefinition`s under public names. Any failure here
|
||||
* (network error, duplicate raw name in the server's list) rejects and
|
||||
* leaves the previous generation registered untouched.
|
||||
@@ -101,7 +132,7 @@ export async function syncTools(
|
||||
const definitions = new Map<string, ToolDefinition>()
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const response = await client.listTools(cursor ? { cursor } : undefined)
|
||||
const response = await listToolsUncached(client, cursor)
|
||||
for (const tool of response.tools) {
|
||||
const publicName = publicToolName(opts.serverName, tool.name)
|
||||
if (definitions.has(publicName)) {
|
||||
@@ -114,7 +145,7 @@ export async function syncTools(
|
||||
description: tool.description ?? '',
|
||||
parameters: tool.inputSchema,
|
||||
output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)),
|
||||
execute: createExecutor(client, tool.name, opts),
|
||||
execute: createExecutor(client, tool.name, tool.execution?.taskSupport === 'required', opts),
|
||||
})
|
||||
}
|
||||
cursor = response.nextCursor
|
||||
@@ -170,7 +201,7 @@ function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefi
|
||||
content: { type: 'array', items: {} },
|
||||
structuredContent: structuredSchema ?? {},
|
||||
},
|
||||
required: ['content'],
|
||||
required: structuredSchema === undefined ? ['content'] : ['content', 'structuredContent'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
render(_args, value) {
|
||||
@@ -182,9 +213,10 @@ function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefi
|
||||
|
||||
/**
|
||||
* Create an execute function for one MCP tool. The executor closes over the
|
||||
* raw MCP tool name and calls `client.callTool` with it (never the public
|
||||
* name), with abort signal and timeout, then maps the result to harness
|
||||
* ContentBlocks.
|
||||
* raw MCP tool name and sends an uncached `tools/call` request with it (never
|
||||
* the public name), with abort signal and timeout, then maps the result to
|
||||
* harness ContentBlocks. Owning the raw request prevents the SDK's internal
|
||||
* per-page schema cache from pre-validating a different contract.
|
||||
*
|
||||
* When the MCP server returns `isError: true`, the executor throws so that
|
||||
* the ToolRegistry's catch path produces an `isError` result for the model.
|
||||
@@ -192,33 +224,30 @@ function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefi
|
||||
function createExecutor(
|
||||
client: Client,
|
||||
rawName: string,
|
||||
taskRequired: boolean,
|
||||
opts: ToolBridgeOptions,
|
||||
): ToolDefinition['execute'] {
|
||||
return async (args: unknown, exec: ToolExecution) => {
|
||||
if (taskRequired) {
|
||||
throw new Error(`Tool "${rawName}" requires task-based execution, which this bridge does not support`)
|
||||
}
|
||||
// The agent loop passes `JSON.parse(model_arguments)` which is usually an
|
||||
// object, but can be any JSON value if the model misbehaves (outputs a bare
|
||||
// string/number/null). Fallback to {} lets the MCP server produce a
|
||||
// specific "missing required param" error the model can learn from.
|
||||
const argsObj = (typeof args === 'object' && args !== null ? args : {}) as Record<string, unknown>
|
||||
const result = await client.callTool(
|
||||
{ name: rawName, arguments: argsObj },
|
||||
undefined,
|
||||
{
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
timeout: opts.toolCallTimeoutMs,
|
||||
},
|
||||
)
|
||||
const result = await callToolUncached(client, rawName, argsObj, exec, opts)
|
||||
|
||||
// The SDK may return a legacy `toolResult` shape; normalize to content array.
|
||||
if (!('content' in result) || !Array.isArray(result.content)) {
|
||||
if (!Array.isArray(result.content)) {
|
||||
const rendered: unknown = 'toolResult' in result
|
||||
? JSON.stringify(result.toolResult)
|
||||
: '(no output)'
|
||||
const text = typeof rendered === 'string' ? rendered : '(no output)'
|
||||
if ('isError' in result && result.isError === true) throw new Error(text)
|
||||
if (result.isError === true) throw new Error(text)
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
...'structuredContent' in result && result.structuredContent !== undefined
|
||||
...result.structuredContent !== undefined
|
||||
? { structuredContent: result.structuredContent as JsonValue }
|
||||
: {},
|
||||
}
|
||||
@@ -232,13 +261,13 @@ function createExecutor(
|
||||
const text = extractText(content, rawName)
|
||||
|
||||
// MCP isError → throw so ToolRegistry produces an isError result for the model.
|
||||
if ('isError' in result && result.isError === true) {
|
||||
if (result.isError === true) {
|
||||
throw new Error(text)
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
...'structuredContent' in result && result.structuredContent !== undefined
|
||||
...result.structuredContent !== undefined
|
||||
? { structuredContent: result.structuredContent as JsonValue }
|
||||
: {},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -14,6 +16,7 @@ interface MockTool {
|
||||
description?: string
|
||||
inputSchema: Record<string, unknown>
|
||||
outputSchema?: Record<string, unknown>
|
||||
execution?: { taskSupport?: 'optional' | 'required' | 'forbidden' }
|
||||
}
|
||||
|
||||
interface MockCallResult {
|
||||
@@ -23,9 +26,26 @@ interface MockCallResult {
|
||||
}
|
||||
|
||||
function createMockClient(tools: MockTool[], callResult: MockCallResult = { content: [{ type: 'text', text: 'ok' }] }) {
|
||||
const listTools = vi.fn(async (
|
||||
_params?: Record<string, unknown>,
|
||||
): Promise<{ tools: MockTool[]; nextCursor: string | undefined }> => ({ tools, nextCursor: undefined }))
|
||||
const callTool = vi.fn(async (
|
||||
_params?: Record<string, unknown>,
|
||||
_compatibilitySchema?: unknown,
|
||||
_options?: unknown,
|
||||
): Promise<Record<string, unknown>> => ({ ...callResult }))
|
||||
return {
|
||||
listTools: vi.fn().mockResolvedValue({ tools, nextCursor: undefined }),
|
||||
callTool: vi.fn().mockResolvedValue(callResult),
|
||||
listTools,
|
||||
callTool,
|
||||
request: vi.fn(async (
|
||||
request: { method: string; params?: Record<string, unknown> },
|
||||
_schema: unknown,
|
||||
options?: unknown,
|
||||
): Promise<unknown> => {
|
||||
if (request.method === 'tools/list') return listTools(request.params)
|
||||
if (request.method === 'tools/call') return callTool(request.params, undefined, options)
|
||||
throw new Error(`unexpected MCP request: ${request.method}`)
|
||||
}),
|
||||
setNotificationHandler: vi.fn(),
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -205,6 +225,80 @@ describe('syncTools', () => {
|
||||
expect(ctx.tools.get('mcp__srv__page1')).toBeDefined()
|
||||
expect(ctx.tools.get('mcp__srv__page2')).toBeDefined()
|
||||
})
|
||||
|
||||
it('owns output validation independently of the SDK per-page cache', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
serverTransport.onmessage = (message) => {
|
||||
if (!('id' in message) || !('method' in message)) return
|
||||
const params = 'params' in message ? message.params : undefined
|
||||
let result: Record<string, unknown>
|
||||
if (message.method === 'initialize') {
|
||||
const protocolVersion = params && 'protocolVersion' in params
|
||||
? params.protocolVersion
|
||||
: '2025-11-25'
|
||||
result = {
|
||||
protocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 'raw-test', version: '1' },
|
||||
}
|
||||
} else if (message.method === 'tools/list') {
|
||||
const cursor = params && 'cursor' in params ? params.cursor : undefined
|
||||
result = cursor === undefined
|
||||
? {
|
||||
tools: [{
|
||||
name: 'supported',
|
||||
inputSchema: { type: 'object' },
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { answer: { type: 'integer' } },
|
||||
required: ['answer'],
|
||||
},
|
||||
}],
|
||||
nextCursor: 'page-2',
|
||||
}
|
||||
: {
|
||||
tools: [{
|
||||
name: 'future-schema',
|
||||
inputSchema: { type: 'object' },
|
||||
outputSchema: { type: 'object', patternProperties: { '^x-': { type: 'string' } } },
|
||||
}],
|
||||
}
|
||||
} else if (message.method === 'tools/call') {
|
||||
const name = params && 'name' in params ? params.name : undefined
|
||||
result = name === 'supported'
|
||||
? { content: [{ type: 'text', text: 'missing structured content' }] }
|
||||
: { content: [42, null], structuredContent: ['kept', { nested: true }] }
|
||||
} else {
|
||||
result = {}
|
||||
}
|
||||
void serverTransport.send({ jsonrpc: '2.0', id: message.id, result })
|
||||
}
|
||||
await serverTransport.start()
|
||||
const client = new Client({ name: 'cache-independent-test', version: '1' })
|
||||
await client.connect(clientTransport)
|
||||
|
||||
try {
|
||||
await syncTools(client, ctx, defaultOpts, new Map())
|
||||
|
||||
const missing = await ctx.tools.execute({
|
||||
callId: CallId('missing'), name: 'mcp__srv__supported', arguments: {},
|
||||
})
|
||||
expect(missing.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } })
|
||||
expect(missing.error?.message).toContain('structuredContent')
|
||||
|
||||
const fallback = await ctx.tools.execute({
|
||||
callId: CallId('fallback'), name: 'mcp__srv__future-schema', arguments: {},
|
||||
})
|
||||
if (fallback.isError) throw new Error('unsupported schema must use the bridge fallback')
|
||||
expect(fallback.value).toEqual({
|
||||
content: [42, null],
|
||||
structuredContent: ['kept', { nested: true }],
|
||||
})
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool execution', () => {
|
||||
@@ -360,6 +454,21 @@ describe('tool execution', () => {
|
||||
expect('value' in result).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects tools that require task-based execution', async () => {
|
||||
const client = createMockClient([
|
||||
{ name: 'task-only', inputSchema: { type: 'object' }, execution: { taskSupport: 'required' } },
|
||||
])
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('task-only'), name: 'mcp__srv__task-only', arguments: {},
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.message).toContain('requires task-based execution')
|
||||
expect(client.callTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes abort signal to callTool', async () => {
|
||||
const controller = new AbortController()
|
||||
const client = createMockClient(
|
||||
|
||||
@@ -149,7 +149,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
])
|
||||
const synthetic = loaded.events.find(e => e.type === 'tool/result')
|
||||
expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({
|
||||
callId: CallId('call-x'), isError: true, error: { info: { code: 'interrupted' } },
|
||||
callId: CallId('call-x'), isError: true, error: { code: 'interrupted' },
|
||||
})
|
||||
// The synthetic result carries the SAME callId as the orphaned tool-call,
|
||||
// so deriveMessages() pairs them — no provider-invalid dangling call.
|
||||
|
||||
@@ -165,7 +165,7 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
|
||||
// A result needs a prior matching call in the same step. (The converse
|
||||
// does NOT hold: a call may have no result — a throwing tool-execution
|
||||
// pipeline step ends the turn with no tool/result, which is legal.)
|
||||
const syntheticInterrupted = event.data.isError && event.data.error?.info?.code === 'interrupted'
|
||||
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
|
||||
if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
|
||||
throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('session-log invariants', () => {
|
||||
callId: CallId('crashed'),
|
||||
content: [{ type: 'text', text: 'interrupted' }],
|
||||
isError: true,
|
||||
error: { message: 'interrupted', info: { name: 'InterruptedError', code: 'interrupted' } },
|
||||
error: { name: 'InterruptedError', code: 'interrupted' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
@@ -500,7 +500,7 @@ describe('surface contract under the invariants composition', () => {
|
||||
callId: CallId('rewrite'),
|
||||
content: [{ type: 'text' as const, text: 'original' }],
|
||||
isError: true,
|
||||
error: { message: 'exit 1', info: { name: 'ExitError', code: 'EXIT_1' } },
|
||||
error: { name: 'ExitError', code: 'EXIT_1' },
|
||||
meta: { presentation: { kind: 'terminal', output: 'full output' } },
|
||||
futureField: { nested: ['preserve', 1] },
|
||||
}
|
||||
@@ -585,7 +585,7 @@ describe('surface contract under the invariants composition', () => {
|
||||
['callId', { callId: CallId('forged') }],
|
||||
['turn', { turn: 2 }],
|
||||
['step', { step: 2 }],
|
||||
['error', { error: { message: 'exit 1', info: { name: 'ExitError', code: 'DIFFERENT' } } }],
|
||||
['error', { error: { name: 'ExitError', code: 'DIFFERENT' } }],
|
||||
['meta', { meta: { presentation: { kind: 'generic' } } }],
|
||||
['future data', { futureField: { nested: ['changed'] } }],
|
||||
])('rejects a content rewrite with altered %s', async (_label, altered) => {
|
||||
|
||||
Reference in New Issue
Block a user