Files
2026-08-14 23:41:57 +08:00

657 lines
24 KiB
Go

package repl
// Tests for the line-based REPL (#106): slash-command dispatch (action prints
// message and does NOT run; prompt runs; unknown command errors and does NOT
// run; /exit and EOF exit cleanly), multi-turn history accumulation, and
// streaming assistant text. The REPL is driven with a fake provider so no
// network is involved — the whole read → run → stream-print loop runs over an
// in-memory input reader and output buffer.
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/prompts"
"github.com/smallnest/pigo/internal/cli/ui"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
"github.com/smallnest/pigo/internal/session"
)
// replProvider is a minimal Provider that streams one scripted text turn per
// StreamCompletion call and records how many times it was called, so a test can
// assert whether a run was launched.
type replProvider struct {
reply string
calls int
}
func (p *replProvider) Name() string { return "faux" }
func (p *replProvider) Models() []provider.Model {
return []provider.Model{{Provider: "faux", ID: "faux"}}
}
func (p *replProvider) StreamCompletion(ctx context.Context, req provider.CompletionRequest) (*provider.AssistantMessageEventStream, error) {
p.calls++
partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
withText := partial
withText.Content = agentcore.ContentList{agentcore.NewTextContent(p.reply)}
final := withText
final.StopReason = agentcore.StopReasonEndTurn
s := provider.NewAssistantMessageEventStream(0)
go func() {
_ = s.Emit(ctx, provider.StreamStartEvent{Partial: partial})
_ = s.Emit(ctx, provider.StreamTextEvent{Partial: withText})
_ = s.Emit(ctx, provider.StreamDoneEvent{Message: final})
s.Close()
}()
return s, nil
}
// newTestDeps builds replDeps wired to the fake provider and a temp session
// store, with a registry carrying one action command and one prompt command so
// slash dispatch can be exercised. actionRuns/promptResolved report whether each
// command fired.
func newTestDeps(t *testing.T, p provider.Provider) (replDeps, *session.Store) {
t.Helper()
store, err := session.NewStore(t.TempDir())
if err != nil {
t.Fatalf("new store: %v", err)
}
live := &cli.LiveConfig{Model: "faux", ProviderName: "faux", Provider: p}
reg := runtime.NewSlashRegistry()
reg.AddBuiltin(runtime.SlashCommand{
Name: "ping",
Action: func(string) string { return "pong" },
})
reg.AddUser(runtime.SlashCommand{
Name: "echo",
Expand: func(args string) string { return "expanded: " + args },
})
deps := replDeps{
store: store,
header: session.SessionHeader{ID: session.NewID(time.Now().UTC()), Model: "faux", Provider: "faux"},
agentCtx: &agentcore.AgentContext{},
live: live,
reg: agenttool.NewToolRegistry(),
slash: reg,
creds: provider.NewCredentialStore(nil),
}
return deps, store
}
// TestREPLExitCommand verifies /exit ends the loop cleanly with no error and no
// agent run.
func TestREPLExitCommand(t *testing.T) {
p := &replProvider{reply: "hi"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL returned error: %v", err)
}
if p.calls != 0 {
t.Errorf("/exit must not launch a run, got %d calls", p.calls)
}
}
// TestREPLQuitCommand verifies /quit is an alias for /exit.
func TestREPLQuitCommand(t *testing.T) {
p := &replProvider{reply: "hi"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("/quit\n"), &out, deps); err != nil {
t.Fatalf("runREPL returned error: %v", err)
}
if p.calls != 0 {
t.Errorf("/quit must not launch a run, got %d calls", p.calls)
}
}
// TestREPLEOFExits verifies EOF (no /exit) ends the loop cleanly.
func TestREPLEOFExits(t *testing.T) {
p := &replProvider{reply: "hi"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
// Empty input → immediate EOF.
if err := runREPL(strings.NewReader(""), &out, deps); err != nil {
t.Fatalf("EOF should exit cleanly, got: %v", err)
}
if p.calls != 0 {
t.Errorf("EOF with no input must not run, got %d calls", p.calls)
}
}
// TestREPLFinalLineNoNewline verifies the ReadString-based loop handles a final
// input line without a trailing newline: the line is still run as a prompt and
// the loop then exits cleanly on EOF. (The previous bufio.Scanner had the same
// behavior; this pins it for the reader-based loop.)
func TestREPLFinalLineNoNewline(t *testing.T) {
p := &replProvider{reply: "hi"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("hello"), &out, deps); err != nil {
t.Fatalf("runREPL on no-trailing-newline input: %v", err)
}
if p.calls != 1 {
t.Errorf("runs fired = %d, want 1 (the final line should run once)", p.calls)
}
}
// TestREPLEmptyLineIgnored verifies blank lines are skipped without running.
func TestREPLEmptyLineIgnored(t *testing.T) {
p := &replProvider{reply: "hi"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("\n \n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 0 {
t.Errorf("blank lines must not run, got %d calls", p.calls)
}
}
// TestREPLActionCommandNoRun verifies an action slash command prints its message
// and does NOT launch an agent run.
func TestREPLActionCommandNoRun(t *testing.T) {
p := &replProvider{reply: "hi"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("/ping\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 0 {
t.Errorf("action command must not run, got %d calls", p.calls)
}
if !strings.Contains(out.String(), "pong") {
t.Errorf("action command message not printed, out=%q", out.String())
}
}
// TestREPLPromptCommandRuns verifies a prompt slash command expands and launches
// a run.
func TestREPLPromptCommandRuns(t *testing.T) {
p := &replProvider{reply: "ack"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("/echo hello\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 1 {
t.Fatalf("prompt command should launch exactly 1 run, got %d", p.calls)
}
// The expanded prompt must have been appended as the user message.
if len(deps.agentCtx.Messages) == 0 {
t.Fatal("expected messages in context after run")
}
first, ok := deps.agentCtx.Messages[0].(agentcore.UserMessage)
if !ok || agentcore.ContentToText(first.Content) != "expanded: hello" {
t.Errorf("first message = %T %q, want expanded prompt", deps.agentCtx.Messages[0], agentcore.ContentToText(first.Content))
}
}
// TestREPLUnknownCommandNoRun verifies an unknown slash command prints an error
// and does NOT run or crash.
func TestREPLUnknownCommandNoRun(t *testing.T) {
p := &replProvider{reply: "hi"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("/nope\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 0 {
t.Errorf("unknown command must not run, got %d calls", p.calls)
}
if !strings.Contains(strings.ToLower(out.String()), "unknown") {
t.Errorf("expected an unknown-command error line, out=%q", out.String())
}
}
// TestREPLModelSwitchTakesEffect verifies the /model action command switches the
// live model mid-session (via registerLiveCommands + resolveProvider) without
// launching a run, and that the switch is reflected in live for the next turn.
func TestREPLModelSwitchTakesEffect(t *testing.T) {
p := &replProvider{reply: "hi"}
deps, _ := newTestDeps(t, p)
// Register the real live action commands (/model, /models, /help) against the
// same live config the REPL runs on, so /model mutates it.
prompts.RegisterLiveCommands(deps.slash, deps.live)
var out bytes.Buffer
// /model with no arg reports the current model; /model <id> switches to an
// Ollama preset (no API key required); /exit ends the loop.
in := strings.NewReader("/model\n/model ollama/llama3.3\n/exit\n")
if err := runREPL(in, &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 0 {
t.Errorf("/model actions must not launch a run, got %d calls", p.calls)
}
if deps.live.Model != "ollama/llama3.3" || deps.live.ProviderName != "ollama" {
t.Errorf("live not switched: model=%q provider=%q", deps.live.Model, deps.live.ProviderName)
}
s := out.String()
if !strings.Contains(s, "faux") {
t.Errorf("/model (no arg) should report the current model, out=%q", s)
}
if !strings.Contains(s, "ollama/llama3.3") {
t.Errorf("/model switch should confirm the new model, out=%q", s)
}
}
// TestREPLPersistsModelIntoHeader verifies that after a run the session header
// records the live model/provider (US-006: a /model switch is persisted with the
// session), by reloading the saved session and inspecting its header.
func TestREPLPersistsModelIntoHeader(t *testing.T) {
p := &replProvider{reply: "ok"}
deps, store := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("hello\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
headers, err := store.List()
if err != nil {
t.Fatalf("store.List: %v", err)
}
if len(headers) != 1 {
t.Fatalf("expected 1 saved session, got %d", len(headers))
}
if headers[0].Model != "faux" || headers[0].Provider != "faux" {
t.Errorf("header model/provider = %q/%q, want live faux/faux", headers[0].Model, headers[0].Provider)
}
}
// TestRenderToolResultTodoFull verifies the todo tool's multi-line progress
// block is rendered in full under a "← todo:" header (US-011: REPL shows
// progress on update), while a non-todo result stays a one-line summary.
func TestRenderToolResultTodoFull(t *testing.T) {
var out bytes.Buffer
ui.RenderToolResult(&out, agentcore.ToolResultMessage{
RoleField: agentcore.RoleToolResult, ToolName: "todo",
Content: agentcore.ContentList{agentcore.NewTextContent("Todos:\n [x] a\n [ ] b\n(1/2 completed)")},
})
s := out.String()
for _, want := range []string{"← todo:", "[x] a", "[ ] b", "(1/2 completed)"} {
if !strings.Contains(s, want) {
t.Errorf("todo render missing %q, out=%q", want, s)
}
}
out.Reset()
ui.RenderToolResult(&out, agentcore.ToolResultMessage{
RoleField: agentcore.RoleToolResult, ToolName: "bash",
Content: agentcore.ContentList{agentcore.NewTextContent("line1\nline2")},
})
if got := out.String(); !strings.Contains(got, "← result: line1") {
t.Errorf("non-todo render = %q, want a one-line summary", got)
}
}
// TestReplayTranscriptRendersRoles verifies a resumed session's prior messages
// are echoed by role (user / assistant / tool result) before the first new
// prompt (US-006 acceptance: resumed conversation is replayed).
func TestReplayTranscriptRendersRoles(t *testing.T) {
msgs := []agentcore.AgentMessage{
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("what is 2+2")}},
agentcore.AssistantMessage{
RoleField: agentcore.RoleAssistant,
Content: agentcore.ContentList{agentcore.NewTextContent("Let me compute."), agentcore.NewToolCallContent("c1", "calc", []byte(`{"expr":"2+2"}`))},
},
agentcore.ToolResultMessage{RoleField: agentcore.RoleToolResult, ToolCallID: "c1", ToolName: "calc", Content: agentcore.ContentList{agentcore.NewTextContent("4")}},
}
var out bytes.Buffer
replayTranscript(&out, msgs)
s := out.String()
for _, want := range []string{"> what is 2+2", "Let me compute.", `→ tool: calc {"expr":"2+2"}`, "← result: 4"} {
if !strings.Contains(s, want) {
t.Errorf("replay missing %q, out=%q", want, s)
}
}
}
// TestREPLTreePrintsAndSwitchesBranch drives the /tree command end to end over
// the REPL: after two turns, "/tree" prints a numbered tree with the current-leaf
// marker; "/tree 1" switches the active leaf to the first node, so the next
// prompt branches from there — leaving the original branch intact on disk (US-007,
// #123).
func TestREPLTreePrintsAndSwitchesBranch(t *testing.T) {
p := &replProvider{reply: "reply"}
deps, store := newTestDeps(t, p)
var out bytes.Buffer
// Two turns build a linear history (4 messages), then /tree lists it, /tree 1
// switches to the first node, a new prompt branches, then /exit.
in := strings.NewReader("first\nsecond\n/tree\n/tree 1\nbranched\n/exit\n")
if err := runREPL(in, &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
s := out.String()
if !strings.Contains(s, "← current") {
t.Errorf("/tree should mark the current leaf, out=%q", s)
}
if !strings.Contains(s, "1. user:") {
t.Errorf("/tree should number entries starting at the root user message, out=%q", s)
}
if !strings.Contains(s, "switched to branch at node 1") {
t.Errorf("/tree 1 should confirm the switch, out=%q", s)
}
// The on-disk tree must retain both branches: the original 4-message line plus
// the new branch off node 1. Reload and confirm the root has 2 children.
_, entries, err := store.LoadEntries(deps.header.ID)
if err != nil {
t.Fatalf("LoadEntries: %v", err)
}
rootID := ""
for _, e := range entries {
if e.ParentID == "" {
rootID = e.ID
break
}
}
if rootID == "" {
t.Fatal("no root entry found")
}
kids := 0
for _, e := range entries {
if e.ParentID == rootID {
kids++
}
}
if kids != 2 {
t.Errorf("root should have 2 children after branching, got %d (entries=%d)", kids, len(entries))
}
}
// TestREPLTreeEmptyNoop verifies /tree on a fresh session (no messages) prints a
// friendly notice and does not crash or run.
func TestREPLTreeEmptyNoop(t *testing.T) {
p := &replProvider{reply: "hi"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("/tree\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 0 {
t.Errorf("/tree must not launch a run, got %d calls", p.calls)
}
if !strings.Contains(out.String(), "empty") {
t.Errorf("/tree on empty session should say so, out=%q", out.String())
}
}
// is printed, and history accumulates across two turns in the shared context.
func TestREPLStreamsAndAccumulatesHistory(t *testing.T) {
p := &replProvider{reply: "the answer"}
deps, store := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("first question\nsecond question\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 2 {
t.Fatalf("two prompts should launch 2 runs, got %d", p.calls)
}
// The streamed reply text must appear in the output.
if !strings.Contains(out.String(), "the answer") {
t.Errorf("assistant reply not streamed to output, out=%q", out.String())
}
// History: user(first) + assistant + user(second) + assistant = 4 messages.
if len(deps.agentCtx.Messages) != 4 {
t.Fatalf("expected 4 accumulated messages, got %d", len(deps.agentCtx.Messages))
}
u0, _ := deps.agentCtx.Messages[0].(agentcore.UserMessage)
u2, _ := deps.agentCtx.Messages[2].(agentcore.UserMessage)
if agentcore.ContentToText(u0.Content) != "first question" || agentcore.ContentToText(u2.Content) != "second question" {
t.Errorf("history not accumulated in order: %q, %q", agentcore.ContentToText(u0.Content), agentcore.ContentToText(u2.Content))
}
// The session must have been persisted after the runs.
headers, err := store.List()
if err != nil {
t.Fatalf("store.List: %v", err)
}
if len(headers) != 1 {
t.Errorf("expected 1 saved session, got %d", len(headers))
}
}
// errProvider streams a single turn that ends with stopReason error carrying an
// ErrorMessage, mimicking how the loop surfaces a request failure (e.g. a 4xx
// from the endpoint) as a terminal assistant message rather than a Go error.
type errProvider struct {
reason string
calls int
}
func (p *errProvider) Name() string { return "faux" }
func (p *errProvider) Models() []provider.Model {
return []provider.Model{{Provider: "faux", ID: "faux"}}
}
func (p *errProvider) StreamCompletion(ctx context.Context, req provider.CompletionRequest) (*provider.AssistantMessageEventStream, error) {
p.calls++
partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
final := partial
final.StopReason = agentcore.StopReasonError
final.ErrorMessage = p.reason
s := provider.NewAssistantMessageEventStream(0)
go func() {
_ = s.Emit(ctx, provider.StreamStartEvent{Partial: partial})
_ = s.Emit(ctx, provider.StreamErrorEvent{Message: final})
s.Close()
}()
return s, nil
}
// TestREPLSurfacesTurnError verifies a turn that ends with stopReason error is
// printed to the user instead of returning silently to the prompt. Without this
// an API failure (delivered as a terminal error message, not a run error) would
// produce no output at all.
func TestREPLSurfacesTurnError(t *testing.T) {
p := &errProvider{reason: "401 unauthorized: bad api key"}
deps, _ := newTestDeps(t, p)
deps.live.Provider = p
var out bytes.Buffer
if err := runREPL(strings.NewReader("hello\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 1 {
t.Fatalf("expected 1 run, got %d", p.calls)
}
got := out.String()
if !strings.Contains(got, "error:") || !strings.Contains(got, "401 unauthorized: bad api key") {
t.Errorf("turn error not surfaced to output, out=%q", got)
}
}
// emptyProvider streams a clean end_turn with no content, thinking, or tool
// calls — the shape produced when an endpoint accepts the request with a 200 but
// returns nothing this protocol can decode.
type emptyProvider struct{ calls int }
func (p *emptyProvider) Name() string { return "faux" }
func (p *emptyProvider) Models() []provider.Model {
return []provider.Model{{Provider: "faux", ID: "faux"}}
}
func (p *emptyProvider) StreamCompletion(ctx context.Context, req provider.CompletionRequest) (*provider.AssistantMessageEventStream, error) {
p.calls++
partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
final := partial
final.StopReason = agentcore.StopReasonEndTurn
s := provider.NewAssistantMessageEventStream(0)
go func() {
_ = s.Emit(ctx, provider.StreamStartEvent{Partial: partial})
_ = s.Emit(ctx, provider.StreamDoneEvent{Message: final})
s.Close()
}()
return s, nil
}
// TestREPLNotesEmptyResponse verifies a clean turn that produced no output at
// all is flagged with a note rather than returning silently to the prompt.
func TestREPLNotesEmptyResponse(t *testing.T) {
p := &emptyProvider{}
deps, _ := newTestDeps(t, p)
deps.live.Provider = p
var out bytes.Buffer
if err := runREPL(strings.NewReader("hello\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 1 {
t.Fatalf("expected 1 run, got %d", p.calls)
}
if got := out.String(); !strings.Contains(got, "empty response from the model") {
t.Errorf("empty response not flagged, out=%q", got)
}
}
// TestREPLExportImportRoundTrip drives /export and /import end to end over the
// REPL: after a turn, "/export <path>" writes a JSONL file, then "/import
// <path>" materializes it as a fresh session and switches to it (US-008, #124).
func TestREPLExportImportRoundTrip(t *testing.T) {
p := &replProvider{reply: "the answer"}
deps, store := newTestDeps(t, p)
origID := deps.header.ID
out := filepath.Join(t.TempDir(), "sess.jsonl")
var buf bytes.Buffer
in := strings.NewReader("hello\n/export " + out + "\n/import " + out + "\n/exit\n")
if err := runREPL(in, &buf, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
s := buf.String()
if !strings.Contains(s, "exported") {
t.Errorf("/export should confirm, out=%q", s)
}
if !strings.Contains(s, "imported") {
t.Errorf("/import should confirm, out=%q", s)
}
// The export file must exist and be non-empty.
if info, err := os.Stat(out); err != nil || info.Size() == 0 {
t.Fatalf("export file missing or empty: err=%v", err)
}
// The import creates a new session distinct from the original, so the store
// should now hold at least 2 sessions.
headers, err := store.List()
if err != nil {
t.Fatalf("store.List: %v", err)
}
var foundNew bool
for _, h := range headers {
if h.ID != origID && h.ParentSession == origID {
foundNew = true
}
}
if !foundNew {
t.Errorf("expected an imported session with ParentSession=%q, headers=%+v", origID, headers)
}
}
// TestREPLExportDefaultsToJSONL verifies "/export" with no path defaults to
// "<session-id>.jsonl" and does not launch an agent run.
func TestREPLExportDefaultsToJSONL(t *testing.T) {
p := &replProvider{reply: "ok"}
deps, _ := newTestDeps(t, p)
dir := t.TempDir()
// Run inside a temp dir so the default relative filename lands there.
cwd, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
defer os.Chdir(cwd)
var buf bytes.Buffer
if err := runREPL(strings.NewReader("hi\n/export\n/exit\n"), &buf, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
want := deps.header.ID + ".jsonl"
if _, err := os.Stat(filepath.Join(dir, want)); err != nil {
t.Errorf("default export file %q not created: %v", want, err)
}
}
// TestREPLImportTokenBoundary verifies that a command sharing a prefix with
// /import (e.g. "/important") is NOT treated as /import — it falls through to
// slash resolution and reports an unknown command rather than importing.
func TestREPLImportTokenBoundary(t *testing.T) {
p := &replProvider{reply: "ok"}
deps, _ := newTestDeps(t, p)
var buf bytes.Buffer
if err := runREPL(strings.NewReader("/important\n/exit\n"), &buf, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if strings.Contains(buf.String(), "imported") {
t.Errorf("/important must not trigger /import, out=%q", buf.String())
}
if p.calls != 0 {
t.Errorf("/important should not launch a run, got %d calls", p.calls)
}
}
// TestREPLSessionStats drives the /session command: after a turn it prints the
// session id, message count, token estimate, model, and compaction count without
// launching another run (US-009, #125).
func TestREPLSessionStats(t *testing.T) {
p := &replProvider{reply: "the answer"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("hello\n/session\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 1 {
t.Errorf("/session must not launch a run (only the prompt), got %d calls", p.calls)
}
s := out.String()
for _, want := range []string{"session:", "messages:", "tokens (est):", "model:", "compactions:"} {
if !strings.Contains(s, want) {
t.Errorf("/session output missing %q, out=%q", want, s)
}
}
// After one turn the context holds user + assistant = 2 messages.
if !strings.Contains(s, "messages: 2") {
t.Errorf("/session should report 2 messages, out=%q", s)
}
}
// TestREPLCopyEmpty verifies /copy on a session with no assistant reply prints a
// friendly notice rather than copying or crashing.
func TestREPLCopyEmpty(t *testing.T) {
p := &replProvider{reply: "hi"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("/copy\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 0 {
t.Errorf("/copy must not launch a run, got %d calls", p.calls)
}
if !strings.Contains(out.String(), "nothing to copy") {
t.Errorf("/copy on empty session should say so, out=%q", out.String())
}
}
// TestREPLCopyDegradesToPrint verifies /copy degrades to printing the last reply
// when no clipboard utility is available (PATH pointed at an empty dir), so the
// content is never lost.
func TestREPLCopyDegradesToPrint(t *testing.T) {
t.Setenv("PATH", t.TempDir())
p := &replProvider{reply: "the important answer"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("ask\n/copy\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
s := out.String()
if !strings.Contains(s, "no clipboard utility") {
t.Errorf("/copy should report missing clipboard utility, out=%q", s)
}
if !strings.Contains(s, "the important answer") {
t.Errorf("/copy should print the reply when degrading, out=%q", s)
}
}