2026-07-08 11:45:46 +08:00
import { describe , expect , it } from 'vitest'
import { Context } from 'cordis'
2026-07-28 13:55:59 +08:00
import { createUserMessage , CallId } from '@deepseek-ai/dsh-llm'
2026-07-18 12:21:15 +08:00
import { SessionId } from '@deepseek-ai/dsh-session'
2026-07-18 12:33:49 +08:00
import type { Agent } from '@deepseek-ai/dsh-agent'
2026-07-14 02:32:35 +08:00
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
2026-07-16 17:39:53 +08:00
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
2026-07-08 11:45:46 +08:00
import * as ToolCordis from '../src/index.ts'
import { MockAdapter , textResponse , toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
2026-07-27 16:57:26 +08:00
import { call , REVERSE_TOOL_CODE , setup , text } from './helpers.ts'
2026-07-08 11:45:46 +08:00
/**
* Full-loop integration: a scripted mock model mounts a plugin that registers
* a NEW tool, calls that tool on the very next step (tool schemas are
* reassembled per step — the real loop proves the self-extension contract),
* and unmounts it again. Only the model is mocked; the sandbox, the fiber
* tree, and the session log are real.
*/
async function harness ( adapter : MockAdapter ) : Promise < Context > {
const ctx = new Context ( )
2026-07-16 17:39:53 +08:00
await mountAgentLoopTestDependencies ( ctx )
2026-07-08 11:45:46 +08:00
await ctx . plugin ( AgentLoop , { agents : [ ] } )
await ctx . plugin ( ToolCordis )
ctx . llm . registerAdapter ( [ 'mock' ] , adapter )
return ctx
}
2026-07-14 02:32:35 +08:00
function waitForIdle ( ctx : Context , agent : Agent ) : Promise < void > {
2026-07-08 11:45:46 +08:00
return new Promise ( ( resolve ) = > {
2026-08-06 12:13:14 +08:00
const dispose = ctx . on ( 'agent/status' , ( { agent : subject , status } ) = > {
2026-07-08 11:45:46 +08:00
if ( subject === agent && status === 'idle' ) {
dispose ( )
resolve ( )
}
} )
} )
}
describe ( 'cordis tools through the agent loop' , ( ) = > {
it ( 'mounts a tool, calls it on the next step, and unmounts it — all as real tool/call events' , async ( ) = > {
const adapter = new MockAdapter ( [
2026-07-27 21:20:06 +08:00
toolCallResponse ( 'call-1' , 'cordis_mount' , { code : REVERSE_TOOL_CODE } , 'Extending myself.' ) ,
2026-07-08 11:45:46 +08:00
toolCallResponse ( 'call-2' , 'reverse_text' , { text : 'harness' } ) ,
2026-07-27 21:20:06 +08:00
toolCallResponse ( 'call-3' , 'cordis_unmount' , { id : 'dyn-1' } ) ,
2026-07-08 11:45:46 +08:00
textResponse ( 'Done.' ) ,
] )
const ctx = await harness ( adapter )
2026-07-18 12:21:15 +08:00
const agent = ctx . agentLoop . create ( SessionId ( 'it-cordis' ) , { provider : 'mock' , model : 'mock' } )
2026-07-08 11:45:46 +08:00
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'give yourself reverse_text, use it, clean up' } ] , source : { kind : 'user' } } ) )
2026-07-08 11:45:46 +08:00
await waitForIdle ( ctx , agent )
const log = agent . session . events
const calls = log . filter ( event = > event . type === 'tool/call' ) . map ( event = > event . data . name )
2026-07-27 21:20:06 +08:00
expect ( calls ) . toEqual ( [ 'cordis_mount' , 'reverse_text' , 'cordis_unmount' ] )
2026-07-08 11:45:46 +08:00
const results = log . filter ( event = > event . type === 'tool/result' )
2026-07-28 13:55:59 +08:00
expect ( results . map ( event = > event . data . message . content [ 0 ] . isError ) ) . toEqual ( [ false , false , false ] )
const reversed = results [ 1 ] ! . data . message . content [ 0 ] . content
2026-07-08 11:45:46 +08:00
. filter ( block = > block . type === 'text' )
. map ( block = > block . text )
. join ( '' )
expect ( reversed ) . toBe ( 'ssenrah' )
// After the unmount the self-made tool is gone from the registry.
expect ( ctx . tools . get ( 'reverse_text' ) ) . toBeUndefined ( )
} )
2026-07-27 16:57:26 +08:00
2026-07-27 21:20:06 +08:00
it ( 'keeps a temporary Plugin across turns, unmounts it, and does not restore it in a new runtime' , async ( ) = > {
2026-07-27 16:57:26 +08:00
const adapter = new MockAdapter ( [
2026-07-27 21:20:06 +08:00
toolCallResponse ( 'mount-1' , 'cordis_mount' , { code : 'return { name: \'turn-marker\', apply() {} }' } ) ,
2026-07-27 16:57:26 +08:00
toolCallResponse ( 'inspect-1' , 'cordis_inspect' , { what : 'temporary' } ) ,
textResponse ( 'Turn one complete.' ) ,
toolCallResponse ( 'inspect-2' , 'cordis_inspect' , { what : 'temporary' } ) ,
2026-07-27 21:20:06 +08:00
toolCallResponse ( 'unmount-1' , 'cordis_unmount' , { id : 'dyn-1' } ) ,
2026-07-27 16:57:26 +08:00
toolCallResponse ( 'inspect-3' , 'cordis_inspect' , { what : 'temporary' } ) ,
textResponse ( 'Turn two complete.' ) ,
] )
const ctx = await harness ( adapter )
const agent = ctx . agentLoop . create ( SessionId ( 'it-cordis-turn-lifetime' ) , { provider : 'mock' , model : 'mock' } )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'Mount the marker and inspect it.' } ] , source : { kind : 'user' } } ) )
2026-07-27 16:57:26 +08:00
await waitForIdle ( ctx , agent )
2026-07-28 13:55:59 +08:00
agent . followup ( createUserMessage ( { content : [ { type : 'text' , text : 'On this later turn, inspect the marker, unmount it, then inspect again.' } ] , source : { kind : 'user' } } ) )
2026-07-27 16:57:26 +08:00
await waitForIdle ( ctx , agent )
const resultText = new Map (
agent . session . events
. filter ( event = > event . type === 'tool/result' )
2026-07-28 13:55:59 +08:00
. map ( event = > [ event . data . message . source . callId , event . data . message . content [ 0 ] . content . filter ( block = > block . type === 'text' ) . map ( block = > block . text ) . join ( '' ) ] ) ,
2026-07-27 16:57:26 +08:00
)
expect ( resultText . get ( CallId ( 'inspect-1' ) ) ) . toContain ( 'Temporary Plugin dyn-1: turn-marker [running]' )
expect ( resultText . get ( CallId ( 'inspect-2' ) ) ) . toContain ( 'Temporary Plugin dyn-1: turn-marker [running]' )
2026-07-27 21:20:06 +08:00
expect ( resultText . get ( CallId ( 'unmount-1' ) ) ) . toBe ( 'Temporary Plugin dyn-1 was unmounted and removed.' )
2026-07-27 16:57:26 +08:00
expect ( resultText . get ( CallId ( 'inspect-3' ) ) ) . toContain ( 'No temporary Plugins are running.' )
const restarted = await setup ( )
expect ( text ( await call ( restarted , 'cordis_inspect' , { what : 'temporary' } ) ) ) . toContain ( 'No temporary Plugins are running.' )
} )
2026-07-08 11:45:46 +08:00
} )