657 lines
24 KiB
Go
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)
|
|
}
|
|
}
|