Skip to content

Commit 220d360

Browse files
committed
feat: add three e2e tests for statePersistence
1 parent 31d27a7 commit 220d360

5 files changed

Lines changed: 288 additions & 15 deletions

e2e/echo_test.go

Lines changed: 236 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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

105286
type 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

109293
func 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
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[
2+
{
3+
"expectMessage": "",
4+
"responseMessage": "Hello! I'm ready to help you."
5+
},
6+
{
7+
"expectMessage": "First message before state save.",
8+
"responseMessage": "Echo: First message before state save."
9+
},
10+
{
11+
"expectMessage": "Test initial prompt",
12+
"responseMessage": "Echo: Test initial prompt"
13+
},
14+
{
15+
"expectMessage": "Different initial prompt",
16+
"responseMessage": "Echo: Different initial prompt"
17+
}
18+
]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[
2+
{
3+
"expectMessage": "",
4+
"responseMessage": "Hello! I'm ready to help you."
5+
},
6+
{
7+
"expectMessage": "Different initial prompt",
8+
"responseMessage": "Echo: Different initial prompt"
9+
}
10+
]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[
2+
{
3+
"expectMessage": "",
4+
"responseMessage": "Hello! I'm ready to help you."
5+
},
6+
{
7+
"expectMessage": "Test initial prompt",
8+
"responseMessage": "Echo: Test initial prompt"
9+
}
10+
]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
{
3+
"expectMessage": "",
4+
"responseMessage": "Hello! I'm ready to help you."
5+
},
6+
{
7+
"expectMessage": "Test initial prompt",
8+
"responseMessage": "Echo: Test initial prompt"
9+
},
10+
{
11+
"expectMessage": "Different initial prompt",
12+
"responseMessage": "Echo: Different initial prompt"
13+
}
14+
]

0 commit comments

Comments
 (0)