//go:build unix
// A read through a ChatClient built before a real daemon restart refreshes
// its connection before another turn without losing the first turn's transcript.
// A fake daemon cannot exercise process lifetime or on-disk session persistence.
package e2e
import (
"context"
"fmt"
"strings"
"testing"
"time"
"albedo/cli/internal/daemon"
"albedo/cli/internal/tui"
tea "charm.land/bubbletea/v2"
)
// restartDaemon replaces the suite daemon under the same hermetic home and
// records its new pid for later scenarios.
func restartDaemon(t *testing.T) daemon.ConnectionSnapshot {
t.Helper()
old := suite.daemonPID
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := daemon.StopDaemon(ctx, conn(t)); err != nil {
t.Fatalf("shutdown request: %v", err)
}
if !awaitExit(old, 15*time.Second) {
t.Fatalf("daemon %d did not exit after shutdown", old)
}
if _, stderr, err := runCLI("sessions"); err != nil {
t.Fatalf("rebooting the daemon: %v\n%s", err, stderr)
}
deadline := time.Now().Add(2 * time.Minute)
for {
snap, err := readDaemonSnapshot(suite.home)
if err == nil && snap.Pid != old {
suite.daemonPID = snap.Pid
return snap
}
if time.Now().After(deadline) {
t.Fatalf("no daemon other than %d published daemon.json after the restart", old)
}
time.Sleep(100 * time.Millisecond)
}
}
// TestChatClientReadReconnectsAfterDaemonRestart recovers a read before
// sending another turn through the same client and persisted session.
func TestChatClientReadReconnectsAfterDaemonRestart(t *testing.T) {
profile := providerRoute(t, echoReply)
workspace := t.TempDir()
id := newSession(t, workspace)
// The client exists before the restart and is the only thing this test
// uses afterwards; a client created after the reboot would prove nothing.
shared := conn(t)
client := daemon.NewChatClient(shared, id)
prompt1 := "turn before the restart"
if _, err := client.Send(context.Background(), prompt1, nil); err != nil {
t.Fatalf("first send: %v", err)
}
waitIdle(t, id, profile, 1)
oldSnap := shared.Snapshot()
snap := restartDaemon(t)
page, err := client.History(context.Background(), 0, 120)
if err != nil || len(page.Events) == 0 {
t.Fatalf("read recovery after restart: %+v, %v", page, err)
}
prompt2 := "turn after the restart"
sent, err := client.Send(context.Background(), prompt2, nil)
if err != nil || sent == nil || !sent.OK {
t.Fatalf("second send through the same client after the restart: ok=%v err=%v", sent != nil && sent.OK, err)
}
waitIdle(t, id, profile, 2)
// The shared connection must now describe the restarted daemon, not the
// one it was built on.
if shared.Pid() != snap.Pid {
t.Errorf("connection pid is %d, restarted daemon is %d", shared.Pid(), snap.Pid)
}
if shared.Token() != snap.Token {
t.Errorf("connection token is not the restarted daemon's")
}
if want := fmt.Sprintf("http://127.0.0.1:%d", snap.Port); shared.BaseURL() != want {
t.Errorf("connection base url is %s, want %s", shared.BaseURL(), want)
}
if shared.Token() == oldSnap.Token {
t.Errorf("the token never changed, so this run did not exercise a real restart")
}
// Both turns crossed the whole stack to the provider.
requests := suite.provider.requests(profile)
if len(requests) < 2 {
t.Fatalf("provider profile %s saw %d requests, want one per turn", profile, len(requests))
}
body, _ := requests[0]["body"].(map[string]any)
if forwarded := lastUserText(body); !strings.Contains(forwarded, prompt1) {
t.Errorf("first provider request does not carry the first prompt %q: %q", prompt1, forwarded)
}
body, _ = requests[len(requests)-1]["body"].(map[string]any)
if forwarded := lastUserText(body); !strings.Contains(forwarded, prompt2) {
t.Errorf("last provider request does not carry the post-restart prompt: %q", forwarded)
}
// The transcript survived the restart in the same session.
reply := strings.Join(eventText(streamSnapshot(t, id), "message"), "\n")
if !strings.Contains(reply, "echo: "+prompt1) || !strings.Contains(reply, "echo: "+prompt2) {
t.Fatalf("transcript after the restart is missing a turn; got:\n%s", reply)
}
}
// A stopped turn survives a daemon restart as idle-but-interrupted. Reopening
// it must accept ordinary input without a slash command changing its phase.
func TestTUIReopensInterruptedSessionWithoutCompacting(t *testing.T) {
entered, release := make(chan struct{}), make(chan struct{})
defer close(release)
profile := providerRoute(t, func(request map[string]any) string {
if lastUserText(request) == "stop this turn" {
close(entered)
<-release
}
return echoReply(request)
})
id := newSession(t, t.TempDir())
client := daemon.NewChatClient(conn(t), id)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := client.Send(ctx, "stop this turn", nil); err != nil {
t.Fatal(err)
}
select {
case <-entered:
case <-ctx.Done():
t.Fatal("the turn never reached the provider")
}
if interrupted, err := client.Interrupt(ctx); err != nil || !interrupted {
t.Fatalf("interrupt: %v, %v", interrupted, err)
}
waitIdle(t, id, profile, 1)
restartDaemon(t)
status, err := daemon.NewChatClient(conn(t), id).GetStatus(ctx)
if err != nil || !status.Idle || status.Phase == nil || *status.Phase != "interrupted" {
t.Fatalf("reopened status: %+v, %v", status, err)
}
session := daemonSession(t, id)
d := driveTUI(t, &session)
defer d.App.Chat.Close()
page, err := client.History(ctx, 0, 120)
if err != nil {
t.Fatal(err)
}
for _, event := range page.Events {
d.Update(tui.ChatStreamEventMsg{
SessionID: id, Generation: d.App.Chat.Generation, Event: event,
})
}
if !strings.Contains(d.View(), "stop this turn") {
t.Fatal("the interrupted transcript did not load")
}
d.connected()
d.App.Chat.TextArea.SetValue("continue without compacting")
command := d.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if command == nil {
t.Fatal("the composer refused the new prompt")
}
for _, msg := range d.results(command) {
if _, ok := msg.(tui.ChatTurnSentMsg); ok {
d.Update(msg)
}
}
waitIdle(t, id, profile, 2)
replies := strings.Join(eventText(streamSnapshot(t, id), "message"), "\n")
if !strings.Contains(replies, "echo: continue without compacting") {
t.Fatalf("the reopened session did not answer: %q", replies)
}
}