first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
@@ -0,0 +1,62 @@
package repl
// Tests for formatSlashAutocompleteLabel (US-010, #340): the Tab-completion
// label renders as "name <argument-hint> - description", omitting the hint
// segment when absent and falling back to the first body line for description.
import (
"bufio"
"io"
"strings"
"testing"
"github.com/smallnest/pigo/internal/runtime"
)
func TestFormatSlashAutocompleteLabel(t *testing.T) {
cases := []struct {
name string
cmd runtime.SlashCommand
want string
}{
{"hint+desc", runtime.SlashCommand{Name: "review", ArgumentHint: "<PR-URL>", Description: "Review PRs"}, "review <PR-URL> - Review PRs"},
{"desc only", runtime.SlashCommand{Name: "review", Description: "Review PRs"}, "review - Review PRs"},
{"hint only", runtime.SlashCommand{Name: "wr", ArgumentHint: "[instructions]"}, "wr [instructions]"},
{"neither", runtime.SlashCommand{Name: "model"}, "model"},
}
for _, c := range cases {
if got := formatSlashAutocompleteLabel(c.cmd); got != c.want {
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
}
}
}
// TestFormatSlashAutocompleteLabelDescriptionFallback verifies the description
// fallback from #334 (first non-empty body line) flows through to the label.
func TestFormatSlashAutocompleteLabelDescriptionFallback(t *testing.T) {
cmd, err := runtime.ParseUserCommand("bare", []byte("First line is the desc\nbody"))
if err != nil {
t.Fatal(err)
}
if got := formatSlashAutocompleteLabel(cmd); got != "bare - First line is the desc" {
t.Errorf("fallback label = %q, want \"bare - First line is the desc\"", got)
}
}
// TestSlashAutocompleteSuggestionStillCompletesName verifies that a slash
// command with an argument-hint is still completable by name (Tab inserts
// "/name"); the label is a display annotation, not the inserted text.
func TestSlashAutocompleteSuggestionStillCompletesName(t *testing.T) {
reg := runtime.NewSlashRegistry()
reg.AddBuiltin(runtime.SlashCommand{Name: "model", Action: func(string) string { return "" }})
reg.AddUser(runtime.SlashCommand{
Name: "review",
ArgumentHint: "<PR-URL>",
Description: "Review PRs",
Expand: func(string) string { return "" },
})
e := newREPLLineEditor(strings.NewReader(""), bufio.NewReader(strings.NewReader("")), io.Discard, reg, nil)
if got := e.suggestion("/rev"); got != "/review" {
t.Errorf("suggestion(/rev) = %q, want /review (name, not the label)", got)
}
}
+58
View File
@@ -0,0 +1,58 @@
package repl
// Tests for /btw discoverability (#283, US-006/FR-10): /btw must appear in the
// /help listing and be completable at the REPL slash prompt. Both flow from
// registering "btw" as a listed built-in in registerLiveCommands; these tests
// pin that so the command can't silently drop off help/completion.
import (
"strings"
"testing"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/prompts"
"github.com/smallnest/pigo/internal/runtime"
)
// TestBtwListedInHelp verifies registerLiveCommands registers /btw so /help (and
// any consumer of reg.List()) surfaces it with a usage description.
func TestBtwListedInHelp(t *testing.T) {
reg := runtime.NewSlashRegistry()
prompts.RegisterLiveCommands(reg, &cli.LiveConfig{})
var btw *runtime.SlashCommand
for _, c := range reg.List() {
if c.Name == "btw" {
cc := c
btw = &cc
break
}
}
if btw == nil {
t.Fatalf("registerLiveCommands must register /btw so /help lists it")
}
if !strings.Contains(btw.Description, "side question") {
t.Errorf("/btw help description should explain the side question, got %q", btw.Description)
}
}
// TestBtwSlashCompletion verifies the line editor completes "/b" to "/btw" once
// the command is registered — the same reg.List() drives both help and
// completion, so registration is all that's needed.
func TestBtwSlashCompletion(t *testing.T) {
reg := runtime.NewSlashRegistry()
prompts.RegisterLiveCommands(reg, &cli.LiveConfig{})
e := newREPLLineEditor(nil, nil, nil, reg, nil)
cands := e.suggestions("/bt")
found := false
for _, c := range cands {
if c == "/btw" {
found = true
break
}
}
if !found {
t.Errorf("expected /btw among completions for %q, got %v", "/bt", cands)
}
}
@@ -0,0 +1,148 @@
package repl
// Isolation / zero-pollution tests for /btw (#284, PRD Success Metrics). These
// lock the feature's most important correctness guarantee: a side thread's
// question and answer NEVER touch the main conversation and NEVER hit disk. The
// tests drive the whole runREPL loop with the fake replProvider and assert, in
// one place, every observable that a leak would perturb: the main context's
// message count AND content, the session store on disk, and the persistence
// bookkeeping (deps.persisted / deps.curLeaf / deps.header.UpdatedAt).
import (
"bytes"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
// snapshotMessages returns the flattened text of every message so a test can
// assert the main context is byte-for-byte unchanged, not merely the same
// length (a leak could replace a message without changing the count). Content
// is a field on the concrete message types, not on the Message interface, so we
// type-switch on the two kinds /btw could ever append.
func snapshotMessages(msgs agentcore.MessageList) []string {
out := make([]string, len(msgs))
for i, m := range msgs {
var text string
switch v := m.(type) {
case agentcore.UserMessage:
text = agentcore.ContentToText(v.Content)
case agentcore.AssistantMessage:
text = agentcore.ContentToText(v.Content)
}
out[i] = m.Role() + ":" + text
}
return out
}
// seedMainContext appends a real user+assistant exchange to the main context so
// the isolation tests start from a non-empty conversation — proving /btw leaves
// existing history untouched, not just that it avoids growing an empty slice.
func seedMainContext(deps *replDeps) {
deps.agentCtx.Messages = append(deps.agentCtx.Messages,
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("main question")}},
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("main answer")}},
)
}
// TestBtwMainContextZeroGrowth verifies one /btw Q&A leaves the main context's
// message count AND content exactly as before (AC-1).
func TestBtwMainContextZeroGrowth(t *testing.T) {
p := &replProvider{reply: "side answer"}
deps, _ := newTestDeps(t, p)
seedMainContext(&deps)
before := snapshotMessages(deps.agentCtx.Messages)
var out bytes.Buffer
if err := runREPL(strings.NewReader("/btw a quick question?\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 1 {
t.Fatalf("expected exactly 1 side run, got %d", p.calls)
}
after := snapshotMessages(deps.agentCtx.Messages)
if len(after) != len(before) {
t.Fatalf("main context grew: %d → %d messages", len(before), len(after))
}
for i := range before {
if after[i] != before[i] {
t.Fatalf("main context message %d changed:\n before %q\n after %q", i, before[i], after[i])
}
}
}
// TestBtwNoStoreWrites verifies /btw writes nothing to the session store: no
// entries are appended for the session on disk (AC-2).
func TestBtwNoStoreWrites(t *testing.T) {
p := &replProvider{reply: "answer"}
deps, store := newTestDeps(t, p)
seedMainContext(&deps)
var out bytes.Buffer
if err := runREPL(strings.NewReader("/btw does this persist?\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
// The main loop persists only on a real turn; /btw must trigger none. Since
// the seeded messages were never run through streamRun, the store should hold
// no entries for this session at all.
if _, entries, err := store.LoadEntries(deps.header.ID); err == nil && len(entries) != 0 {
t.Fatalf("/btw must not write to the store, got %d entries", len(entries))
}
}
// TestBtwFollowUpsLeaveContextUnchanged verifies that ≥3 follow-ups in the same
// side thread still leave the main context's count and content unchanged (AC-3).
func TestBtwFollowUpsLeaveContextUnchanged(t *testing.T) {
p := &replProvider{reply: "ok"}
deps, _ := newTestDeps(t, p)
seedMainContext(&deps)
before := snapshotMessages(deps.agentCtx.Messages)
var out bytes.Buffer
// One /btw plus three bare follow-ups, then leave the thread and exit.
in := strings.NewReader("/btw first?\nsecond?\nthird?\nfourth?\n/exit\n/exit\n")
if err := runREPL(in, &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 4 {
t.Fatalf("expected 4 side runs (1 + 3 follow-ups), got %d", p.calls)
}
after := snapshotMessages(deps.agentCtx.Messages)
if len(after) != len(before) {
t.Fatalf("main context grew across follow-ups: %d → %d", len(before), len(after))
}
for i := range before {
if after[i] != before[i] {
t.Fatalf("follow-ups changed main message %d: %q → %q", i, before[i], after[i])
}
}
}
// TestBtwPersistenceBookkeepingUnchanged verifies /btw does not advance the
// persistence bookkeeping: deps.persisted, deps.curLeaf and deps.header.UpdatedAt
// are all identical before and after (AC-4).
func TestBtwPersistenceBookkeepingUnchanged(t *testing.T) {
p := &replProvider{reply: "x"}
deps, _ := newTestDeps(t, p)
seedMainContext(&deps)
// Give the bookkeeping non-zero starting values so the test would catch a
// reset-to-zero as well as an increment.
deps.persisted = 2
deps.curLeaf = "leaf-abc"
beforeUpdated := deps.header.UpdatedAt
var out bytes.Buffer
if err := runREPL(strings.NewReader("/btw hi\nmore?\n/exit\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if deps.persisted != 2 {
t.Errorf("deps.persisted changed: 2 → %d", deps.persisted)
}
if deps.curLeaf != "leaf-abc" {
t.Errorf("deps.curLeaf changed: %q → %q", "leaf-abc", deps.curLeaf)
}
if !deps.header.UpdatedAt.Equal(beforeUpdated) {
t.Errorf("deps.header.UpdatedAt changed: %v → %v", beforeUpdated, deps.header.UpdatedAt)
}
}
+171
View File
@@ -0,0 +1,171 @@
package repl
// Tests for the /btw side-thread command (#279): a side question must run an
// agent stream but MUST NOT mutate or persist the main conversation. These
// drive the whole runREPL loop with the fake replProvider, then assert the main
// context and persistence state are unchanged.
import (
"bytes"
"context"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/cli/btw"
)
// TestBtwDoesNotPolluteMainContext verifies that "/btw <q>" launches a run
// (provider called) yet appends nothing to deps.agentCtx.Messages.
func TestBtwDoesNotPolluteMainContext(t *testing.T) {
p := &replProvider{reply: "side answer"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("/btw why pointers?\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 1 {
t.Fatalf("expected side question to launch exactly 1 run, got %d", p.calls)
}
if len(deps.agentCtx.Messages) != 0 {
t.Fatalf("main context must be untouched by /btw, got %d messages", len(deps.agentCtx.Messages))
}
if !strings.Contains(out.String(), btw.BtwHeader) {
t.Errorf("expected side-thread header %q in output", btw.BtwHeader)
}
if !strings.Contains(out.String(), "side answer") {
t.Errorf("expected the side answer to be printed, got: %q", out.String())
}
}
// TestBtwDoesNotPersist verifies /btw writes nothing to disk: deps.persisted and
// deps.curLeaf are unchanged, and no session entries were appended.
func TestBtwDoesNotPersist(t *testing.T) {
p := &replProvider{reply: "answer"}
deps, store := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("/btw quick q\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if deps.persisted != 0 {
t.Fatalf("deps.persisted must stay 0 after /btw, got %d", deps.persisted)
}
if deps.curLeaf != "" {
t.Fatalf("deps.curLeaf must stay empty after /btw, got %q", deps.curLeaf)
}
if _, entries, err := store.LoadEntries(deps.header.ID); err == nil && len(entries) != 0 {
t.Fatalf("no session entries should be persisted by /btw, got %d", len(entries))
}
}
// TestBtwBareUsage verifies bare "/btw" with no prior side thread does not
// launch a run and prints usage guidance.
func TestBtwBareUsage(t *testing.T) {
p := &replProvider{reply: "unused"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
if err := runREPL(strings.NewReader("/btw\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 0 {
t.Fatalf("bare /btw must not launch a run, got %d calls", p.calls)
}
if !strings.Contains(out.String(), "usage: /btw") {
t.Errorf("expected usage hint for bare /btw, got: %q", out.String())
}
}
// TestBtwBareReopensLastThread verifies that after a side thread exists, a bare
// "/btw" reopens it, replays the prior side Q&A, and lets the user keep asking
// in the SAME thread. The main context stays untouched throughout.
func TestBtwBareReopensLastThread(t *testing.T) {
p := &replProvider{reply: "side answer"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
// Open a side thread and ask once, leave it, then bare /btw reopens it and
// asks a follow-up, then leave again and exit the REPL.
in := strings.NewReader("/btw first question?\n/exit\n/btw\nsecond question?\n/exit\n/exit\n")
if err := runREPL(in, &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 2 {
t.Fatalf("expected 2 side runs (1 initial + 1 after reopen), got %d", p.calls)
}
if len(deps.agentCtx.Messages) != 0 {
t.Fatalf("main context must stay untouched, got %d messages", len(deps.agentCtx.Messages))
}
s := out.String()
// The reopen must not print the bare-/btw usage hint (a thread existed).
if strings.Contains(s, "usage: /btw") {
t.Errorf("bare /btw with an existing thread must not print usage, got: %q", s)
}
// The replay must echo the earlier question.
if !strings.Contains(s, "first question?") {
t.Errorf("reopen should replay the earlier side question, got: %q", s)
}
}
// TestBtwFollowUpsShareThread verifies that after "/btw <q>" the user can ask
// follow-ups at the btw prompt (without retyping /btw), each launching a run,
// and that none of them pollute the main context. "/exit" leaves the thread.
func TestBtwFollowUpsShareThread(t *testing.T) {
p := &replProvider{reply: "ok"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
// First /btw asks once; then two bare follow-ups; then /exit leaves the side
// thread; then /exit ends the REPL.
in := strings.NewReader("/btw first?\nsecond?\nthird?\n/exit\n/exit\n")
if err := runREPL(in, &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 3 {
t.Fatalf("expected 3 side runs (1 initial + 2 follow-ups), got %d", p.calls)
}
if len(deps.agentCtx.Messages) != 0 {
t.Fatalf("follow-ups must not pollute main context, got %d messages", len(deps.agentCtx.Messages))
}
if !strings.Contains(out.String(), "left side thread") {
t.Errorf("expected 'left side thread' on /exit from the side thread")
}
}
// TestBtwFollowUpLoopAccumulates verifies the side context grows across
// follow-ups so a later question sees the earlier Q&A.
func TestBtwFollowUpLoopAccumulates(t *testing.T) {
side := &agentcore.AgentContext{}
deps, _ := newTestDeps(t, &replProvider{reply: "a"})
setCancel := func(context.CancelFunc) {}
settings := btw.ResolveBtwSettings(&bytes.Buffer{}, &deps)
btw.AskSide(setCancel, &bytes.Buffer{}, &deps, side, settings, "q1")
n1 := len(side.Messages)
btw.AskSide(setCancel, &bytes.Buffer{}, &deps, side, settings, "q2")
if len(side.Messages) <= n1 {
t.Fatalf("side context should accumulate across follow-ups: %d then %d", n1, len(side.Messages))
}
}
// appending to the side thread cannot reach the main slice.
func TestNewSideContextIsolated(t *testing.T) {
main := &agentcore.AgentContext{
SystemPrompt: "sys",
Messages: agentcore.MessageList{
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("hi")}},
},
}
side := btw.NewSideContext(main)
if side.SystemPrompt != "sys" {
t.Errorf("side thread should inherit the system prompt")
}
if len(side.Messages) != 1 {
t.Fatalf("side thread should be seeded with the main messages, got %d", len(side.Messages))
}
side.Messages = append(side.Messages, agentcore.UserMessage{RoleField: agentcore.RoleUser})
if len(main.Messages) != 1 {
t.Fatalf("appending to the side thread must not grow the main context, got %d", len(main.Messages))
}
}
+41
View File
@@ -0,0 +1,41 @@
package repl
import (
"strings"
"testing"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/prompts"
"github.com/smallnest/pigo/internal/runtime"
)
// runtimeHelpRegistry builds a slash registry with the live built-in commands
// (/help, /model, /exit, /quit, …) registered against a throwaway live config,
// mirroring what buildSlashRegistry wires up for the real REPL.
func runtimeHelpRegistry(t *testing.T) *runtime.SlashRegistry {
t.Helper()
reg := runtime.NewSlashRegistry()
prompts.RegisterLiveCommands(reg, &cli.LiveConfig{Model: "faux", ProviderName: "faux"})
return reg
}
// TestHelpListingColorized verifies the /help action emits ANSI codes when
// color is enabled — the command names are highlighted, not plain.
func TestHelpListingColorized(t *testing.T) {
t.Setenv("NO_COLOR", "1") // force the deterministic (plain) branch
reg := runtimeHelpRegistry(t)
out, err := reg.ResolveOutcome("/help")
if err != nil {
t.Fatalf("resolve /help: %v", err)
}
// With NO_COLOR the listing must be plain text (no escape codes) and still
// contain the command names.
if strings.Contains(out.Message, "\033[") {
t.Errorf("NO_COLOR listing should carry no escape codes, got %q", out.Message)
}
for _, want := range []string{"/help", "/exit", "/quit"} {
if !strings.Contains(out.Message, want) {
t.Errorf("/help listing missing %q, out=%q", want, out.Message)
}
}
}
+240
View File
@@ -0,0 +1,240 @@
// This file implements the manual `/dream` REPL command (SPEC §4.1, US-007):
// it spawns the process-isolated memory-consolidation subprocess
// (`pigo --dream [--dream-dry-run] -C <projectDir>`), captures the single-line
// Report JSON the child writes to stdout (SPEC §4.2), and renders it as a
// full-table change report. `--dry-run` runs the same analysis without writing
// (the subprocess enforces that; the command only reflects report.DryRun).
//
// The report renderers (RenderReportTable / RenderReportLine) live here rather
// than in internal/dream/report.go so the presentation layer stays in the CLI
// package and to keep dream internals conflict-free. RenderReportLine is
// exported because the startup background trigger (#526) reuses it for its
// one-line summary.
package repl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/smallnest/pigo/internal/cli/ui"
"github.com/smallnest/pigo/internal/dream"
)
// dreamSubprocessResult is the parsed outcome of one spawn: the decoded Report
// plus whatever the child wrote to stderr (surfaced in error messages so a
// failing run is diagnosable). It is returned by the spawn seam so the
// parse-and-render path can be unit-tested against canned JSON without spawning
// a real LLM-backed dream.
type dreamSubprocessResult struct {
report dream.Report
stderr string
}
// spawnDream is the seam that launches the dream subprocess and decodes its
// stdout Report. It is a package var so tests can substitute a canned stdout
// (avoiding a real LLM-backed run — see acceptance criteria). The production
// implementation is spawnDreamSubprocess.
var spawnDream = spawnDreamSubprocess
// spawnDreamSubprocess runs `pigo --dream [--dream-dry-run] -C <projectDir>` to
// completion, capturing stdout (the single-line Report JSON, SPEC §4.2) and
// stderr (progress/diagnostics). A non-zero exit or unparseable stdout is
// returned as an error carrying the stderr tail so the caller can print a clear
// failure (SPEC §6.1). It never mutates the REPL's own state.
func spawnDreamSubprocess(ctx context.Context, projectDir string, dryRun bool) (dreamSubprocessResult, error) {
exe, err := os.Executable()
if err != nil {
return dreamSubprocessResult{}, fmt.Errorf("resolve pigo executable: %w", err)
}
args := []string{"--dream"}
if dryRun {
args = append(args, "--dream-dry-run")
}
if projectDir != "" {
args = append(args, "-C", projectDir)
}
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, exe, args...)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
errTail := strings.TrimSpace(stderr.String())
if runErr != nil {
// Exit 1 (or a kill/timeout) → failed (SPEC §6.1). Surface the stderr
// tail so the user sees why.
if errTail != "" {
return dreamSubprocessResult{stderr: errTail}, fmt.Errorf("%w: %s", runErr, errTail)
}
return dreamSubprocessResult{}, runErr
}
var report dream.Report
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &report); err != nil {
// Unparseable stdout is treated as failed (SPEC §6.1).
return dreamSubprocessResult{stderr: errTail}, fmt.Errorf("parse report: %w", err)
}
return dreamSubprocessResult{report: report, stderr: errTail}, nil
}
// runDream handles an intercepted `/dream` (or `/dream --dry-run`) line: it
// checks for a live lock (so a background dream already running yields the
// "已有 dream 在运行" notice rather than a confusing empty report), spawns the
// consolidation subprocess with a progress indication, and renders the returned
// Report as a full table. A failed or unparseable run prints a clear error and
// returns to the prompt without crashing the REPL (SPEC §6.1).
func runDream(out io.Writer, deps replDeps, line string) {
dryRun := dreamHasDryRun(line)
// Pre-spawn lock check: if a background (or other) dream already holds a live
// lock, the subprocess would just skip and emit an all-zero report,
// indistinguishable from "nothing changed". Detect it here so the manual
// command can tell the user (SPEC §6.1 locked row: "已有 dream 在运行").
if dreamLockHeld(deps.memoryRoot) {
fmt.Fprintln(out, ui.Colorize(ui.Enabled(), ui.Yellow, "已有 dream 在运行 (a dream consolidation is already running)"))
return
}
progress := "Dreaming… (consolidating memory)"
if dryRun {
progress = "Dreaming… (dry-run, analyzing memory — nothing will be written)"
}
fmt.Fprintln(out, ui.Colorize(ui.Enabled(), ui.Dim, progress))
// Bound the subprocess so a hung LLM-backed run cannot wedge the REPL
// indefinitely (SPEC §6.3/§11.2: parent context timeout, default 10min). On
// timeout CommandContext kills the child and spawnDream surfaces a failed run.
ctx, cancel := context.WithTimeout(context.Background(), dreamRunTimeout)
defer cancel()
res, err := spawnDream(ctx, deps.cwd, dryRun)
if err != nil {
fmt.Fprintf(out, "%s %v\n", ui.Colorize(ui.Enabled(), ui.Red, "dream failed:"), err)
return
}
RenderReportTable(out, res.report)
}
// dreamRunTimeout bounds a manual /dream subprocess (SPEC §6.3 default 10min).
var dreamRunTimeout = 10 * time.Minute
// dreamHasDryRun reports whether the /dream command line carries the --dry-run
// flag. It accepts "--dry-run" as a standalone token so "/dream --dry-run" and
// "/dream --dry-run" both match, while a bare "/dream" does not.
func dreamHasDryRun(line string) bool {
for _, f := range strings.Fields(line) {
if f == "--dry-run" {
return true
}
}
return false
}
// dreamLockPayload mirrors the on-disk dream.lock body (SPEC §3.1:
// {"pid":..,"started_at":..}). It is decoded read-only in the parent to detect
// a running dream; the authoritative lock logic lives in internal/dream/lock.go.
type dreamLockPayload struct {
StartedAt time.Time `json:"started_at"`
}
// dreamLockHeld reports whether a live (non-stale) dream lock exists under
// memoryRoot. A missing root/lock or a stale lock (older than
// dream.DefaultStaleAfter, matching the runner's takeover rule) reads as not
// held. Read-only: it never creates, removes, or takes over the lock — that is
// the subprocess Runner's job.
func dreamLockHeld(memoryRoot string) bool {
if memoryRoot == "" {
return false
}
path := filepath.Join(memoryRoot, "global", "dream", "dream.lock")
data, err := os.ReadFile(path)
if err != nil {
return false
}
var info dreamLockPayload
if err := json.Unmarshal(data, &info); err != nil {
// Malformed lock body: the runner treats it as stale/takeable, so it is
// not a live lock from the user's perspective.
return false
}
if info.StartedAt.IsZero() {
return false
}
return time.Since(info.StartedAt) <= dream.DefaultStaleAfter
}
// RenderReportTable writes the full change report (SPEC §2.2/§6.1 manual row):
// one aligned row per counter, byte/file before→after, and any Notes. A dry-run
// report is clearly labeled DRY-RUN and states that nothing was written.
func RenderReportTable(out io.Writer, r dream.Report) {
enabled := ui.Enabled()
if r.DryRun {
fmt.Fprintln(out, ui.Colorize(enabled, ui.Bold, "dream report [DRY-RUN — nothing written]"))
} else {
fmt.Fprintln(out, ui.Colorize(enabled, ui.Bold, "dream report"))
}
rows := []struct {
label string
value string
}{
{"merged", fmt.Sprintf("%d", r.Merged)},
{"deduped", fmt.Sprintf("%d", r.Deduped)},
{"paths-cleaned", fmt.Sprintf("%d", r.PathsCleaned)},
{"pruned", fmt.Sprintf("%d", r.Pruned)},
{"distilled", fmt.Sprintf("%d", r.Distilled)},
{"bytes", fmt.Sprintf("%s → %s", formatBytes(r.BytesBefore), formatBytes(r.BytesAfter))},
{"files", fmt.Sprintf("%d → %d", r.FilesBefore, r.FilesAfter)},
{"reconciled", fmt.Sprintf("indexed %d, pruned %d", r.Reconciled.Indexed, r.Reconciled.Pruned)},
}
width := 0
for _, row := range rows {
if len(row.label) > width {
width = len(row.label)
}
}
for _, row := range rows {
label := ui.Colorize(enabled, ui.Dim, fmt.Sprintf(" %-*s", width, row.label))
fmt.Fprintf(out, "%s %s\n", label, row.value)
}
if len(r.Notes) > 0 {
fmt.Fprintln(out, ui.Colorize(enabled, ui.Dim, " notes:"))
for _, n := range r.Notes {
fmt.Fprintf(out, " - %s\n", n)
}
}
}
// RenderReportLine renders a compact one-line summary of a dream Report. It is
// exported so the startup background trigger (#526) can reuse it for its
// non-intrusive one-line notice (SPEC §6.1 background row). A dry-run report is
// prefixed [DRY-RUN].
func RenderReportLine(r dream.Report) string {
prefix := "dream:"
if r.DryRun {
prefix = "dream [DRY-RUN]:"
}
return fmt.Sprintf("%s merged %d, deduped %d, paths-cleaned %d, pruned %d, distilled %d, %s→%s, %d→%d files",
prefix, r.Merged, r.Deduped, r.PathsCleaned, r.Pruned, r.Distilled,
formatBytes(r.BytesBefore), formatBytes(r.BytesAfter), r.FilesBefore, r.FilesAfter)
}
// formatBytes renders a byte count as a compact human-readable string (B/KB/MB).
// It uses 1024-based units and one decimal place above 1KB, matching the terse
// style of the rest of the REPL status output.
func formatBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%dB", n)
}
div, exp := int64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%cB", float64(n)/float64(div), "KMGT"[exp])
}
+255
View File
@@ -0,0 +1,255 @@
package repl
import (
"bytes"
"context"
"strings"
"testing"
"github.com/smallnest/pigo/internal/dream"
)
// sampleReport is a non-trivial Report used across the renderer tests: every
// counter is distinct so a mis-wired field is caught, and Notes/Reconciled are
// populated so their rendering is exercised too.
func sampleReport() dream.Report {
r := dream.Report{
Merged: 2,
Deduped: 1,
PathsCleaned: 3,
Pruned: 4,
Distilled: 5,
BytesBefore: 4096,
BytesAfter: 2048,
FilesBefore: 9,
FilesAfter: 7,
Notes: []string{"pruned stale entry X", "no new sessions"},
}
r.Reconciled.Indexed = 6
r.Reconciled.Pruned = 1
return r
}
func TestRenderReportTable(t *testing.T) {
var buf bytes.Buffer
RenderReportTable(&buf, sampleReport())
got := buf.String()
// Key counts must appear (label + value).
for _, want := range []string{
"dream report",
"merged", "deduped", "paths-cleaned", "pruned", "distilled",
"4.0KB → 2.0KB", // bytes before→after
"9 → 7", // files before→after
"indexed 6, pruned 1",
"pruned stale entry X",
"no new sessions",
} {
if !strings.Contains(got, want) {
t.Errorf("full table missing %q\n---\n%s", want, got)
}
}
// A non-dry-run report must NOT carry the DRY-RUN label.
if strings.Contains(got, "DRY-RUN") {
t.Errorf("non-dry-run table should not show DRY-RUN label:\n%s", got)
}
}
func TestRenderReportTableDryRun(t *testing.T) {
r := sampleReport()
r.DryRun = true
var buf bytes.Buffer
RenderReportTable(&buf, r)
got := buf.String()
if !strings.Contains(got, "DRY-RUN") {
t.Errorf("dry-run table must show DRY-RUN label:\n%s", got)
}
if !strings.Contains(got, "nothing written") {
t.Errorf("dry-run table should state nothing was written:\n%s", got)
}
}
func TestRenderReportLine(t *testing.T) {
got := RenderReportLine(sampleReport())
for _, want := range []string{
"dream:",
"merged 2", "deduped 1", "paths-cleaned 3", "pruned 4", "distilled 5",
"4.0KB→2.0KB", "9→7 files",
} {
if !strings.Contains(got, want) {
t.Errorf("one-line summary missing %q: %q", want, got)
}
}
if strings.Contains(got, "DRY-RUN") {
t.Errorf("non-dry-run line should not show DRY-RUN: %q", got)
}
}
func TestRenderReportLineDryRun(t *testing.T) {
r := sampleReport()
r.DryRun = true
got := RenderReportLine(r)
if !strings.Contains(got, "DRY-RUN") {
t.Errorf("dry-run line must show DRY-RUN: %q", got)
}
}
func TestRenderReportZeroValue(t *testing.T) {
// The zero value is a valid "nothing changed" report and must render cleanly.
var buf bytes.Buffer
RenderReportTable(&buf, dream.Report{})
if line := RenderReportLine(dream.Report{}); !strings.Contains(line, "merged 0") {
t.Errorf("zero-value line should render zero counts: %q", line)
}
if !strings.Contains(buf.String(), "0B → 0B") {
t.Errorf("zero-value table should render 0B → 0B:\n%s", buf.String())
}
}
func TestFormatBytes(t *testing.T) {
cases := []struct {
in int64
want string
}{
{0, "0B"},
{512, "512B"},
{1024, "1.0KB"},
{1536, "1.5KB"},
{1048576, "1.0MB"},
}
for _, c := range cases {
if got := formatBytes(c.in); got != c.want {
t.Errorf("formatBytes(%d) = %q, want %q", c.in, got, c.want)
}
}
}
func TestDreamHasDryRun(t *testing.T) {
cases := []struct {
line string
want bool
}{
{"/dream", false},
{"/dream --dry-run", true},
{"/dream --dry-run", true},
{"/dream --dryrun", false},
{"/dream extra --dry-run", true},
}
for _, c := range cases {
if got := dreamHasDryRun(c.line); got != c.want {
t.Errorf("dreamHasDryRun(%q) = %v, want %v", c.line, got, c.want)
}
}
}
// TestRunDreamRendersCannedReport exercises the parse+render path via the spawn
// seam with a canned Report, without spawning a real LLM-backed dream.
func TestRunDreamRendersCannedReport(t *testing.T) {
orig := spawnDream
t.Cleanup(func() { spawnDream = orig })
spawnDream = func(_ context.Context, _ string, dryRun bool) (dreamSubprocessResult, error) {
r := sampleReport()
r.DryRun = dryRun
return dreamSubprocessResult{report: r}, nil
}
var buf bytes.Buffer
runDream(&buf, replDeps{}, "/dream")
got := buf.String()
if !strings.Contains(got, "dream report") || !strings.Contains(got, "merged") {
t.Errorf("runDream should render the full table:\n%s", got)
}
if strings.Contains(got, "DRY-RUN") {
t.Errorf("non-dry-run runDream should not show DRY-RUN:\n%s", got)
}
}
func TestRunDreamDryRunLabel(t *testing.T) {
orig := spawnDream
t.Cleanup(func() { spawnDream = orig })
spawnDream = func(_ context.Context, _ string, dryRun bool) (dreamSubprocessResult, error) {
r := sampleReport()
r.DryRun = dryRun
return dreamSubprocessResult{report: r}, nil
}
var buf bytes.Buffer
runDream(&buf, replDeps{}, "/dream --dry-run")
if !strings.Contains(buf.String(), "DRY-RUN") {
t.Errorf("/dream --dry-run should render DRY-RUN label:\n%s", buf.String())
}
}
// TestRunDreamFailure asserts a subprocess failure (exit 1 / unparseable stdout)
// prints a clear error and does not crash the REPL (SPEC §6.1).
func TestRunDreamFailure(t *testing.T) {
orig := spawnDream
t.Cleanup(func() { spawnDream = orig })
spawnDream = func(_ context.Context, _ string, _ bool) (dreamSubprocessResult, error) {
return dreamSubprocessResult{}, errFake
}
var buf bytes.Buffer
runDream(&buf, replDeps{}, "/dream")
if !strings.Contains(buf.String(), "dream failed") {
t.Errorf("failed run should print an error:\n%s", buf.String())
}
}
var errFake = &fakeErr{}
type fakeErr struct{}
func (*fakeErr) Error() string { return "boom" }
func TestDreamLockHeld(t *testing.T) {
// No memory root → never held.
if dreamLockHeld("") {
t.Fatal("empty memoryRoot must read as not held")
}
root := t.TempDir()
// No lock file yet → not held.
if dreamLockHeld(root) {
t.Fatal("missing lock must read as not held")
}
// Acquire a real lock via the dream package → held.
lock, err := dream.AcquireLock(root)
if err != nil {
t.Fatalf("AcquireLock: %v", err)
}
if !dreamLockHeld(root) {
t.Error("a freshly acquired lock must read as held")
}
// Release → not held.
if err := lock.Release(); err != nil {
t.Fatalf("Release: %v", err)
}
if dreamLockHeld(root) {
t.Error("a released lock must read as not held")
}
}
// TestRunDreamLockedNotice asserts the manual command surfaces the locked
// message and does NOT spawn when a live lock is present (SPEC §6.1).
func TestRunDreamLockedNotice(t *testing.T) {
root := t.TempDir()
lock, err := dream.AcquireLock(root)
if err != nil {
t.Fatalf("AcquireLock: %v", err)
}
t.Cleanup(func() { _ = lock.Release() })
orig := spawnDream
t.Cleanup(func() { spawnDream = orig })
spawned := false
spawnDream = func(_ context.Context, _ string, _ bool) (dreamSubprocessResult, error) {
spawned = true
return dreamSubprocessResult{}, nil
}
var buf bytes.Buffer
runDream(&buf, replDeps{memoryRoot: root}, "/dream")
if spawned {
t.Error("runDream must not spawn while a live lock is held")
}
if !strings.Contains(buf.String(), "已有 dream 在运行") {
t.Errorf("locked run should print the locked notice:\n%s", buf.String())
}
}
+58
View File
@@ -0,0 +1,58 @@
// This file wires the startup background trigger for /dream memory
// consolidation (US-008, FR-4/FR-17): when an interactive REPL session starts,
// if [dream].enabled and a consolidation is due (per dream.State.Due), the dream
// subprocess is spawned in the BACKGROUND so it never delays the first user
// response, and a non-intrusive one-line summary is printed on completion.
//
// The decision + goroutine live in dream.Scheduler; this file only supplies the
// CLI-side spawn seam (reusing spawnDream from dream_repl.go) and the one-line
// notice renderer (RenderReportLine). The subprocess's O_EXCL lock enforces
// single-instance, so a second trigger just yields a skipped child (silent).
package repl
import (
"context"
"fmt"
"io"
"github.com/smallnest/pigo/internal/cli/ui"
"github.com/smallnest/pigo/internal/dream"
)
// dreamStartupScheduler owns the startup auto-trigger decision. It is stateless
// (dream.Scheduler is a zero-size type), so a package value is enough; tests
// exercise this path through the spawnDream seam rather than replacing it.
var dreamStartupScheduler dream.Scheduler
// maybeStartBackgroundDream launches an auto-consolidation in the background at
// interactive session startup when dream is enabled and due. It never blocks:
// the due check is a single state.json read and any spawn runs in a goroutine,
// so the first prompt is served immediately (SPEC FR-4 / §8.2). The completion
// notice is a single dim line via RenderReportLine; a skipped, no-op, or failed
// run prints nothing (SPEC §6.1).
//
// out is written to from the background goroutine, so callers must pass a writer
// safe for a late async line (the REPL uses os.Stdout, where a one-line notice
// simply appears in scrollback). It is a no-op when memoryRoot is empty (dream
// state has nowhere to live) or dream is disabled/not due.
func maybeStartBackgroundDream(out io.Writer, memoryRoot, projectDir string, cfg dream.Config) bool {
if memoryRoot == "" {
return false
}
return dreamStartupScheduler.MaybeRunBackground(context.Background(), dream.BackgroundDeps{
MemoryRoot: memoryRoot,
ProjectDir: projectDir,
Config: cfg,
Spawn: func(ctx context.Context, dir string) (dream.Report, error) {
// Bound the background run like a manual /dream (SPEC §6.3): a hung
// LLM-backed pass is killed rather than leaking a goroutine forever.
ctx, cancel := context.WithTimeout(ctx, dreamRunTimeout)
defer cancel()
res, err := spawnDream(ctx, dir, false)
return res.report, err
},
OnReport: func(r dream.Report) {
fmt.Fprintln(out, ui.Colorize(ui.Enabled(), ui.Dim, RenderReportLine(r)))
},
})
}
@@ -0,0 +1,132 @@
package repl
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/smallnest/pigo/internal/dream"
)
// syncWriter is a concurrency-safe writer whose first Write closes done, so a
// test can wait for the async one-line notice and then read it under the same
// lock the background goroutine wrote it under (bytes.Buffer is not safe for
// concurrent use).
type syncWriter struct {
mu sync.Mutex
buf bytes.Buffer
done chan struct{}
once sync.Once
}
func newSyncWriter() *syncWriter { return &syncWriter{done: make(chan struct{})} }
func (w *syncWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
n, err := w.buf.Write(p)
w.once.Do(func() { close(w.done) })
return n, err
}
func (w *syncWriter) String() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.String()
}
// seedDueDreamState writes a state.json under memoryRoot old enough that a
// 7-day-interval dream is due, so maybeStartBackgroundDream will spawn.
func seedDueDreamState(t *testing.T, memoryRoot string) {
t.Helper()
if err := dream.SaveState(memoryRoot, dream.State{
LastRunAt: time.Now().Add(-30 * 24 * time.Hour),
LastStatus: "ok",
}); err != nil {
t.Fatalf("SaveState: %v", err)
}
}
func TestMaybeStartBackgroundDream_NoticeOnChanges(t *testing.T) {
root := t.TempDir()
seedDueDreamState(t, root)
orig := spawnDream
t.Cleanup(func() { spawnDream = orig })
spawnDream = func(_ context.Context, dir string, dryRun bool) (dreamSubprocessResult, error) {
if dryRun {
t.Errorf("background trigger must not run in dry-run mode")
}
if dir != "/proj/y" {
t.Errorf("spawn got dir %q, want /proj/y", dir)
}
return dreamSubprocessResult{report: dream.Report{Merged: 3}}, nil
}
w := newSyncWriter()
if !maybeStartBackgroundDream(w, root, "/proj/y", dream.NewConfig(nil, 7, 20)) {
t.Fatal("due+enabled dream should launch a background run")
}
select {
case <-w.done:
case <-time.After(2 * time.Second):
t.Fatal("one-line notice not written within timeout")
}
if got := w.String(); !strings.Contains(got, "dream:") || !strings.Contains(got, "merged 3") {
t.Fatalf("one-line notice missing/incorrect: %q", got)
}
}
func TestMaybeStartBackgroundDream_DisabledNoSpawn(t *testing.T) {
root := t.TempDir()
seedDueDreamState(t, root)
orig := spawnDream
t.Cleanup(func() { spawnDream = orig })
spawnDream = func(context.Context, string, bool) (dreamSubprocessResult, error) {
t.Fatal("disabled dream must not spawn a subprocess")
return dreamSubprocessResult{}, nil
}
enabledFalse := false
var buf bytes.Buffer
if maybeStartBackgroundDream(&buf, root, "/proj/y", dream.NewConfig(&enabledFalse, 7, 20)) {
t.Fatal("disabled dream must not launch")
}
}
func TestMaybeStartBackgroundDream_EmptyRootNoSpawn(t *testing.T) {
orig := spawnDream
t.Cleanup(func() { spawnDream = orig })
spawnDream = func(context.Context, string, bool) (dreamSubprocessResult, error) {
t.Fatal("empty memory root must not spawn")
return dreamSubprocessResult{}, nil
}
var buf bytes.Buffer
if maybeStartBackgroundDream(&buf, "", "/proj/y", dream.NewConfig(nil, 7, 20)) {
t.Fatal("empty memory root must not launch")
}
}
func TestMaybeStartBackgroundDream_NeverRunNoSpawn(t *testing.T) {
// A fresh memory root (no state.json) is "never run" → not due → no spawn.
root := filepath.Join(t.TempDir(), "empty")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
orig := spawnDream
t.Cleanup(func() { spawnDream = orig })
spawnDream = func(context.Context, string, bool) (dreamSubprocessResult, error) {
t.Fatal("never-run state must not spawn (first run is manual)")
return dreamSubprocessResult{}, nil
}
var buf bytes.Buffer
if maybeStartBackgroundDream(&buf, root, "/proj/y", dream.NewConfig(nil, 7, 20)) {
t.Fatal("never-run dream must not launch")
}
}
+61
View File
@@ -0,0 +1,61 @@
package repl
// Tests for /help's template listing (US-011, #341): formatHelpLine renders
// "/name <argument-hint> - description (source: <tier>)" (hint omitted when
// absent), and the /help Action includes prompt templates with their hint and
// source tier alongside built-ins.
import (
"strings"
"testing"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/prompts"
"github.com/smallnest/pigo/internal/runtime"
)
func TestFormatHelpLine(t *testing.T) {
cases := []struct {
name string
cmd runtime.SlashCommand
want string
}{
{"hint+desc global", runtime.SlashCommand{Name: "review", ArgumentHint: "<PR-URL>", Description: "Review PRs", Tier: runtime.TierGlobal}, "/review <PR-URL> - Review PRs (source: global)"},
{"desc only builtin", runtime.SlashCommand{Name: "help", Description: "list commands", Tier: runtime.TierBuiltin}, "/help - list commands (source: builtin)"},
{"hint only cli", runtime.SlashCommand{Name: "wr", ArgumentHint: "[instructions]", Tier: runtime.TierCLI}, "/wr [instructions] (source: cli)"},
{"neither project", runtime.SlashCommand{Name: "deploy", Tier: runtime.TierProject}, "/deploy (source: project)"},
{"settings tier", runtime.SlashCommand{Name: "audit", Description: "audit changelog", Tier: runtime.TierSettings}, "/audit - audit changelog (source: settings)"},
}
for _, c := range cases {
if got := formatHelpLine(c.cmd); got != c.want {
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
}
}
}
// TestHelpActionIncludesTemplateLabelAndTier verifies the /help Action lists a
// prompt template with its argument-hint, description, and source tier.
func TestHelpActionIncludesTemplateLabelAndTier(t *testing.T) {
reg := runtime.NewSlashRegistry()
prompts.RegisterLiveCommands(reg, &cli.LiveConfig{Model: "test", ProviderName: "test"})
reg.AddUser(runtime.SlashCommand{
Name: "review",
ArgumentHint: "<PR-URL>",
Description: "Review PRs",
Tier: runtime.TierGlobal,
Expand: func(string) string { return "" },
})
out, err := reg.ResolveOutcome("/help")
if err != nil {
t.Fatalf("ResolveOutcome /help: %v", err)
}
if !out.Handled || out.Kind != runtime.SlashAction {
t.Fatalf("/help should be a handled action, got handled=%v kind=%v", out.Handled, out.Kind)
}
for _, want := range []string{"/review", "<PR-URL>", "Review PRs", "(source: global)"} {
if !strings.Contains(out.Message, want) {
t.Errorf("/help output missing %q:\n%s", want, out.Message)
}
}
}
+53
View File
@@ -0,0 +1,53 @@
// This file makes replDeps satisfy cli.Host: the accessor and mutator methods
// let the /goal, /btw, /status and REPL logic reach the session's collaborators
// and mutable state through the cli.Host contract rather than the concrete
// aggregate. The compile-time assertion below fails the build if replDeps drifts
// out of conformance.
package repl
import (
"bufio"
"sync"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/plugin"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
"github.com/smallnest/pigo/internal/session"
"github.com/smallnest/pigo/internal/trust"
)
var _ cli.Host = (*replDeps)(nil)
func (d *replDeps) Store() *session.Store { return d.store }
func (d *replDeps) Header() session.SessionHeader { return d.header }
func (d *replDeps) AgentCtx() *agentcore.AgentContext { return d.agentCtx }
func (d *replDeps) Live() *cli.LiveConfig { return d.live }
func (d *replDeps) Registry() *agenttool.ToolRegistry { return d.reg }
func (d *replDeps) Reminders() *runtime.ReminderRegistry { return d.reminders }
func (d *replDeps) Slash() *runtime.SlashRegistry { return d.slash }
func (d *replDeps) Creds() *provider.CredentialStore { return d.creds }
func (d *replDeps) Notifier() *plugin.EventNotifier { return d.notifier }
func (d *replDeps) NotifierHandle() func(agentcore.AgentEvent) { return d.notifierHandle() }
func (d *replDeps) Trust() *trust.Manager { return d.trust }
func (d *replDeps) Goal() *agenttool.GoalState { return d.goal }
func (d *replDeps) Telemetry() *cli.TelemetryHolder { return d.telemetry }
func (d *replDeps) Dispatcher() *hooks.Dispatcher { return d.dispatcher }
func (d *replDeps) HookDeps() run.HookDeps { return d.hookDeps }
func (d *replDeps) Cwd() string { return d.cwd }
func (d *replDeps) Input() *bufio.Reader { return d.in }
func (d *replDeps) ConfirmMu() *sync.Mutex { return d.confirmMu }
func (d *replDeps) CurLeaf() string { return d.curLeaf }
func (d *replDeps) SetCurLeaf(id string) { d.curLeaf = id }
func (d *replDeps) Persisted() int { return d.persisted }
func (d *replDeps) SetPersisted(n int) { d.persisted = n }
func (d *replDeps) LastBtw() *agentcore.AgentContext { return d.lastBtw }
func (d *replDeps) SetLastBtw(ctx *agentcore.AgentContext) { d.lastBtw = ctx }
func (d *replDeps) LastBtwBase() int { return d.lastBtwBase }
func (d *replDeps) SetLastBtwBase(n int) { d.lastBtwBase = n }
+261
View File
@@ -0,0 +1,261 @@
// This file wires the line-based REPL (US-003) and session persistence
// (US-024, #43) into the pigo command. When invoked without a prompt on a
// terminal, pigo starts the REPL loop (see repl.go); each run's messages are
// persisted to a local JSONL session so the conversation can be listed, resumed
// and replayed later.
package repl
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sync"
"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/headless"
"github.com/smallnest/pigo/internal/cli/prompts"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/dream"
"github.com/smallnest/pigo/internal/plugin"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
"github.com/smallnest/pigo/internal/session"
"github.com/smallnest/pigo/internal/trust"
)
// sessionStore returns the session store for the interactive REPL. It is a thin
// alias for headless.SessionStore so the REPL and headless runs share one store
// rooted at ~/.pigo/sessions (or PIGO_HOME).
func sessionStore() (*session.Store, error) {
return headless.SessionStore()
}
// Options carries the resolved run configuration plus optional
// resume state into Run.
type Options struct {
Model string
ProviderName string
Provider provider.Provider
BaseURL string
APIKey string
Protocol string
// ThinkingLevel is the resolved reasoning-effort level (US-023): it seeds the
// live run config so every REPL turn requests it, until a control command
// changes it.
ThinkingLevel agentcore.ThinkingLevel
Tools []agentcore.AgentTool
SysPrompt string
// ResumeID, when non-empty, resumes an existing session: its messages seed
// the context and replayed transcript. Otherwise a fresh session is created.
ResumeID string
// Approve, when true, grants the launch directory session trust before the
// run so the first-launch trust prompt is skipped and side-effect tools run
// without per-call confirmation (mirrors pi's --approve/-a).
Approve bool
// Skills is the pre-loaded skill set (loaded once by setupAgentEnv, shared
// with prompt injection). Each is registered as a /skill-name command. Empty
// under --no-skills, so nothing is registered.
Skills []*runtime.Skill
// Plugins holds the loaded plugin manager so the REPL can deliver lifecycle
// events to subscribed plugins (US-017, #133). It may be nil (no plugins).
Plugins *plugin.Manager
// ConfigPrompts holds prompt-template paths from the config.toml `prompts`
// array (settings tier); each is a file or dir loaded non-recursively.
ConfigPrompts []string
// CliPrompts holds --prompt-template paths (CLI tier, repeatable).
CliPrompts []string
// NoPromptTemplates disables all prompt-template discovery (global, project,
// settings, CLI); built-in slash commands are unaffected. Independent of
// --no-skills.
NoPromptTemplates bool
// Dream is the resolved [dream] configuration (US-008). Run uses it to decide
// whether to launch the startup background consolidation; a zero value
// (Enabled false) disables the auto-trigger entirely.
Dream dream.Config
}
// Run starts the line-based REPL over a persisted session. It keeps
// a single growing AgentContext across prompts (so turns share history) and
// saves the session's messages after each run completes (see runREPL/streamRun
// in repl.go).
func Run(opts Options) error {
creds := provider.NewCredentialStore(nil)
creds.SetOverride(opts.ProviderName, opts.APIKey)
reg := run.ToolRegistry(opts.Tools)
store, err := sessionStore()
if err != nil {
return err
}
// Resolve the launch directory once (pigo does not cd during a session). It is
// the trust key, the directory side-effect tools are gated against, and — new
// for #526 — the value stamped onto a fresh SessionHeader.Cwd so the session is
// attributed to a project and a later /dream pass can distill it under the
// right scope (mirrors headless.headlessCwd). An unresolvable cwd yields ""
// (the session stays unattributed) rather than aborting the session.
cwd, cwdErr := os.Getwd()
// Establish the session: resume an existing one or create a fresh header.
now := time.Now().UTC()
var (
agentCtx *agentcore.AgentContext
header session.SessionHeader
history []agentcore.AgentMessage
curLeaf string // active leaf id on resume; "" for a fresh session
)
if opts.ResumeID != "" {
// Interactive resume always appends a fresh user message before running,
// so a session that ended normally (trailing assistant reply) is resumable
// here. Load the raw session and rebuild the context directly.
h, entries, err := store.LoadEntries(opts.ResumeID)
if err != nil {
return err
}
msgs := make(agentcore.MessageList, len(entries))
for i, e := range entries {
msgs[i] = e.Message
}
if len(entries) > 0 {
curLeaf = entries[len(entries)-1].ID
}
header = h
agentCtx = &agentcore.AgentContext{SystemPrompt: h.SystemPrompt, Messages: msgs, Tools: opts.Tools}
history = msgs
if agentCtx.SystemPrompt == "" {
agentCtx.SystemPrompt = opts.SysPrompt
}
} else {
agentCtx = &agentcore.AgentContext{SystemPrompt: opts.SysPrompt, Tools: opts.Tools}
header = session.SessionHeader{
ID: session.NewID(now),
CreatedAt: now,
UpdatedAt: now,
Model: opts.Model,
Provider: opts.ProviderName,
SystemPrompt: opts.SysPrompt,
Cwd: cwd,
}
}
// live holds the run configuration that a control command (e.g. /model) may
// mutate mid-session. streamRun reads it on each prompt so a model switch
// takes effect on the next turn; header is updated so the switch is persisted
// with the session.
live := &cli.LiveConfig{
Model: opts.Model,
ProviderName: opts.ProviderName,
Provider: opts.Provider,
BaseURL: opts.BaseURL,
Protocol: opts.Protocol,
ThinkingLevel: opts.ThinkingLevel,
ContextWindow: cli.DefaultContextWindow,
}
// Project trust (US-018, #134): load the persisted trust store for the
// launch directory. A load failure (e.g. a corrupted trust.json) is
// non-fatal: trust is disabled (mgr stays nil) and the REPL still runs -
// the store is surfaced rather than silently overwritten. cwd is captured
// once above since pigo does not cd during a session; if it cannot be resolved
// trust is disabled too, since an empty cwd would silently never match.
mgr, mgrErr := trust.NewManager(trust.DefaultPath())
if mgrErr != nil {
fmt.Fprintf(os.Stderr, "pigo: trust store unavailable, trust disabled: %v\n", mgrErr)
mgr = nil
}
if cwdErr != nil && mgr != nil {
fmt.Fprintf(os.Stderr, "pigo: cannot resolve working directory, trust disabled: %v\n", cwdErr)
mgr = nil
}
// in is the shared input reader for the main loop and the tool-call
// confirmation prompt (see repl.go). Wrapping os.Stdin once here means both
// read from the same buffer.
reader := bufio.NewReaderSize(os.Stdin, replScanBufInit)
// Wire slash-commands: built-ins (compile-time) plus any user templates under
// ~/.pigo/commands (mirrors the commands/*.md convention) plus skills under
// ~/.agents/skills. A load error is non-fatal — the REPL still runs with the
// built-ins. Instance built-ins that need live state (/model, /help) are
// registered against `live`.
slash, err := prompts.BuildSlashRegistry(live, opts.Skills, opts.Plugins, prompts.PromptTemplateSources{
Settings: opts.ConfigPrompts,
CLI: opts.CliPrompts,
Disable: opts.NoPromptTemplates,
ProjectDir: filepath.Join(cwd, ".pigo", "prompts"),
ProjectTrusted: mgr != nil && mgr.IsTrusted(cwd),
})
if err != nil {
fmt.Fprintf(os.Stderr, "pigo: slash-commands: %v\n", err)
}
trust.RegisterCommand(slash, mgr, cwd)
// --approve grants the launch directory session trust up front (mirrors pi's
// --approve/-a), so the first-launch prompt is skipped and side-effect tools
// run without per-call confirmation. Otherwise, on the first launch in an
// undecided directory, ask the user how much to trust it before any tool
// runs. This happens before replay so the trust question is the first thing
// the user sees, not their prior history.
trust.EstablishTrust(os.Stdout, reader, mgr, cwd, opts.Approve)
// Replay the resumed conversation so the user sees history before re-prompting.
if len(history) > 0 {
replayTranscript(os.Stdout, history)
}
// Startup background consolidation (US-008, FR-4/FR-17): if dream is enabled
// and due, spawn `pigo --dream` in a goroutine now so it runs while the user
// works — it never blocks the first prompt, and prints a one-line notice on
// completion. The dream state/lock live under dream.ResolveMemoryRoot (the
// same root the subprocess consolidates), independent of whether the memory
// tool is wired into this session. Not-due / disabled is a cheap no-op.
maybeStartBackgroundDream(os.Stdout, dream.ResolveMemoryRoot(), cwd, opts.Dream)
return runREPL(os.Stdin, os.Stdout, replDeps{
store: store,
header: header,
agentCtx: agentCtx,
live: live,
reg: reg,
reminders: run.TodoReminders(opts.Tools),
slash: slash,
creds: creds,
trust: mgr,
cwd: cwd,
in: reader,
confirmMu: &sync.Mutex{},
curLeaf: curLeaf,
persisted: len(history),
memoryRoot: run.MemoryRootFromTools(opts.Tools),
memstore: run.MemoryStoreFromTools(opts.Tools),
snap: run.SnapshotRecorderFromTools(opts.Tools),
jobs: run.BashJobStoreFromTools(opts.Tools),
notifier: plugin.NewEventNotifier(opts.Plugins, os.Stderr),
goal: agenttool.NewGoalState(),
telemetry: cli.NewTelemetryHolder(),
})
}
// formatHelpLine renders one slash-command line for /help as
// "/name <argument-hint> - description (source: <tier>)", omitting the hint
// segment when absent. It is the plain, testable form of the /help line; the
// /help Action applies color on top of the same structure.
func formatHelpLine(c runtime.SlashCommand) string {
s := "/" + c.Name
if c.ArgumentHint != "" {
s += " " + c.ArgumentHint
}
if c.Description != "" {
s += " - " + c.Description
}
s += " (source: " + c.Tier.String() + ")"
return s
}
+766
View File
@@ -0,0 +1,766 @@
package repl
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"unicode/utf8"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
)
// errLineInterrupted aliases cli.ErrLineInterrupted so the editor's own returns
// and tests keep the short local name while subpackages (e.g. /btw) recognize
// the same sentinel through the exported cli.ErrLineInterrupted.
var errLineInterrupted = cli.ErrLineInterrupted
// mlBuffer models the readLine input as one or more lines with a cursor at
// (row, col), col counted in runes within the current line. A fresh buffer
// holds a single empty line with the cursor at the origin. It is the data
// model the multi-line REPL editor (continuation, Shift+Enter, cross-line
// movement/editing, rendering, history) is built on; single-line input behaves
// exactly as a plain string with the cursor at its end.
type mlBuffer struct {
lines []string
row int
col int
}
func newMLBuffer() *mlBuffer { return &mlBuffer{lines: []string{""}} }
// String joins the lines with "\n" for submission. A single empty line yields
// "" and there is never a trailing newline.
func (b *mlBuffer) String() string { return strings.Join(b.lines, "\n") }
// isEmpty reports whether the buffer is a single empty line.
func (b *mlBuffer) isEmpty() bool { return len(b.lines) == 1 && b.lines[0] == "" }
// single reports whether the buffer holds exactly one line.
func (b *mlBuffer) single() bool { return len(b.lines) == 1 }
// line returns the text of the current cursor line.
func (b *mlBuffer) line() string { return b.lines[b.row] }
// setString replaces the whole buffer with s (which may contain "\n"), placing
// the cursor at the end of the last line. Used when the entire input is swapped
// wholesale — accepting a suggestion, browsing history, or restoring a
// multi-line entry.
func (b *mlBuffer) setString(s string) {
b.lines = strings.Split(s, "\n")
b.row = len(b.lines) - 1
b.col = utf8.RuneCountInString(b.lines[b.row])
}
// insert adds s (which must not contain "\n") at the cursor on the current
// line and advances col past it.
func (b *mlBuffer) insert(s string) {
line := b.lines[b.row]
off := runeOffset(line, b.col)
b.lines[b.row] = line[:off] + s + line[off:]
b.col += utf8.RuneCountInString(s)
}
// backspace deletes the rune immediately left of the cursor on the current
// line. At column 0 it merges the current line into the previous one (the
// cursor landing at the merge point), unless already on the first line, where
// it is a no-op. Multi-byte runes are removed whole.
func (b *mlBuffer) backspace() {
if b.col == 0 {
if b.row == 0 {
return
}
prev := b.lines[b.row-1]
b.col = utf8.RuneCountInString(prev)
b.lines[b.row-1] = prev + b.lines[b.row]
b.lines = append(b.lines[:b.row], b.lines[b.row+1:]...)
b.row--
return
}
line := b.lines[b.row]
start := runeOffset(line, b.col-1)
end := runeOffset(line, b.col)
b.lines[b.row] = line[:start] + line[end:]
b.col--
}
// newline splits the current line at the cursor, moving the text right of the
// cursor onto a fresh line below and placing the cursor at its start. It is how
// Shift+Enter (and, later, backslash continuation) turn one line into two.
func (b *mlBuffer) newline() {
line := b.lines[b.row]
off := runeOffset(line, b.col)
head, tail := line[:off], line[off:]
rest := append([]string{}, b.lines[b.row+1:]...)
b.lines = append(b.lines[:b.row], head, tail)
b.lines = append(b.lines, rest...)
b.row++
b.col = 0
}
// left moves the cursor one rune left, crossing to the end of the previous
// line when already at column 0. It is a no-op at the buffer origin.
func (b *mlBuffer) left() {
if b.col > 0 {
b.col--
} else if b.row > 0 {
b.row--
b.col = utf8.RuneCountInString(b.lines[b.row])
}
}
// right moves the cursor one rune right, crossing to the start of the next
// line when already at the end of the current line. It is a no-op at the end
// of the last line.
func (b *mlBuffer) right() {
if b.col < utf8.RuneCountInString(b.lines[b.row]) {
b.col++
} else if b.row < len(b.lines)-1 {
b.row++
b.col = 0
}
}
// up moves the cursor to the previous line, clamping the column to that line's
// length. It is a no-op on the first line.
func (b *mlBuffer) up() {
if b.row > 0 {
b.row--
if n := utf8.RuneCountInString(b.lines[b.row]); b.col > n {
b.col = n
}
}
}
// down moves the cursor to the next line, clamping the column to that line's
// length. It is a no-op on the last line.
func (b *mlBuffer) down() {
if b.row < len(b.lines)-1 {
b.row++
if n := utf8.RuneCountInString(b.lines[b.row]); b.col > n {
b.col = n
}
}
}
// home moves the cursor to the start of the current line.
func (b *mlBuffer) home() { b.col = 0 }
// end moves the cursor to the end of the current line.
func (b *mlBuffer) end() { b.col = utf8.RuneCountInString(b.lines[b.row]) }
// enterContinues collapses the current line's trailing backslash run and
// reports whether a pressed Enter should continue onto a new line instead of
// submitting. A run of k trailing backslashes pairs up as k/2 literal
// backslashes (each "\\" → one "\"); an odd run has one extra backslash that
// escapes the newline, so the caller inserts a continuation line. The run is
// always collapsed to k/2 backslashes, so the escaping "\" never survives into
// submitted text.
func (b *mlBuffer) enterContinues() bool {
line := b.lines[b.row]
k := 0
for i := len(line) - 1; i >= 0 && line[i] == '\\'; i-- {
k++
}
if k == 0 {
return false
}
b.lines[b.row] = line[:len(line)-k] + strings.Repeat("\\", k/2)
if n := utf8.RuneCountInString(b.lines[b.row]); b.col > n {
b.col = n
}
return k%2 == 1
}
// visibleWidth returns the number of terminal columns s occupies, skipping ANSI
// CSI escape sequences so a colored prompt still aligns its continuation lines.
// Wide runes (CJK ideographs, fullwidth forms, most emoji) count as two columns.
func visibleWidth(s string) int {
w := 0
for i := 0; i < len(s); {
if s[i] == 0x1b {
i++
if i < len(s) && s[i] == '[' {
i++
for i < len(s) && !(s[i] >= 0x40 && s[i] <= 0x7e) {
i++
}
}
if i < len(s) {
i++
}
continue
}
r, size := utf8.DecodeRuneInString(s[i:])
i += size
w += runeWidth(r)
}
return w
}
// displayWidth returns the number of terminal columns the plain string s
// occupies, summing each rune's cell width. Unlike visibleWidth it does not
// strip ANSI escapes — callers pass already-plain buffer text.
func displayWidth(s string) int {
w := 0
for _, r := range s {
w += runeWidth(r)
}
return w
}
// runeWidth reports how many terminal cells a rune occupies: 0 for combining /
// zero-width marks, 2 for East Asian wide and fullwidth characters (and most
// emoji), 1 otherwise. This is what keeps the cursor aligned when the line
// contains CJK text, where one rune spans two columns.
func runeWidth(r rune) int {
switch {
case r == 0:
return 0
case (r >= 0x0300 && r <= 0x036F), // combining diacritical marks
(r >= 0x1AB0 && r <= 0x1AFF), // combining diacritical marks extended
(r >= 0x1DC0 && r <= 0x1DFF), // combining diacritical marks supplement
(r >= 0x20D0 && r <= 0x20FF), // combining marks for symbols
(r >= 0xFE20 && r <= 0xFE2F), // combining half marks
r == 0x200B: // zero width space
return 0
case (r >= 0x1100 && r <= 0x115F), // Hangul Jamo
(r >= 0x2E80 && r <= 0x303E), // CJK radicals, Kangxi, CJK symbols
(r >= 0x3041 && r <= 0x33FF), // Hiragana, Katakana, CJK compat
(r >= 0x3400 && r <= 0x4DBF), // CJK Ext A
(r >= 0x4E00 && r <= 0x9FFF), // CJK Unified Ideographs
(r >= 0xA000 && r <= 0xA4CF), // Yi
(r >= 0xAC00 && r <= 0xD7A3), // Hangul syllables
(r >= 0xF900 && r <= 0xFAFF), // CJK compat ideographs
(r >= 0xFE10 && r <= 0xFE19), // vertical forms
(r >= 0xFE30 && r <= 0xFE6F), // CJK compat forms
(r >= 0xFF00 && r <= 0xFF60), // fullwidth forms
(r >= 0xFFE0 && r <= 0xFFE6), // fullwidth signs
(r >= 0x1F300 && r <= 0x1FAFF), // emoji & pictographs
(r >= 0x20000 && r <= 0x3FFFD): // CJK Ext B and beyond
return 2
default:
return 1
}
}
// runeOffset converts a rune column into a byte offset within s.
func runeOffset(s string, col int) int {
off := 0
for i := 0; i < col && off < len(s); i++ {
_, size := utf8.DecodeRuneInString(s[off:])
off += size
}
return off
}
// replLineEditor adds a small shell-style editing layer without turning the
// line-oriented REPL back into a full-screen TUI. On terminals it shows the
// best completion in dim text as the user types. Pipes and tests keep using the
// ordinary buffered reader.
type replLineEditor struct {
in *bufio.Reader
terminal *os.File
out io.Writer
slash *runtime.SlashRegistry
history []string // oldest to newest
models []string
}
func newREPLLineEditor(in io.Reader, buffered *bufio.Reader, out io.Writer, slash *runtime.SlashRegistry, history []string) *replLineEditor {
e := &replLineEditor{in: buffered, out: out, slash: slash}
if f, ok := in.(*os.File); ok {
if info, err := f.Stat(); err == nil && info.Mode()&os.ModeCharDevice != 0 {
e.terminal = f
}
}
for _, h := range history {
e.remember(h)
}
seen := map[string]bool{}
for _, m := range provider.PresetCatalog {
if !seen[m.ID] {
e.models = append(e.models, m.ID)
seen[m.ID] = true
}
}
return e
}
func (e *replLineEditor) remember(line string) {
line = strings.TrimSpace(line)
if line == "" {
return
}
e.history = append(e.history, line)
if len(e.history) > 200 {
e.history = e.history[len(e.history)-200:]
}
}
// formatSlashAutocompleteLabel renders a slash command for the Tab-completion
// hint as "name <argument-hint> - description" (mirrors pi's autocomplete). The
// argument-hint is shown verbatim (frontmatter supplies its own <angle>/
// [square] brackets); it and the description are omitted when absent, so a
// bare command renders as just its name.
func formatSlashAutocompleteLabel(cmd runtime.SlashCommand) string {
label := cmd.Name
if cmd.ArgumentHint != "" {
label += " " + cmd.ArgumentHint
}
if cmd.Description != "" {
label += " - " + cmd.Description
}
return label
}
// suggestion returns the single best completion for input, or "" when there is
// none. It is the head of the ordered candidate list (see suggestions).
func (e *replLineEditor) suggestion(input string) string {
if cands := e.suggestions(input); len(cands) > 0 {
return cands[0]
}
return ""
}
// suggestions returns every completion candidate for input, best first, so the
// caller can cycle through them with the arrow keys. Candidates are gathered in
// priority order — slash commands, then recent inputs, then the /model catalog
// — deduplicated, with the raw input itself excluded.
func (e *replLineEditor) suggestions(input string) []string {
if input == "" {
return nil
}
lower := strings.ToLower(input)
var out []string
seen := map[string]bool{}
add := func(s string) {
if s == "" || s == input || seen[s] {
return
}
seen[s] = true
out = append(out, s)
}
if strings.HasPrefix(input, "/") && !strings.ContainsAny(input, " \t") {
var commands []string
for _, cmd := range e.slash.List() {
commands = append(commands, "/"+cmd.Name)
}
sort.Strings(commands)
for _, cmd := range commands {
if strings.HasPrefix(strings.ToLower(cmd), lower) {
add(cmd)
}
}
}
for i := len(e.history) - 1; i >= 0; i-- {
if strings.HasPrefix(strings.ToLower(e.history[i]), lower) {
add(e.history[i])
}
}
if strings.HasPrefix(lower, "/model ") {
query := strings.TrimSpace(input[len("/model "):])
for i := len(e.history) - 1; i >= 0; i-- {
h := e.history[i]
if !strings.HasPrefix(h, "/model ") {
continue
}
id := strings.TrimSpace(h[len("/model "):])
if query == "" || modelMatches(id, query) {
add(h)
}
}
for _, id := range e.models {
if query == "" || modelMatches(id, query) {
add("/model " + id)
}
}
}
return out
}
func modelMatches(id, query string) bool {
id, query = strings.ToLower(id), strings.ToLower(query)
if strings.HasPrefix(id, query) {
return true
}
if slash := strings.LastIndexByte(id, '/'); slash >= 0 {
return strings.HasPrefix(id[slash+1:], query)
}
return false
}
// parseCSIParams splits a CSI-u parameter list ("<code>[;<mod>]") into the key
// code and modifier. A missing modifier defaults to 1 (no modifier).
func parseCSIParams(params []byte) (code, mod int) {
parts := strings.Split(string(params), ";")
code = atoiDefault(parts[0], 0)
mod = 1
if len(parts) > 1 {
mod = atoiDefault(parts[1], 1)
}
return code, mod
}
func atoiDefault(s string, def int) int {
if n, err := strconv.Atoi(s); err == nil {
return n
}
return def
}
func (e *replLineEditor) ReadLine(prompt string) (string, error) {
if e.terminal == nil {
fmt.Fprint(e.out, prompt)
return e.in.ReadString('\n')
}
probe := exec.Command("stty", "-g")
probe.Stdin = e.terminal
state, err := probe.CombinedOutput()
if err != nil {
fmt.Fprint(e.out, prompt)
return e.in.ReadString('\n')
}
raw := exec.Command("stty", "raw", "-echo")
raw.Stdin = e.terminal
if err := raw.Run(); err != nil {
fmt.Fprint(e.out, prompt)
return e.in.ReadString('\n')
}
// Ask the terminal to report modified keys so Shift+Enter is distinguishable
// from a bare Enter: enable xterm modifyOtherKeys level 1 and push a CSI-u
// (fixterms/kitty) keyboard mode. Level 1 (not 2) reports only keys without
// a standard encoding — so Shift+Enter is escaped while ordinary Tab/Enter
// stay untouched. Terminals that ignore these simply never send the reports,
// and the user falls back to backslash continuation.
fmt.Fprint(e.out, "\x1b[>4;1m\x1b[>1u")
defer func() {
// Restore the terminal's key reporting before the stty state, so we
// never leave it stuck in CSI-u/modifyOtherKeys mode on any exit path.
fmt.Fprint(e.out, "\x1b[<u\x1b[>4;0m")
restore := exec.Command("stty", strings.TrimSpace(string(state)))
restore.Stdin = e.terminal
_ = restore.Run()
}()
return e.editLoop(prompt)
}
// editLoop runs the raw-mode key-processing loop over e.in, kept separate from
// readLine's terminal setup so it can be driven by a programmable io.Reader in
// tests (no real TTY required). It returns the submitted text (lines joined by
// "\n") or an error.
func (e *replLineEditor) editLoop(prompt string) (string, error) {
// buf models the input as a multi-line buffer with a cursor. For this
// single-line editing layer the cursor stays at the end of the sole line,
// so buf behaves exactly like the former input string; the buffer model is
// what later cross-line editing/rendering is built on.
buf := newMLBuffer()
// selected indexes into the current candidate list. It advances with the
// up/down arrows so the user can cycle through suggestions; it resets to 0
// (the best match) whenever the input text changes, since the candidate list
// is recomputed from scratch.
selected := 0
// histNav tracks the position while browsing prior inputs with the arrow
// keys on a blank line: -1 means not browsing, otherwise it indexes into
// e.history (oldest to newest). It resets to -1 whenever the user edits the
// line, so history browsing is only active while stepping through entries.
histNav := -1
// visible returns the suggestion currently shown/accepted: the candidate at
// the selected index, clamped to the available list.
visible := func() string {
cands := e.suggestions(buf.String())
if len(cands) == 0 {
return ""
}
if selected >= len(cands) {
selected = len(cands) - 1
}
if selected < 0 {
selected = 0
}
return cands[selected]
}
// promptW is the prompt's visible width; continuation lines are indented to
// that column so every line's text starts at the same place, with a dim
// marker standing in for the prompt.
promptW := visibleWidth(prompt)
contPrefix := strings.Repeat(" ", promptW)
if promptW >= 2 {
contPrefix = strings.Repeat(" ", promptW-2) + "\033[2m·\033[0m "
}
// prevCursorRow is the screen row (relative to the block's first line) the
// cursor was left on by the previous render, so the next render can climb
// back to the top of the block before clearing and redrawing it.
prevCursorRow := 0
render := func() {
// Return to the top-left of the block drawn last time and clear it plus
// anything below, so shrinking the buffer leaves no stale rows/chars.
if prevCursorRow > 0 {
fmt.Fprintf(e.out, "\033[%dA", prevCursorRow)
}
fmt.Fprint(e.out, "\r\033[J")
for i, line := range buf.lines {
if i == 0 {
fmt.Fprintf(e.out, "%s%s", prompt, line)
} else {
fmt.Fprintf(e.out, "\r\n%s%s", contPrefix, line)
}
}
// The dim completion hint only fits on a single line with the cursor at
// its end, where it can't collide with continuation rows.
if buf.single() && buf.col == utf8.RuneCountInString(buf.lines[0]) {
if s := visible(); s != "" {
input := buf.lines[0]
if strings.HasPrefix(s, "/") {
// Slash command: render the argument-hint + description label
// (mirrors pi's autocomplete) instead of the bare name suffix.
label := s
if cmd, ok := e.slash.Lookup(s[1:]); ok {
label = formatSlashAutocompleteLabel(cmd)
}
fmt.Fprintf(e.out, "\033[2m -> %s\033[0m", label)
} else if strings.HasPrefix(s, input) {
fmt.Fprintf(e.out, "\033[2m%s\033[0m", s[len(input):])
} else {
fmt.Fprintf(e.out, "\033[2m → %s\033[0m", s)
}
}
}
// Reposition to the logical (row, col): after the draw the cursor sits
// at the end of the last line, so climb to the target row, then step
// right past the prefix and the display width of the runes left of the
// cursor (wide CJK runes span two columns, so count cells, not runes).
if up := len(buf.lines) - 1 - buf.row; up > 0 {
fmt.Fprintf(e.out, "\033[%dA", up)
}
fmt.Fprint(e.out, "\r")
curLine := buf.lines[buf.row]
cursorCells := displayWidth(curLine[:runeOffset(curLine, buf.col)])
if col := promptW + cursorCells; col > 0 {
fmt.Fprintf(e.out, "\033[%dC", col)
}
prevCursorRow = buf.row
}
// tryEnter handles a pressed Enter shared by the raw, CSI-u, and
// modifyOtherKeys report paths: if the current line ends with an unescaped
// backslash it continues onto a new line and reports submitted=false;
// otherwise it emits the newline and reports the block as submitted.
tryEnter := func() (string, bool) {
if buf.enterContinues() {
buf.end()
buf.newline()
selected = 0
histNav = -1
return "", false
}
// Move below the whole rendered block before the newline so the
// submitted lines stay on screen and the next prompt starts clean.
if down := len(buf.lines) - 1 - buf.row; down > 0 {
fmt.Fprintf(e.out, "\033[%dB", down)
}
fmt.Fprint(e.out, "\r\n")
return buf.String(), true
}
render()
for {
b, err := e.in.ReadByte()
if err != nil {
return buf.String(), err
}
switch b {
case '\r', '\n':
if res, done := tryEnter(); done {
return res, nil
}
case 1: // Ctrl+A moves to line start.
buf.home()
case 5: // Ctrl+E moves to line end.
buf.end()
case 3: // Ctrl+C
fmt.Fprint(e.out, "^C\r\n")
return "", errLineInterrupted
case 4: // Ctrl+D
if buf.isEmpty() {
fmt.Fprint(e.out, "\r\n")
return "", io.EOF
}
case 9: // Tab accepts the visible suggestion.
if s := visible(); s != "" {
buf.setString(s)
selected = 0
histNav = -1
}
case 8, 127:
buf.backspace()
selected = 0
histNav = -1
case 27:
// Parse a full CSI sequence so multi-parameter reports (CSI-u key
// events like Shift+Enter's \x1b[13;2u) are handled, not just the
// bare arrow sequences. → accepts the visible suggestion, ↑/↓ cycle
// candidates or browse history on a blank line, and Enter reports
// (code 13) either submit or insert a newline depending on the
// modifier. Any other sequence is consumed and ignored so it never
// leaks into the submitted text.
b2, escErr := e.in.ReadByte()
if escErr != nil {
return buf.String(), escErr
}
if b2 == '[' {
var params []byte
var final byte
for {
c, cErr := e.in.ReadByte()
if cErr != nil {
return buf.String(), cErr
}
if c >= 0x40 && c <= 0x7e {
final = c
break
}
params = append(params, c)
}
switch final {
case 'u': // CSI-u key report: "<code>[;<mod>]u".
code, mod := parseCSIParams(params)
ctrl := (mod-1)&4 != 0
switch {
case code == 13:
if mod >= 2 {
buf.newline()
selected = 0
histNav = -1
} else if res, done := tryEnter(); done {
return res, nil
}
case ctrl && code == 'd':
// Ctrl+D: EOF on an empty line. Under the kitty keyboard
// protocol (enabled via \x1b[>1u) the terminal reports it here
// as a CSI-u event, not the raw 0x04 byte the case-4 arm handles.
if buf.isEmpty() {
fmt.Fprint(e.out, "\r\n")
return "", io.EOF
}
case ctrl && code == 'c':
// Ctrl+C: same story — delivered as a CSI-u report rather than
// the raw 0x03 byte once the kitty keyboard mode is active.
fmt.Fprint(e.out, "^C\r\n")
return "", errLineInterrupted
}
case '~': // modifyOtherKeys ("27;<mod>;<code>~") or Home/End ("1~"/"4~").
parts := strings.Split(string(params), ";")
if len(parts) == 3 && atoiDefault(parts[0], -1) == 27 {
mod := atoiDefault(parts[1], 1)
code := atoiDefault(parts[2], 0)
if code == 13 {
if mod >= 2 {
buf.newline()
selected = 0
histNav = -1
} else if res, done := tryEnter(); done {
return res, nil
}
}
} else if len(parts) == 1 {
switch atoiDefault(parts[0], -1) {
case 1, 7: // Home
buf.home()
case 4, 8: // End
buf.end()
}
}
case 'C': // right arrow: accept a visible suggestion, else move the cursor
if len(params) == 0 {
if s := visible(); s != "" {
buf.setString(s)
selected = 0
histNav = -1
} else {
buf.right()
}
}
case 'D': // left arrow moves the cursor (cross-line at column 0)
if len(params) == 0 {
buf.left()
}
case 'H': // Home
if len(params) == 0 {
buf.home()
}
case 'F': // End
if len(params) == 0 {
buf.end()
}
case 'A': // up arrow
if len(params) == 0 {
if !buf.single() {
buf.up()
} else if buf.isEmpty() || histNav >= 0 {
// Browse history: step toward older entries.
if histNav < 0 {
histNav = len(e.history)
}
if histNav > 0 {
histNav--
buf.setString(e.history[histNav])
selected = 0
}
} else if n := len(e.suggestions(buf.String())); n > 0 {
selected = (selected - 1 + n) % n
}
}
case 'B': // down arrow
if len(params) == 0 {
if !buf.single() {
buf.down()
} else if histNav >= 0 {
// Browse history: step toward newer entries; past the
// newest, return to a blank line.
if histNav < len(e.history)-1 {
histNav++
buf.setString(e.history[histNav])
} else {
histNav = -1
buf.setString("")
}
selected = 0
} else if n := len(e.suggestions(buf.String())); n > 0 {
selected = (selected + 1) % n
}
}
}
}
default:
bytes := []byte{b}
want := 1
switch {
case b&0xe0 == 0xc0:
want = 2
case b&0xf0 == 0xe0:
want = 3
case b&0xf8 == 0xf0:
want = 4
}
for len(bytes) < want {
next, readErr := e.in.ReadByte()
if readErr != nil {
return buf.String(), readErr
}
bytes = append(bytes, next)
}
buf.insert(string(bytes))
selected = 0
histNav = -1
}
render()
}
}
+599
View File
@@ -0,0 +1,599 @@
package repl
import (
"bufio"
"bytes"
"errors"
"io"
"strings"
"testing"
"github.com/smallnest/pigo/internal/runtime"
)
func testLineEditor(history ...string) *replLineEditor {
reg := runtime.NewSlashRegistry()
reg.AddBuiltin(runtime.SlashCommand{Name: "model", Action: func(string) string { return "" }})
reg.AddBuiltin(runtime.SlashCommand{Name: "models", Action: func(string) string { return "" }})
return newREPLLineEditor(strings.NewReader(""), bufio.NewReader(strings.NewReader("")), io.Discard, reg, history)
}
func TestLineEditorPrefersMostRecentMatchingInput(t *testing.T) {
e := testLineEditor("explain old", "other", "explain recent")
if got := e.suggestion("exp"); got != "explain recent" {
t.Fatalf("suggestion = %q, want most recent match", got)
}
}
func TestLineEditorCompletesSlashCommands(t *testing.T) {
e := testLineEditor()
if got := e.suggestion("/mod"); got != "/model" {
t.Fatalf("suggestion = %q, want /model", got)
}
}
func TestLineEditorCompletesModelsByRecentUseAndBasename(t *testing.T) {
e := testLineEditor("/model openai/gpt-4o")
if got := e.suggestion("/model "); got != "/model openai/gpt-4o" {
t.Fatalf("empty model suggestion = %q", got)
}
if got := e.suggestion("/model gpt"); got != "/model openai/gpt-4o" {
t.Fatalf("recent model suggestion = %q", got)
}
e = testLineEditor()
got := e.suggestion("/model deepseek")
if got == "" || !strings.HasPrefix(got, "/model ") {
t.Fatalf("catalog model suggestion = %q", got)
}
}
func TestLineEditorSuggestionsAreOrderedAndDeduped(t *testing.T) {
// Two recent inputs plus a slash command all sharing a prefix: the caller
// cycles this list with the arrow keys, so ordering (best first) and
// dedup both matter.
e := testLineEditor("explain old", "explain recent", "explain recent")
cands := e.suggestions("exp")
if len(cands) != 2 {
t.Fatalf("suggestions = %v, want 2 unique candidates", cands)
}
if cands[0] != "explain recent" || cands[1] != "explain old" {
t.Fatalf("suggestions = %v, want most-recent first", cands)
}
// The head of the list must match the single-suggestion helper.
if e.suggestion("exp") != cands[0] {
t.Fatalf("suggestion head %q != suggestions[0] %q", e.suggestion("exp"), cands[0])
}
}
func TestLineEditorSlashCommandsCycleAllMatches(t *testing.T) {
e := testLineEditor()
cands := e.suggestions("/mode")
// Both /model and /models share the prefix; cycling must expose both.
if len(cands) != 2 || cands[0] != "/model" || cands[1] != "/models" {
t.Fatalf("suggestions = %v, want [/model /models]", cands)
}
}
func TestMLBufferEmptyBehavesLikeEmptyString(t *testing.T) {
b := newMLBuffer()
if !b.isEmpty() {
t.Fatalf("fresh buffer should be empty")
}
if !b.single() {
t.Fatalf("fresh buffer should be single-line")
}
if got := b.String(); got != "" {
t.Fatalf("empty buffer String() = %q, want \"\"", got)
}
}
func TestMLBufferSingleLineInsert(t *testing.T) {
b := newMLBuffer()
b.insert("hello")
if got := b.String(); got != "hello" {
t.Fatalf("String() = %q, want %q", got, "hello")
}
if b.col != 5 {
t.Fatalf("col = %d, want 5", b.col)
}
if b.isEmpty() {
t.Fatalf("buffer with text should not be empty")
}
}
func TestMLBufferBackspaceRemovesLastRune(t *testing.T) {
b := newMLBuffer()
b.insert("héllo") // multi-byte rune to exercise UTF-8 handling
b.backspace()
if got := b.String(); got != "héll" {
t.Fatalf("after backspace String() = %q, want %q", got, "héll")
}
// Remove the multi-byte rune specifically.
b.setString("café")
b.backspace()
if got := b.String(); got != "caf" {
t.Fatalf("multi-byte backspace String() = %q, want %q", got, "caf")
}
}
func TestMLBufferBackspaceEmptyIsNoop(t *testing.T) {
b := newMLBuffer()
b.backspace()
if got := b.String(); got != "" {
t.Fatalf("backspace on empty buffer String() = %q, want \"\"", got)
}
if b.col != 0 || b.row != 0 {
t.Fatalf("cursor moved on empty backspace: row=%d col=%d", b.row, b.col)
}
}
func TestMLBufferSetStringJoinsWithNewline(t *testing.T) {
b := newMLBuffer()
b.setString("line1\nline2\nline3")
if got := b.String(); got != "line1\nline2\nline3" {
t.Fatalf("String() = %q, want round-trip", got)
}
if b.single() {
t.Fatalf("multi-line buffer reported single()")
}
// Cursor lands at end of last line.
if b.row != 2 || b.col != 5 {
t.Fatalf("cursor = (%d,%d), want (2,5)", b.row, b.col)
}
// No trailing newline is introduced.
if strings.HasSuffix(b.String(), "\n") {
t.Fatalf("String() has trailing newline: %q", b.String())
}
}
// editorWithOutput is editorWithInput but captures the editor's terminal
// output so raw-mode rendering (escape sequences) can be asserted.
func editorWithOutput(input string, out io.Writer, history ...string) *replLineEditor {
reg := runtime.NewSlashRegistry()
reg.AddBuiltin(runtime.SlashCommand{Name: "model", Action: func(string) string { return "" }})
r := strings.NewReader(input)
return newREPLLineEditor(r, bufio.NewReader(r), out, reg, history)
}
func TestVisibleWidthSkipsANSI(t *testing.T) {
if w := visibleWidth("pigo> "); w != 6 {
t.Fatalf("plain width = %d, want 6", w)
}
if w := visibleWidth("\033[2m·\033[0m "); w != 2 {
t.Fatalf("dim marker width = %d, want 2 (marker + space)", w)
}
if w := visibleWidth("café> "); w != 6 {
t.Fatalf("multi-byte width = %d, want 6 runes", w)
}
}
func TestRuneAndDisplayWidthHandleWideRunes(t *testing.T) {
// ASCII and Latin-1 accents are one cell; CJK ideographs and fullwidth
// forms are two; combining marks are zero.
if w := runeWidth('a'); w != 1 {
t.Fatalf("width('a') = %d, want 1", w)
}
if w := runeWidth('é'); w != 1 {
t.Fatalf("width('é') = %d, want 1", w)
}
if w := runeWidth('中'); w != 2 {
t.Fatalf("width('中') = %d, want 2", w)
}
if w := runeWidth('́'); w != 0 { // combining acute accent
t.Fatalf("width(combining) = %d, want 0", w)
}
// A mixed CJK/ASCII string sums per-cell.
if w := displayWidth("你好a"); w != 5 {
t.Fatalf("displayWidth(\"你好a\") = %d, want 5", w)
}
// visibleWidth (ANSI-stripping) agrees on wide runes.
if w := visibleWidth("\033[2m中\033[0m"); w != 2 {
t.Fatalf("visibleWidth wide = %d, want 2", w)
}
}
func TestEditLoopCursorAccountsForWideRunes(t *testing.T) {
// Typing a CJK char must move the cursor two cells, not one, so the final
// reposition after "中" (prompt width 2 + 2 cells) lands at column 4.
var out bytes.Buffer
e := editorWithOutput("中", &out)
// Drive one render by feeding the rune then EOF (no submit needed).
if _, err := e.editLoop("> "); err == nil {
t.Fatalf("expected EOF error from truncated input")
}
s := out.String()
if !strings.Contains(s, "\033[4C") {
t.Fatalf("cursor not repositioned to column 4 for wide rune:\n%q", s)
}
if strings.Contains(s, "\033[3C") {
t.Fatalf("cursor used rune-count column 3 (wide rune miscounted):\n%q", s)
}
}
func TestEditLoopRendersContinuationAndClears(t *testing.T) {
// "foo" + Shift+Enter + "bar" builds a two-line buffer; the continuation
// line is indented with the dim marker and each redraw clears the block.
var out bytes.Buffer
e := editorWithOutput("foo\x1b[13;2ubar\r", &out)
got, err := e.editLoop("> ")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "foo\nbar" {
t.Fatalf("submitted %q, want %q", got, "foo\nbar")
}
s := out.String()
if !strings.Contains(s, "\033[2m·\033[0m bar") {
t.Fatalf("output missing dim continuation prefix before %q:\n%q", "bar", s)
}
if !strings.Contains(s, "\033[J") {
t.Fatalf("output never clears the block with \\033[J:\n%q", s)
}
}
// editorWithInput builds an editor whose editLoop reads the given byte stream,
// so raw-mode key handling can be exercised without a real terminal.
func editorWithInput(input string, history ...string) *replLineEditor {
reg := runtime.NewSlashRegistry()
reg.AddBuiltin(runtime.SlashCommand{Name: "model", Action: func(string) string { return "" }})
reg.AddBuiltin(runtime.SlashCommand{Name: "models", Action: func(string) string { return "" }})
r := strings.NewReader(input)
return newREPLLineEditor(r, bufio.NewReader(r), io.Discard, reg, history)
}
func TestMLBufferNewlineSplitsAtCursor(t *testing.T) {
b := newMLBuffer()
b.insert("abcdef")
b.col = 3 // cursor between "abc" and "def"
b.newline()
if got := b.String(); got != "abc\ndef" {
t.Fatalf("newline split = %q, want %q", got, "abc\ndef")
}
if b.row != 1 || b.col != 0 {
t.Fatalf("cursor after newline = (%d,%d), want (1,0)", b.row, b.col)
}
}
func TestEditLoopPlainEnterSubmits(t *testing.T) {
e := editorWithInput("abc\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "abc" {
t.Fatalf("submitted %q, want %q", got, "abc")
}
}
func TestEditLoopShiftEnterInsertsNewline(t *testing.T) {
// CSI-u Shift+Enter is \x1b[13;2u; a bare \r then submits.
e := editorWithInput("abc\x1b[13;2udef\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "abc\ndef" {
t.Fatalf("submitted %q, want %q", got, "abc\ndef")
}
}
func TestEditLoopModifyOtherKeysEnterInsertsNewline(t *testing.T) {
// modifyOtherKeys Shift+Enter is \x1b[27;2;13~.
e := editorWithInput("x\x1b[27;2;13~y\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "x\ny" {
t.Fatalf("submitted %q, want %q", got, "x\ny")
}
}
func TestEditLoopCSIuPlainEnterSubmits(t *testing.T) {
// Unmodified Enter reported as CSI-u \x1b[13u must submit, not newline.
e := editorWithInput("hi\x1b[13u")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "hi" {
t.Fatalf("submitted %q, want %q", got, "hi")
}
}
func TestEditLoopCSIuCtrlDEmptyReturnsEOF(t *testing.T) {
// Under the kitty keyboard protocol Ctrl+D on an empty line arrives as the
// CSI-u report \x1b[100;5u (code 'd', ctrl modifier) rather than raw 0x04,
// and must still exit with io.EOF.
e := editorWithInput("\x1b[100;5u")
got, err := e.editLoop("")
if !errors.Is(err, io.EOF) {
t.Fatalf("want io.EOF, got err=%v got=%q", err, got)
}
}
func TestEditLoopCSIuCtrlDNonEmptyIgnored(t *testing.T) {
// Ctrl+D on a non-empty line is a no-op (matches the raw-byte behavior); the
// following Enter submits the typed text.
e := editorWithInput("ab\x1b[100;5u\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "ab" {
t.Fatalf("submitted %q, want %q", got, "ab")
}
}
func TestEditLoopCSIuCtrlCInterrupts(t *testing.T) {
// Ctrl+C reported as CSI-u \x1b[99;5u must interrupt the line.
e := editorWithInput("\x1b[99;5u")
_, err := e.editLoop("")
if !errors.Is(err, errLineInterrupted) {
t.Fatalf("want errLineInterrupted, got %v", err)
}
}
func TestMLBufferLeftRightCrossLines(t *testing.T) {
b := newMLBuffer()
b.setString("ab\ncd") // cursor at end of "cd" → (1,2)
b.left() // (1,1)
b.left() // (1,0)
if b.row != 1 || b.col != 0 {
t.Fatalf("after two lefts = (%d,%d), want (1,0)", b.row, b.col)
}
b.left() // cross to end of "ab" → (0,2)
if b.row != 0 || b.col != 2 {
t.Fatalf("left at line start = (%d,%d), want (0,2)", b.row, b.col)
}
b.right() // cross to start of "cd" → (1,0)
if b.row != 1 || b.col != 0 {
t.Fatalf("right at line end = (%d,%d), want (1,0)", b.row, b.col)
}
// Left at the very origin is a no-op.
b.setString("x")
b.home()
b.left()
if b.row != 0 || b.col != 0 {
t.Fatalf("left at origin moved cursor: (%d,%d)", b.row, b.col)
}
// Right at the very end is a no-op.
b.end()
b.right()
if b.row != 0 || b.col != 1 {
t.Fatalf("right at end moved cursor: (%d,%d)", b.row, b.col)
}
}
func TestMLBufferUpDownClampColumn(t *testing.T) {
b := newMLBuffer()
b.setString("long line\nhi") // cursor at end of "hi" → (1,2)
b.up() // move to "long line", col stays 2
if b.row != 0 || b.col != 2 {
t.Fatalf("up = (%d,%d), want (0,2)", b.row, b.col)
}
b.end() // col = 9
b.down()
// Down to "hi" (len 2) clamps col from 9 to 2.
if b.row != 1 || b.col != 2 {
t.Fatalf("down clamp = (%d,%d), want (1,2)", b.row, b.col)
}
// Up on the first line is a no-op; down on the last line is a no-op.
b.setString("a\nb")
b.up()
b.up()
if b.row != 0 {
t.Fatalf("up past first line: row=%d", b.row)
}
b.down()
b.down()
if b.row != 1 {
t.Fatalf("down past last line: row=%d", b.row)
}
}
func TestMLBufferHomeEnd(t *testing.T) {
b := newMLBuffer()
b.setString("héllo") // multi-byte, 5 runes
b.home()
if b.col != 0 {
t.Fatalf("home col = %d, want 0", b.col)
}
b.end()
if b.col != 5 {
t.Fatalf("end col = %d, want 5", b.col)
}
}
func TestEditLoopLeftArrowThenInsert(t *testing.T) {
// Type "abc", move left once (\x1b[D), insert "X", submit → "abXc".
e := editorWithInput("abc\x1b[DX\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "abXc" {
t.Fatalf("submitted %q, want %q", got, "abXc")
}
}
func TestEditLoopUpArrowEditsPreviousLine(t *testing.T) {
// "a" + Shift+Enter + "b", then up-arrow to line 0, End, insert "Z":
// line 0 becomes "aZ", line 1 stays "b" → "aZ\nb".
e := editorWithInput("a\x1b[13;2ub\x1b[A\x1b[FZ\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "aZ\nb" {
t.Fatalf("submitted %q, want %q", got, "aZ\nb")
}
}
func TestMLBufferInsertMidLine(t *testing.T) {
b := newMLBuffer()
b.setString("abc")
b.home()
b.right() // cursor between "a" and "bc"
b.insert("X")
if got := b.String(); got != "aXbc" {
t.Fatalf("mid-line insert = %q, want %q", got, "aXbc")
}
if b.col != 2 {
t.Fatalf("col after insert = %d, want 2", b.col)
}
}
func TestMLBufferBackspaceMergesLines(t *testing.T) {
b := newMLBuffer()
b.setString("ab\ncd") // cursor at (1,2)
b.home() // cursor at (1,0)
b.backspace() // merge line 1 into line 0
if got := b.String(); got != "abcd" {
t.Fatalf("merge = %q, want %q", got, "abcd")
}
if b.row != 0 || b.col != 2 {
t.Fatalf("cursor after merge = (%d,%d), want (0,2)", b.row, b.col)
}
// Merge with a multi-byte previous line lands the cursor by rune count.
b.setString("café\nx")
b.home()
b.backspace()
if got := b.String(); got != "caféx" {
t.Fatalf("multi-byte merge = %q, want %q", got, "caféx")
}
if b.col != 4 {
t.Fatalf("cursor col after multi-byte merge = %d, want 4", b.col)
}
}
func TestMLBufferBackspaceFirstLineCol0IsNoop(t *testing.T) {
b := newMLBuffer()
b.setString("abc")
b.home()
b.backspace()
if got := b.String(); got != "abc" {
t.Fatalf("col-0 backspace on first line = %q, want unchanged", got)
}
if b.row != 0 || b.col != 0 {
t.Fatalf("cursor moved: (%d,%d)", b.row, b.col)
}
}
func TestEditLoopCrossLineBackspaceMerges(t *testing.T) {
// "ab" + Shift+Enter + "cd" → two lines; Home to line-1 start, backspace
// merges into "abcd", submit.
e := editorWithInput("ab\x1b[13;2ucd\x1b[H\x7f\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "abcd" {
t.Fatalf("submitted %q, want %q", got, "abcd")
}
}
func TestEditLoopBackslashContinues(t *testing.T) {
// A line ending with a single unescaped "\" + Enter continues; a second
// line + Enter submits the two-line block without the continuation "\".
e := editorWithInput("foo\\\rbar\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "foo\nbar" {
t.Fatalf("submitted %q, want %q", got, "foo\nbar")
}
}
func TestEditLoopEscapedBackslashSubmits(t *testing.T) {
// A line ending with "\\" (escaped) + Enter submits, keeping one literal
// backslash — it does not continue.
e := editorWithInput("foo\\\\\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "foo\\" {
t.Fatalf("submitted %q, want %q", got, "foo\\")
}
}
func TestEditLoopBackslashMultipleContinuations(t *testing.T) {
// Three continued lines accumulate into a three-line block.
e := editorWithInput("a\\\rb\\\rc\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "a\nb\nc" {
t.Fatalf("submitted %q, want %q", got, "a\nb\nc")
}
}
func TestMLBufferEnterContinuesTrailingRuns(t *testing.T) {
// Odd run continues and collapses to k/2 backslashes; even run submits and
// halves the run.
b := newMLBuffer()
b.setString("x\\") // one trailing backslash
if !b.enterContinues() {
t.Fatalf("single trailing backslash should continue")
}
if got := b.line(); got != "x" {
t.Fatalf("line after continue = %q, want %q", got, "x")
}
b.setString("y\\\\") // two trailing backslashes
if b.enterContinues() {
t.Fatalf("escaped double backslash should not continue")
}
if got := b.line(); got != "y\\" {
t.Fatalf("line after submit = %q, want %q", got, "y\\")
}
b.setString("z\\\\\\") // three trailing backslashes → continue, keep one
if !b.enterContinues() {
t.Fatalf("triple trailing backslash should continue")
}
if got := b.line(); got != "z\\" {
t.Fatalf("line after triple continue = %q, want %q", got, "z\\")
}
}
func TestRememberPreservesInternalNewlines(t *testing.T) {
// A submitted multi-line block is stored as ONE history record; remember
// trims only the outer whitespace, never the internal newlines.
e := testLineEditor()
e.remember(" foo\nbar ")
if len(e.history) != 1 {
t.Fatalf("history = %v, want a single record", e.history)
}
if e.history[0] != "foo\nbar" {
t.Fatalf("remembered %q, want %q (internal newline kept)", e.history[0], "foo\nbar")
}
}
func TestEditLoopHistoryRestoresMultiLineAndSubmits(t *testing.T) {
// Up-arrow on a blank line restores the newest history entry; a multi-line
// record comes back as a multi-line buffer that submits intact with its
// internal newline (i.e. the block is one message, not two).
e := editorWithInput("\x1b[A\r", "foo\nbar")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "foo\nbar" {
t.Fatalf("restored+submitted %q, want %q", got, "foo\nbar")
}
}
func TestEditLoopMultiLineSubmitJoinsWithNewline(t *testing.T) {
// Three Shift+Enter lines submit as one \n-joined message, so the full
// multi-line string reaches the agent in a single turn.
e := editorWithInput("a\x1b[13;2ub\x1b[13;2uc\r")
got, err := e.editLoop("")
if err != nil {
t.Fatalf("editLoop error: %v", err)
}
if got != "a\nb\nc" {
t.Fatalf("submitted %q, want %q", got, "a\nb\nc")
}
}
@@ -0,0 +1,254 @@
package repl
// Tests for plugin slash-command wiring (#265): a plugin-declared command
// (Manager.Commands()) is registered into the REPL slash registry as a hybrid
// (Run) command, and invoking it calls Plugin.CallCommand, surfaces the
// returned notifications, and runs the returned prompt as the next turn. A real
// plugin subprocess is compiled and Discover-loaded so the JSON-RPC transport,
// handshake and commands/call round-trip are exercised end to end.
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"runtime"
"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/plugin"
"github.com/smallnest/pigo/internal/provider"
rt "github.com/smallnest/pigo/internal/runtime"
"github.com/smallnest/pigo/internal/session"
)
// cmdPluginMain is a standalone plugin that declares one "hello" slash command
// and answers commands/call by echoing back a prompt built from the command's
// args plus one notification. It lets the test assert registration, notification
// surfacing, and prompt injection. It also records the raw arguments it received
// so the test can confirm a bare invocation sends a JSON string ("") not null.
const cmdPluginMain = `package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
)
type req struct {
ID *json.RawMessage ` + "`json:\"id\"`" + `
Method string ` + "`json:\"method\"`" + `
Params json.RawMessage ` + "`json:\"params\"`" + `
}
func main() {
sc := bufio.NewScanner(os.Stdin)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
w := bufio.NewWriter(os.Stdout)
for sc.Scan() {
var r req
if err := json.Unmarshal(sc.Bytes(), &r); err != nil {
continue
}
switch r.Method {
case "initialize":
reply(w, r.ID, json.RawMessage(` + "`" + `{"name":"greeter","commands":[{"name":"hello","description":"say hello"}]}` + "`" + `))
case "commands/call":
var p struct {
Name string ` + "`json:\"name\"`" + `
Args json.RawMessage ` + "`json:\"arguments\"`" + `
}
json.Unmarshal(r.Params, &p)
// args is a JSON string (never null); decode it to prove the contract.
var argText string
json.Unmarshal(p.Args, &argText)
res, _ := json.Marshal(map[string]any{
"prompt": "please greet " + argText,
"notifications": []map[string]any{
{"message": "invoked hello", "type": "info"},
},
})
reply(w, r.ID, res)
case "shutdown":
return
}
}
}
func reply(w *bufio.Writer, id *json.RawMessage, result json.RawMessage) {
if id == nil {
return
}
out, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
fmt.Fprintf(w, "%s\n", out)
w.Flush()
}
`
// buildPluginInDir compiles src into an executable named bin inside dir and
// returns nothing (the executable path is dir/bin). Discover loads any
// executable regular file directly under dir.
func buildPluginInDir(t *testing.T, dir, bin, src string) {
t.Helper()
srcPath := filepath.Join(dir, "plugin_main.go")
if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil {
t.Fatalf("write plugin source: %v", err)
}
binPath := filepath.Join(dir, bin)
if runtime.GOOS == "windows" {
binPath += ".exe"
}
cmd := exec.Command("go", "build", "-o", binPath, srcPath)
cmd.Env = os.Environ()
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build plugin: %v\n%s", err, out)
}
// Remove the source so Discover only sees the executable (a .go file is not
// executable, but keeping the dir clean avoids any ambiguity).
_ = os.Remove(srcPath)
}
// loadTestManager compiles the greeter plugin into a fresh dir and Discover-loads
// it, returning a Manager with exactly that one plugin. The caller must Close it.
func loadTestManager(t *testing.T) *plugin.Manager {
t.Helper()
// Build in a build dir, then move only the binary into the plugins dir so
// Discover (which loads every executable file in the dir) sees just the one
// plugin executable.
buildDir := t.TempDir()
buildPluginInDir(t, buildDir, "greeter", cmdPluginMain)
pluginsDir := t.TempDir()
binName := "greeter"
if runtime.GOOS == "windows" {
binName += ".exe"
}
if err := os.Rename(filepath.Join(buildDir, binName), filepath.Join(pluginsDir, binName)); err != nil {
t.Fatalf("move plugin binary: %v", err)
}
mgr, err := plugin.Discover(pluginsDir, os.Stderr, os.Stderr)
if err != nil {
t.Fatalf("Discover: %v", err)
}
if len(mgr.Commands()) != 1 || mgr.Commands()[0].Spec.Name != "hello" {
t.Fatalf("expected one discovered command 'hello', got %+v", mgr.Commands())
}
return mgr
}
// TestBuildSlashRegistryRegistersPluginCommand verifies a discovered plugin
// command is registered as a resolvable slash command in the registry.
func TestBuildSlashRegistryRegistersPluginCommand(t *testing.T) {
t.Setenv("PIGO_HOME", t.TempDir())
mgr := loadTestManager(t)
defer mgr.Close()
reg, err := prompts.BuildSlashRegistry(&cli.LiveConfig{Model: "faux", ProviderName: "faux"}, nil, mgr, prompts.PromptTemplateSources{})
if err != nil {
t.Fatalf("buildSlashRegistry: %v", err)
}
if _, ok := reg.Lookup("hello"); !ok {
t.Fatalf("plugin command /hello was not registered in the slash registry")
}
// Resolving it must run the plugin (side effect), surface its notification,
// and yield the plugin's prompt to run.
out, err := reg.ResolveOutcome("/hello world")
if err != nil {
t.Fatalf("ResolveOutcome(/hello): %v", err)
}
if !out.Handled || out.Kind != rt.SlashPrompt {
t.Fatalf("outcome = %+v, want handled SlashPrompt", out)
}
if !strings.Contains(out.Message, "invoked hello") {
t.Errorf("notification not surfaced in Message: %q", out.Message)
}
if out.Prompt != "please greet world" {
t.Errorf("Prompt = %q, want plugin-returned prompt", out.Prompt)
}
}
// TestBuiltinWinsOverPluginCommand verifies a built-in command of the same name
// wins over a plugin command (existing precedence preserved): the plugin command
// is shadowed and the built-in's behavior is what resolves.
func TestBuiltinWinsOverPluginCommand(t *testing.T) {
t.Setenv("PIGO_HOME", t.TempDir())
mgr := loadTestManager(t)
defer mgr.Close()
reg := rt.NewSlashRegistry()
reg.AddBuiltin(rt.SlashCommand{
Name: "hello",
Action: func(string) string { return "builtin hello" },
})
prompts.RegisterPluginCommands(reg, mgr)
if names := reg.Shadowed(); len(names) != 1 || names[0].Name != "hello" {
t.Fatalf("plugin command should be shadowed by built-in, shadowed=%v", names)
}
out, err := reg.ResolveOutcome("/hello there")
if err != nil {
t.Fatalf("ResolveOutcome: %v", err)
}
if out.Kind != rt.SlashAction || out.Message != "builtin hello" {
t.Errorf("built-in must win: outcome = %+v", out)
}
}
// TestREPLPluginCommandInjectsPrompt drives the full REPL: invoking a plugin
// slash command prints its notification and runs the returned prompt as the next
// agent turn (so the fake provider is called once and the injected prompt lands
// in the conversation history).
func TestREPLPluginCommandInjectsPrompt(t *testing.T) {
t.Setenv("PIGO_HOME", t.TempDir())
mgr := loadTestManager(t)
defer mgr.Close()
reg, err := prompts.BuildSlashRegistry(&cli.LiveConfig{Model: "faux", ProviderName: "faux"}, nil, mgr, prompts.PromptTemplateSources{})
if err != nil {
t.Fatalf("buildSlashRegistry: %v", err)
}
store, err := session.NewStore(t.TempDir())
if err != nil {
t.Fatalf("new store: %v", err)
}
p := &replProvider{reply: "hi there"}
live := &cli.LiveConfig{Model: "faux", ProviderName: "faux", Provider: p}
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),
}
var out bytes.Buffer
if err := runREPL(strings.NewReader("/hello world\n/exit\n"), &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
// The plugin's notification must have been printed.
if !strings.Contains(out.String(), "invoked hello") {
t.Errorf("plugin notification not printed, out=%q", out.String())
}
// The returned prompt must have run exactly one turn.
if p.calls != 1 {
t.Fatalf("plugin command should inject and run exactly 1 turn, got %d", p.calls)
}
// The injected prompt must be the user message that started the turn.
if len(deps.agentCtx.Messages) == 0 {
t.Fatal("expected messages in context after the injected turn")
}
u0, ok := deps.agentCtx.Messages[0].(agentcore.UserMessage)
if !ok || agentcore.ContentToText(u0.Content) != "please greet world" {
t.Errorf("injected prompt not run as the turn: %+v", deps.agentCtx.Messages[0])
}
}
+237
View File
@@ -0,0 +1,237 @@
// This file wires the remote-control bridge (internal/remotecontrol, #442) into
// the interactive REPL (#443). It adds the "/remote-control" command that
// starts/stops an in-process HTTP+WebSocket server mirroring the session to a
// paired browser on the LAN, tees REPL output to that browser, merges
// browser-submitted prompts into the input loop, and routes tool-call
// confirmations to the browser while a client is connected.
//
// The design keeps the non-remote path byte-identical: when no remote session
// is active, teeWriter forwards only to stdout, the input select degenerates to
// a plain editor read (the remote channel is nil), and confirmations use the
// local stdin prompt unchanged.
package repl
import (
"context"
"fmt"
"io"
"strings"
"sync"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/remotecontrol"
"github.com/smallnest/pigo/internal/trust"
)
// teeWriter is an io.Writer that always forwards to a primary writer (the
// terminal) and, when a secondary is set, mirrors the same bytes to it (the
// remote browser). It is safe for concurrent Write/setSecondary because the
// bridge output writer is fed from the REPL goroutine while the secondary is
// toggled by the /remote-control command on the same goroutine, but writes to
// the WebSocket happen on other goroutines; the mutex keeps the swap atomic.
type teeWriter struct {
primary io.Writer
mu sync.Mutex
second io.Writer
}
func newTeeWriter(primary io.Writer) *teeWriter { return &teeWriter{primary: primary} }
func (t *teeWriter) setSecondary(w io.Writer) {
t.mu.Lock()
t.second = w
t.mu.Unlock()
}
func (t *teeWriter) Write(p []byte) (int, error) {
// The primary write is authoritative for the returned count/err so terminal
// behavior is unchanged; a mirror failure never breaks the local session.
n, err := t.primary.Write(p)
t.mu.Lock()
second := t.second
t.mu.Unlock()
if second != nil {
_, _ = second.Write(p)
}
return n, err
}
// remoteSession owns the running server + bridge for one /remote-control
// activation. It is nil in deps until the command starts a session, and is
// cleared on stop.
type remoteSession struct {
server *remotecontrol.Server
bridge *remotecontrol.Bridge
url string
}
// inputChan returns the bridge's remote-input channel while a session is
// active, or nil when inactive. A nil channel blocks forever in a select, so
// the input loop transparently ignores remote input when remote control is off.
func (rs *remoteSession) inputChan() <-chan string {
if rs == nil || rs.bridge == nil {
return nil
}
return rs.bridge.RemoteInput()
}
// hasClient reports whether a browser is currently paired and connected.
func (rs *remoteSession) hasClient() bool {
return rs != nil && rs.bridge != nil && rs.bridge.Enabled()
}
// runRemoteControl handles the "/remote-control" command and its "stop"/"status"
// subcommands. It mutates deps in place (deps.remote, deps.tee) so the input
// loop and output tee pick up the change on the next iteration.
func runRemoteControl(out io.Writer, deps *replDeps, line string) {
arg := strings.TrimSpace(strings.TrimPrefix(line, "/remote-control"))
switch arg {
case "stop":
stopRemoteControl(out, deps)
case "status", "":
if arg == "status" {
remoteControlStatus(out, deps)
return
}
startRemoteControl(out, deps)
default:
fmt.Fprintf(out, "usage: /remote-control [stop|status]\n")
}
}
func startRemoteControl(out io.Writer, deps *replDeps) {
if deps.remote != nil {
fmt.Fprintf(out, "remote control already running: %s\n", deps.remote.url)
return
}
// Handler is set after the server is built (SetHandler), but NewServer takes
// it up front; the bridge's Sink is the server itself, so build the server
// first with the bridge as handler once the bridge exists. To break the
// cycle we construct the server, then the bridge (Sink=server), then tell the
// server to route client frames to the bridge.
// The connect/disconnect callbacks print a terminal notice so the operator
// sees when a browser gains or loses remote access to this session (§7.3).
// They run on the server's WebSocket goroutine and only write a line, so they
// don't block.
cfg := remotecontrol.Config{
OnClientConnect: func(remoteAddr string) {
fmt.Fprintf(out, "\n[remote-control] browser connected from %s\n", remoteAddr)
},
OnClientDisconnect: func() {
fmt.Fprintf(out, "\n[remote-control] browser disconnected\n")
},
}
srv := remotecontrol.NewServer(cfg, nil)
bridge := remotecontrol.NewBridge(srv)
srv.SetHandler(bridge)
url, err := srv.Start()
if err != nil {
fmt.Fprintf(out, "remote control: %v\n", err)
return
}
rs := &remoteSession{server: srv, bridge: bridge, url: url}
deps.remote = rs
if deps.tee != nil {
deps.tee.setSecondary(bridge.OutputWriter())
}
fmt.Fprintf(out, "\nRemote control started. Open this URL on a device on the same network:\n\n %s\n\n", url)
if qr, qerr := remotecontrol.Render(url); qerr == nil {
fmt.Fprintln(out, qr)
}
fmt.Fprintln(out, "Run /remote-control stop to end the session.")
}
func stopRemoteControl(out io.Writer, deps *replDeps) {
if deps.remote == nil {
fmt.Fprintln(out, "remote control is not running")
return
}
if deps.tee != nil {
deps.tee.setSecondary(nil)
}
_ = deps.remote.server.Stop(context.Background())
deps.remote = nil
fmt.Fprintln(out, "remote control stopped")
}
func remoteControlStatus(out io.Writer, deps *replDeps) {
if deps.remote == nil {
fmt.Fprintln(out, "remote control: off")
return
}
state := "waiting for a browser to connect"
if deps.remote.hasClient() {
state = "browser connected"
}
fmt.Fprintf(out, "remote control: on (%s)\n %s\n", state, deps.remote.url)
}
// beforeToolCall builds the tool-call confirmation seam for a turn. It always
// constructs the local stdin prompt (trust.BeforeToolCall) and, when a remote
// session exists, wraps it with bridgeBeforeToolCall so confirmations route to a
// paired browser while one is connected. When deps.remote is nil the wrapper is
// skipped entirely, so the returned func is exactly the local seam — the
// non-remote path is byte-identical to before (#443).
func beforeToolCall(deps replDeps, out io.Writer) agentcore.BeforeToolCallFunc {
local := trust.BeforeToolCall(deps.trust, deps.cwd, deps.in, out, deps.confirmMu)
if deps.remote == nil {
return local
}
return bridgeBeforeToolCall(deps.trust, deps.cwd, deps.remote, out, deps.confirmMu, local)
}
// bridgeBeforeToolCall wraps the local stdin confirmation seam so that while a
// browser is connected, side-effect tool-call confirmations are routed to the
// browser instead of blocking on the local terminal. When no browser is
// connected it delegates to the local prompt so behavior is unchanged.
//
// This mirrors trust.BeforeToolCall's gating (side-effect tools only, honoring
// session trust) but delegates the allow/always decision to the remote client
// via Bridge.Confirm. A ctx cancellation (e.g. SIGINT) makes Confirm return
// remote=false, which we treat as a denial so an interrupted run does not
// silently proceed. Refinements (local-answer race, timeouts) are the hardening
// node's job (#445).
func bridgeBeforeToolCall(mgr *trust.Manager, cwd string, rs *remoteSession, out io.Writer, mu *sync.Mutex, local agentcore.BeforeToolCallFunc) agentcore.BeforeToolCallFunc {
return func(ctx context.Context, call agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision {
if !rs.hasClient() || mgr == nil {
if local != nil {
return local(ctx, call)
}
return nil
}
if !trust.SideEffectTools[call.Name] {
return nil
}
if mu != nil {
mu.Lock()
defer mu.Unlock()
}
if mgr.IsTrusted(cwd) {
return nil
}
summary := trust.ToolCallSummary(call)
fmt.Fprintf(out, "\npigo wants to run %q — approve on the paired device…\n", call.Name)
d, remote := rs.bridge.Confirm(ctx, call.Name, summary)
if !remote {
// Interrupted / cancelled before the browser answered: deny.
return blockToolCall(call, cwd)
}
if d.Always {
mgr.SetSessionTrust(cwd)
}
if !d.Approve {
return blockToolCall(call, cwd)
}
return nil
}
}
func blockToolCall(call agentcore.AgentToolCall, cwd string) *agentcore.BeforeToolCallDecision {
msg := fmt.Sprintf("tool %q blocked: %s is not trusted (use /trust to trust this project)", call.Name, cwd)
return &agentcore.BeforeToolCallDecision{
Block: true,
Content: &agentcore.ContentList{agentcore.NewTextContent(msg)},
}
}
File diff suppressed because it is too large Load Diff
+656
View File
@@ -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)
}
}
+160
View File
@@ -0,0 +1,160 @@
// This file implements the /rewind command (edit checkpoint / rewind): pigo's
// analogue of Claude Code's Esc-Esc rewind. Where /tree only moves the
// conversation leaf, /rewind also restores the working tree — it replays the
// file-snapshot journal (see agenttool.FileSnapshotRecorder) so a turn's write
// and edit mutations are rolled back, then switches the active conversation leaf
// to the point before that turn. The two together return the session to an
// earlier state in code and dialogue at once.
//
// Scope (v1): only pigo's own write/edit tools are journaled. Files changed by
// bash commands are not captured and are left untouched by a rewind.
package repl
import (
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/session"
)
// rewindLabel derives a short one-line description of a turn from its prompt, for
// display in the /rewind list. It collapses whitespace and truncates so the list
// stays scannable.
func rewindLabel(prompt string) string {
label := strings.Join(strings.Fields(prompt), " ")
const max = 60
if len(label) > max {
label = label[:max-1] + "…"
}
return label
}
// runRewind handles the /rewind command. With no argument it persists the live
// turn and prints the numbered restore points (most useful last). With "/rewind
// N" it restores files to their state before the N-th listed point and switches
// the conversation to the leaf that preceded that turn.
func runRewind(out io.Writer, deps *replDeps, line string) {
if deps.snap == nil {
fmt.Fprintln(out, "rewind is unavailable (file tools are disabled)")
return
}
// Persist any un-saved turn first so the just-run turn's restore point exists
// and the leaf ids we switch to are on disk.
cli.PersistTurn(out, deps)
points := deps.snap.Points()
fields := strings.Fields(line)
if len(fields) < 2 {
printRewindPoints(out, points)
return
}
if len(points) == 0 {
fmt.Fprintln(out, "no restore points yet — file edits create them")
return
}
n, err := strconv.Atoi(fields[1])
if err != nil || n < 1 || n > len(points) {
fmt.Fprintf(out, "invalid selection %q — run /rewind to list points (1..%d)\n", fields[1], len(points))
return
}
leafID, restored, warnings, rErr := deps.snap.Restore(n - 1)
if rErr != nil {
fmt.Fprintf(out, "pigo: rewind failed: %v\n", rErr)
return
}
if len(restored) > 0 {
fmt.Fprintf(out, "restored %d file(s):\n", len(restored))
for _, p := range restored {
fmt.Fprintf(out, " %s\n", displayPath(deps.cwd, p))
}
} else {
fmt.Fprintln(out, "no files to restore for this point")
}
for _, w := range warnings {
fmt.Fprintf(out, " warning: %s\n", w)
}
// Move the conversation back to the leaf that preceded the turn, rebuilding the
// shared context from that leaf's root→leaf path (same mechanism as /tree). An
// empty leaf id means the turn was the first in the session: reset to an empty
// conversation.
if !rewindConversation(out, deps, leafID) {
return
}
fmt.Fprintf(out, "rewound to before point %d — next prompt continues from here\n", n)
}
// rewindConversation switches the active leaf to leafID and rebuilds the shared
// context from its path. A "" leafID resets to an empty conversation (the turn
// was the session's first). It reports whether the switch succeeded.
func rewindConversation(out io.Writer, deps *replDeps, leafID string) bool {
if leafID == "" {
deps.agentCtx.Messages = nil
deps.curLeaf = ""
deps.persisted = 0
return true
}
_, entries, err := deps.store.LoadEntries(deps.header.ID)
if err != nil {
fmt.Fprintf(out, "pigo: cannot read session tree: %v\n", err)
return false
}
path := session.PathToLeaf(entries, leafID)
if len(path) == 0 {
fmt.Fprintf(out, "pigo: restore point's conversation node is no longer in the tree; files were restored but the conversation was left unchanged\n")
return false
}
msgs := make(agentcore.MessageList, len(path))
for i, e := range path {
msgs[i] = e.Message
}
deps.agentCtx.Messages = msgs
deps.curLeaf = leafID
deps.persisted = len(msgs)
return true
}
// printRewindPoints renders the numbered restore points, oldest first, showing
// when each was made, how many files it touched, and the turn's label.
func printRewindPoints(out io.Writer, points []agenttool.RestorePoint) {
if len(points) == 0 {
fmt.Fprintln(out, "no restore points yet — file edits create them")
return
}
fmt.Fprintln(out, "restore points (run /rewind <n> to roll files + conversation back to before that point):")
for i, p := range points {
files := len(p.Snapshots)
unit := "files"
if files == 1 {
unit = "file"
}
when := p.Time.Local().Format(time.Kitchen)
label := p.Label
if label == "" {
label = "(no prompt)"
}
fmt.Fprintf(out, " %d. %s %d %s %s\n", i+1, when, files, unit, label)
}
}
// displayPath shortens an absolute snapshot path to a workspace-relative form for
// display when it lives under cwd; otherwise it returns the absolute path.
func displayPath(cwd, abs string) string {
if cwd == "" {
return abs
}
if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
return rel
}
return abs
}
+89
View File
@@ -0,0 +1,89 @@
// Tests for the /rewind command wiring: listing restore points and restoring
// files + conversation. The file-snapshot journal itself is tested in
// agenttool; here we exercise runRewind's REPL-level behavior over a replDeps.
package repl
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
)
// /rewind with no argument lists the committed restore points.
func TestREPLRewindListsPoints(t *testing.T) {
p := &replProvider{reply: "hi"}
deps, _ := newTestDeps(t, p)
deps.snap = agenttool.NewFileSnapshotRecorder()
dir := t.TempDir()
f := filepath.Join(dir, "a.txt")
if err := os.WriteFile(f, []byte("v0"), 0o644); err != nil {
t.Fatal(err)
}
deps.snap.Record(f)
deps.snap.Commit("", "add feature X")
var out bytes.Buffer
runRewind(&out, &deps, "/rewind")
got := out.String()
if !strings.Contains(got, "restore points") || !strings.Contains(got, "add feature X") {
t.Errorf("listing missing expected content:\n%s", got)
}
}
// /rewind N restores the file to its baseline and resets the conversation when
// the point's leaf is empty (it was the session's first turn).
func TestREPLRewindRestoresFiles(t *testing.T) {
p := &replProvider{reply: "hi"}
deps, _ := newTestDeps(t, p)
deps.snap = agenttool.NewFileSnapshotRecorder()
deps.agentCtx.Messages = agentcore.MessageList{
agentcore.UserMessage{RoleField: agentcore.RoleUser},
}
deps.persisted = 1
dir := t.TempDir()
f := filepath.Join(dir, "a.txt")
if err := os.WriteFile(f, []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
deps.snap.Record(f) // baseline "original"
if err := os.WriteFile(f, []byte("changed"), 0o644); err != nil {
t.Fatal(err)
}
deps.snap.Commit("", "edit a.txt")
var out bytes.Buffer
runRewind(&out, &deps, "/rewind 1")
if data, _ := os.ReadFile(f); string(data) != "original" {
t.Errorf("file not restored: got %q, want original", string(data))
}
if len(deps.agentCtx.Messages) != 0 {
t.Errorf("conversation not reset: %d messages remain", len(deps.agentCtx.Messages))
}
if len(deps.snap.Points()) != 0 {
t.Errorf("journal not truncated after rewind")
}
if !strings.Contains(out.String(), "rewound to before point 1") {
t.Errorf("missing confirmation:\n%s", out.String())
}
}
// /rewind is unavailable when file tools are disabled (nil recorder).
func TestREPLRewindDisabled(t *testing.T) {
p := &replProvider{reply: "hi"}
deps, _ := newTestDeps(t, p)
deps.snap = nil
var out bytes.Buffer
runRewind(&out, &deps, "/rewind")
if !strings.Contains(out.String(), "unavailable") {
t.Errorf("want unavailable message, got:\n%s", out.String())
}
}
+165
View File
@@ -0,0 +1,165 @@
package repl
// Tests for default skill loading from ~/.agents/skills and its exposure as
// /skill-name slash commands. skillsDir honors the PIGO_SKILLS_DIR override so
// the loader can be pointed at a temp dir without touching the real home dir.
import (
"os"
"path/filepath"
"testing"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/prompts"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/runtime"
)
// writeSkill creates a skill markdown file with the given frontmatter body.
func writeSkill(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatalf("write skill %s: %v", name, err)
}
}
// findSkill returns the skill with the given name from the set, or nil.
func findSkill(skills []*runtime.Skill, name string) *runtime.Skill {
for _, s := range skills {
if s.Frontmatter.Name == name {
return s
}
}
return nil
}
// TestLoadSkillsFromDir verifies skills in PIGO_SKILLS_DIR are loaded and expose
// a /skill-name slash command whose expansion is the skill body.
func TestLoadSkillsFromDir(t *testing.T) {
dir := t.TempDir()
t.Setenv("PIGO_SKILLS_DIR", dir)
t.Setenv("PIGO_HOME", t.TempDir())
writeSkill(t, dir, "greet.md", "---\nname: greet\ndescription: say hello\n---\nYou are a friendly greeter.")
skills, err := run.LoadSkills(false)
if err != nil {
t.Fatalf("run.LoadSkills: %v", err)
}
s := findSkill(skills, "greet")
if s == nil {
t.Fatal("greet skill not loaded")
}
c := s.SlashCommand()
if c.Name != "greet" {
t.Errorf("Name = %q, want greet", c.Name)
}
if c.Description != "say hello" {
t.Errorf("Description = %q, want 'say hello'", c.Description)
}
if c.Expand == nil {
t.Fatal("skill command must be a prompt command (Expand != nil)")
}
if got := c.Expand(""); got != "You are a friendly greeter." {
t.Errorf("Expand(\"\") = %q, want the skill body", got)
}
}
// TestLoadSkillsNoSkills verifies --no-skills skips discovery entirely: no
// skills are loaded and the skills dir is left untouched (no bootstrap).
func TestLoadSkillsNoSkills(t *testing.T) {
dir := t.TempDir()
t.Setenv("PIGO_SKILLS_DIR", dir)
t.Setenv("PIGO_HOME", t.TempDir())
skills, err := run.LoadSkills(true)
if err != nil {
t.Fatalf("run.LoadSkills(true): %v", err)
}
if len(skills) != 0 {
t.Errorf("got %d skills, want 0 under --no-skills", len(skills))
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read skills dir: %v", err)
}
if len(entries) != 0 {
t.Errorf("--no-skills must not bootstrap; skills dir has %d entries", len(entries))
}
}
// TestBuildSlashRegistryIncludesSkills verifies buildSlashRegistry wires the
// pre-loaded skills into the registry so /skill-name resolves.
func TestBuildSlashRegistryIncludesSkills(t *testing.T) {
dir := t.TempDir()
t.Setenv("PIGO_SKILLS_DIR", dir)
// Keep the user-commands path from touching a real home dir.
t.Setenv("PIGO_HOME", t.TempDir())
writeSkill(t, dir, "summarize.md", "---\nname: summarize\ndescription: summarize input\n---\nSummarize the following: $ARGUMENTS")
skills, err := run.LoadSkills(false)
if err != nil {
t.Fatalf("run.LoadSkills: %v", err)
}
reg, err := prompts.BuildSlashRegistry(&cli.LiveConfig{Model: "test", ProviderName: "test"}, skills, nil, prompts.PromptTemplateSources{})
if err != nil {
t.Fatalf("buildSlashRegistry: %v", err)
}
out, err := reg.ResolveOutcome("/summarize hello world")
if err != nil {
t.Fatalf("ResolveOutcome: %v", err)
}
if !out.Handled {
t.Fatal("/summarize should be handled by the registry")
}
if out.Kind != runtime.SlashPrompt {
t.Errorf("Kind = %v, want SlashPrompt", out.Kind)
}
if out.Prompt != "Summarize the following: hello world" {
t.Errorf("Prompt = %q, want $ARGUMENTS substituted", out.Prompt)
}
}
// TestBuildSlashRegistryNoSkills verifies that when no skills are passed (as
// under --no-skills, where loadSkills returns nil), a /skill-name command is not
// registered even though a skill file exists on disk (mirrors pi's --no-skills).
func TestBuildSlashRegistryNoSkills(t *testing.T) {
t.Setenv("PIGO_HOME", t.TempDir())
reg, err := prompts.BuildSlashRegistry(&cli.LiveConfig{Model: "test", ProviderName: "test"}, nil, nil, prompts.PromptTemplateSources{})
if err != nil {
t.Fatalf("buildSlashRegistry: %v", err)
}
// With no skills registered, /summarize is an unknown command (an error)
// rather than a handled one.
if _, err := reg.ResolveOutcome("/summarize hello world"); err == nil {
t.Error("/summarize must be unknown when no skills are registered")
}
}
// TestLoadSkillsBootstrapsBuiltinSkills verifies that on a fresh
// PIGO_SKILLS_DIR the built-in skills are installed and loaded (first-run
// bootstrap), so e.g. /prd resolves without any manual install.
func TestLoadSkillsBootstrapsBuiltinSkills(t *testing.T) {
dir := t.TempDir()
t.Setenv("PIGO_SKILLS_DIR", dir)
t.Setenv("PIGO_HOME", t.TempDir())
skills, err := run.LoadSkills(false)
if err != nil {
t.Fatalf("run.LoadSkills: %v", err)
}
reg, err := prompts.BuildSlashRegistry(&cli.LiveConfig{Model: "test", ProviderName: "test"}, skills, nil, prompts.PromptTemplateSources{})
if err != nil {
t.Fatalf("buildSlashRegistry: %v", err)
}
for _, name := range []string{"/prd", "/refactor", "/architecture-diagram", "/weather"} {
out, err := reg.ResolveOutcome(name)
if err != nil {
t.Errorf("%s should be registered after bootstrap: %v", name, err)
continue
}
if !out.Handled {
t.Errorf("%s should be handled by the registry after bootstrap", name)
}
}
}
+127
View File
@@ -0,0 +1,127 @@
// This file holds the REPL-integration and headless-flag tests for /status
// (US-005, #295) that drive the package-main REPL harness (runREPL/newTestDeps)
// or inspect CLI flags. The direct-call rendering tests live in
// internal/cli/status alongside RunStatus.
package repl
import (
"bytes"
"strings"
"testing"
flag "github.com/spf13/pflag"
)
func TestStatusGuard(t *testing.T) {
// The guard logic is in the REPL loop: matches "/status" or "/status "
// but not "/statusfoo" or "/statusbar"
testCases := []struct {
line string
want bool
}{
{"/status", true},
{"/status ", true},
{"/status foo", true},
{"/statusbar", false},
{"/statusfoo", false},
{"/status123", false},
{"/stat", false},
{"/session", false},
}
for _, tc := range testCases {
got := (tc.line == "/status" || strings.HasPrefix(tc.line, "/status "))
if got != tc.want {
t.Errorf("line %q: got %v, want %v", tc.line, got, tc.want)
}
}
}
func TestRunStatusViaREPL(t *testing.T) {
// Verify that /status is intercepted in the REPL loop and doesn't invoke the model
p := &replProvider{reply: "should not be called"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
in := strings.NewReader("/status\n/exit\n")
if err := runREPL(in, &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 0 {
t.Errorf("expected 0 model calls for /status, got %d", p.calls)
}
output := out.String()
if !strings.Contains(output, "runtime config:") {
t.Error("expected REPL /status output to contain 'runtime config:'")
}
if !strings.Contains(output, "context:") {
t.Error("expected REPL /status output to contain 'context:'")
}
}
func TestStatusFooNotIntercepted(t *testing.T) {
// Verify that "/statusfoo" is NOT intercepted as "/status"
p := &replProvider{reply: "model called"}
deps, _ := newTestDeps(t, p)
var out bytes.Buffer
in := strings.NewReader("/statusfoo\n/exit\n")
if err := runREPL(in, &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 0 {
t.Errorf("expected 0 model calls for /statusfoo, got %d", p.calls)
}
output := out.String()
if strings.Contains(output, "runtime config:") {
t.Error("expected /statusfoo to NOT run the status command")
}
}
// TestStatusNotInHeadless verifies /status is REPL-only: there is no --status
// CLI flag. (The /status intercept lives in runREPL only; headless print mode
// never runs the REPL loop, so "/status" there is treated as an ordinary
// prompt, not a command.)
func TestStatusNotInHeadless(t *testing.T) {
if f := flag.Lookup("status"); f != nil {
t.Errorf("--status flag should not exist (headless must not expose /status), got %v", f)
}
}
// TestStatusE2EViaREPL drives /status through the REPL loop intercept and
// asserts every section is present and the model is never invoked.
func TestStatusE2EViaREPL(t *testing.T) {
p := &replProvider{reply: "should not be called"}
deps, _ := newTestDeps(t, p)
deps.cwd = "/tmp/e2e-repl"
deps.live.Model = "e2e-model"
deps.live.ProviderName = "e2e-prov"
deps.live.ContextWindow = 128000
var out bytes.Buffer
in := strings.NewReader("/status\n/exit\n")
if err := runREPL(in, &out, deps); err != nil {
t.Fatalf("runREPL: %v", err)
}
if p.calls != 0 {
t.Errorf("expected 0 model calls for /status, got %d", p.calls)
}
got := out.String()
for _, want := range []string{
"runtime config:",
"model: e2e-model",
"context:",
"project & environment:",
"credentials & connectivity:",
"telemetry:",
"no telemetry yet",
} {
if !strings.Contains(got, want) {
t.Errorf("REPL /status: expected output to contain %q", want)
}
}
}