@@ -56,8 +56,11 @@ describe('AppServer', () => {
5656 it ( 'persists completed items and terminal state while publishing deltas transiently' , async ( ) => {
5757 const events : ProtocolEvent [ ] = [ ] ;
5858 const executor : TurnExecutor = {
59- execute : async ( { publishDelta } ) => {
59+ execute : async ( { publishDelta, publishToolStarted , publishToolCompleted , publishUsage } ) => {
6060 publishDelta ( 'assistant-stream' , 'hel' ) ;
61+ publishToolStarted ( 'tool-1' , 'Read' , { file_path : 'README.md' } ) ;
62+ publishToolCompleted ( 'tool-1' , { content : 'contents' } ) ;
63+ publishUsage ( { inputTokens : 1 , outputTokens : 2 } ) ;
6164 return {
6265 items : [ { type : 'assistant_message' , payload : { text : 'hello' } } ] ,
6366 } ;
@@ -94,6 +97,9 @@ describe('AppServer', () => {
9497 } ) ,
9598 } ) ;
9699 expect ( events . map ( ( event ) => event . type ) ) . toContain ( 'item.delta' ) ;
100+ expect ( events . map ( ( event ) => event . type ) ) . toEqual (
101+ expect . arrayContaining ( [ 'tool.started' , 'tool.completed' , 'usage.updated' ] ) ,
102+ ) ;
97103 expect ( ( read . result as { turns : Array < { items : unknown [ ] } > } ) . turns [ 0 ] ?. items ) . toHaveLength ( 2 ) ;
98104 } ) ;
99105
@@ -136,6 +142,102 @@ describe('AppServer', () => {
136142 expect ( events . filter ( ( event ) => event . type === 'turn.completed' ) ) . toHaveLength ( 0 ) ;
137143 } ) ;
138144
145+ it ( 'round-trips approval and user-input requests through the active turn' , async ( ) => {
146+ const events : ProtocolEvent [ ] = [ ] ;
147+ const responses : string [ ] = [ ] ;
148+ const executor : TurnExecutor = {
149+ execute : async ( { requestApproval, requestUserInput } ) => {
150+ responses . push ( await requestApproval ( 'Bash' , 'Run tests?' ) ) ;
151+ responses . push (
152+ await requestUserInput ( {
153+ question : 'Choose scope' ,
154+ options : [ { label : 'All' , description : 'Run every test' } ] ,
155+ } ) ,
156+ ) ;
157+ return { } ;
158+ } ,
159+ } ;
160+ const server = new AppServer ( { executor, onEvent : ( event ) => events . push ( event ) } ) ;
161+ const thread = await server . handle ( request ( 1 , 'thread/start' , { cwd : '/workspace' } ) ) ;
162+ const threadId = ( thread . result as { id : string } ) . id ;
163+ const started = await server . handle (
164+ request ( 2 , 'turn/start' , { threadId, input : { text : 'test' } } ) ,
165+ ) ;
166+ const turnId = ( started . result as { id : string } ) . id ;
167+ const approval = events . find ( ( event ) => event . type === 'approval.requested' ) ;
168+ expect ( approval ) . toEqual (
169+ expect . objectContaining ( { type : 'approval.requested' , threadId, turnId, toolName : 'Bash' } ) ,
170+ ) ;
171+
172+ await expect (
173+ server . handle (
174+ request ( 3 , 'approval/respond' , {
175+ threadId,
176+ turnId,
177+ requestId : approval ?. type === 'approval.requested' ? approval . requestId : '' ,
178+ decision : 'allow' ,
179+ } ) ,
180+ ) ,
181+ ) . resolves . toEqual ( { id : 3 , result : { accepted : true } } ) ;
182+ await Promise . resolve ( ) ;
183+
184+ const question = events . find ( ( event ) => event . type === 'user-input.requested' ) ;
185+ expect ( question ) . toEqual (
186+ expect . objectContaining ( { type : 'user-input.requested' , threadId, turnId } ) ,
187+ ) ;
188+ await server . handle (
189+ request ( 4 , 'user-input/respond' , {
190+ threadId,
191+ turnId,
192+ requestId : question ?. type === 'user-input.requested' ? question . requestId : '' ,
193+ answer : 'All' ,
194+ } ) ,
195+ ) ;
196+ await server . waitForIdle ( ) ;
197+
198+ expect ( responses ) . toEqual ( [ 'allow' , 'All' ] ) ;
199+ expect ( events . filter ( ( event ) => event . type === 'turn.completed' ) ) . toHaveLength ( 1 ) ;
200+ await expect (
201+ server . handle (
202+ request ( 5 , 'approval/respond' , {
203+ threadId,
204+ turnId,
205+ requestId : approval ?. type === 'approval.requested' ? approval . requestId : '' ,
206+ decision : 'allow' ,
207+ } ) ,
208+ ) ,
209+ ) . resolves . toEqual ( {
210+ id : 5 ,
211+ error : expect . objectContaining ( { code : 'invalid_request' } ) ,
212+ } ) ;
213+ } ) ;
214+
215+ it ( 'releases a pending interaction when its turn is interrupted' , async ( ) => {
216+ let decision : string | undefined ;
217+ const executor : TurnExecutor = {
218+ execute : async ( { requestApproval } ) => {
219+ decision = await requestApproval ( 'Bash' , 'Run forever?' ) ;
220+ return { } ;
221+ } ,
222+ } ;
223+ const server = new AppServer ( { executor } ) ;
224+ const thread = await server . handle ( request ( 1 , 'thread/start' , { cwd : '/workspace' } ) ) ;
225+ const threadId = ( thread . result as { id : string } ) . id ;
226+ const started = await server . handle (
227+ request ( 2 , 'turn/start' , { threadId, input : { text : 'wait' } } ) ,
228+ ) ;
229+ const turnId = ( started . result as { id : string } ) . id ;
230+
231+ await server . handle ( request ( 3 , 'turn/interrupt' , { threadId, turnId } ) ) ;
232+ await server . waitForIdle ( ) ;
233+
234+ expect ( decision ) . toBe ( 'deny' ) ;
235+ const read = await server . handle ( request ( 4 , 'thread/read' , { threadId } ) ) ;
236+ expect ( read . result ) . toEqual (
237+ expect . objectContaining ( { turns : [ expect . objectContaining ( { status : 'interrupted' } ) ] } ) ,
238+ ) ;
239+ } ) ;
240+
139241 it ( 'marks an orphaned active turn interrupted when a new process resumes it' , async ( ) => {
140242 const root = await mkdtemp ( join ( tmpdir ( ) , 'deepcode-app-server-' ) ) ;
141243 temporaryRoots . push ( root ) ;
0 commit comments