11import type { MockInstance } from 'vitest' ;
22
3- const { clearScreen, outputHelp, parse, render } = vi . hoisted ( ( ) => ( {
3+ type RunAction = ( model : string , prompt : string ) => Promise < void > ;
4+
5+ const {
6+ clearScreen,
7+ createSystemMessage,
8+ executeTool,
9+ outputHelp,
10+ parse,
11+ renderApp,
12+ streamChat,
13+ } = vi . hoisted ( ( ) => ( {
414 clearScreen : vi . fn ( ) ,
15+ createSystemMessage : vi . fn ( ( ) => ( {
16+ role : 'system' ,
17+ content : 'system prompt' ,
18+ } ) ) ,
19+ executeTool : vi . fn ( ) ,
520 outputHelp : vi . fn ( ) ,
621 parse : vi . fn ( ) ,
7- render : vi . fn ( ) ,
22+ renderApp : vi . fn ( ) ,
23+ streamChat : vi . fn ( ) ,
24+ } ) ) ;
25+
26+ const commandState = vi . hoisted ( ( ) => ( {
27+ runAction : null as RunAction | null ,
828} ) ) ;
929
10- vi . mock ( './utils' , ( ) => ( { screen : { clear : clearScreen } } ) ) ;
11- vi . mock ( 'ink' , ( ) => ( { render } ) ) ;
30+ vi . mock ( './utils' , ( ) => ( {
31+ agents : { createSystemMessage } ,
32+ ollama : { streamChat } ,
33+ screen : { clear : clearScreen } ,
34+ tools : { TOOLS : [ 'mock-tool' ] , executeTool } ,
35+ } ) ) ;
36+ vi . mock ( './tui' , ( ) => ( { renderApp } ) ) ;
1237
1338vi . mock ( 'cac' , ( ) => ( {
1439 default : ( ) => ( {
1540 version : vi . fn ( ) ,
1641 help : vi . fn ( ) ,
42+ command : vi . fn ( ( ) => ( {
43+ action : vi . fn ( ( callback : RunAction ) => {
44+ commandState . runAction = callback ;
45+ } ) ,
46+ } ) ) ,
1747 outputHelp,
1848 parse,
1949 } ) ,
@@ -23,38 +53,183 @@ import { main } from './cli';
2353
2454describe ( 'cli' , ( ) => {
2555 let stdoutSpy : MockInstance < typeof process . stdout . write > ;
56+ let stderrSpy : MockInstance < typeof process . stderr . write > ;
2657
2758 beforeEach ( ( ) => {
2859 stdoutSpy = vi
2960 . spyOn ( process . stdout , 'write' )
3061 . mockImplementation ( ( ) => true ) ;
62+ stderrSpy = vi
63+ . spyOn ( process . stderr , 'write' )
64+ . mockImplementation ( ( ) => true ) ;
3165 } ) ;
3266
3367 afterEach ( ( ) => {
3468 vi . clearAllMocks ( ) ;
3569 stdoutSpy . mockRestore ( ) ;
70+ stderrSpy . mockRestore ( ) ;
71+ process . exitCode = undefined ;
3672 } ) ;
3773
38- it ( 'renders TUI with no args' , ( ) => {
39- main ( [ ] ) ;
74+ it ( 'renders TUI with no args' , async ( ) => {
75+ await main ( [ ] ) ;
4076 expect ( clearScreen ) . toHaveBeenCalledOnce ( ) ;
41- expect ( render ) . toHaveBeenCalledOnce ( ) ;
77+ expect ( renderApp ) . toHaveBeenCalledOnce ( ) ;
4278 expect ( parse ) . not . toHaveBeenCalled ( ) ;
4379 } ) ;
4480
45- it ( 'calls parse with --help' , ( ) => {
46- main ( [ '--help' ] ) ;
81+ it ( 'calls parse with --help' , async ( ) => {
82+ await main ( [ '--help' ] ) ;
4783 expect ( parse ) . toHaveBeenCalledWith ( [ 'node' , 'code-ollama' , '--help' ] ) ;
4884 expect ( outputHelp ) . not . toHaveBeenCalled ( ) ;
4985 } ) ;
5086
51- it ( 'calls parse with --version' , ( ) => {
52- main ( [ '--version' ] ) ;
87+ it ( 'calls parse with --version' , async ( ) => {
88+ await main ( [ '--version' ] ) ;
5389 expect ( parse ) . toHaveBeenCalledWith ( [ 'node' , 'code-ollama' , '--version' ] ) ;
5490 } ) ;
5591
56- it ( 'calls parse with -v' , ( ) => {
57- main ( [ '-v' ] ) ;
92+ it ( 'calls parse with -v' , async ( ) => {
93+ await main ( [ '-v' ] ) ;
5894 expect ( parse ) . toHaveBeenCalledWith ( [ 'node' , 'code-ollama' , '-v' ] ) ;
5995 } ) ;
96+
97+ it ( 'calls parse for run without rendering TUI' , async ( ) => {
98+ await main ( [ 'run' , 'gemma4' , 'review diff' ] ) ;
99+ expect ( renderApp ) . not . toHaveBeenCalled ( ) ;
100+ expect ( parse ) . toHaveBeenCalledWith ( [
101+ 'node' ,
102+ 'code-ollama' ,
103+ 'run' ,
104+ 'gemma4' ,
105+ 'review diff' ,
106+ ] ) ;
107+ } ) ;
108+
109+ it ( 'streams one-off run output with the provided model' , async ( ) => {
110+ streamChat . mockImplementationOnce ( async function * ( ) {
111+ await Promise . resolve ( ) ;
112+ yield { type : 'content' , content : 'Review complete.' } ;
113+ } ) ;
114+
115+ await commandState . runAction ?.( 'gemma4' , 'review diff' ) ;
116+
117+ expect ( createSystemMessage ) . toHaveBeenCalledOnce ( ) ;
118+ expect ( streamChat ) . toHaveBeenCalledWith (
119+ [
120+ { role : 'system' , content : 'system prompt' } ,
121+ { role : 'user' , content : 'review diff' } ,
122+ ] ,
123+ 'gemma4' ,
124+ [ 'mock-tool' ] ,
125+ ) ;
126+ expect ( stdoutSpy ) . toHaveBeenNthCalledWith ( 1 , 'Review complete.' ) ;
127+ expect ( stdoutSpy ) . toHaveBeenNthCalledWith ( 2 , '\n' ) ;
128+ } ) ;
129+
130+ it ( 'executes tool calls and continues the run conversation' , async ( ) => {
131+ streamChat
132+ . mockImplementationOnce ( async function * ( ) {
133+ await Promise . resolve ( ) ;
134+ yield {
135+ type : 'tool_calls' ,
136+ tool_calls : [
137+ {
138+ function : {
139+ name : 'run_shell' ,
140+ arguments : { command : 'git diff --stat' } ,
141+ } ,
142+ } ,
143+ ] ,
144+ } ;
145+ } )
146+ . mockImplementationOnce ( async function * ( ) {
147+ await Promise . resolve ( ) ;
148+ yield { type : 'content' , content : 'Diff reviewed.' } ;
149+ } ) ;
150+ executeTool . mockResolvedValueOnce ( {
151+ content : ' src/cli.tsx | 10 +++++++++-' ,
152+ } ) ;
153+
154+ await commandState . runAction ?.( 'gemma4' , 'review diff' ) ;
155+
156+ expect ( executeTool ) . toHaveBeenCalledWith ( 'run_shell' , {
157+ command : 'git diff --stat' ,
158+ } ) ;
159+ expect ( streamChat ) . toHaveBeenNthCalledWith (
160+ 2 ,
161+ [
162+ { role : 'system' , content : 'system prompt' } ,
163+ { role : 'user' , content : 'review diff' } ,
164+ { role : 'assistant' , content : '' } ,
165+ {
166+ role : 'system' ,
167+ content : 'Tool run_shell result:\n src/cli.tsx | 10 +++++++++-' ,
168+ } ,
169+ ] ,
170+ 'gemma4' ,
171+ [ 'mock-tool' ] ,
172+ ) ;
173+ expect ( stdoutSpy ) . toHaveBeenNthCalledWith ( 1 , 'Diff reviewed.' ) ;
174+ expect ( stdoutSpy ) . toHaveBeenNthCalledWith ( 2 , '\n' ) ;
175+ } ) ;
176+
177+ it ( 'includes tool execution errors in the follow-up run conversation' , async ( ) => {
178+ streamChat
179+ . mockImplementationOnce ( async function * ( ) {
180+ await Promise . resolve ( ) ;
181+ yield {
182+ type : 'tool_calls' ,
183+ tool_calls : [
184+ {
185+ function : {
186+ name : 'run_shell' ,
187+ arguments : { command : 'git diff --stat' } ,
188+ } ,
189+ } ,
190+ ] ,
191+ } ;
192+ } )
193+ . mockImplementationOnce ( async function * ( ) {
194+ await Promise . resolve ( ) ;
195+ yield { type : 'content' , content : 'Tool error handled.' } ;
196+ } ) ;
197+ executeTool . mockResolvedValueOnce ( {
198+ content : 'partial output' ,
199+ error : 'shell failed' ,
200+ } ) ;
201+
202+ await commandState . runAction ?.( 'gemma4' , 'review diff' ) ;
203+
204+ expect ( streamChat ) . toHaveBeenNthCalledWith (
205+ 2 ,
206+ [
207+ { role : 'system' , content : 'system prompt' } ,
208+ { role : 'user' , content : 'review diff' } ,
209+ { role : 'assistant' , content : '' } ,
210+ {
211+ role : 'system' ,
212+ content :
213+ 'Tool run_shell result:\npartial output\nError: shell failed' ,
214+ } ,
215+ ] ,
216+ 'gemma4' ,
217+ [ 'mock-tool' ] ,
218+ ) ;
219+ expect ( stdoutSpy ) . toHaveBeenNthCalledWith ( 1 , 'Tool error handled.' ) ;
220+ expect ( stdoutSpy ) . toHaveBeenNthCalledWith ( 2 , '\n' ) ;
221+ } ) ;
222+
223+ it ( 'reports run errors and sets exit code' , async ( ) => {
224+ streamChat . mockImplementationOnce ( async function * ( ) {
225+ await Promise . resolve ( ) ;
226+ throw new Error ( 'Ollama unavailable' ) ;
227+ yield { type : 'content' , content : '' } ;
228+ } ) ;
229+
230+ await commandState . runAction ?.( 'gemma4' , 'review diff' ) ;
231+
232+ expect ( stderrSpy ) . toHaveBeenCalledWith ( 'Error: Ollama unavailable\n' ) ;
233+ expect ( process . exitCode ) . toBe ( 1 ) ;
234+ } ) ;
60235} ) ;
0 commit comments