first commit
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
// This file implements the /btw command (mirrors Claude Code's /btw and the pi
|
||||
// agent extension @narumitw/pi-btw): a throwaway "side thread" for asking the
|
||||
// model a quick side question that must NOT pollute the main conversation.
|
||||
//
|
||||
// /btw is intercepted in the REPL loop rather than routed through a slash Action
|
||||
// closure because it must run an agent stream and read the live main context —
|
||||
// none of which a pure string→string Action can do, exactly like /compact and
|
||||
// /goal. It reaches the session's collaborators and mutable state through the
|
||||
// cli.Host contract and reads follow-up lines through cli.Editor, so it need not
|
||||
// import the concrete replDeps aggregate that assembles them.
|
||||
//
|
||||
// Isolation contract (the whole point of the feature): a side thread runs on a
|
||||
// COPY of the main conversation as background, and its question/answer are only
|
||||
// ever appended to that copy — never to host.AgentCtx().Messages. Nothing is
|
||||
// persisted: no store.Save, no change to the persisted cursor / current leaf /
|
||||
// header timestamp. Closing the side thread, switching sessions or restarting
|
||||
// pigo discards everything.
|
||||
//
|
||||
// Scope: /btw is intercepted in the REPL loop and runs a side question against a
|
||||
// copy of the main context (#279); it supports multi-turn follow-ups in the same
|
||||
// ephemeral thread (#280), bare-/btw reopen of the most recent side thread this
|
||||
// process (#281), and an optional model/thinking override config (#282, see
|
||||
// btw_config.go) that affects only the side thread.
|
||||
package btw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"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/cli/ui"
|
||||
"github.com/smallnest/pigo/internal/compaction"
|
||||
"github.com/smallnest/pigo/internal/hooks"
|
||||
"github.com/smallnest/pigo/internal/provider"
|
||||
"github.com/smallnest/pigo/internal/runtime"
|
||||
"github.com/smallnest/pigo/internal/trust"
|
||||
)
|
||||
|
||||
// btwHeader is the fixed banner shown when entering a side thread, so the user
|
||||
// always knows the current input is a throwaway side question, not the main
|
||||
// conversation (mirrors pi-btw's "btw · side thread" header).
|
||||
const btwHeader = "btw · side thread"
|
||||
|
||||
// BtwHeader exposes the side-thread banner text so callers (and tests) can
|
||||
// recognize it in output.
|
||||
const BtwHeader = btwHeader
|
||||
|
||||
// btwPrompt is the input prompt shown for follow-up questions inside a side
|
||||
// thread, distinguishing it from the main "pigo(model)>" prompt.
|
||||
const btwPrompt = "btw> "
|
||||
|
||||
// RunBtw handles a /btw invocation. With an argument it starts a fresh side
|
||||
// thread, asks that question, then enters a follow-up loop so the user can keep
|
||||
// asking in the same ephemeral thread. Bare "/btw" reopens the most recent side
|
||||
// thread from this process — replaying its Q&A history — and drops back into the
|
||||
// follow-up loop; if none exists yet it guides the user to supply a question
|
||||
// (US-004, #281). setCancel publishes the active run's cancel func so the REPL's
|
||||
// SIGINT handler can interrupt the side run, reusing the same plumbing as a
|
||||
// normal turn.
|
||||
//
|
||||
// The main context is never mutated: RunBtw builds a private side AgentContext
|
||||
// seeded with a copy of the main messages, runs every turn against that copy,
|
||||
// and returns without touching host.AgentCtx() or persisting anything. The side
|
||||
// thread is retained in-process (host.LastBtw()) so a later bare /btw can reopen
|
||||
// it, but it is never written to disk — restarting pigo discards it.
|
||||
func RunBtw(setCancel func(context.CancelFunc), out io.Writer, host cli.Host, editor cli.Editor, line string) {
|
||||
question := strings.TrimSpace(strings.TrimPrefix(line, "/btw"))
|
||||
// Resolve the side thread's model/thinking once per invocation from the
|
||||
// session defaults overlaid with btw.json (#282). Re-read each call so an
|
||||
// edit takes effect next time with no restart.
|
||||
settings := ResolveBtwSettings(out, host)
|
||||
if question == "" {
|
||||
// Bare /btw: reopen the most recent side thread if one exists this process,
|
||||
// replaying its history; otherwise guide the user to supply a question.
|
||||
if host.LastBtw() == nil {
|
||||
fmt.Fprintln(out, "usage: /btw <question> — ask a quick side question without touching the main conversation")
|
||||
return
|
||||
}
|
||||
printBtwHeader(out)
|
||||
replaySideHistory(out, host.LastBtw(), host.LastBtwBase())
|
||||
if editor != nil {
|
||||
btwFollowUpLoop(setCancel, out, host, editor, host.LastBtw(), settings)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
side := NewSideContext(host.AgentCtx())
|
||||
// Remember this thread so a later bare /btw can reopen it. LastBtwBase marks
|
||||
// where the copied background ends and the side Q&A begins, so a reopen only
|
||||
// replays the side turns, not the whole main transcript.
|
||||
host.SetLastBtw(side)
|
||||
host.SetLastBtwBase(len(side.Messages))
|
||||
printBtwHeader(out)
|
||||
AskSide(setCancel, out, host, side, settings, question)
|
||||
// Follow-up loop: keep answering in the same ephemeral thread until the user
|
||||
// exits. A nil editor (direct test callers that only ask one question) skips
|
||||
// the loop entirely, so a single /btw asks exactly one question and returns.
|
||||
if editor != nil {
|
||||
btwFollowUpLoop(setCancel, out, host, editor, side, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// replaySideHistory prints the side thread's own Q&A (everything after the
|
||||
// copied main-conversation background at index base) when a bare /btw reopens a
|
||||
// prior thread, so the user can browse earlier answers before continuing. Only
|
||||
// user questions and assistant text are shown; tool activity is omitted to keep
|
||||
// the recap compact.
|
||||
func replaySideHistory(out io.Writer, side *agentcore.AgentContext, base int) {
|
||||
if base > len(side.Messages) {
|
||||
base = len(side.Messages)
|
||||
}
|
||||
for _, msg := range side.Messages[base:] {
|
||||
switch m := msg.(type) {
|
||||
case agentcore.UserMessage:
|
||||
fmt.Fprintf(out, "%s %s\n", ui.Colorize(ui.Enabled(), ui.Dim, "you:"), agentcore.ContentToText(m.Content))
|
||||
case agentcore.AssistantMessage:
|
||||
if text := agentcore.ContentToText(m.Content); text != "" {
|
||||
rendered := ui.RenderMarkdown(text)
|
||||
fmt.Fprint(out, rendered)
|
||||
if !strings.HasSuffix(rendered, "\n") {
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// btwFollowUpLoop reads follow-up questions and answers them in the same side
|
||||
// context, so each answer sees the prior side Q&A (FR-4). It exits on /exit,
|
||||
// /quit, EOF, or an idle Ctrl+C (errLineInterrupted) — the same exit affordances
|
||||
// as the main REPL, but confined to the side thread (FR-5). A blank line is
|
||||
// ignored (stays in the thread). Nothing here touches the main context.
|
||||
func btwFollowUpLoop(setCancel func(context.CancelFunc), out io.Writer, host cli.Host, editor cli.Editor, side *agentcore.AgentContext, settings BtwRunSettings) {
|
||||
for {
|
||||
raw, err := editor.ReadLine(btwPrompt)
|
||||
if errors.Is(err, cli.ErrLineInterrupted) {
|
||||
// Idle Ctrl+C at the side prompt leaves the thread (a Ctrl+C during a
|
||||
// run is handled inside askSide via the SIGINT cancel plumbing).
|
||||
fmt.Fprintln(out, "left side thread")
|
||||
return
|
||||
}
|
||||
q := strings.TrimSpace(raw)
|
||||
if err != nil && q == "" {
|
||||
// EOF or read error with no partial line: leave the thread.
|
||||
fmt.Fprintln(out, "left side thread")
|
||||
return
|
||||
}
|
||||
if q == "/exit" || q == "/quit" {
|
||||
fmt.Fprintln(out, "left side thread")
|
||||
return
|
||||
}
|
||||
if q == "" {
|
||||
continue
|
||||
}
|
||||
AskSide(setCancel, out, host, side, settings, q)
|
||||
}
|
||||
}
|
||||
|
||||
// NewSideContext builds the side thread's private AgentContext. Its Messages are
|
||||
// a fresh slice seeded with a shallow COPY of the main messages (the elements
|
||||
// are immutable value/interface messages, so a copied slice header is enough to
|
||||
// guarantee appends to the side thread never reach the main context's Messages).
|
||||
// The system prompt and tools are shared by value; only Messages diverges.
|
||||
func NewSideContext(main *agentcore.AgentContext) *agentcore.AgentContext {
|
||||
msgs := make(agentcore.MessageList, len(main.Messages))
|
||||
copy(msgs, main.Messages)
|
||||
return &agentcore.AgentContext{
|
||||
SystemPrompt: main.SystemPrompt,
|
||||
Messages: msgs,
|
||||
Tools: main.Tools,
|
||||
}
|
||||
}
|
||||
|
||||
// printBtwHeader prints the side-thread banner.
|
||||
func printBtwHeader(out io.Writer) {
|
||||
fmt.Fprintln(out, ui.Colorize(ui.Enabled(), ui.Dim, btwHeader))
|
||||
}
|
||||
|
||||
// AskSide appends the question to the side context and streams one answer,
|
||||
// mirroring streamRun's rendering but targeting the side context so nothing is
|
||||
// written back to the main conversation or to disk. It reuses the REPL's SIGINT
|
||||
// cancel plumbing via setCancel. The model/provider/thinking come from settings
|
||||
// (session defaults overlaid with btw.json, #282), never from host.Live(), so a
|
||||
// /btw override cannot leak into the main session.
|
||||
func AskSide(setCancel func(context.CancelFunc), out io.Writer, host cli.Host, side *agentcore.AgentContext, settings BtwRunSettings, question string) {
|
||||
content, err := ui.BuildUserContent(question)
|
||||
if err != nil {
|
||||
fmt.Fprintf(out, "pigo: %v\n", err)
|
||||
return
|
||||
}
|
||||
side.Messages = append(side.Messages, agentcore.UserMessage{
|
||||
RoleField: agentcore.RoleUser,
|
||||
Content: content,
|
||||
})
|
||||
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
setCancel(cancel)
|
||||
defer func() {
|
||||
cancel()
|
||||
setCancel(nil)
|
||||
}()
|
||||
|
||||
// Show a transient status while the model works (FR-9). It is printed on its
|
||||
// own line; the streamed answer follows below it.
|
||||
fmt.Fprintln(out, ui.Colorize(ui.Enabled(), ui.Dim, "Answering…"))
|
||||
|
||||
cfg := runtime.RunConfig{
|
||||
LoopConfig: runtime.LoopConfig{
|
||||
Model: settings.Model,
|
||||
Provider: settings.ProviderName,
|
||||
ThinkingLevel: settings.ThinkingLevel,
|
||||
Stream: provider.StreamFnFromProvider(settings.Provider),
|
||||
GetAPIKey: host.Creds().GetAPIKey,
|
||||
ContextWindow: host.Live().ContextWindow,
|
||||
Compaction: compaction.DefaultCompactionSettings,
|
||||
},
|
||||
Batch: agenttool.BatchConfig{
|
||||
ToolExecutorConfig: agenttool.ToolExecutorConfig{
|
||||
Registry: host.Registry(),
|
||||
BeforeToolCall: trust.BeforeToolCall(host.Trust(), host.Cwd(), host.Input(), out, host.ConfirmMu()),
|
||||
},
|
||||
},
|
||||
Reminders: host.Reminders(),
|
||||
}
|
||||
// Wire the per-turn hook seams onto the side run's cfg; nil dispatcher is a
|
||||
// no-op (FR-18).
|
||||
if d := host.Dispatcher(); d != nil {
|
||||
run.InstallSeams(&cfg, d, host.HookDeps())
|
||||
}
|
||||
stream := runtime.StartRun(runCtx, side, cfg)
|
||||
drainSideStream(runCtx, out, host, stream)
|
||||
}
|
||||
|
||||
// chainBtwEvent returns the OnEvent observer for a /btw side run: the plugin
|
||||
// notifier, with the SessionEnd/PreCompact hook notifier chained after it when
|
||||
// hooks are configured, mirroring the REPL's OnEvent composition.
|
||||
func chainBtwEvent(host cli.Host) func(agentcore.AgentEvent) {
|
||||
notifier := host.NotifierHandle()
|
||||
d := host.Dispatcher()
|
||||
if d == nil {
|
||||
return notifier
|
||||
}
|
||||
deps := host.HookDeps()
|
||||
hookEvent := hooks.NewHookNotifier(d, deps.SessionID, deps.ProjectDir).Handle
|
||||
if notifier == nil {
|
||||
return hookEvent
|
||||
}
|
||||
return func(ev agentcore.AgentEvent) {
|
||||
notifier(ev)
|
||||
hookEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// drainSideStream prints the streamed assistant text and tool activity of a side
|
||||
// run, mirroring streamRun/drainGoalStream. It blocks until the run ends. Unlike
|
||||
// the main loop it persists nothing.
|
||||
func drainSideStream(ctx context.Context, out io.Writer, host cli.Host, stream *runtime.LoopEventStream) {
|
||||
var reply strings.Builder
|
||||
flushReply := func() {
|
||||
if reply.Len() == 0 {
|
||||
return
|
||||
}
|
||||
rendered := ui.RenderMarkdown(reply.String())
|
||||
fmt.Fprint(out, rendered)
|
||||
if !strings.HasSuffix(rendered, "\n") {
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
reply.Reset()
|
||||
}
|
||||
_, err := runtime.DrainStream(ctx, stream, runtime.StreamHandler{
|
||||
OnEvent: chainBtwEvent(host),
|
||||
OnText: func(delta string) {
|
||||
reply.WriteString(delta)
|
||||
},
|
||||
OnTurnEnd: func(msg agentcore.AssistantMessage, results []agentcore.ToolResultMessage) {
|
||||
flushReply()
|
||||
for _, c := range msg.ToolCalls() {
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(ui.Enabled(), ui.Green, "→ tool:"), ui.ToolCallLabel(c))
|
||||
}
|
||||
for _, tr := range results {
|
||||
ui.RenderToolResult(out, tr)
|
||||
}
|
||||
},
|
||||
})
|
||||
flushReply()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
// A Ctrl+C during the run cancels just this answer; the follow-up loop
|
||||
// then returns to the btw prompt so the user can ask again or exit with
|
||||
// another Ctrl+C (FR-5).
|
||||
fmt.Fprintln(out, "^C interrupted — answer cancelled")
|
||||
} else {
|
||||
fmt.Fprintf(out, "error: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// This file implements the /btw model/thinking override config (US-005, #282):
|
||||
// an optional per-command config that lets a side thread use a different model
|
||||
// and/or reasoning effort than the main session, without touching the main
|
||||
// session's settings (mirrors pi-btw's pi-btw.json).
|
||||
//
|
||||
// The config lives at $PIGO_HOME/btw.json (or ~/.pigo/btw.json). It is read
|
||||
// fresh on every /btw invocation, so editing it takes effect on the next call
|
||||
// with no restart. A missing file, an empty object, or an absent field all mean
|
||||
// "inherit the session default" silently — only a malformed file or an
|
||||
// unusable model override produces a (non-fatal) warning.
|
||||
package btw
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/cli"
|
||||
"github.com/smallnest/pigo/internal/cli/run"
|
||||
"github.com/smallnest/pigo/internal/cli/ui"
|
||||
"github.com/smallnest/pigo/internal/provider"
|
||||
)
|
||||
|
||||
// btwConfig is the on-disk shape of ~/.pigo/btw.json. Both fields are optional;
|
||||
// an absent field (nil / empty) inherits the session default. Pointers/empty
|
||||
// strings distinguish "not set" from a real value so a partial file still falls
|
||||
// back per-field.
|
||||
type btwConfig struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
ThinkingLevel string `json:"thinkingLevel,omitempty"`
|
||||
}
|
||||
|
||||
// BtwRunSettings is the resolved model/provider/thinking a side run uses. It is
|
||||
// computed once per /btw invocation from the session defaults overlaid with
|
||||
// btw.json, and passed down to AskSide so every turn of that invocation uses
|
||||
// the same settings.
|
||||
type BtwRunSettings struct {
|
||||
Model string
|
||||
ProviderName string
|
||||
Provider provider.Provider
|
||||
ThinkingLevel agentcore.ThinkingLevel
|
||||
}
|
||||
|
||||
// btwConfigPath returns the path to the /btw override config, or "" when the
|
||||
// config directory cannot be resolved (then the config is treated as absent).
|
||||
func btwConfigPath() string {
|
||||
dir := run.ConfigDir()
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, "btw.json")
|
||||
}
|
||||
|
||||
// loadBtwConfig reads and parses btw.json. A missing file returns a zero config
|
||||
// with no error (inherit everything). A malformed file returns an error so the
|
||||
// caller can warn and fall back. An empty object parses to a zero config.
|
||||
func loadBtwConfig(path string) (btwConfig, error) {
|
||||
if path == "" {
|
||||
return btwConfig{}, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return btwConfig{}, nil
|
||||
}
|
||||
return btwConfig{}, err
|
||||
}
|
||||
var cfg btwConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return btwConfig{}, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ResolveBtwSettings computes the model/provider/thinking a side run should use.
|
||||
// It starts from the session defaults (host.Live()) and overlays btw.json:
|
||||
//
|
||||
// - No config / empty object / absent fields → inherit the session values.
|
||||
// - thinkingLevel set → validate and override (invalid value warns, falls back).
|
||||
// - model set → resolve its provider (reusing resolveProvider like /model);
|
||||
// if the model cannot be resolved/authenticated, warn on one line and fall
|
||||
// back to the session model+provider.
|
||||
//
|
||||
// A malformed config file warns once and inherits everything. Nothing here
|
||||
// mutates the session live config, so the override is confined to the side
|
||||
// thread (FR-8).
|
||||
func ResolveBtwSettings(out io.Writer, host cli.Host) BtwRunSettings {
|
||||
live := host.Live()
|
||||
s := BtwRunSettings{
|
||||
Model: live.Model,
|
||||
ProviderName: live.ProviderName,
|
||||
Provider: live.Provider,
|
||||
ThinkingLevel: live.ThinkingLevel,
|
||||
}
|
||||
|
||||
cfg, err := loadBtwConfig(btwConfigPath())
|
||||
if err != nil {
|
||||
fmt.Fprintf(out, "%s\n", ui.Colorize(ui.Enabled(), ui.Dim, "btw: ignoring invalid btw.json: "+err.Error()))
|
||||
return s
|
||||
}
|
||||
|
||||
if lvl := strings.TrimSpace(cfg.ThinkingLevel); lvl != "" {
|
||||
if v, ok := validThinkingLevel(lvl); ok {
|
||||
s.ThinkingLevel = v
|
||||
} else {
|
||||
fmt.Fprintf(out, "%s\n", ui.Colorize(ui.Enabled(), ui.Dim, fmt.Sprintf("btw: ignoring invalid thinkingLevel %q, using %q", lvl, s.ThinkingLevel)))
|
||||
}
|
||||
}
|
||||
|
||||
if model := strings.TrimSpace(cfg.Model); model != "" && model != s.Model {
|
||||
prov, providerName, perr := provider.ResolveProvider(model, live.BaseURL, live.Protocol, "", os.Getenv)
|
||||
if perr != nil {
|
||||
fmt.Fprintf(out, "%s\n", ui.Colorize(ui.Enabled(), ui.Dim, fmt.Sprintf("btw: cannot use model %q (%v), falling back to %q", model, perr, s.Model)))
|
||||
} else {
|
||||
s.Model = model
|
||||
s.ProviderName = providerName
|
||||
s.Provider = prov
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// validThinkingLevel reports whether s is one of the known reasoning-effort
|
||||
// levels and returns the typed value. It mirrors the enum in agentcore so an
|
||||
// invalid btw.json value can be rejected without importing the config layer.
|
||||
func validThinkingLevel(s string) (agentcore.ThinkingLevel, bool) {
|
||||
switch agentcore.ThinkingLevel(s) {
|
||||
case agentcore.ThinkingOff, agentcore.ThinkingMinimal, agentcore.ThinkingLow,
|
||||
agentcore.ThinkingMedium, agentcore.ThinkingHigh, agentcore.ThinkingXHigh, agentcore.ThinkingMax:
|
||||
return agentcore.ThinkingLevel(s), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package btw
|
||||
|
||||
// Tests for the /btw model/thinking override config (#282, US-005): btw.json
|
||||
// overlays the session defaults for the side thread only, is read fresh each
|
||||
// call, and falls back silently on missing/empty/partial config.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/cli"
|
||||
)
|
||||
|
||||
// fakeHost satisfies cli.Host by embedding the interface (so every method is
|
||||
// present) while overriding only Live(), the sole accessor ResolveBtwSettings
|
||||
// reads. The embedded nil interface would panic if any other method were
|
||||
// called, which these tests never do.
|
||||
type fakeHost struct {
|
||||
cli.Host
|
||||
live *cli.LiveConfig
|
||||
}
|
||||
|
||||
func (f fakeHost) Live() *cli.LiveConfig { return f.live }
|
||||
|
||||
// withBtwConfig points PIGO_HOME at a temp dir and writes btw.json with the
|
||||
// given contents (or removes it when contents is ""), returning nothing — the
|
||||
// temp dir is cleaned up by t.TempDir. It restores PIGO_HOME after the test.
|
||||
func withBtwConfig(t *testing.T, contents string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PIGO_HOME", dir)
|
||||
if contents != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "btw.json"), []byte(contents), 0o644); err != nil {
|
||||
t.Fatalf("write btw.json: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBtwConfigAbsentInherits verifies that with no btw.json the side settings
|
||||
// equal the session defaults.
|
||||
func TestBtwConfigAbsentInherits(t *testing.T) {
|
||||
withBtwConfig(t, "") // no file
|
||||
live := &cli.LiveConfig{Model: "sess-model", ProviderName: "sess-prov", ThinkingLevel: agentcore.ThinkingMedium}
|
||||
host := fakeHost{live: live}
|
||||
|
||||
var warn bytes.Buffer
|
||||
s := ResolveBtwSettings(&warn, host)
|
||||
if s.Model != live.Model || s.ProviderName != live.ProviderName {
|
||||
t.Errorf("absent config must inherit model/provider, got %q/%q", s.Model, s.ProviderName)
|
||||
}
|
||||
if s.ThinkingLevel != agentcore.ThinkingMedium {
|
||||
t.Errorf("absent config must inherit thinkingLevel, got %q", s.ThinkingLevel)
|
||||
}
|
||||
if warn.Len() != 0 {
|
||||
t.Errorf("absent config must not warn, got %q", warn.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBtwConfigEmptyObjectInherits verifies that an empty JSON object inherits
|
||||
// everything without warning.
|
||||
func TestBtwConfigEmptyObjectInherits(t *testing.T) {
|
||||
withBtwConfig(t, "{}")
|
||||
live := &cli.LiveConfig{Model: "sess-model", ThinkingLevel: agentcore.ThinkingLow}
|
||||
host := fakeHost{live: live}
|
||||
|
||||
var warn bytes.Buffer
|
||||
s := ResolveBtwSettings(&warn, host)
|
||||
if s.Model != live.Model || s.ThinkingLevel != agentcore.ThinkingLow {
|
||||
t.Errorf("empty object must inherit, got model=%q thinking=%q", s.Model, s.ThinkingLevel)
|
||||
}
|
||||
if warn.Len() != 0 {
|
||||
t.Errorf("empty object must not warn, got %q", warn.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBtwConfigThinkingOverride verifies a valid thinkingLevel is applied while
|
||||
// the model still inherits (partial config falls back per-field).
|
||||
func TestBtwConfigThinkingOverride(t *testing.T) {
|
||||
withBtwConfig(t, `{"thinkingLevel":"high"}`)
|
||||
live := &cli.LiveConfig{Model: "sess-model", ThinkingLevel: agentcore.ThinkingLow}
|
||||
host := fakeHost{live: live}
|
||||
|
||||
var warn bytes.Buffer
|
||||
s := ResolveBtwSettings(&warn, host)
|
||||
if s.ThinkingLevel != agentcore.ThinkingHigh {
|
||||
t.Errorf("expected thinkingLevel override 'high', got %q", s.ThinkingLevel)
|
||||
}
|
||||
if s.Model != live.Model {
|
||||
t.Errorf("model must still inherit when only thinkingLevel is set, got %q", s.Model)
|
||||
}
|
||||
if warn.Len() != 0 {
|
||||
t.Errorf("valid override must not warn, got %q", warn.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBtwConfigInvalidThinkingWarnsAndFallsBack verifies an invalid thinkingLevel
|
||||
// warns on one line and keeps the session value.
|
||||
func TestBtwConfigInvalidThinkingWarnsAndFallsBack(t *testing.T) {
|
||||
withBtwConfig(t, `{"thinkingLevel":"bogus"}`)
|
||||
live := &cli.LiveConfig{Model: "sess-model", ThinkingLevel: agentcore.ThinkingMedium}
|
||||
host := fakeHost{live: live}
|
||||
|
||||
var warn bytes.Buffer
|
||||
s := ResolveBtwSettings(&warn, host)
|
||||
if s.ThinkingLevel != agentcore.ThinkingMedium {
|
||||
t.Errorf("invalid thinkingLevel must fall back to session value, got %q", s.ThinkingLevel)
|
||||
}
|
||||
if !strings.Contains(warn.String(), "thinkingLevel") {
|
||||
t.Errorf("expected a warning about the invalid thinkingLevel, got %q", warn.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBtwConfigMalformedWarnsAndInherits verifies a malformed JSON file warns
|
||||
// once and inherits every field (never crashes /btw).
|
||||
func TestBtwConfigMalformedWarnsAndInherits(t *testing.T) {
|
||||
withBtwConfig(t, `{not json`)
|
||||
live := &cli.LiveConfig{Model: "sess-model", ThinkingLevel: agentcore.ThinkingLow}
|
||||
host := fakeHost{live: live}
|
||||
|
||||
var warn bytes.Buffer
|
||||
s := ResolveBtwSettings(&warn, host)
|
||||
if s.Model != live.Model || s.ThinkingLevel != agentcore.ThinkingLow {
|
||||
t.Errorf("malformed config must inherit, got model=%q thinking=%q", s.Model, s.ThinkingLevel)
|
||||
}
|
||||
if !strings.Contains(warn.String(), "invalid btw.json") {
|
||||
t.Errorf("expected a malformed-config warning, got %q", warn.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBtwConfigDoesNotMutateSession verifies ResolveBtwSettings never mutates
|
||||
// the session live config, so a /btw override cannot leak into the main session
|
||||
// (FR-8).
|
||||
func TestBtwConfigDoesNotMutateSession(t *testing.T) {
|
||||
withBtwConfig(t, `{"thinkingLevel":"xhigh"}`)
|
||||
live := &cli.LiveConfig{Model: "sess-model", ThinkingLevel: agentcore.ThinkingLow}
|
||||
host := fakeHost{live: live}
|
||||
|
||||
_ = ResolveBtwSettings(&bytes.Buffer{}, host)
|
||||
if live.ThinkingLevel != agentcore.ThinkingLow {
|
||||
t.Errorf("session thinkingLevel must be unchanged by /btw config, got %q", live.ThinkingLevel)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user