1- import { describe , expect , test } from "bun:test"
1+ import { afterEach , describe , expect , test } from "bun:test"
2+ import { createRoot } from "solid-js"
3+ import { OpenCode } from "@opencode/client/promise"
4+ import { createData } from "@opencode/client/solid"
25import type { ModelSelection } from "@/providers/models/selection"
36import type { SessionMessageUser } from "@opencode/client/promise"
47import { Skill } from "@opencode/schema/skill"
58import type { ActiveComposerAdapter , ComposerControls , ComposerSession , NewSessionComposerAdapter } from "./adapter"
69import { createMemoryComposerState } from "./state"
710import { createComposerSubmit } from "./submit"
811
12+ const disposers : Array < ( ) => void > = [ ]
13+ afterEach ( ( ) => disposers . splice ( 0 ) . forEach ( ( dispose ) => dispose ( ) ) )
14+
915const selectedModel = {
1016 id : "model-1" ,
1117 name : "Model 1" ,
@@ -87,6 +93,33 @@ function session(input: {
8793 switchAgent ?: ComposerSession [ "api" ] [ "switchAgent" ]
8894 switchModel ?: ComposerSession [ "api" ] [ "switchModel" ]
8995} ) : ComposerSession {
96+ const api = OpenCode . make ( { baseUrl : "http://opencode.test" } )
97+ const data = createRoot ( ( dispose ) => {
98+ disposers . push ( dispose )
99+ return createData ( {
100+ directory : "C:/repo" ,
101+ event : { on : ( ) => ( ) => { } , listen : ( ) => ( ) => { } } ,
102+ api : ( ) => ( {
103+ ...api ,
104+ session : {
105+ ...api . session ,
106+ prompt : async ( value ) => {
107+ if ( ! value . id ) throw new Error ( "Client admission must supply a message ID" )
108+ input . calls . push ( "prompt" )
109+ await input . prompt ( value )
110+ return {
111+ id : value . id ,
112+ sessionID : value . sessionID ,
113+ time : { created : Date . now ( ) } ,
114+ type : "user" ,
115+ delivery : value . delivery ?? "steer" ,
116+ payload : { text : value . text } ,
117+ }
118+ } ,
119+ } ,
120+ } ) ,
121+ } )
122+ } )
90123 return {
91124 id : "session-1" ,
92125 directory : "C:/repo" ,
@@ -111,10 +144,8 @@ function session(input: {
111144 location : { command : { list : ( ) => [ ] } } ,
112145 session : {
113146 setStatus : ( _sessionID , status ) => input . statuses ?. push ( status ) ,
114- prompt : async ( value ) => {
115- input . calls . push ( "prompt" )
116- await input . prompt ( value )
117- } ,
147+ mutate : data . session . mutate ,
148+ prompt : data . session . prompt ,
118149 } ,
119150 } ,
120151 }
@@ -352,6 +383,48 @@ describe("Composer submission", () => {
352383 expect ( state . current ( ) ) . toEqual ( [ { type : "text" , content : "" , start : 0 , end : 0 } ] )
353384 } )
354385
386+ test . each ( [ false , true ] ) ( "reserves prompt admission before later session mutations (image: %s)" , async ( image ) => {
387+ const state = createMemoryComposerState ( { prompt : "replace history" } ) . capture ( )
388+ if ( image )
389+ state . set ( [
390+ ...state . current ( ) ,
391+ {
392+ type : "image" ,
393+ id : "attachment" ,
394+ filename : "image.png" ,
395+ mime : "image/png" ,
396+ blob : { id : "attachment" , url : "data:image/png;base64,YQ==" } ,
397+ } ,
398+ ] )
399+ const calls : string [ ] = [ ]
400+ const target = session ( {
401+ calls,
402+ current : ( ) => ( { agent : "build" , model : { id : "model-1" , providerID : "provider-1" , variant : "balanced" } } ) ,
403+ prompt : async ( value ) => {
404+ expect ( value . files ?. length ?? 0 ) . toBe ( image ? 1 : 0 )
405+ } ,
406+ } )
407+ const adapter : ActiveComposerAdapter = {
408+ kind : "active-session" ,
409+ state,
410+ ready : ( ) => true ,
411+ controls,
412+ working : ( ) => false ,
413+ session : ( ) => target ,
414+ interrupt : async ( ) => undefined ,
415+ submitted ( ) { } ,
416+ setEditor ( ) { } ,
417+ }
418+
419+ const submitted = submitInput ( adapter ) . submit ( new Event ( "submit" ) )
420+ const redo = target . data . session . mutate ( target . id , async ( ) => {
421+ calls . push ( "redo" )
422+ } )
423+ await Promise . all ( [ submitted , redo ] )
424+
425+ expect ( calls ) . toEqual ( [ "switch-model" , "prompt" , "redo" ] )
426+ } )
427+
355428 test ( "starts and promotes a New Session once before admitting its first prompt" , async ( ) => {
356429 const draft = createMemoryComposerState ( { prompt : "first prompt" } ) . capture ( )
357430 const promoted = createMemoryComposerState ( { prompt : "restored draft" } ) . capture ( )
@@ -610,6 +683,57 @@ describe("Composer submission", () => {
610683 expect ( history ) . toEqual ( [ `add:${ text } ` , `remove:${ text } ` , `add:${ text } ` , `remove:${ text } ` ] )
611684 } )
612685
686+ test ( "preserves browser comments through failed admission and same-ID retry" , async ( ) => {
687+ const state = createMemoryComposerState ( { prompt : "Update this button" } ) . capture ( )
688+ const comment = {
689+ type : "browser" as const ,
690+ tabID : "tab_00000000-0000-4000-8000-000000000000" ,
691+ url : "http://localhost:5173/" ,
692+ element : { selector : "#save" , label : "button#save" } ,
693+ comment : "Rename this" ,
694+ }
695+ state . context . add ( { ...comment , commentID : "browser-comment" } )
696+ const requests : Parameters < ComposerSession [ "data" ] [ "session" ] [ "prompt" ] > [ 0 ] [ ] = [ ]
697+ const failed = Promise . withResolvers < void > ( )
698+ const accepted = Promise . withResolvers < void > ( )
699+ const target = session ( {
700+ calls : [ ] ,
701+ prompt : async ( value ) => {
702+ requests . push ( value )
703+ if ( requests . length <= 2 ) throw new Error ( "network unavailable" )
704+ accepted . resolve ( )
705+ } ,
706+ } )
707+ const adapter : ActiveComposerAdapter = {
708+ kind : "active-session" ,
709+ state,
710+ ready : ( ) => true ,
711+ controls,
712+ working : ( ) => false ,
713+ session : ( ) => target ,
714+ interrupt : async ( ) => undefined ,
715+ submitted ( ) { } ,
716+ setEditor ( ) { } ,
717+ }
718+ const submission = submitInput ( adapter , { missingSelection ( ) { } , failed : ( ) => failed . resolve ( ) } )
719+
720+ await submission . submit ( new Event ( "submit" ) )
721+ await failed . promise
722+ expect ( state . current ( ) ) . toMatchObject ( [ { type : "text" , content : "Update this button" } ] )
723+ expect ( state . context . items ( ) ) . toMatchObject ( [ { ...comment , commentID : "browser-comment" } ] )
724+
725+ await submission . submit ( new Event ( "submit" ) )
726+ await accepted . promise
727+ expect ( requests ) . toHaveLength ( 3 )
728+ expect ( new Set ( requests . map ( ( request ) => request . id ) ) . size ) . toBe ( 1 )
729+ requests . forEach ( ( request ) => {
730+ expect ( request . metadata ?. comments ) . toEqual ( [ comment ] )
731+ expect ( request . text ) . toContain ( 'selector "#save"' )
732+ expect ( request . text ) . toContain ( "Rename this" )
733+ } )
734+ expect ( state . context . items ( ) ) . toEqual ( [ ] )
735+ } )
736+
613737 test ( "forwards structured mentions to custom commands" , async ( ) => {
614738 const state = createMemoryComposerState ( ) . capture ( )
615739 state . set ( [
0 commit comments