//go:build unix
// Prompt scenarios: `albedo -p` sends one prompt, blocks until its turn ends,
// and prints only the final assistant text, so a script can pipe it.
package e2e
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"albedo/cli/internal/config"
"albedo/cli/internal/daemon"
)
// A new prompt runs in a fresh session on the model named provider/model, and
// stdout is exactly the reply; `albedo models` lists that same spelling.
func TestPromptPrintsTheFinalReplyFromTheNamedModel(t *testing.T) {
profile := providerRoute(t, echoReply)
model := profile + "/fixture-model"
if listed := cli(t, "models"); !strings.Contains(listed, model+"\n") {
t.Fatalf("albedo models does not list %s:\n%s", model, listed)
}
stdout := cli(t, "-p", "print me", "--model", model)
if stdout != "echo: print me\n" {
t.Fatalf("albedo -p printed %q", stdout)
}
if got := len(suite.provider.requests(profile)); got != 1 {
t.Fatalf("provider %s saw %d requests, want 1", profile, got)
}
}
// --model on an existing session moves only that session: the turn reaches
// the other provider, and new sessions still default to the active one.
func TestPromptToASessionSwitchesItsModelWithoutChangingTheDefault(t *testing.T) {
other := "PromptSwitchTarget"
if err := suite.provider.addProfile(other, echoReply); err != nil {
t.Fatal(err)
}
profile := providerRoute(t, echoReply)
id := newSession(t, t.TempDir())
stdout := cli(t, "--prompt", "switch", "--session", id, "--model", other+"/fixture-model")
if stdout != "echo: switch\n" {
t.Fatalf("albedo -p -s printed %q", stdout)
}
if len(suite.provider.requests(other)) != 1 || len(suite.provider.requests(profile)) != 0 {
t.Fatalf("turn went to the wrong provider: %s=%d %s=%d", other,
len(suite.provider.requests(other)), profile, len(suite.provider.requests(profile)))
}
if session := daemonSession(t, id); session.Provider != other {
t.Fatalf("session provider is %q, want %q", session.Provider, other)
}
profiles, err := daemon.ProviderProfiles(context.Background(), conn(t))
if err != nil || profiles.Active != profile {
t.Fatalf("default provider became %q (%v), want %q", profiles.Active, err, profile)
}
}
// A turn that fails must end the command with the failure, not hang or print
// an empty reply; a model no provider offers fails before any session exists.
func TestPromptReportsAFailedTurnAndAnUnknownModel(t *testing.T) {
profile := t.Name()
if err := daemon.SaveProvider(context.Background(), conn(t), profile, config.Settings{
Extension: "openai",
BaseURL: suite.provider.server.URL + "/t/missing",
APIKey: "fixture-key",
Model: "fixture-model",
Protocol: "chat_completions",
}); err != nil {
t.Fatal(err)
}
stdout, stderr, err := runCLI("-p", "fail", "--model", profile+"/fixture-model")
if err == nil || stdout != "" || !strings.Contains(stderr, "404") {
t.Fatalf("failed turn: err=%v stdout=%q stderr=%q", err, stdout, stderr)
}
before := len(daemonSessions(t))
_, stderr, err = runCLI("-p", "never sent", "--model", "no-such-model")
if err == nil || !strings.Contains(stderr, "albedo models") {
t.Fatalf("unknown model: err=%v stderr=%q", err, stderr)
}
if after := len(daemonSessions(t)); after != before {
t.Fatalf("an unknown model still created a session (%d -> %d)", before, after)
}
}
// --timeout fails the command once it passes and stops the turn it started:
// the session is idle long before the stalled provider would have answered.
func TestPromptTimeoutStopsTheTurn(t *testing.T) {
release := make(chan struct{})
t.Cleanup(func() { close(release) })
profile := providerRoute(t, func(request map[string]any) string {
select {
case <-release:
case <-time.After(10 * time.Second):
}
return echoReply(request)
})
started := time.Now()
stdout, stderr, err := runCLI("-p", "stall", "--model", profile+"/fixture-model", "--timeout", "1s")
if err == nil || stdout != "" || !strings.Contains(stderr, "timed out after 1s") {
t.Fatalf("timed-out prompt: err=%v stdout=%q stderr=%q", err, stdout, stderr)
}
_, id, _ := strings.Cut(strings.TrimSpace(stderr), "the session is ")
for {
status, err := daemon.NewChatClient(conn(t), id).GetStatus(context.Background())
if err != nil {
t.Fatalf("status of %q: %v", id, err)
}
if status.Idle {
break
}
if time.Since(started) > 6*time.Second {
t.Fatal("the timed-out turn is still running")
}
time.Sleep(100 * time.Millisecond)
}
}
// Cancellation while queued must leave the already running turn alone.
func TestPromptTimeoutWhileQueuedDoesNotInterruptAnotherTurn(t *testing.T) {
entered, release := make(chan struct{}), make(chan struct{})
releaseClosed := false
defer func() {
if !releaseClosed {
close(release)
}
}()
profile := providerRoute(t, func(request map[string]any) string {
if lastUserText(request) == "other turn" {
close(entered)
<-release
}
return echoReply(request)
})
id := newSession(t, t.TempDir())
client := daemon.NewChatClient(conn(t), id)
if _, err := client.Send(t.Context(), "other turn", nil); err != nil {
t.Fatal(err)
}
select {
case <-entered:
case <-time.After(10 * time.Second):
t.Fatal("other turn never started")
}
stdout, stderr, err := runCLI("--prompt", "queued turn", "--session", id, "--timeout", "1s")
if err == nil || stdout != "" || !strings.Contains(stderr, "timed out after 1s") {
t.Fatalf("queued timeout: %v %q %q", err, stdout, stderr)
}
status, statusErr := client.GetStatus(t.Context())
if statusErr != nil || status.Idle {
t.Fatalf("queued cancellation stopped the other turn: %+v %v", status, statusErr)
}
if !strings.Contains(stderr, "queued submission cancelled") || strings.Contains(stderr, "stopped") {
t.Fatalf("incorrect cancellation report: %s", stderr)
}
close(release)
releaseClosed = true
waitIdle(t, id, profile, 1)
if got := len(suite.provider.requests(profile)); got != 1 {
t.Fatalf("cancelled queued prompt still ran: %d requests", got)
}
outcome, err := client.CancelSubmission(t.Context(), "missing-submission")
if err != nil || outcome != "not_pending" {
t.Fatalf("missing cancellation: %q %v", outcome, err)
}
}
// Both identified submissions contribute to the same next turn. Targeted
// cancellation must leave that shared turn running, including anonymous input.
func TestSubmissionCancellationLeavesSharedRunningTurn(t *testing.T) {
firstEntered, firstRelease := make(chan struct{}), make(chan struct{})
sharedEntered, sharedRelease := make(chan struct{}), make(chan struct{})
firstReleased, sharedReleased := false, false
defer func() {
if !firstReleased {
close(firstRelease)
}
if !sharedReleased {
close(sharedRelease)
}
}()
profile := providerRoute(t, func(request map[string]any) string {
if lastUserText(request) == "blocking turn" {
close(firstEntered)
<-firstRelease
} else {
close(sharedEntered)
<-sharedRelease
}
return echoReply(request)
})
id := newSession(t, t.TempDir())
client := daemon.NewChatClient(conn(t), id)
if _, err := client.Send(t.Context(), "blocking turn", nil); err != nil {
t.Fatal(err)
}
select {
case <-firstEntered:
case <-time.After(10 * time.Second):
t.Fatal("first turn never started")
}
if _, err := client.SendSubmission(t.Context(), "identified contribution", "shared-submission"); err != nil {
t.Fatal(err)
}
if _, err := client.Send(t.Context(), "anonymous contribution", nil); err != nil {
t.Fatal(err)
}
if _, err := client.SendSubmission(t.Context(), "cancel this queued contribution", "queued-submission"); err != nil {
t.Fatal(err)
}
outcome, cancelErr := client.CancelSubmission(t.Context(), "queued-submission")
if cancelErr != nil || outcome != "cancelled_queued" {
t.Fatalf("queued cancellation = %q %v", outcome, cancelErr)
}
outcome, cancelErr = client.CancelSubmission(t.Context(), "queued-submission")
if cancelErr != nil || outcome != "not_pending" {
t.Fatalf("repeated queued cancellation = %q %v", outcome, cancelErr)
}
close(firstRelease)
firstReleased = true
select {
case <-sharedEntered:
case <-time.After(10 * time.Second):
t.Fatal("shared turn never started")
}
for range 2 {
outcome, err := client.CancelSubmission(t.Context(), "shared-submission")
if err != nil || outcome != "shared_running" {
t.Fatalf("shared cancellation = %q, %v", outcome, err)
}
}
status, err := client.GetStatus(t.Context())
if err != nil || status.Idle {
t.Fatalf("shared turn stopped: %+v %v", status, err)
}
close(sharedRelease)
sharedReleased = true
waitIdle(t, id, profile, 2)
requests := suite.provider.requests(profile)
encoded, marshalErr := json.Marshal(requests[1])
if marshalErr != nil || strings.Contains(string(encoded), "cancel this queued contribution") || !strings.Contains(string(encoded), "identified contribution") || !strings.Contains(string(encoded), "anonymous contribution") {
t.Fatalf("queued cancellation damaged sibling inputs: %s %v", encoded, marshalErr)
}
}
// Cancellation races turn admission and completion. Once that target settles,
// repeating cancellation cannot stop a later exclusively owned turn.
func TestSubmissionCancellationRacesStartAndCompletion(t *testing.T) {
firstEntered, firstRelease := make(chan struct{}), make(chan struct{})
laterEntered, laterRelease := make(chan struct{}), make(chan struct{})
firstReleased, laterReleased := false, false
defer func() {
if !firstReleased {
close(firstRelease)
}
if !laterReleased {
close(laterRelease)
}
}()
profile := providerRoute(t, func(request map[string]any) string {
switch lastUserText(request) {
case "hold first turn":
close(firstEntered)
<-firstRelease
case "hold later turn":
close(laterEntered)
<-laterRelease
}
return echoReply(request)
})
id := newSession(t, t.TempDir())
client := daemon.NewChatClient(conn(t), id)
if _, err := client.Send(t.Context(), "hold first turn", nil); err != nil {
t.Fatal(err)
}
select {
case <-firstEntered:
case <-time.After(10 * time.Second):
t.Fatal("first turn never started")
}
const submissionID = "racing-target"
if _, err := client.SendSubmission(t.Context(), "short target turn", submissionID); err != nil {
t.Fatal(err)
}
type cancellation struct {
err error
outcome string
}
result := make(chan cancellation, 1)
start := make(chan struct{})
go func() {
<-start
outcome, err := client.CancelSubmission(t.Context(), submissionID)
result <- cancellation{err: err, outcome: outcome}
}()
close(start)
close(firstRelease)
firstReleased = true
var cancelled cancellation
select {
case cancelled = <-result:
case <-time.After(10 * time.Second):
t.Fatal("racing cancellation did not return")
}
if cancelled.err != nil {
t.Fatal(cancelled.err)
}
switch cancelled.outcome {
case "cancelled_queued", "interrupt_requested", "not_pending":
default:
t.Fatalf("unexpected racing cancellation outcome %q", cancelled.outcome)
}
waitIdle(t, id, profile, 1)
if _, err := client.SendSubmission(t.Context(), "hold later turn", "later-owner"); err != nil {
t.Fatal(err)
}
select {
case <-laterEntered:
case <-time.After(10 * time.Second):
t.Fatal("later turn never started")
}
outcome, err := client.CancelSubmission(t.Context(), submissionID)
if err != nil || outcome != "not_pending" {
t.Fatalf("settled target cancellation = %q %v", outcome, err)
}
status, err := client.GetStatus(t.Context())
if err != nil || status.Idle {
t.Fatalf("old cancellation stopped a later turn: %+v %v", status, err)
}
close(laterRelease)
laterReleased = true
waitIdle(t, id, profile, 2)
}