11import '@ocap/repo-tools/test-utils/mock-endoify' ;
22
3+ import type {
4+ ChatMessage ,
5+ ChatResult ,
6+ ToolCall ,
7+ } from '@ocap/kernel-language-model-service' ;
38import { describe , expect , it , vi } from 'vitest' ;
49
510import { makeChatAgent } from './chat-agent.ts' ;
611import type { BoundChat } from './chat-agent.ts' ;
712import { capability } from '../capabilities/capability.ts' ;
813
9- const makeChat = ( responses : string [ ] ) : BoundChat => {
10- let call = 0 ;
11- return async ( ) => {
12- const index = call ;
13- call += 1 ;
14- return {
15- id : String ( index ) ,
16- model : 'test' ,
17- choices : [
18- {
19- message : {
20- role : 'assistant' as const ,
21- content : responses [ index ] ?? '' ,
22- } ,
23- index : 0 ,
24- finish_reason : 'stop' ,
25- } ,
26- ] ,
27- usage : { prompt_tokens : 0 , completion_tokens : 0 , total_tokens : 0 } ,
28- } ;
29- } ;
30- } ;
14+ const makeToolCall = (
15+ id : string ,
16+ name : string ,
17+ args : Record < string , unknown > ,
18+ ) : ToolCall => ( {
19+ id,
20+ type : 'function' ,
21+ function : { name, arguments : JSON . stringify ( args ) } ,
22+ } ) ;
23+
24+ const makeTextResponse = ( content : string ) : ChatResult => ( {
25+ id : '0' ,
26+ model : 'test' ,
27+ choices : [
28+ {
29+ message : { role : 'assistant' , content } ,
30+ index : 0 ,
31+ finish_reason : 'stop' ,
32+ } ,
33+ ] ,
34+ usage : { prompt_tokens : 0 , completion_tokens : 0 , total_tokens : 0 } ,
35+ } ) ;
36+
37+ const makeToolCallResponse = (
38+ id : string ,
39+ toolCalls : ToolCall [ ] ,
40+ ) : ChatResult => ( {
41+ id,
42+ model : 'test' ,
43+ choices : [
44+ {
45+ message : { role : 'assistant' , content : '' , tool_calls : toolCalls } ,
46+ index : 0 ,
47+ finish_reason : 'tool_calls' ,
48+ } ,
49+ ] ,
50+ usage : { prompt_tokens : 0 , completion_tokens : 0 , total_tokens : 0 } ,
51+ } ) ;
3152
3253const noCapabilities = { } ;
3354
3455describe ( 'makeChatAgent' , ( ) => {
35- it ( 'returns plain text response when model does not invoke a capability ' , async ( ) => {
36- const chat = makeChat ( [ 'Hello, world!' ] ) ;
56+ it ( 'returns plain text response when model does not invoke a tool ' , async ( ) => {
57+ const chat : BoundChat = async ( ) => makeTextResponse ( 'Hello, world!' ) ;
3758 const agent = makeChatAgent ( { chat, capabilities : noCapabilities } ) ;
3859
3960 const result = await agent . task ( 'say hello' ) ;
4061 expect ( result ) . toBe ( 'Hello, world!' ) ;
4162 } ) ;
4263
43- it ( 'returns result when model invokes end capability' , async ( ) => {
44- const chat = makeChat ( [
45- '{"name": "end", "args": {"final": "the answer is 42"}}' ,
46- ] ) ;
47- const agent = makeChatAgent ( { chat, capabilities : noCapabilities } ) ;
48-
49- const result = await agent . task ( 'what is the answer?' ) ;
50- expect ( result ) . toBe ( 'the answer is 42' ) ;
51- } ) ;
52-
53- it ( 'dispatches a user capability and continues to end' , async ( ) => {
64+ it ( 'dispatches a tool call and returns final text answer' , async ( ) => {
5465 const add = vi . fn ( async ( { a, b } : { a : number ; b : number } ) => a + b ) ;
5566 const addCap = capability ( add , {
5667 description : 'Add two numbers' ,
@@ -61,100 +72,158 @@ describe('makeChatAgent', () => {
6172 returns : { type : 'number' } ,
6273 } ) ;
6374
64- const chat = makeChat ( [
65- '{"name": "add", "args": {"a": 3, "b": 4}}' ,
66- '{"name": "end", "args": {"final": "7"}}' ,
67- ] ) ;
68- const agent = makeChatAgent ( {
69- chat,
70- capabilities : { add : addCap } ,
71- } ) ;
75+ let call = 0 ;
76+ const chat : BoundChat = async ( ) => {
77+ call += 1 ;
78+ if ( call === 1 ) {
79+ return makeToolCallResponse ( '0' , [
80+ makeToolCall ( 'c1' , 'add' , { a : 3 , b : 4 } ) ,
81+ ] ) ;
82+ }
83+ return makeTextResponse ( '7' ) ;
84+ } ;
85+
86+ const agent = makeChatAgent ( { chat, capabilities : { add : addCap } } ) ;
7287
7388 const result = await agent . task ( 'add 3 and 4' ) ;
7489 expect ( add ) . toHaveBeenCalledWith ( { a : 3 , b : 4 } ) ;
7590 expect ( result ) . toBe ( '7' ) ;
7691 } ) ;
7792
78- it ( 'injects tool result into messages before next turn' , async ( ) => {
79- const messages : string [ ] [ ] = [ ] ;
80- const chat : BoundChat = async ( chatMsgs ) => {
81- messages . push ( chatMsgs . map ( ( chatMsg ) => chatMsg . content ) ) ;
82- const turn = messages . length - 1 ;
83- return {
84- id : String ( turn ) ,
85- model : 'test' ,
86- choices : [
87- {
88- message : {
89- role : 'assistant' as const ,
90- content :
91- turn === 0
92- ? '{"name": "ping", "args": {}}'
93- : '{"name": "end", "args": {"final": "done"}}' ,
94- } ,
95- index : 0 ,
96- finish_reason : 'stop' ,
97- } ,
98- ] ,
99- usage : { prompt_tokens : 0 , completion_tokens : 0 , total_tokens : 0 } ,
100- } ;
101- } ;
102-
93+ it ( 'injects tool result message before next turn' , async ( ) => {
94+ const recorded : ChatMessage [ ] [ ] = [ ] ;
10395 const ping = capability ( async ( ) => 'pong' , {
10496 description : 'Ping' ,
10597 args : { } ,
10698 returns : { type : 'string' } ,
10799 } ) ;
100+
101+ let call = 0 ;
102+ const chat : BoundChat = async ( { messages } ) => {
103+ recorded . push ( [ ...messages ] ) ;
104+ call += 1 ;
105+ if ( call === 1 ) {
106+ return makeToolCallResponse ( '0' , [ makeToolCall ( 'c1' , 'ping' , { } ) ] ) ;
107+ }
108+ return makeTextResponse ( 'done' ) ;
109+ } ;
110+
108111 const agent = makeChatAgent ( { chat, capabilities : { ping } } ) ;
109112 await agent . task ( 'ping' ) ;
110113
111- // Second turn messages should include the tool result
114+ // Second turn must include the tool result message
115+ const secondTurn = recorded [ 1 ] ?? [ ] ;
112116 expect (
113- messages [ 1 ] ?. some ( ( content ) => content . includes ( '[Result of ping]' ) ) ,
117+ secondTurn . some (
118+ ( message ) => message . role === 'tool' && message . tool_call_id === 'c1' ,
119+ ) ,
114120 ) . toBe ( true ) ;
121+ expect ( secondTurn . some ( ( message ) => message . content === '"pong"' ) ) . toBe (
122+ true ,
123+ ) ;
115124 } ) ;
116125
117- it ( 'appends error message for unknown capability and continues' , async ( ) => {
118- const chat = makeChat ( [
119- '{"name": "nonexistent", "args": {}}' ,
120- '{"name": "end", "args": {"final": "recovered"}}' ,
121- ] ) ;
122- const agent = makeChatAgent ( { chat, capabilities : noCapabilities } ) ;
126+ it ( 'injects error message for unknown tool and continues' , async ( ) => {
127+ const recorded : ChatMessage [ ] [ ] = [ ] ;
128+ let call = 0 ;
129+ const chat : BoundChat = async ( { messages } ) => {
130+ recorded . push ( [ ...messages ] ) ;
131+ call += 1 ;
132+ if ( call === 1 ) {
133+ return makeToolCallResponse ( '0' , [
134+ makeToolCall ( 'c1' , 'nonexistent' , { } ) ,
135+ ] ) ;
136+ }
137+ return makeTextResponse ( 'recovered' ) ;
138+ } ;
123139
140+ const agent = makeChatAgent ( { chat, capabilities : noCapabilities } ) ;
124141 const result = await agent . task ( 'do something' ) ;
142+
125143 expect ( result ) . toBe ( 'recovered' ) ;
144+ const secondTurn = recorded [ 1 ] ?? [ ] ;
145+ expect (
146+ secondTurn . some (
147+ ( message ) =>
148+ message . role === 'tool' &&
149+ message . content . includes ( 'Unknown capability' ) ,
150+ ) ,
151+ ) . toBe ( true ) ;
126152 } ) ;
127153
128154 it ( 'throws when invocation budget is exceeded' , async ( ) => {
129- // Always invokes a capability but never ends
130155 const ping = capability ( async ( ) => 'pong' , {
131156 description : 'Ping' ,
132157 args : { } ,
133158 } ) ;
134- const neverEnd = makeChat (
135- Array . from ( { length : 20 } , ( ) => '{"name": "ping", "args": {}}' ) ,
136- ) ;
137- const agent = makeChatAgent ( {
138- chat : neverEnd ,
139- capabilities : { ping } ,
140- } ) ;
159+ const chat : BoundChat = async ( ) =>
160+ makeToolCallResponse ( '0' , [ makeToolCall ( 'c1' , 'ping' , { } ) ] ) ;
161+
162+ const agent = makeChatAgent ( { chat, capabilities : { ping } } ) ;
141163
142164 await expect (
143165 agent . task ( 'go' , undefined , { invocationBudget : 3 } ) ,
144166 ) . rejects . toThrow ( 'Invocation budget exceeded' ) ;
145167 } ) ;
146168
147- it ( 'applies judgment to end result' , async ( ) => {
148- const chat = makeChat ( [ '{"name": "end", "args": {"final": 99}}' ] ) ;
169+ it ( 'applies judgment to final answer' , async ( ) => {
170+ const chat : BoundChat = async ( ) => makeTextResponse ( 'hello' ) ;
171+ const agent = makeChatAgent ( { chat, capabilities : noCapabilities } ) ;
172+
173+ const isNumber = ( result : unknown ) : result is number =>
174+ typeof result === 'number' ;
175+ await expect ( agent . task ( 'go' , isNumber ) ) . rejects . toThrow ( 'Invalid result' ) ;
176+ } ) ;
177+
178+ it ( 'passes tools to the chat function' , async ( ) => {
179+ const recordedTools : unknown [ ] = [ ] ;
180+ const ping = capability ( async ( ) => 'pong' , {
181+ description : 'Ping the server' ,
182+ args : { } ,
183+ returns : { type : 'string' } ,
184+ } ) ;
185+
186+ const chat : BoundChat = async ( { tools } ) => {
187+ recordedTools . push ( tools ) ;
188+ return makeTextResponse ( 'done' ) ;
189+ } ;
190+
191+ const agent = makeChatAgent ( { chat, capabilities : { ping } } ) ;
192+ await agent . task ( 'go' ) ;
193+
194+ expect ( recordedTools [ 0 ] ) . toStrictEqual ( [
195+ {
196+ type : 'function' ,
197+ function : {
198+ name : 'ping' ,
199+ description : 'Ping the server' ,
200+ parameters : { type : 'object' , properties : { } , required : [ ] } ,
201+ } ,
202+ } ,
203+ ] ) ;
204+ } ) ;
205+
206+ it ( 'passes undefined tools when there are no capabilities' , async ( ) => {
207+ let recordedTools : unknown = 'not-set' ;
208+ const chat : BoundChat = async ( { tools } ) => {
209+ recordedTools = tools ;
210+ return makeTextResponse ( 'done' ) ;
211+ } ;
212+
149213 const agent = makeChatAgent ( { chat, capabilities : noCapabilities } ) ;
214+ await agent . task ( 'go' ) ;
150215
151- const isString = ( result : unknown ) : result is string =>
152- typeof result === 'string' ;
153- await expect ( agent . task ( 'go' , isString ) ) . rejects . toThrow ( 'Invalid result' ) ;
216+ expect ( recordedTools ) . toBeUndefined ( ) ;
154217 } ) ;
155218
156219 it ( 'accumulates experiences across tasks' , async ( ) => {
157- const chat = makeChat ( [ 'hello' , 'world' ] ) ;
220+ let call = 0 ;
221+ const responses = [ 'hello' , 'world' ] ;
222+ const chat : BoundChat = async ( ) => {
223+ const response = makeTextResponse ( responses [ call ] ?? '' ) ;
224+ call += 1 ;
225+ return response ;
226+ } ;
158227 const agent = makeChatAgent ( { chat, capabilities : noCapabilities } ) ;
159228
160229 await agent . task ( 'first' ) ;
0 commit comments