fix(experimental): drop unknown casts from the mods bridge and its tests
Type the mod tool's output as the answering text, read JSON into a typed unknown, and replace the test doubles that went through unknown with a real $ over a slow invoke, an Agent-comparable facts object, loosely typed on() calls, and the Loader's own import path.
This commit is contained in:
1 parent
35873604ad
commit
54fa38795f
7 files changed
+47
-33
No files matched your search
@@ -293,9 +293,9 @@ export function createHostOps(options: HostOpsOptions): OpTable<AgentBinding> {
|
||||
description,
|
||||
parameters: inputSchema,
|
||||
output: {
|
||||
// The answering hook's text, already rendered as content blocks.
|
||||
schema: { type: 'array' },
|
||||
render: (_args, value) => value as unknown as ContentBlock[],
|
||||
// The answering hook's result text.
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
||||
},
|
||||
execute() {
|
||||
return Promise.reject(new Error(`${mod.name} registered ${fullName} but no tool.call hook answered it; add on('tool.call', { tool: '${fullName}' }, hook)`))
|
||||
|
||||
@@ -17,7 +17,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { AssistantMessage, ContentBlock, ContextFormed, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { PostToolDecision, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { ModsEngine } from './engine.ts'
|
||||
import { createHostOps, toolCallResultOf } from './host-ops.ts'
|
||||
import type { AgentBinding } from './host-ops.ts'
|
||||
@@ -307,8 +306,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
return beneath
|
||||
}
|
||||
if (modTools.has(exec.name) && answer.isError !== true) {
|
||||
const content: ContentBlock[] = [{ type: 'text', text }]
|
||||
return { isError: false, value: content as unknown as JsonValue, content }
|
||||
return { isError: false, value: text, content: [{ type: 'text', text }] }
|
||||
}
|
||||
// A built-in tool's success value must satisfy its own output schema, so a
|
||||
// mod's answer in its place is reported as an error-shaped result.
|
||||
|
||||
@@ -124,7 +124,8 @@ function readJson(path: string): unknown {
|
||||
throw new Error(`${path}: cannot read: ${messageOf(error)}`)
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as unknown
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
return parsed
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`${path}: invalid JSON: ${messageOf(error)}`)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BudgetClock, dispatch, ENGINE_ORIGIN, HookTimeoutError } from '../src/chain.ts'
|
||||
import type { DispatchRequest, LoadedMod, RegisteredHook } from '../src/chain.ts'
|
||||
import { createModsApi } from '../src/api.ts'
|
||||
import type { AnyHook, HookMatcher, ModsApi } from '../src/types.ts'
|
||||
|
||||
const mod = (name: string, order = 0): LoadedMod => ({ name, version: undefined, root: '/mods/' + name, modulePath: '', options: {}, order })
|
||||
@@ -212,17 +213,16 @@ describe('dispatch: failures', () => {
|
||||
core: () => new Promise((resolve) => { setTimeout(() => { resolve({ result: 'slow core' }) }, 60) }),
|
||||
report,
|
||||
budgetMs: 25,
|
||||
api: (_hook, clock) => ({
|
||||
clock: {
|
||||
async sleepOutside(ms: number) {
|
||||
clock.pause()
|
||||
await new Promise(resolve => setTimeout(resolve, ms))
|
||||
clock.resume()
|
||||
},
|
||||
},
|
||||
}) as unknown as ModsApi,
|
||||
// A real `$` whose every call takes 60 ms to answer: the clock pauses for the call.
|
||||
api: (hooked, clock) => createModsApi({
|
||||
mod: hooked.mod,
|
||||
clock,
|
||||
invoke: () => new Promise((resolve) => { setTimeout(() => { resolve('contents') }, 60) }),
|
||||
timers: { after: () => ({ cancel() {} }), every: () => ({ cancel() {} }) },
|
||||
report: () => {},
|
||||
}),
|
||||
hooks: [hook(a, 'tool.call', async ($, e, next) => {
|
||||
await ($ as unknown as { clock: { sleepOutside(ms: number): Promise<void> } }).clock.sleepOutside(60)
|
||||
expect(await $.fs.read('notes.md')).toBe('contents')
|
||||
return next(e)
|
||||
})],
|
||||
}))
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import CommandRuntime from '@deepseek-ai/dsh-commands'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
@@ -44,9 +45,22 @@ function setup(ctx = new Context()) {
|
||||
return { ctx, ops, engine, context, call, registrations }
|
||||
}
|
||||
|
||||
/** The members of an Agent the host ops read; a real Agent is assignable to it, which is what the assertion below relies on. */
|
||||
interface AgentFacts {
|
||||
readonly id: string
|
||||
readonly ctx: Context
|
||||
readonly options: { readonly model?: string }
|
||||
readonly session: {
|
||||
readonly id: string
|
||||
readonly header: { readonly id: string; readonly createdAt: number; readonly cwd?: string }
|
||||
requestHeader(): { readonly config: { readonly provider: string; readonly model: string } } | undefined
|
||||
deriveMessages(): Message[]
|
||||
}
|
||||
}
|
||||
|
||||
/** A fake agent with the session facts the ops read; its scoped ctx is the root ctx. */
|
||||
function fakeAgent(ctx: Context, overrides: { cwd?: string; model?: string; requestModel?: string } = {}): Agent {
|
||||
return {
|
||||
const facts: AgentFacts = {
|
||||
id: 'fake',
|
||||
ctx,
|
||||
options: overrides.model === undefined ? {} : { model: overrides.model },
|
||||
@@ -54,9 +68,10 @@ function fakeAgent(ctx: Context, overrides: { cwd?: string; model?: string; requ
|
||||
id: 'fake',
|
||||
header: { id: 'fake', createdAt: 1, ...overrides.cwd === undefined ? {} : { cwd: overrides.cwd } },
|
||||
requestHeader: () => overrides.requestModel === undefined ? undefined : { config: { provider: 'p', model: overrides.requestModel } },
|
||||
deriveMessages: () => [],
|
||||
deriveMessages: (): Message[] => [],
|
||||
},
|
||||
} as unknown as Agent
|
||||
}
|
||||
return facts as Agent
|
||||
}
|
||||
|
||||
describe('values', () => {
|
||||
|
||||
@@ -56,13 +56,9 @@ it('loads from cordis.yml, counts the model\'s tool calls, and answers /tally th
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`Unexpected fixture module: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
// Without a Node internal loader the Loader imports bare names through this
|
||||
// test runner's module graph, which resolves workspace packages to `src`.
|
||||
context.loader.internal = undefined
|
||||
await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
|
||||
await context.loader.await()
|
||||
for (const entry of context.loader.entries()) await entry.fiber?.await()
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { LoadedMod } from '../src/chain.ts'
|
||||
import { ModsEngine, OpDenied } from '../src/engine.ts'
|
||||
import { readModManifest } from '../src/manifest.ts'
|
||||
import { createOn, HookRegistry, loadHooksModule } from '../src/module.ts'
|
||||
import type { AnyHook, ModsApi } from '../src/types.ts'
|
||||
import type { AnyHook, HookRegistration, ModsApi } from '../src/types.ts'
|
||||
|
||||
const FIXTURES = resolve(import.meta.dirname, 'fixtures')
|
||||
const mod = (name: string, order = 0): LoadedMod => ({ name, version: undefined, root: '/mods/' + name, modulePath: '', options: {}, order })
|
||||
@@ -25,15 +25,18 @@ describe('createOn', () => {
|
||||
|
||||
it('refuses what claude plugin validate refuses', () => {
|
||||
const { on } = createOn(mod('a'))
|
||||
// A JavaScript mod can pass anything, so the checks are exercised through an untyped call.
|
||||
const loose = on as (...args: unknown[]) => HookRegistration
|
||||
expect(() => on('tool.call', noop)).not.toThrow()
|
||||
expect(() => on(42 as unknown as string, noop)).toThrow(/not a string literal/)
|
||||
expect(() => loose(42, noop)).toThrow(/not a string literal/)
|
||||
expect(() => on('tool.calls', noop)).toThrow(/"tool.calls" is not an event/)
|
||||
expect(() => on('session.start', noop)).not.toThrow()
|
||||
expect(() => on('session.start', noop)).toThrow(/on\("session.start"\) is registered twice without a matcher/)
|
||||
expect(() => on('session.start', { cwd: '/x' }, noop)).not.toThrow()
|
||||
expect(() => on('turn.start', 'nope' as unknown as AnyHook)).toThrow(/needs a hook function/)
|
||||
expect(() => on('turn.start', 'nope' as unknown as Record<string, string>, noop)).toThrow(/matcher must be an object/)
|
||||
expect(() => { on('turn.complete', noop).catch('nope' as unknown as AnyHook) }).toThrow(/needs a handler function/)
|
||||
expect(() => loose('turn.start', 'nope')).toThrow(/needs a hook function/)
|
||||
expect(() => loose('turn.start', 'nope', noop)).toThrow(/matcher must be an object/)
|
||||
const attach = on('turn.complete', noop).catch as (handler: unknown) => void
|
||||
expect(() => { attach('nope') }).toThrow(/needs a handler function/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,7 +106,8 @@ describe('ModsEngine', () => {
|
||||
expect(await second.state.get(ref)).toEqual({ value: undefined })
|
||||
e.forgetState('s1')
|
||||
expect(await first.state.get(ref)).toEqual({ value: undefined })
|
||||
await expect(first.state.get({ plugin: 1 as unknown as string, key: 'x' })).rejects.toThrow(/needs \{ plugin, key \} strings/)
|
||||
await expect(e.invoke(owner, 'state.get', { plugin: 1, key: 'x' }, { key: 's1' }, new AbortController().signal))
|
||||
.rejects.toThrow(/needs \{ plugin, key \} strings/)
|
||||
const before = Date.now()
|
||||
expect(await first.clock.now()).toBeGreaterThanOrEqual(before)
|
||||
await first.clock.sleep(1)
|
||||
|
||||
Reference in new issue
Block a user