first commit
This commit is contained in:
@@ -0,0 +1,656 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user