@@ -40,7 +40,8 @@ func TestE2E(t *testing.T) {
4040 t .Run ("basic" , func (t * testing.T ) {
4141 ctx , cancel := context .WithTimeout (context .Background (), testTimeout )
4242 defer cancel ()
43- script , apiClient := setup (ctx , t , nil )
43+ script , apiClient , cleanup := setup (ctx , t , nil , true )
44+ defer cleanup ()
4445 messageReq := agentapisdk.PostMessageParams {
4546 Content : "This is a test message." ,
4647 Type : agentapisdk .MessageTypeUser ,
@@ -60,7 +61,8 @@ func TestE2E(t *testing.T) {
6061 ctx , cancel := context .WithTimeout (context .Background (), testTimeout )
6162 defer cancel ()
6263
63- script , apiClient := setup (ctx , t , nil )
64+ script , apiClient , cleanup := setup (ctx , t , nil , true )
65+ defer cleanup ()
6466 messageReq := agentapisdk.PostMessageParams {
6567 Content : "What is the answer to life, the universe, and everything?" ,
6668 Type : agentapisdk .MessageTypeUser ,
@@ -86,41 +88,245 @@ func TestE2E(t *testing.T) {
8688 ctx , cancel := context .WithTimeout (context .Background (), testTimeout )
8789 defer cancel ()
8890
89- script , apiClient := setup (ctx , t , & params {
91+ script , apiClient , cleanup := setup (ctx , t , & params {
9092 cmdFn : func (ctx context.Context , t testing.TB , serverPort int , binaryPath , cwd , scriptFilePath string ) (string , []string ) {
9193 defCmd , defArgs := defaultCmdFn (ctx , t , serverPort , binaryPath , cwd , scriptFilePath )
9294 script := fmt .Sprintf (`echo "hello agent" | %s %s` , defCmd , strings .Join (defArgs , " " ))
9395 return "/bin/sh" , []string {"-c" , script }
9496 },
95- })
97+ }, true )
98+ defer cleanup ()
9699 require .NoError (t , waitAgentAPIStable (ctx , t , apiClient , 5 * time .Second , "stdin" ))
97100 msgResp , err := apiClient .GetMessages (ctx )
98101 require .NoError (t , err , "Failed to get messages via SDK" )
99102 require .Len (t , msgResp .Messages , 3 )
100103 require .Equal (t , script [0 ].ExpectMessage , strings .TrimSpace (msgResp .Messages [1 ].Content ))
101104 require .Equal (t , script [0 ].ResponseMessage , strings .TrimSpace (msgResp .Messages [2 ].Content ))
102105 })
106+
107+ t .Run ("state_persistence" , func (t * testing.T ) {
108+ ctx , cancel := context .WithTimeout (context .Background (), testTimeout )
109+ defer cancel ()
110+
111+ // Create a temporary state file
112+ stateFile := filepath .Join (t .TempDir (), "state.json" )
113+ scriptFilePath := filepath .Join ("testdata" , "state_persistence.json" )
114+
115+ // Step 1: Start server with state persistence enabled and send first message
116+ script , apiClient , cleanup := setup (ctx , t , & params {
117+ stateFile : stateFile ,
118+ scriptFilePath : scriptFilePath ,
119+ }, true )
120+
121+ // Send first message
122+ messageReq := agentapisdk.PostMessageParams {
123+ Content : "First message before state save." ,
124+ Type : agentapisdk .MessageTypeUser ,
125+ }
126+ _ , err := apiClient .PostMessage (ctx , messageReq )
127+ require .NoError (t , err , "Failed to send first message" )
128+ require .NoError (t , waitAgentAPIStable (ctx , t , apiClient , operationTimeout , "first message" ))
129+
130+ // Verify messages before shutdown
131+ msgResp , err := apiClient .GetMessages (ctx )
132+ require .NoError (t , err , "Failed to get messages before shutdown" )
133+ require .Len (t , msgResp .Messages , 3 , "Expected 3 messages before shutdown" )
134+ require .Equal (t , script [0 ].ResponseMessage , strings .TrimSpace (msgResp .Messages [0 ].Content ))
135+ require .Equal (t , script [1 ].ExpectMessage , strings .TrimSpace (msgResp .Messages [1 ].Content ))
136+ require .Equal (t , script [1 ].ResponseMessage , strings .TrimSpace (msgResp .Messages [2 ].Content ))
137+
138+ // Step 2: Stop server (triggers state save)
139+ cleanup ()
140+
141+ // Give filesystem a moment to sync
142+ time .Sleep (100 * time .Millisecond )
143+
144+ // Verify state file was created
145+ require .FileExists (t , stateFile , "State file should exist after shutdown" )
146+
147+ // Step 3: Start new server instance and load state
148+ // Note: We don't wait for stable here because the echo agent will try to replay
149+ // from the beginning, which conflicts with restored state. We just verify the
150+ // state was loaded and messages are present.
151+ _ , apiClient2 , cleanup2 := setup (ctx , t , & params {
152+ stateFile : stateFile ,
153+ scriptFilePath : scriptFilePath ,
154+ }, false )
155+ defer cleanup2 ()
156+
157+ // Give the server a moment to load state
158+ time .Sleep (500 * time .Millisecond )
159+
160+ // Step 4: Verify state was restored by checking messages via API
161+ msgResp2 , err := apiClient2 .GetMessages (ctx )
162+ require .NoError (t , err , "Failed to get messages after state restore" )
163+ require .Len (t , msgResp2 .Messages , 3 , "Expected 3 messages after state restore" )
164+
165+ // Verify all messages match the state before shutdown
166+ require .Equal (t , script [0 ].ResponseMessage , strings .TrimSpace (msgResp2 .Messages [0 ].Content ))
167+ require .Equal (t , script [1 ].ExpectMessage , strings .TrimSpace (msgResp2 .Messages [1 ].Content ))
168+ require .Equal (t , script [1 ].ResponseMessage , strings .TrimSpace (msgResp2 .Messages [2 ].Content ))
169+ })
170+
171+ t .Run ("state_persistence_initial_prompt" , func (t * testing.T ) {
172+ ctx , cancel := context .WithTimeout (context .Background (), testTimeout )
173+ defer cancel ()
174+
175+ // Create a temporary state file
176+ stateFile := filepath .Join (t .TempDir (), "state.json" )
177+ scriptFilePath := filepath .Join ("testdata" , "state_persistence_initial_prompt.json" )
178+
179+ // Step 1: Start server with initial prompt
180+ initialPrompt1 := "Test initial prompt"
181+ _ , apiClient , cleanup := setup (ctx , t , & params {
182+ stateFile : stateFile ,
183+ scriptFilePath : scriptFilePath ,
184+ initialPrompt : initialPrompt1 ,
185+ }, true )
186+
187+ // Verify initial prompt was sent (should have 3 messages: agent greeting + initial prompt + response)
188+ msgResp , err := apiClient .GetMessages (ctx )
189+ require .NoError (t , err , "Failed to get messages after initial prompt" )
190+ require .Len (t , msgResp .Messages , 3 , "Expected 3 messages after initial prompt" )
191+ require .Equal (t , "Hello! I'm ready to help you." , strings .TrimSpace (msgResp .Messages [0 ].Content ))
192+ require .Equal (t , initialPrompt1 , strings .TrimSpace (msgResp .Messages [1 ].Content ))
193+ require .Equal (t , "Echo: Test initial prompt" , strings .TrimSpace (msgResp .Messages [2 ].Content ))
194+
195+ // Step 2: Close server
196+ cleanup ()
197+ time .Sleep (100 * time .Millisecond )
198+ require .FileExists (t , stateFile , "State file should exist after shutdown" )
199+
200+ // Step 3: Restart WITHOUT an initial prompt
201+ _ , apiClient2 , cleanup2 := setup (ctx , t , & params {
202+ stateFile : stateFile ,
203+ scriptFilePath : scriptFilePath ,
204+ }, false )
205+ defer cleanup2 ()
206+ time .Sleep (500 * time .Millisecond )
207+
208+ // Step 4: Verify initial prompt was NOT sent again (should still have 3 messages)
209+ msgResp2 , err := apiClient2 .GetMessages (ctx )
210+ require .NoError (t , err , "Failed to get messages after restart without initial prompt" )
211+ require .Len (t , msgResp2 .Messages , 3 , "Expected 3 messages (initial prompt should not be sent again)" )
212+ require .Equal (t , initialPrompt1 , strings .TrimSpace (msgResp2 .Messages [1 ].Content ))
213+
214+ // Step 5: Close server
215+ cleanup2 ()
216+ time .Sleep (100 * time .Millisecond )
217+
218+ // Step 6: Restart with same initial prompt
219+ _ , apiClient3 , cleanup3 := setup (ctx , t , & params {
220+ stateFile : stateFile ,
221+ scriptFilePath : scriptFilePath ,
222+ initialPrompt : initialPrompt1 ,
223+ }, false )
224+ defer cleanup3 ()
225+ time .Sleep (500 * time .Millisecond )
226+
227+ // Step 7: Verify same initial prompt was NOT sent again (should still have 3 messages)
228+ msgResp3 , err := apiClient3 .GetMessages (ctx )
229+ require .NoError (t , err , "Failed to get messages after restart with same initial prompt" )
230+ require .Len (t , msgResp3 .Messages , 3 , "Expected 3 messages (same initial prompt should not be sent again)" )
231+
232+ })
233+
234+ t .Run ("state_persistence_different_initial_prompt" , func (t * testing.T ) {
235+ ctx , cancel := context .WithTimeout (context .Background (), testTimeout )
236+ defer cancel ()
237+
238+ // Create a temporary state file
239+ stateFile := filepath .Join (t .TempDir (), "state.json" )
240+
241+ // Step 1: Start server with initial prompt "Test initial prompt" using phase1 script
242+ initialPrompt1 := "Test initial prompt"
243+ _ , apiClient , cleanup := setup (ctx , t , & params {
244+ stateFile : stateFile ,
245+ scriptFilePath : filepath .Join ("testdata" , "state_persistence_different_initial_prompt_phase1.json" ),
246+ initialPrompt : initialPrompt1 ,
247+ }, true )
248+
249+ // Verify initial prompt was sent (3 messages: greeting + prompt + response)
250+ msgResp , err := apiClient .GetMessages (ctx )
251+ require .NoError (t , err , "Failed to get messages after initial prompt" )
252+ require .Len (t , msgResp .Messages , 3 , "Expected 3 messages after initial prompt" )
253+ require .Equal (t , "Hello! I'm ready to help you." , strings .TrimSpace (msgResp .Messages [0 ].Content ))
254+ require .Equal (t , initialPrompt1 , strings .TrimSpace (msgResp .Messages [1 ].Content ))
255+ require .Equal (t , "Echo: Test initial prompt" , strings .TrimSpace (msgResp .Messages [2 ].Content ))
256+
257+ // Step 2: Close server
258+ cleanup ()
259+ time .Sleep (100 * time .Millisecond )
260+ require .FileExists (t , stateFile , "State file should exist after shutdown" )
261+
262+ // Step 3: Restart with DIFFERENT initial prompt using a different script
263+ initialPrompt2 := "Different initial prompt"
264+ _ , apiClient2 , cleanup2 := setup (ctx , t , & params {
265+ stateFile : stateFile ,
266+ scriptFilePath : filepath .Join ("testdata" , "state_persistence_different_initial_prompt.json" ),
267+ initialPrompt : initialPrompt2 ,
268+ }, false )
269+ defer cleanup2 ()
270+
271+ // Wait for initial prompt to be processed
272+ time .Sleep (1 * time .Second )
273+ require .NoError (t , waitAgentAPIStable (ctx , t , apiClient2 , operationTimeout , "after different initial prompt" ))
274+
275+ // Step 4: Verify new initial prompt WAS sent (5 messages: 3 previous + 2 new)
276+ msgResp2 , err := apiClient2 .GetMessages (ctx )
277+ require .NoError (t , err , "Failed to get messages after different initial prompt" )
278+ require .Len (t , msgResp2 .Messages , 5 , "Expected 5 messages after different initial prompt (3 previous + 2 new)" )
279+ // Verify the new initial prompt and response were added
280+ require .Equal (t , initialPrompt2 , strings .TrimSpace (msgResp2 .Messages [3 ].Content ))
281+ require .Equal (t , "Echo: Different initial prompt" , strings .TrimSpace (msgResp2 .Messages [4 ].Content ))
282+
283+ })
103284}
104285
105286type params struct {
106- cmdFn func (ctx context.Context , t testing.TB , serverPort int , binaryPath , cwd , scriptFilePath string ) (string , []string )
287+ cmdFn func (ctx context.Context , t testing.TB , serverPort int , binaryPath , cwd , scriptFilePath string ) (string , []string )
288+ stateFile string
289+ scriptFilePath string
290+ initialPrompt string
107291}
108292
109293func defaultCmdFn (ctx context.Context , t testing.TB , serverPort int , binaryPath , cwd , scriptFilePath string ) (string , []string ) {
110294 return binaryPath , []string {"server" , fmt .Sprintf ("--port=%d" , serverPort ), "--" , "go" , "run" , filepath .Join (cwd , "echo.go" ), scriptFilePath }
111295}
112296
113- func setup (ctx context.Context , t testing.TB , p * params ) ([]ScriptEntry , * agentapisdk.Client ) {
297+ func stateCmdFn (stateFile , initialPrompt string ) func (ctx context.Context , t testing.TB , serverPort int , binaryPath , cwd , scriptFilePath string ) (string , []string ) {
298+ return func (ctx context.Context , t testing.TB , serverPort int , binaryPath , cwd , scriptFilePath string ) (string , []string ) {
299+ args := []string {
300+ "server" ,
301+ fmt .Sprintf ("--port=%d" , serverPort ),
302+ fmt .Sprintf ("--state-file=%s" , stateFile ),
303+ }
304+ if initialPrompt != "" {
305+ args = append (args , fmt .Sprintf ("--initial-prompt=%s" , initialPrompt ))
306+ }
307+ args = append (args , "--" , "go" , "run" , filepath .Join (cwd , "echo.go" ), scriptFilePath )
308+ return binaryPath , args
309+ }
310+ }
311+
312+ func setup (ctx context.Context , t testing.TB , p * params , waitForStable bool ) ([]ScriptEntry , * agentapisdk.Client , func ()) {
114313 t .Helper ()
115314
116315 if p == nil {
117316 p = & params {}
118317 }
119318 if p .cmdFn == nil {
120- p .cmdFn = defaultCmdFn
319+ if p .stateFile != "" {
320+ p .cmdFn = stateCmdFn (p .stateFile , p .initialPrompt )
321+ } else {
322+ p .cmdFn = defaultCmdFn
323+ }
121324 }
122325
123- scriptFilePath := filepath .Join ("testdata" , filepath .Base (t .Name ())+ ".json" )
326+ scriptFilePath := p .scriptFilePath
327+ if scriptFilePath == "" {
328+ scriptFilePath = filepath .Join ("testdata" , filepath .Base (t .Name ())+ ".json" )
329+ }
124330 data , err := os .ReadFile (scriptFilePath )
125331 require .NoError (t , err , "Failed to read test script file: %s" , scriptFilePath )
126332
@@ -175,22 +381,37 @@ func setup(ctx context.Context, t testing.TB, p *params) ([]ScriptEntry, *agenta
175381 logOutput (t , "SERVER-STDERR" , stderr )
176382 }()
177383
178- // Clean up process
179- t . Cleanup ( func () {
384+ // Create cleanup function
385+ cleanup := func () {
180386 if cmd .Process != nil {
181- _ = cmd .Process .Kill ()
182- _ = cmd .Wait ()
387+ // Send SIGTERM to allow graceful shutdown and state save
388+ _ = cmd .Process .Signal (os .Interrupt )
389+ // Wait for process to exit gracefully (with timeout)
390+ done := make (chan error , 1 )
391+ go func () {
392+ done <- cmd .Wait ()
393+ }()
394+ select {
395+ case <- done :
396+ // Process exited gracefully
397+ case <- time .After (5 * time .Second ):
398+ // Timeout, force kill
399+ _ = cmd .Process .Kill ()
400+ <- done
401+ }
183402 }
184403 wg .Wait ()
185- })
404+ }
186405
187406 serverURL := fmt .Sprintf ("http://localhost:%d" , serverPort )
188407 require .NoError (t , waitForServer (ctx , t , serverURL , healthCheckTimeout ), "Server not ready" )
189408 apiClient , err := agentapisdk .NewClient (serverURL )
190409 require .NoError (t , err , "Failed to create agentapi SDK client" )
191410
192- require .NoError (t , waitAgentAPIStable (ctx , t , apiClient , operationTimeout , "setup" ))
193- return script , apiClient
411+ if waitForStable {
412+ require .NoError (t , waitAgentAPIStable (ctx , t , apiClient , operationTimeout , "setup" ))
413+ }
414+ return script , apiClient , cleanup
194415}
195416
196417// logOutput logs process output with prefix
0 commit comments