first commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user