diff --git a/packages/experimental/claude-code-mods/src/host-ops.ts b/packages/experimental/claude-code-mods/src/host-ops.ts index 894f9a9312..e4e7da0b61 100644 --- a/packages/experimental/claude-code-mods/src/host-ops.ts +++ b/packages/experimental/claude-code-mods/src/host-ops.ts @@ -293,9 +293,9 @@ export function createHostOps(options: HostOpsOptions): OpTable { 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)`)) diff --git a/packages/experimental/claude-code-mods/src/index.ts b/packages/experimental/claude-code-mods/src/index.ts index abbf5d8931..52b0b99466 100644 --- a/packages/experimental/claude-code-mods/src/index.ts +++ b/packages/experimental/claude-code-mods/src/index.ts @@ -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 { 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. diff --git a/packages/experimental/claude-code-mods/src/manifest.ts b/packages/experimental/claude-code-mods/src/manifest.ts index 46e5a4a2be..472cba3aee 100644 --- a/packages/experimental/claude-code-mods/src/manifest.ts +++ b/packages/experimental/claude-code-mods/src/manifest.ts @@ -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)}`) } diff --git a/packages/experimental/claude-code-mods/tests/chain.spec.ts b/packages/experimental/claude-code-mods/tests/chain.spec.ts index 8b37603988..25387aab18 100644 --- a/packages/experimental/claude-code-mods/tests/chain.spec.ts +++ b/packages/experimental/claude-code-mods/tests/chain.spec.ts @@ -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 } }).clock.sleepOutside(60) + expect(await $.fs.read('notes.md')).toBe('contents') return next(e) })], })) diff --git a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts index ec7d4a13e1..845a12d4ca 100644 --- a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts +++ b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts @@ -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', () => { diff --git a/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts b/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts index b7ec708d00..9e3705ea1b 100644 --- a/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts +++ b/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts @@ -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 + // 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() diff --git a/packages/experimental/claude-code-mods/tests/module.spec.ts b/packages/experimental/claude-code-mods/tests/module.spec.ts index 5f2ed0d257..c66388fa6d 100644 --- a/packages/experimental/claude-code-mods/tests/module.spec.ts +++ b/packages/experimental/claude-code-mods/tests/module.spec.ts @@ -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, 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)