Compare commits
2 Commits
v0.0.5
..
7b8bad5e62
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b8bad5e62 | |||
| f235b8ce90 |
@@ -20,13 +20,13 @@ model/provider switching, and local tools.
|
|||||||
mkdir -p ~/.agentu
|
mkdir -p ~/.agentu
|
||||||
cp agentu.example.yaml ~/.agentu/config.yaml
|
cp agentu.example.yaml ~/.agentu/config.yaml
|
||||||
export AGENTU_API_KEY='your-api-key'
|
export AGENTU_API_KEY='your-api-key'
|
||||||
go run ./cmd/agentu
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `--yolo` only when you want agentu to write files or run shell commands:
|
Use `--yolo` only when you want agentu to write files or run shell commands:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/agentu --yolo
|
go run . --yolo
|
||||||
```
|
```
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
@@ -82,7 +82,7 @@ top-level OpenAI-compatible request field named `thinking`.
|
|||||||
## CLI Flags
|
## CLI Flags
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/agentu [flags]
|
go run . [flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
- `--config <path>` loads a YAML config file. Default: `~/.agentu/config.yaml`.
|
- `--config <path>` loads a YAML config file. Default: `~/.agentu/config.yaml`.
|
||||||
@@ -150,7 +150,7 @@ Run the verification suite:
|
|||||||
```sh
|
```sh
|
||||||
go test ./...
|
go test ./...
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go build ./cmd/agentu
|
go build .
|
||||||
```
|
```
|
||||||
|
|
||||||
Local config and build outputs are intentionally ignored by git.
|
Local config and build outputs are intentionally ignored by git.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"agentu/internal/llm"
|
"agentu/pkg/llm"
|
||||||
"agentu/internal/tools"
|
"agentu/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"agentu/internal/llm"
|
"agentu/pkg/llm"
|
||||||
"agentu/internal/tools"
|
"agentu/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"agentu/internal/agent"
|
"agentu/internal/agent"
|
||||||
"agentu/internal/config"
|
"agentu/pkg/config"
|
||||||
"agentu/internal/llm"
|
"agentu/pkg/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"agentu/internal/agent"
|
"agentu/internal/agent"
|
||||||
"agentu/internal/config"
|
"agentu/pkg/config"
|
||||||
"agentu/internal/llm"
|
"agentu/pkg/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type recordingProvider struct {
|
type recordingProvider struct {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"agentu/internal/llm"
|
"agentu/pkg/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Session represents a persisted conversation.
|
// Session represents a persisted conversation.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"agentu/internal/llm"
|
"agentu/pkg/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func tempStore(t *testing.T) *Store {
|
func tempStore(t *testing.T) *Store {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"agentu/internal/llm"
|
"agentu/pkg/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultCodeSymbolFileLimit = 50
|
const defaultCodeSymbolFileLimit = 50
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"agentu/internal/llm"
|
"agentu/pkg/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileReadTool struct {
|
type FileReadTool struct {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"agentu/internal/llm"
|
"agentu/pkg/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ShellRunTool struct {
|
type ShellRunTool struct {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"agentu/internal/llm"
|
"agentu/pkg/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const MaxToolOutputBytes = 64 * 1024
|
const MaxToolOutputBytes = 64 * 1024
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"agentu/internal/llm"
|
"agentu/pkg/llm"
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
|
|||||||
+279
-39
@@ -6,10 +6,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"agentu/internal/agent"
|
"agentu/internal/agent"
|
||||||
"agentu/internal/llm"
|
"agentu/pkg/llm"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
"github.com/charmbracelet/bubbles/textarea"
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
@@ -21,6 +23,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
minInputLines = 1
|
minInputLines = 1
|
||||||
maxInputLines = 6
|
maxInputLines = 6
|
||||||
|
maxCommandPaletteItems = 8
|
||||||
|
maxInputHistory = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -89,6 +93,10 @@ type model struct {
|
|||||||
messages []message
|
messages []message
|
||||||
status string
|
status string
|
||||||
contextUsage string
|
contextUsage string
|
||||||
|
inputHistory []string
|
||||||
|
historyIndex int
|
||||||
|
draftInput string
|
||||||
|
|
||||||
running bool
|
running bool
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
events <-chan tea.Msg
|
events <-chan tea.Msg
|
||||||
@@ -120,6 +128,11 @@ type slashCommand struct {
|
|||||||
Description string
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type commandMatch struct {
|
||||||
|
command slashCommand
|
||||||
|
score int
|
||||||
|
}
|
||||||
|
|
||||||
var slashCommands = []slashCommand{
|
var slashCommands = []slashCommand{
|
||||||
{Name: "/clear", Description: "reset context"},
|
{Name: "/clear", Description: "reset context"},
|
||||||
{Name: "/compact", Description: "compress context"},
|
{Name: "/compact", Description: "compress context"},
|
||||||
@@ -174,6 +187,7 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
|
|||||||
input: input,
|
input: input,
|
||||||
spinner: spin,
|
spinner: spin,
|
||||||
status: "ready",
|
status: "ready",
|
||||||
|
historyIndex: -1,
|
||||||
}
|
}
|
||||||
m.refreshContextUsage()
|
m.refreshContextUsage()
|
||||||
return m
|
return m
|
||||||
@@ -249,6 +263,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m.submit()
|
return m.submit()
|
||||||
|
case "ctrl+p":
|
||||||
|
if m.running {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.previousInputHistory()
|
||||||
|
m.afterInputChanged()
|
||||||
|
return m, nil
|
||||||
|
case "ctrl+n":
|
||||||
|
if m.running {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.nextInputHistory()
|
||||||
|
m.afterInputChanged()
|
||||||
|
return m, nil
|
||||||
case "alt+enter", "ctrl+j":
|
case "alt+enter", "ctrl+j":
|
||||||
return m.insertInputNewline()
|
return m.insertInputNewline()
|
||||||
}
|
}
|
||||||
@@ -297,6 +325,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if !m.running {
|
if !m.running {
|
||||||
nextInput, cmd := m.input.Update(msg)
|
nextInput, cmd := m.input.Update(msg)
|
||||||
m.input = nextInput
|
m.input = nextInput
|
||||||
|
m.historyIndex = -1
|
||||||
|
m.draftInput = ""
|
||||||
m.afterInputChanged()
|
m.afterInputChanged()
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
@@ -343,6 +373,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
|
|||||||
if input == "" {
|
if input == "" {
|
||||||
return *m, nil
|
return *m, nil
|
||||||
}
|
}
|
||||||
|
m.rememberInput(input)
|
||||||
|
|
||||||
switch input {
|
switch input {
|
||||||
case "/exit", "/quit":
|
case "/exit", "/quit":
|
||||||
@@ -402,6 +433,8 @@ func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
|
|||||||
enter := tea.KeyMsg{Type: tea.KeyEnter}
|
enter := tea.KeyMsg{Type: tea.KeyEnter}
|
||||||
nextInput, cmd := m.input.Update(enter)
|
nextInput, cmd := m.input.Update(enter)
|
||||||
m.input = nextInput
|
m.input = nextInput
|
||||||
|
m.historyIndex = -1
|
||||||
|
m.draftInput = ""
|
||||||
m.afterInputChanged()
|
m.afterInputChanged()
|
||||||
return *m, cmd
|
return *m, cmd
|
||||||
}
|
}
|
||||||
@@ -472,6 +505,10 @@ func (m model) renderMessages() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m model) messageView(r role, content string, width int) string {
|
func (m model) messageView(r role, content string, width int) string {
|
||||||
|
if r == roleTool {
|
||||||
|
return m.toolMessageView(content, width)
|
||||||
|
}
|
||||||
|
|
||||||
label := roleLabel(r)
|
label := roleLabel(r)
|
||||||
style := m.styles.AssistantMsg
|
style := m.styles.AssistantMsg
|
||||||
labelStyle := m.styles.SystemLabel
|
labelStyle := m.styles.SystemLabel
|
||||||
@@ -534,39 +571,112 @@ func roleLabel(r role) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type toolLogParts struct {
|
||||||
|
name string
|
||||||
|
args string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseToolLog(content string) toolLogParts {
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if content == "" {
|
||||||
|
return toolLogParts{}
|
||||||
|
}
|
||||||
|
idx := strings.IndexFunc(content, unicode.IsSpace)
|
||||||
|
if idx < 0 {
|
||||||
|
return toolLogParts{name: content}
|
||||||
|
}
|
||||||
|
return toolLogParts{
|
||||||
|
name: strings.TrimSpace(content[:idx]),
|
||||||
|
args: strings.TrimSpace(content[idx+1:]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) toolMessageView(content string, width int) string {
|
||||||
|
parts := parseToolLog(content)
|
||||||
|
header := m.styles.ToolName.Render(strings.TrimSpace(iconTool + " " + parts.name))
|
||||||
|
lines := []string{header}
|
||||||
|
if parts.args != "" {
|
||||||
|
args := fitLine(parts.args, max(1, width-4))
|
||||||
|
lines = append(lines, m.styles.ToolValue.Render(args))
|
||||||
|
}
|
||||||
|
bodyContent := strings.Join(lines, "\n")
|
||||||
|
bodyWidth := messageBodyWidth(bodyContent, width, m.styles.ToolMessage)
|
||||||
|
return m.styles.ToolMessage.Width(bodyWidth).Render(bodyContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
type metaPill struct {
|
||||||
|
text string
|
||||||
|
accent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) renderMetaPills(parts []metaPill) string {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := make([]string, len(parts))
|
||||||
|
for i, part := range parts {
|
||||||
|
plain[i] = part.text
|
||||||
|
}
|
||||||
|
|
||||||
|
width := max(1, m.width-2)
|
||||||
|
visible := len(plain)
|
||||||
|
for visible > 1 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
|
||||||
|
visible--
|
||||||
|
}
|
||||||
|
if lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
|
||||||
|
return m.styles.PillAccent.Render(fitLine(plain[0], width))
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered := make([]string, 0, visible)
|
||||||
|
for i := 0; i < visible; i++ {
|
||||||
|
style := m.styles.Pill
|
||||||
|
if parts[i].accent {
|
||||||
|
style = m.styles.PillAccent
|
||||||
|
}
|
||||||
|
rendered = append(rendered, style.Render(parts[i].text))
|
||||||
|
}
|
||||||
|
return strings.Join(rendered, " ")
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) modelMetaView() string {
|
func (m model) modelMetaView() string {
|
||||||
mode := "read-only tools"
|
mode := "read-only tools"
|
||||||
if m.yolo {
|
if m.yolo {
|
||||||
mode = "yolo tools"
|
mode = "yolo tools"
|
||||||
}
|
}
|
||||||
|
|
||||||
modelName := m.modelName
|
modelName := m.modelName
|
||||||
parts := []string{}
|
|
||||||
if m.models != nil {
|
if m.models != nil {
|
||||||
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
|
if currentModel := m.models.CurrentModel(); currentModel != "" {
|
||||||
parts = append(parts, "session "+sessionName)
|
modelName = currentModel
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := []metaPill{{text: iconModel + " " + modelName, accent: true}}
|
||||||
|
if m.models != nil {
|
||||||
if provider := m.models.CurrentProvider(); provider != "" {
|
if provider := m.models.CurrentProvider(); provider != "" {
|
||||||
parts = append(parts, "provider "+provider)
|
parts = append(parts, metaPill{text: "provider " + provider})
|
||||||
}
|
}
|
||||||
modelName = m.models.CurrentModel()
|
|
||||||
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
|
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
|
||||||
parts = append(parts, "thinking "+currentThinking)
|
parts = append(parts, metaPill{text: "thinking " + currentThinking})
|
||||||
|
}
|
||||||
|
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
|
||||||
|
parts = append(parts, metaPill{text: iconSession + " " + sessionName})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.contextUsage != "" {
|
if m.contextUsage != "" {
|
||||||
parts = append(parts, m.contextUsage)
|
parts = append(parts, metaPill{text: iconContext + " " + m.contextUsage})
|
||||||
}
|
}
|
||||||
parts = append([]string{modelName, mode}, parts...)
|
parts = append(parts, metaPill{text: mode}, metaPill{text: "theme " + string(m.theme.Mode)})
|
||||||
parts = append(parts, "theme "+string(m.theme.Mode))
|
|
||||||
line := fitLine(strings.Join(parts, " · "), max(1, m.width-2))
|
return m.styles.ModelMeta.Width(max(1, m.width)).Render(m.renderMetaPills(parts))
|
||||||
return m.styles.ModelMeta.Width(max(1, m.width)).Render(line)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) inputView() string {
|
func (m model) inputView() string {
|
||||||
suggestions := m.slashSuggestionsView()
|
palette := m.commandPaletteView()
|
||||||
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
|
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
|
||||||
if suggestions != "" {
|
if palette != "" {
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, suggestions, box)
|
return lipgloss.JoinVertical(lipgloss.Left, palette, box)
|
||||||
}
|
}
|
||||||
return box
|
return box
|
||||||
}
|
}
|
||||||
@@ -579,12 +689,12 @@ func (m model) activityView() string {
|
|||||||
if status == "" || status == "ready" {
|
if status == "" || status == "ready" {
|
||||||
status = "working..."
|
status = "working..."
|
||||||
}
|
}
|
||||||
line := fitLine(fmt.Sprintf("%s Working · %s · esc cancels", m.spinner.View(), status), max(1, m.width-2))
|
line := m.styles.StatusInfo.Render(iconWorking) + " Working " + m.spinner.View() + " " + status + " · esc cancels"
|
||||||
return m.styles.Activity.Width(max(1, m.width)).Render(line)
|
return m.styles.Activity.Render(fitLine(line, max(1, m.width-2)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) footerView() string {
|
func (m model) footerView() string {
|
||||||
hint := "enter send · ctrl+j newline · alt+enter newline · / commands"
|
hint := "enter send · ctrl+j newline · alt+enter newline · ctrl+p/ctrl+n history · / commands"
|
||||||
if m.running {
|
if m.running {
|
||||||
hint = "esc cancel · ctrl+c quit"
|
hint = "esc cancel · ctrl+c quit"
|
||||||
}
|
}
|
||||||
@@ -639,6 +749,51 @@ func (m *model) afterInputChanged() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *model) rememberInput(input string) {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if input == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(m.inputHistory) > 0 && m.inputHistory[len(m.inputHistory)-1] == input {
|
||||||
|
m.historyIndex = -1
|
||||||
|
m.draftInput = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.inputHistory = append(m.inputHistory, input)
|
||||||
|
if len(m.inputHistory) > maxInputHistory {
|
||||||
|
m.inputHistory = m.inputHistory[len(m.inputHistory)-maxInputHistory:]
|
||||||
|
}
|
||||||
|
m.historyIndex = -1
|
||||||
|
m.draftInput = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) previousInputHistory() {
|
||||||
|
if len(m.inputHistory) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.historyIndex == -1 {
|
||||||
|
m.draftInput = m.input.Value()
|
||||||
|
m.historyIndex = len(m.inputHistory) - 1
|
||||||
|
} else if m.historyIndex > 0 {
|
||||||
|
m.historyIndex--
|
||||||
|
}
|
||||||
|
m.input.SetValue(m.inputHistory[m.historyIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) nextInputHistory() {
|
||||||
|
if len(m.inputHistory) == 0 || m.historyIndex == -1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.historyIndex < len(m.inputHistory)-1 {
|
||||||
|
m.historyIndex++
|
||||||
|
m.input.SetValue(m.inputHistory[m.historyIndex])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.input.SetValue(m.draftInput)
|
||||||
|
m.historyIndex = -1
|
||||||
|
m.draftInput = ""
|
||||||
|
}
|
||||||
|
|
||||||
func (m *model) syncInputHeight() {
|
func (m *model) syncInputHeight() {
|
||||||
m.input.SetHeight(m.desiredInputHeight())
|
m.input.SetHeight(m.desiredInputHeight())
|
||||||
}
|
}
|
||||||
@@ -661,47 +816,132 @@ func (m model) desiredInputHeight() int {
|
|||||||
|
|
||||||
func (m model) inputBlockHeight() int {
|
func (m model) inputBlockHeight() int {
|
||||||
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
|
height := m.input.Height() + m.styles.InputBox.GetVerticalFrameSize()
|
||||||
if m.showSlashSuggestions() {
|
if m.showCommandPalette() {
|
||||||
height += m.slashSuggestionCount()
|
height += m.commandPaletteHeight()
|
||||||
}
|
}
|
||||||
return height
|
return height
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) slashSuggestionCount() int {
|
func (m model) commandPaletteHeight() int {
|
||||||
prefix := strings.TrimSpace(m.input.Value())
|
matches := commandMatches(commandPaletteQuery(m.input.Value()))
|
||||||
count := 0
|
count := len(matches)
|
||||||
for _, command := range slashCommands {
|
if count > maxCommandPaletteItems {
|
||||||
if strings.HasPrefix(command.Name, prefix) {
|
count = maxCommandPaletteItems
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
count = 1 // "No matching commands"
|
count = 1
|
||||||
}
|
}
|
||||||
return count
|
return 1 + count + m.styles.Palette.GetVerticalFrameSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) showSlashSuggestions() bool {
|
func (m model) showCommandPalette() bool {
|
||||||
value := strings.TrimSpace(m.input.Value())
|
value := strings.TrimSpace(m.input.Value())
|
||||||
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
|
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) slashSuggestionsView() string {
|
func (m model) commandPaletteView() string {
|
||||||
if !m.showSlashSuggestions() {
|
if !m.showCommandPalette() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
prefix := strings.TrimSpace(m.input.Value())
|
matches := commandMatches(commandPaletteQuery(m.input.Value()))
|
||||||
var parts []string
|
lines := []string{m.styles.Header.Render(iconCommand + " Commands")}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
lines = append(lines, m.styles.Muted.Render("No matching commands"))
|
||||||
|
} else {
|
||||||
|
for i, match := range matches {
|
||||||
|
if i >= maxCommandPaletteItems {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("%s %s", m.styles.PaletteMatch.Render(match.command.Name), m.styles.Muted.Render(match.command.Description)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandPaletteQuery(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if !strings.HasPrefix(value, "/") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(value, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandMatches(query string) []commandMatch {
|
||||||
|
matches := make([]commandMatch, 0, len(slashCommands))
|
||||||
for _, command := range slashCommands {
|
for _, command := range slashCommands {
|
||||||
if !strings.HasPrefix(command.Name, prefix) {
|
score, ok := scoreCommand(command, query)
|
||||||
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parts = append(parts, fmt.Sprintf("%s %s", command.Name, command.Description))
|
matches = append(matches, commandMatch{command: command, score: score})
|
||||||
}
|
}
|
||||||
if len(parts) == 0 {
|
sort.Slice(matches, func(i, j int) bool {
|
||||||
parts = append(parts, "No matching commands")
|
if matches[i].score != matches[j].score {
|
||||||
|
return matches[i].score < matches[j].score
|
||||||
}
|
}
|
||||||
return m.styles.CommandHint.Width(max(20, m.width-2)).Render(strings.Join(parts, "\n"))
|
return matches[i].command.Name < matches[j].command.Name
|
||||||
|
})
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreCommand(command slashCommand, query string) (int, bool) {
|
||||||
|
query = strings.ToLower(strings.TrimSpace(query))
|
||||||
|
name := strings.TrimPrefix(strings.ToLower(command.Name), "/")
|
||||||
|
if query == "" || query == name {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, query) {
|
||||||
|
return 100 + len(name) - len(query), true
|
||||||
|
}
|
||||||
|
if score, ok := subsequenceScore(name, query, 200); ok {
|
||||||
|
return score, true
|
||||||
|
}
|
||||||
|
candidate := name + " " + strings.ToLower(command.Description)
|
||||||
|
if score, ok := subsequenceScore(candidate, query, 300); ok {
|
||||||
|
return score, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func subsequenceScore(candidate string, query string, base int) (int, bool) {
|
||||||
|
candidate = strings.ToLower(candidate)
|
||||||
|
query = strings.ToLower(query)
|
||||||
|
if query == "" {
|
||||||
|
return base, true
|
||||||
|
}
|
||||||
|
start := 0
|
||||||
|
last := -1
|
||||||
|
gaps := 0
|
||||||
|
for _, r := range query {
|
||||||
|
found := -1
|
||||||
|
for i, c := range candidate[start:] {
|
||||||
|
if c == r {
|
||||||
|
found = start + i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if last >= 0 {
|
||||||
|
gaps += found - last - 1
|
||||||
|
}
|
||||||
|
last = found
|
||||||
|
start = found + len(string(r))
|
||||||
|
}
|
||||||
|
return base + gaps, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) slashSuggestionCount() int {
|
||||||
|
return m.commandPaletteHeight()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) showSlashSuggestions() bool {
|
||||||
|
return m.showCommandPalette()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) slashSuggestionsView() string {
|
||||||
|
return m.commandPaletteView()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) modelInfo() string {
|
func (m model) modelInfo() string {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"agentu/internal/agent"
|
"agentu/internal/agent"
|
||||||
"agentu/internal/llm"
|
"agentu/pkg/llm"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -21,7 +21,7 @@ func TestModelRendersChatShell(t *testing.T) {
|
|||||||
m = next.(model)
|
m = next.(model)
|
||||||
rendered := m.View()
|
rendered := m.View()
|
||||||
|
|
||||||
for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} {
|
for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter", "ctrl+p/ctrl+n history"} {
|
||||||
if !strings.Contains(rendered, want) {
|
if !strings.Contains(rendered, want) {
|
||||||
t.Fatalf("rendered view missing %q:\n%s", want, rendered)
|
t.Fatalf("rendered view missing %q:\n%s", want, rendered)
|
||||||
}
|
}
|
||||||
@@ -123,11 +123,14 @@ func TestRunningStateRendersActivityIndicator(t *testing.T) {
|
|||||||
m.resize()
|
m.resize()
|
||||||
|
|
||||||
rendered := m.View()
|
rendered := m.View()
|
||||||
for _, want := range []string{"Working", "thinking...", "esc cancels"} {
|
for _, want := range []string{iconWorking, "Working", "thinking...", "esc cancels"} {
|
||||||
if !strings.Contains(rendered, want) {
|
if !strings.Contains(rendered, want) {
|
||||||
t.Fatalf("running view missing %q:\n%s", want, rendered)
|
t.Fatalf("running view missing %q:\n%s", want, rendered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if backgroundPattern.MatchString(m.activityView()) {
|
||||||
|
t.Fatalf("activity view should not paint a background:\n%q", m.activityView())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUserMessageRendersFullWidthBubble(t *testing.T) {
|
func TestUserMessageRendersFullWidthBubble(t *testing.T) {
|
||||||
@@ -276,17 +279,71 @@ func TestInputStartsAtOneLineAndGrows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSlashCommandSuggestions(t *testing.T) {
|
func TestCommandPaletteFuzzyMatches(t *testing.T) {
|
||||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
|
m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true})
|
||||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||||
m = next.(model)
|
m = next.(model)
|
||||||
m.input.SetValue("/")
|
m.input.SetValue("/mdl")
|
||||||
m.afterInputChanged()
|
m.afterInputChanged()
|
||||||
|
|
||||||
rendered := m.View()
|
plain := stripANSI(m.View())
|
||||||
for _, want := range []string{"/clear reset context", "/compact compress context", "/exit quit", "/model model/provider/thinking"} {
|
for _, want := range []string{iconCommand + " Commands", "/model model/provider/thinking"} {
|
||||||
if !strings.Contains(rendered, want) {
|
if !strings.Contains(plain, want) {
|
||||||
t.Fatalf("rendered suggestions missing %q:\n%s", want, rendered)
|
t.Fatalf("rendered palette missing %q:\n%s", want, plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := commandMatches("m")
|
||||||
|
modelIndex := -1
|
||||||
|
renameIndex := -1
|
||||||
|
for i, match := range matches {
|
||||||
|
switch match.command.Name {
|
||||||
|
case "/model":
|
||||||
|
modelIndex = i
|
||||||
|
case "/rename":
|
||||||
|
renameIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if modelIndex < 0 || renameIndex < 0 || modelIndex > renameIndex {
|
||||||
|
t.Fatalf("/model should sort before /rename for /m query: %#v", matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputHistoryNavigation(t *testing.T) {
|
||||||
|
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||||
|
m.rememberInput("first")
|
||||||
|
m.rememberInput("second")
|
||||||
|
|
||||||
|
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
|
||||||
|
m = next.(model)
|
||||||
|
if got := m.input.Value(); got != "second" {
|
||||||
|
t.Fatalf("after ctrl+p input = %q", got)
|
||||||
|
}
|
||||||
|
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
|
||||||
|
m = next.(model)
|
||||||
|
if got := m.input.Value(); got != "first" {
|
||||||
|
t.Fatalf("after second ctrl+p input = %q", got)
|
||||||
|
}
|
||||||
|
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
|
||||||
|
m = next.(model)
|
||||||
|
if got := m.input.Value(); got != "second" {
|
||||||
|
t.Fatalf("after ctrl+n input = %q", got)
|
||||||
|
}
|
||||||
|
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
|
||||||
|
m = next.(model)
|
||||||
|
if got := m.input.Value(); got != "" {
|
||||||
|
t.Fatalf("after second ctrl+n input = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolMessageRendersStructuredCard(t *testing.T) {
|
||||||
|
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||||
|
m.messages = append(m.messages, message{role: roleTool, content: `file_read {"path":"README.md"}`})
|
||||||
|
|
||||||
|
plain := stripANSI(m.renderMessages())
|
||||||
|
for _, want := range []string{iconTool + " file_read", "README.md"} {
|
||||||
|
if !strings.Contains(plain, want) {
|
||||||
|
t.Fatalf("tool card missing %q:\n%s", want, plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-5
@@ -15,6 +15,17 @@ const (
|
|||||||
ThemeDark ThemeMode = "dark"
|
ThemeDark ThemeMode = "dark"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
iconReady = "✓"
|
||||||
|
iconWorking = "◆"
|
||||||
|
iconTool = "▣"
|
||||||
|
iconError = "✕"
|
||||||
|
iconCommand = "⌘"
|
||||||
|
iconSession = "◌"
|
||||||
|
iconContext = "◔"
|
||||||
|
iconModel = "◉"
|
||||||
|
)
|
||||||
|
|
||||||
func ParseThemeMode(value string) (ThemeMode, error) {
|
func ParseThemeMode(value string) (ThemeMode, error) {
|
||||||
switch ThemeMode(strings.ToLower(strings.TrimSpace(value))) {
|
switch ThemeMode(strings.ToLower(strings.TrimSpace(value))) {
|
||||||
case "", ThemeLight:
|
case "", ThemeLight:
|
||||||
@@ -48,11 +59,23 @@ type styles struct {
|
|||||||
App lipgloss.Style
|
App lipgloss.Style
|
||||||
Muted lipgloss.Style
|
Muted lipgloss.Style
|
||||||
Activity lipgloss.Style
|
Activity lipgloss.Style
|
||||||
|
Header lipgloss.Style
|
||||||
ModelMeta lipgloss.Style
|
ModelMeta lipgloss.Style
|
||||||
Footer lipgloss.Style
|
Footer lipgloss.Style
|
||||||
Viewport lipgloss.Style
|
Viewport lipgloss.Style
|
||||||
InputBox lipgloss.Style
|
InputBox lipgloss.Style
|
||||||
CommandHint lipgloss.Style
|
CommandHint lipgloss.Style
|
||||||
|
StatusReady lipgloss.Style
|
||||||
|
StatusWarn lipgloss.Style
|
||||||
|
StatusError lipgloss.Style
|
||||||
|
StatusInfo lipgloss.Style
|
||||||
|
Pill lipgloss.Style
|
||||||
|
PillAccent lipgloss.Style
|
||||||
|
Palette lipgloss.Style
|
||||||
|
PaletteMatch lipgloss.Style
|
||||||
|
ToolName lipgloss.Style
|
||||||
|
ToolKey lipgloss.Style
|
||||||
|
ToolValue lipgloss.Style
|
||||||
UserLabel lipgloss.Style
|
UserLabel lipgloss.Style
|
||||||
ToolLabel lipgloss.Style
|
ToolLabel lipgloss.Style
|
||||||
ErrorLabel lipgloss.Style
|
ErrorLabel lipgloss.Style
|
||||||
@@ -116,14 +139,15 @@ func (t theme) styles() styles {
|
|||||||
Foreground(lipgloss.Color(t.Muted)),
|
Foreground(lipgloss.Color(t.Muted)),
|
||||||
|
|
||||||
Activity: lipgloss.NewStyle().
|
Activity: lipgloss.NewStyle().
|
||||||
Bold(true).
|
Foreground(lipgloss.Color(t.Muted)).
|
||||||
Foreground(lipgloss.Color(t.Background)).
|
|
||||||
Background(lipgloss.Color(t.Title)).
|
|
||||||
Padding(0, 1),
|
Padding(0, 1),
|
||||||
|
|
||||||
|
Header: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(t.Title)),
|
||||||
|
|
||||||
ModelMeta: lipgloss.NewStyle().
|
ModelMeta: lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(t.Text)).
|
Foreground(lipgloss.Color(t.Muted)).
|
||||||
Background(lipgloss.Color(t.SurfaceAlt)).
|
|
||||||
Padding(0, 1),
|
Padding(0, 1),
|
||||||
|
|
||||||
Footer: lipgloss.NewStyle().
|
Footer: lipgloss.NewStyle().
|
||||||
@@ -141,9 +165,51 @@ func (t theme) styles() styles {
|
|||||||
|
|
||||||
CommandHint: lipgloss.NewStyle().
|
CommandHint: lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(t.Muted)).
|
Foreground(lipgloss.Color(t.Muted)).
|
||||||
|
Padding(0, 1),
|
||||||
|
|
||||||
|
StatusReady: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Assistant)),
|
||||||
|
|
||||||
|
StatusWarn: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Tool)),
|
||||||
|
|
||||||
|
StatusError: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Error)),
|
||||||
|
|
||||||
|
StatusInfo: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.User)),
|
||||||
|
|
||||||
|
Pill: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Text)).
|
||||||
Background(lipgloss.Color(t.SurfaceAlt)).
|
Background(lipgloss.Color(t.SurfaceAlt)).
|
||||||
Padding(0, 1),
|
Padding(0, 1),
|
||||||
|
|
||||||
|
PillAccent: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(t.Title)).
|
||||||
|
Background(lipgloss.Color(t.SurfaceAlt)).
|
||||||
|
Padding(0, 1),
|
||||||
|
|
||||||
|
Palette: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Text)).
|
||||||
|
Border(lipgloss.ThickBorder()).
|
||||||
|
BorderForeground(lipgloss.Color(t.Border)).
|
||||||
|
Padding(0, 2),
|
||||||
|
|
||||||
|
PaletteMatch: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(t.User)),
|
||||||
|
|
||||||
|
ToolName: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color(t.Tool)),
|
||||||
|
|
||||||
|
ToolKey: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Muted)),
|
||||||
|
|
||||||
|
ToolValue: lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color(t.Text)),
|
||||||
|
|
||||||
UserLabel: labelStyle(t.User),
|
UserLabel: labelStyle(t.User),
|
||||||
ToolLabel: labelStyle(t.Tool),
|
ToolLabel: labelStyle(t.Tool),
|
||||||
ErrorLabel: labelStyle(t.Error),
|
ErrorLabel: labelStyle(t.Error),
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"agentu/internal/agent"
|
"agentu/internal/agent"
|
||||||
"agentu/internal/config"
|
|
||||||
"agentu/internal/llm"
|
|
||||||
"agentu/internal/session"
|
"agentu/internal/session"
|
||||||
"agentu/internal/tools"
|
"agentu/internal/tools"
|
||||||
"agentu/internal/tui"
|
"agentu/internal/tui"
|
||||||
|
"agentu/pkg/config"
|
||||||
|
"agentu/pkg/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultConfigContent = `# agentu configuration
|
const defaultConfigContent = `# agentu configuration
|
||||||
@@ -112,15 +112,8 @@ func run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resume session
|
if err := initializeSession(modelManager, *resumeID); err != nil {
|
||||||
if *resumeID != "" {
|
return err
|
||||||
if err := modelManager.Resume(*resumeID); err != nil {
|
|
||||||
return fmt.Errorf("resume session: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := modelManager.ResumeLatest(); err != nil {
|
|
||||||
return fmt.Errorf("resume latest session: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
@@ -141,6 +134,19 @@ func run() error {
|
|||||||
return repl(ctx, assistant, modelManager)
|
return repl(ctx, assistant, modelManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initializeSession(modelManager *session.Manager, resumeID string) error {
|
||||||
|
if resumeID != "" {
|
||||||
|
if err := modelManager.Resume(resumeID); err != nil {
|
||||||
|
return fmt.Errorf("resume session: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := modelManager.NewSession(); err != nil {
|
||||||
|
return fmt.Errorf("start new session: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func bootstrapConfig(configPath string) error {
|
func bootstrapConfig(configPath string) error {
|
||||||
resolved, err := config.ResolvePath(configPath)
|
resolved, err := config.ResolvePath(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agentu/internal/agent"
|
||||||
|
"agentu/internal/session"
|
||||||
|
"agentu/pkg/config"
|
||||||
|
"agentu/pkg/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type startupProvider struct{}
|
||||||
|
|
||||||
|
func (p *startupProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
|
||||||
|
return emit(llm.StreamEvent{Content: "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func startupTestManager(t *testing.T) (*session.Manager, *agent.Agent) {
|
||||||
|
t.Helper()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Providers: map[string]config.ProviderConfig{
|
||||||
|
"one": {
|
||||||
|
Name: "one",
|
||||||
|
BaseURL: "https://one.example.com",
|
||||||
|
APIKey: "sk-test",
|
||||||
|
Model: "model-a",
|
||||||
|
Models: []string{"model-a"},
|
||||||
|
Thinking: "none",
|
||||||
|
ThinkingParam: "thinking",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assistant := agent.New(agent.Options{
|
||||||
|
Provider: &startupProvider{},
|
||||||
|
ProviderName: "one",
|
||||||
|
Model: "model-a",
|
||||||
|
})
|
||||||
|
store, err := session.NewStore(filepath.Join(t.TempDir(), "sessions"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manager, err := session.NewManager(cfg, assistant, nil, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return manager, assistant
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitializeSessionStartsNewByDefault(t *testing.T) {
|
||||||
|
manager, assistant := startupTestManager(t)
|
||||||
|
if _, err := manager.NewSession(); err != nil {
|
||||||
|
t.Fatalf("new session: %v", err)
|
||||||
|
}
|
||||||
|
savedID := manager.CurrentSessionID()
|
||||||
|
assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: "old context"}})
|
||||||
|
if err := manager.Save(); err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := initializeSession(manager, ""); err != nil {
|
||||||
|
t.Fatalf("initialize: %v", err)
|
||||||
|
}
|
||||||
|
if manager.CurrentSessionID() == savedID {
|
||||||
|
t.Fatalf("default startup resumed latest session %s", savedID)
|
||||||
|
}
|
||||||
|
for _, msg := range assistant.Messages() {
|
||||||
|
if msg.Content == "old context" {
|
||||||
|
t.Fatal("default startup carried old context into new session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitializeSessionResumesExplicitID(t *testing.T) {
|
||||||
|
manager, assistant := startupTestManager(t)
|
||||||
|
if _, err := manager.NewSession(); err != nil {
|
||||||
|
t.Fatalf("new session: %v", err)
|
||||||
|
}
|
||||||
|
savedID := manager.CurrentSessionID()
|
||||||
|
assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: "old context"}})
|
||||||
|
if err := manager.Save(); err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := initializeSession(manager, savedID); err != nil {
|
||||||
|
t.Fatalf("initialize: %v", err)
|
||||||
|
}
|
||||||
|
if manager.CurrentSessionID() != savedID {
|
||||||
|
t.Fatalf("expected resumed session %s, got %s", savedID, manager.CurrentSessionID())
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, msg := range assistant.Messages() {
|
||||||
|
if msg.Content == "old context" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("explicit resume did not restore saved context")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user