Merge pull request #27 from dj-oyu/codex/fix-telegram-thread-conversation-issues
Add Session DAG Migration Plan to CLAUDE.md for branch/merge subagent orchestration
This commit is contained in:
commit
6d203b79f8
128 changed files with 25374 additions and 4037 deletions
21
.github/workflows/pr.yml
vendored
21
.github/workflows/pr.yml
vendored
|
|
@ -16,6 +16,9 @@ jobs:
|
|||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
|
|
@ -36,8 +39,26 @@ jobs:
|
|||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
- name: Run frontend tests
|
||||
run: |
|
||||
pnpm --dir pkg/miniapp/frontend install --frozen-lockfile
|
||||
pnpm --dir pkg/miniapp/frontend test
|
||||
|
||||
- name: Run go test
|
||||
run: go test ./...
|
||||
|
|
|
|||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -44,6 +44,14 @@ tasks/
|
|||
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
!pkg/miniapp/static/dist/
|
||||
!pkg/miniapp/static/dist/**
|
||||
|
||||
# Windows Application Icon/Resource
|
||||
*.syso
|
||||
|
||||
|
||||
# Frontend dependencies
|
||||
node_modules/
|
||||
|
||||
|
||||
|
|
|
|||
538
CLAUDE.md
538
CLAUDE.md
|
|
@ -19,471 +19,40 @@ Lint: `golangci-lint run`
|
|||
- **Interview tool filtering**: `interviewAllowedTools` in `pkg/agent/loop.go` is the single source of truth for tools available during interview/review phases. Both `filterInterviewTools` (strips definitions before LLM call) and `isToolAllowedDuringInterview` (argument-level gating) reference this map.
|
||||
- **History clear**: `/plan start clear` wipes session history and summary on transition to executing. The Mini App review UI offers two sliders: standard approve and approve-with-clear.
|
||||
|
||||
## Mini App — Static File Serving
|
||||
|
||||
現状は `pkg/miniapp/static/` 以下のファイルを `//go:embed` でバイナリに焼いて個別ルートで配信している。
|
||||
|
||||
```
|
||||
pkg/miniapp/static/
|
||||
index.html ← すべての JS/CSS をインライン
|
||||
map.js ← オーケストレーションルーム描画
|
||||
```
|
||||
|
||||
Go 側: `//go:embed static/index.html static/map.js` + `/miniapp/map.js` ルート。
|
||||
|
||||
### バンドラ導入の検討事項
|
||||
|
||||
- ビルドステップ(Vite / esbuild 等)を挟む場合、成果物ディレクトリ(`static/dist/` 等)を embed する形になる
|
||||
- Go 側は `//go:embed static` でディレクトリごと embed し、`http.FS` で一括配信すれば個別ルートが不要になる
|
||||
- `serveMapJS` など個別ルートは汎用 static ファイルサーバーに統合できる
|
||||
- `serveIndex` での `ORCH_ENABLED` 注入は、テンプレートエンジンまたはビルド時の環境変数注入に移行する必要がある
|
||||
|
||||
### 現時点でやっておける布石(任意)
|
||||
|
||||
- `/miniapp/` 以下を `http.FileServer(http.FS(staticFS))` で一括配信するよう `serveStatic` ハンドラを汎用化する
|
||||
- これにより `map.js`・将来の `assets/*.js` もルート追加なしに自動配信される
|
||||
|
||||
## Known Gaps
|
||||
|
||||
- **Mini App log viewer has no frontend tests**: `renderLogs()` in `pkg/miniapp/static/index.html` is inline vanilla JS with no unit/E2E test coverage. Backend (Go) tests cover `RecentLogs`, `SanitizeFields`, and JSON serialization, but nothing verifies the JS rendering. This allowed the Fields display bug (fields sent but not rendered) to ship undetected.
|
||||
- **No human intervention for heartbeat worktrees**: Heartbeat sessions create git worktrees (`.worktrees/heartbeat-YYYYMMDD/`) but there is no CLI or Mini App command to list, inspect, or manually dispose them. Need a `/plan worktrees` command (or similar) that shows active worktrees with branch/commit info and allows manual merge/dispose. `PruneOrphaned` on startup only removes directories without auto-committing first, so uncommitted changes in orphaned worktrees are silently lost.
|
||||
|
||||
---
|
||||
|
||||
## Subagent Orchestration Design
|
||||
|
||||
> Designed 2026-02-25 on branch `sub-agent-technical-breakdown`.
|
||||
|
||||
### なぜオーケストレーションか
|
||||
|
||||
単純な指示から可能性の木を広げることが目的。conductor は一人でやり遂げるのではなく、探索・深化・fork をサブエージェントに委ねながら大局観を保つ。
|
||||
|
||||
```
|
||||
without orchestration:
|
||||
human → conductor → (全部自分でやる) → result
|
||||
常にボトルネック、逐次処理
|
||||
|
||||
with orchestration:
|
||||
human → conductor ─┬─ scout A ─┐
|
||||
├─ scout B ─┼─ synthesize → deeper insight
|
||||
└─ scout C ─┘
|
||||
conductor は次を考えながら並走
|
||||
```
|
||||
|
||||
**3つの核心原則:**
|
||||
|
||||
1. **Fork** — 同じ問いに複数の切り口で同時探索。sequential queue ではなく tree の展開。
|
||||
2. **管理職の原則** — conductor は subagent の完了を待たない。spawn したら即座に次を設計する。人員を遊ばせないことがポイント。
|
||||
3. **会話の fork** — main thread (conductor ↔ human) は高レベル・戦略的に保つ。subagent への細かい指示出しは branch thread で行い、main thread を汚染しない。subagent の進捗はサマリーだけ main thread に上げる。
|
||||
|
||||
**spawn がデフォルト、subagent は例外:**
|
||||
```
|
||||
spawn = conductor が次を考え続けられる (正しい姿)
|
||||
subagent = conductor が止まる (結果が絶対必要な時だけ)
|
||||
```
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
```
|
||||
Conductor goroutine
|
||||
│ ContainerRequest (task, preset, environment)
|
||||
▼
|
||||
Container goroutine: provision → run → finalize
|
||||
│ ContainerMessage (question / result / status)
|
||||
▼
|
||||
Conductor goroutine
|
||||
│ answer (question への回答)
|
||||
▼ (わからなければ human に escalate)
|
||||
Container goroutine (再開)
|
||||
```
|
||||
|
||||
**escalation chain:**
|
||||
```
|
||||
subagent (clarifying) → question → conductor
|
||||
→ conductor が答えられる: inCh に回答
|
||||
→ conductor もわからない: message tool で human に投げ、回答を転送
|
||||
```
|
||||
|
||||
### SubagentContainer
|
||||
|
||||
goroutine と channel でライフサイクルを表現。goroutine がブロックしている場所が現在の状態。
|
||||
|
||||
```go
|
||||
type ContainerMessage struct {
|
||||
Type string // "question" | "result" | "status"
|
||||
Content string
|
||||
}
|
||||
|
||||
type SubagentContainer struct {
|
||||
inCh chan string // conductor → subagent (回答)
|
||||
outCh chan ContainerMessage // subagent → conductor (質問・結果・進捗)
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
```
|
||||
|
||||
spawn (async) は outCh を返して即リターン。subagent (sync) はその場で result を待つ。
|
||||
|
||||
**tasks map 問題の解消:** goroutine 終了時に `defer orchestrator.active.Delete(id)` + `defer close(outCh)` で自動 GC。
|
||||
|
||||
### SubagentEnvironment (Context Injection)
|
||||
|
||||
conductor は subagent に必要なコンテキストを明示的に渡す。MEMORY.md からの自動注入で冗長な手動記述を排除。
|
||||
|
||||
```go
|
||||
type SubagentEnvironment struct {
|
||||
// 自動注入 (harness が埋める)
|
||||
Workspace string // workspace パス
|
||||
WorktreeDir string // write先、workDir として透過的に機能
|
||||
|
||||
// MEMORY.md から自動抽出 (inject_plan_context: true の場合)
|
||||
PlanTask string // > Task: の内容
|
||||
PlanContext string // ## Context セクション
|
||||
Commands string // ## Commands セクション (build/test/lint)
|
||||
CurrentPhase string // 対象 Phase の内容
|
||||
|
||||
// conductor が明示的に追加
|
||||
Background string // 追加の背景・意図
|
||||
Constraints string // 制約
|
||||
ContextFiles []string // 参照すべきファイルリスト
|
||||
}
|
||||
```
|
||||
|
||||
spawn パラメータ例:
|
||||
```json
|
||||
{
|
||||
"task": "Phase 2 Step 1: implement the rate limiter",
|
||||
"preset": "coder",
|
||||
"context_files": ["pkg/ratelimit/ratelimit.go"],
|
||||
"inject_plan_context": true
|
||||
}
|
||||
```
|
||||
|
||||
### SandboxConfig
|
||||
|
||||
ToolRegistry.Execute() の入口で一括 enforcement。subagent は普通に tool call するつもりで透過的に sandboxed になる。
|
||||
|
||||
```go
|
||||
type SandboxConfig struct {
|
||||
Preset string
|
||||
WriteRoot string // write 系ツールのパス制限
|
||||
AllowedTools map[string]bool
|
||||
ExecPolicy *ExecPolicy // nil = exec 不可
|
||||
SpawnablePresets []string // nil = spawn 不可
|
||||
}
|
||||
|
||||
type ExecPolicy struct {
|
||||
AllowPattern string // 先頭一致 regex; マッチしたコマンドだけ実行可
|
||||
}
|
||||
```
|
||||
|
||||
**透過的隔離:** workDir = worktreeDir として設定することで、AI は自分が隔離されていることに気づかずに振る舞う。picoclaw 側で CoW 的にファイルを引き渡せる。
|
||||
|
||||
### Presets (5種)
|
||||
|
||||
| preset | 性格 | write | exec | search | spawn |
|
||||
|---|---|---|---|---|---|
|
||||
| `scout` | Exploratory | ✗ | ✗ | ✓ | ✗ |
|
||||
| `analyst` | Exploratory | ✗ | go test/vet, git log/diff, grep | ✓ | ✗ |
|
||||
| `coder` | Deliberate | ✓ sandbox | test/lint/fmt 系 | ✓ | ✗ |
|
||||
| `worker` | Deliberate | ✓ sandbox | build/package manager 系 | ✓ | ✗ |
|
||||
| `coordinator` | Deliberate | ✓ sandbox | go/pnpm/bun/curl 系 | ✓ | scout〜worker のみ |
|
||||
|
||||
**性格の分類:**
|
||||
- **Exploratory** (scout/analyst): open-ended、見てきて報告。clarifying フェーズなし。
|
||||
- **Deliberate** (coder/worker/coordinator): 成果物を作る。目標があいまいだと失敗する。clarifying フェーズあり。
|
||||
|
||||
**境界:**
|
||||
- `coder` = 書いて自分で検証できる (package 追加・deploy 不可)
|
||||
- `worker` = インフラも含めてやりきる (package install, CI pipeline 等)
|
||||
- `coordinator` = coordinator を spawn できない (深さ自然制限)
|
||||
|
||||
**npm は全 preset で禁止:** git worktree に node_modules が作られると大量ファイルが生じるため。pnpm は symbolic link で済む、bun も同様。
|
||||
|
||||
**websearch/webfetch は全 preset で許可:** read-only・非破壊のため制限不要。
|
||||
|
||||
#### exec allowlist regex
|
||||
|
||||
```go
|
||||
var presetExecPatterns = map[string]string{
|
||||
"scout": ``,
|
||||
"analyst": `^(go\s+(test|vet)|git\s+(log|diff|status)|curl|wget|grep|find)\b`,
|
||||
"coder": `^(` +
|
||||
`go\s+(test|vet|fmt)|gofmt|goimports|golangci-lint|` +
|
||||
`prettier|eslint|` +
|
||||
`black|ruff|` +
|
||||
`cargo\s+(test|fmt|clippy)|` +
|
||||
`pnpm\s+(test|run\s+(test|lint|format))|` +
|
||||
`bun\s+(test|run\s+(test|lint|format))|` +
|
||||
`uv\s+run\s+` +
|
||||
`)\b`,
|
||||
"worker": `^(` +
|
||||
`go\s+|` +
|
||||
`pnpm\s+(install|add|run|test|build)|` +
|
||||
`bun\s+(install|add|run|test|build)|` +
|
||||
`uv\s+(run|sync|add|pip\s+install)|` +
|
||||
`pip\s+install|` +
|
||||
`cargo\s+` +
|
||||
`)\b`,
|
||||
"coordinator": `^(go\s+|pnpm\s+|bun\s+|curl|wget)\b`,
|
||||
}
|
||||
```
|
||||
|
||||
### Subagent Plan Mode
|
||||
|
||||
Deliberate な preset (coder/worker/coordinator) は in-memory のミニ plan mode を持つ。MEMORY.md には一切触れない (ファイル参照・編集を避けるため)。
|
||||
|
||||
```go
|
||||
type SubagentPlanState int
|
||||
|
||||
const (
|
||||
PlanStateNone SubagentPlanState = iota // Exploratory preset
|
||||
PlanStateClarifying // 目的・制約を確認中
|
||||
PlanStateReview // conductor の承認待ち
|
||||
PlanStateExecuting // 実行中
|
||||
)
|
||||
|
||||
type SubagentPlan struct {
|
||||
State SubagentPlanState
|
||||
Goal string // clarifying で合意した目的
|
||||
Approach []string // proposed なステップ
|
||||
QA []QAItem // 質問・回答の履歴
|
||||
mu sync.Mutex
|
||||
}
|
||||
```
|
||||
|
||||
SubagentContainer がフィールドとして保持。goroutine 終了とともに消える。
|
||||
|
||||
**Deliberate preset の system prompt:**
|
||||
```
|
||||
You are in clarifying mode. Before executing, confirm with the conductor:
|
||||
1. What is the exact goal?
|
||||
2. What are the constraints and acceptance criteria?
|
||||
3. Are there relevant files I should know about?
|
||||
Use the `message` tool to ask questions.
|
||||
When you have clear answers, propose your approach (steps) for review.
|
||||
Do NOT start executing until the conductor approves.
|
||||
```
|
||||
|
||||
**Exploratory preset の system prompt:**
|
||||
```
|
||||
Explore and return findings. Use your best judgment when encountering ambiguity.
|
||||
```
|
||||
|
||||
**fractal 構造:**
|
||||
```
|
||||
human
|
||||
↕ plan mode (MEMORY.md, file-based, 永続)
|
||||
conductor
|
||||
↕ subagent plan mode (in-memory, 揮発)
|
||||
subagent (deliberate)
|
||||
```
|
||||
|
||||
### MEMORY.md Orchestration Section
|
||||
|
||||
executing 中に conductor が自由に書き込める専用エリア。システムはパースしない。
|
||||
|
||||
```markdown
|
||||
## Orchestration
|
||||
|
||||
### Delegated
|
||||
<!-- subagent に委譲したタスクの記録 (spawn 直後に書く) -->
|
||||
- coder-1 (coder): rate limiter 実装 → Phase 2 Step 1
|
||||
- scout-1 (scout): pkg/auth の構造調査
|
||||
|
||||
### Findings
|
||||
<!-- subagent の結果から蓄積した知見 (結果受信後に書く) -->
|
||||
- pkg/auth は middleware パターン、入口は middleware.go (scout-1)
|
||||
- セッションストアは存在しない、JWT が有効 (scout-2)
|
||||
|
||||
### Decisions
|
||||
<!-- fork の意思決定ログ (方向選択時に書く) -->
|
||||
- auth: OAuth2 より JWT を選択 (外部依存なし、scout-2 推奨)
|
||||
```
|
||||
|
||||
conductor の guidance に追記:
|
||||
```
|
||||
After spawning a subagent, record the assignment in ## Orchestration > Delegated.
|
||||
When a subagent reports back, move key findings to ## Orchestration > Findings.
|
||||
When you choose one direction over another, log the rationale in ## Orchestration > Decisions.
|
||||
```
|
||||
|
||||
### Conductor Identity (System Prompt)
|
||||
|
||||
`pkg/agent/context.go` の `getIdentity()` に追加予定:
|
||||
|
||||
```
|
||||
You are picoclaw, a conductor AI agent. Your role is to orchestrate:
|
||||
break work into tasks, delegate them to subagents, and synthesize results —
|
||||
rather than doing everything inline yourself.
|
||||
|
||||
## Orchestration
|
||||
|
||||
You are the conductor, not the performer. Prefer delegation over doing everything inline.
|
||||
|
||||
Use `spawn` (non-blocking) when:
|
||||
- Tasks can run in parallel or in the background
|
||||
- Multiple independent tasks can run simultaneously (spawn each one)
|
||||
- You don't need the result to decide the next step
|
||||
- The operation is long-running (builds, fetches, analysis, file processing)
|
||||
|
||||
Use `subagent` (blocking) when:
|
||||
- You need the result before you can continue
|
||||
- Correctness of next steps depends on the outcome
|
||||
|
||||
Do inline only when:
|
||||
- It's a single fast tool call (read a file, quick search)
|
||||
- Delegation overhead clearly outweighs the benefit
|
||||
|
||||
Default bias: if a task involves more than 2-3 tool calls or can run
|
||||
independently, delegate it. When you spawn, immediately plan what comes next —
|
||||
blocking means you've stopped thinking.
|
||||
|
||||
Fork aggressively: explore multiple directions simultaneously.
|
||||
After spawning a subagent, record the assignment in ## Orchestration > Delegated.
|
||||
When results come back, synthesize and decide the next fork.
|
||||
```
|
||||
|
||||
### Startup Flag
|
||||
|
||||
テスト用途で起動時にオーケストレーション機能を on/off できるようにする。
|
||||
|
||||
**変更箇所:**
|
||||
1. `pkg/config/config.go` — `SubagentsConfig` に `Enabled bool` を追加
|
||||
2. `cmd/picoclaw/cmd_agent.go` — `--orchestration` フラグを追加 (default: false for now)
|
||||
3. `pkg/agent/loop.go` — `registerSharedTools()` で spawn tool 登録を `Enabled` で gate
|
||||
|
||||
```go
|
||||
// pkg/config/config.go
|
||||
type SubagentsConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AllowAgents []string `json:"allow_agents,omitempty"`
|
||||
Model *AgentModelConfig `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
// cmd/picoclaw/cmd_agent.go
|
||||
case "--orchestration":
|
||||
cfg.Agents.Defaults.Subagents.Enabled = true // or toggle
|
||||
```
|
||||
|
||||
### Implementation Files (予定)
|
||||
|
||||
```
|
||||
pkg/tools/
|
||||
container.go — SubagentContainer, ContainerRequest, ContainerMessage,
|
||||
SubagentEnvironment, SubagentPlan
|
||||
sandbox.go — SandboxConfig, ExecPolicy, preset 定義
|
||||
orchestrator.go — Orchestrator (SubagentManager を置き換え)
|
||||
spawn.go — preset / inject_plan_context パラメータ追加
|
||||
pkg/agent/
|
||||
context.go — conductor identity + orchestration guidance 追加
|
||||
```
|
||||
|
||||
### AgentReporter 抽象化 (実装済み 2026-02-25)
|
||||
|
||||
> branch `sub-agent-technical-breakdown`
|
||||
|
||||
`Broadcaster` を `SubagentManager` 内部で生成する密結合を解消し、
|
||||
`orch.AgentReporter` インターフェースを中心に置くリファクタリングを実施。
|
||||
|
||||
#### オーナーシップ
|
||||
|
||||
```
|
||||
AgentLoop
|
||||
├─ owns: *orch.Broadcaster (orchBroadcaster — nil when disabled)
|
||||
└─ holds: orch.AgentReporter (orchReporter = Broadcaster or Noop)
|
||||
├─ passes to → SubagentManager.reporter
|
||||
│ └─ passes to → ToolLoopConfig.Reporter
|
||||
└─ calls directly for main/heartbeat sessions
|
||||
├─ runAgentLoop: ReportSpawn / ReportGC
|
||||
└─ runLLMIteration: ReportStateChange
|
||||
|
||||
cmd_gateway.go
|
||||
└─ agentLoop.GetOrchBroadcaster() → handler.SetOrchBroadcaster()
|
||||
|
||||
miniapp.Handler
|
||||
└─ borrows *orch.Broadcaster for Subscribe/Snapshot (WS 配信)
|
||||
```
|
||||
|
||||
#### インターフェース (`pkg/orch/reporter.go`)
|
||||
|
||||
```go
|
||||
type AgentReporter interface {
|
||||
ReportSpawn(id, label, task string)
|
||||
ReportStateChange(id, state, tool string)
|
||||
ReportConversation(from, to, text string)
|
||||
ReportGC(id, reason string)
|
||||
}
|
||||
var Noop AgentReporter = &noopReporter{} // nil-free; 全メソッドが no-op
|
||||
```
|
||||
|
||||
`Broadcaster` は `AgentReporter` を満たす (`ReportSpawn` 等が `Publish` のラッパー)。
|
||||
|
||||
#### Noop パターン
|
||||
|
||||
```
|
||||
--orchestration なし: orchReporter = orch.Noop → 全 Report* が空振り (panic なし)
|
||||
--orchestration あり: orchReporter = *Broadcaster → WS 配信
|
||||
```
|
||||
|
||||
呼び出し側は `if reporter != nil` チェック不要。
|
||||
|
||||
#### イベント発火の責任分担
|
||||
|
||||
| 発火元 | イベント | 経由 |
|
||||
|--------|---------|------|
|
||||
| `runAgentLoop` | `ReportSpawn` / `ReportGC` | `al.reporter()` |
|
||||
| `runLLMIteration` | `ReportStateChange("waiting"/"toolcall")` | `al.reporter()` |
|
||||
| `SubagentManager.Spawn` | `ReportSpawn` | `sm.reporter` |
|
||||
| `SubagentManager.runTask` | `ReportConversation` / `ReportGC` | `sm.reporter` |
|
||||
| `RunToolLoop` | `ReportStateChange` | `config.Reporter` |
|
||||
|
||||
main / heartbeat / subagent の全セッションが同一 Broadcaster に発火するため、
|
||||
canvas には全エージェントが統一して表示される。
|
||||
|
||||
#### 変更ファイル
|
||||
|
||||
- `pkg/orch/reporter.go` — **新規** インターフェース + Noop
|
||||
- `pkg/orch/broadcaster.go` — `ReportSpawn/StateChange/Conversation/GC` 追加
|
||||
- `pkg/tools/toolloop.go` — `OnStateChange func` → `Reporter AgentReporter + AgentID`
|
||||
- `pkg/tools/subagent.go` — constructor に `reporter` 受け取り、内部 broadcaster 廃止、`GetBroadcaster()` 削除
|
||||
- `pkg/agent/loop.go` — `orchBroadcaster`/`orchReporter` フィールド追加、`SetOrchReporter`/`GetOrchBroadcaster` 追加、`registerSharedTools` シグネチャに `al *AgentLoop` 追加
|
||||
- `cmd/picoclaw/cmd_gateway.go` — `GetOrchBroadcaster()` → `handler.SetOrchBroadcaster()`
|
||||
|
||||
---
|
||||
|
||||
## Memory Optimization Notes
|
||||
|
||||
> Reviewed 2026-02-24 on branch `memory-optimization-review`.
|
||||
|
||||
### 設計レベルの根本原因
|
||||
|
||||
#### D-1. MemoryStore が「ファイル = 正」でパース済み表現をキャッシュできない
|
||||
|
||||
`GetMemoryContext()` 1回で `ReadLongTerm()` が5回以上呼ばれる連鎖。MEMORY.md を外部エディタが直接編集できる設計上、インメモリキャッシュを自然に導入できない。
|
||||
|
||||
対策: (a) content パススルー方式 — 高レベルメソッドだけが1回 ReadLongTerm() を呼び、content を private ヘルパーに渡す。(b) `*ParsedPlan` 常駐 — RAM が潤沢なので MemoryStore にパース済み構造体を持たせる。edit_file 後に `InvalidateCache()` を呼ぶ。
|
||||
|
||||
#### D-2. `FunctionCall.Arguments` が JSON 文字列のままドメイン型に
|
||||
|
||||
ストリーミングループ内の重複 Unmarshal の根本原因。`ToolCall.Arguments map[string]any` のパース済みフィールドも存在するが中途半端に共存している。
|
||||
|
||||
#### D-3. `ToolFunctionDefinition.Parameters` が `map[string]any`
|
||||
|
||||
プロバイダーへ送るたびに Marshal が必要。`json.RawMessage` にすれば一度の marshal で済む。
|
||||
|
||||
#### D-4. 検索プロバイダーに共通フォーマット抽象がない
|
||||
|
||||
`[]string + strings.Join` パターンが3箇所に複製。`Search()` 戻り値を `string` でなく構造体にすれば1箇所で済む。
|
||||
|
||||
#### D-5. `Session.Messages` が可変スライスで全コピーが必要
|
||||
|
||||
`GetHistory()` / `Save()` での防衛的コピーは意図的設計。COW または append-only immutable 構造で解消できる。
|
||||
|
||||
#### D-6. `MemoryStore` のメソッド境界が「ファイル操作単位」
|
||||
|
||||
呼び出し側は複数の値が必要でも複数回呼ぶしかない。D-1 の解決策 (ParsedPlan 常駐) と合わせて解消。
|
||||
|
||||
### コードの匂い — チェックリスト
|
||||
## Subagent Orchestration (実装済み)
|
||||
|
||||
- **Startup flag**: `--orchestration` で on/off。`SubagentsConfig.Enabled` で gate。
|
||||
- **Conductor identity**: orchestration 有効時に conductor identity + spawn/subagent guidance を system prompt へ注入。
|
||||
- **Sandbox/Spawn**: `pkg/tools/sandbox.go`, `pkg/tools/spawn.go` 実装済み。
|
||||
- **AgentReporter**: `orch.AgentReporter` / `orch.Noop` / `orch.Broadcaster` で統一。main/heartbeat/subagent 全セッションが同一 Broadcaster に発火。Mini App は `agentLoop.GetOrchBroadcaster()` → `handler.SetOrchBroadcaster()` で受信。
|
||||
- **Container Model (Q&A escalation)**:
|
||||
- `ContainerMessage` + `inCh`/`outCh` channels on `SubagentTask` — deliberate preset (coder/worker/coordinator) のみ
|
||||
- `ask_conductor` tool — subagent → conductor question (blocking)
|
||||
- `answer_subagent` tool — conductor → subagent answer
|
||||
- `submit_plan` tool — subagent → conductor plan review (blocking)
|
||||
- `review_subagent_plan` tool — conductor → subagent approve/reject
|
||||
- `PendingQuestions()` で conductor LLM loop に question/plan_review を注入
|
||||
- **Deliberate Plan Mode**: `SubagentPlanState` (Clarifying → Review → Executing → Completed)
|
||||
- `runDeliberateTask()`: clarifying phase (ask_conductor + submit_plan のみ) → executing phase (全ツール)
|
||||
- `runExploratoryTask()`: exploratory preset の single-phase loop
|
||||
- **Environment injection**: `extractPlanContext()` で MEMORY.md から Context/Commands/Orchestration セクションを抽出 → subagent system prompt に注入
|
||||
- **SessionRecorder 拡張**: `RecordQuestion()` / `RecordPlanSubmit()` + `TurnQuestion` / `TurnPlanSubmit` TurnKind
|
||||
|
||||
## Session DAG (Phase 0–3 実装済み)
|
||||
|
||||
- **SQLite SessionStore**: `pkg/session/sqlite.go` — `modernc.org/sqlite` (CGO不要)、WAL モード、`sessions` + `turns` テーブル
|
||||
- **SessionStore interface**: `pkg/session/store.go` — Create/Get/List/Append/Turns/Compact/Fork/Prune 等15メソッド
|
||||
- **LegacyAdapter**: `pkg/session/legacy_adapter.go` — SessionStore をラップし SessionManager と同一 API を提供。`Store()` / `AdvanceStored()` で直接 DAG 操作も可能
|
||||
- **CompactOldTurns**: `LegacyAdapter.CompactOldTurns(key, keepLast, summary)` — flush → turn 単位で cut point 算出 → SQLite Compact → キャッシュ更新。`summarizeSession()` から呼び出し (fallback 付き)
|
||||
- **SessionGraph**: `pkg/session/graph.go` — `SessionGraph` + `BeginTurn()` / `TurnWriter`。`LegacyAdapter.Graph()` で取得。将来の段階移行準備
|
||||
- **JSON → SQLite migration**: `pkg/session/migrate.go` — 起動時に `sessions/*.json` を検出 → SQLite import → `.json.migrated` にリネーム
|
||||
- **配線**: `pkg/agent/instance.go` の `Sessions` 型が `*LegacyAdapter` に変更。`sessions.db` を workspace 直下に生成
|
||||
- **SessionRecorder**: `pkg/tools/session_recorder.go` (interface) + `pkg/agent/session_recorder.go` (impl) — SubagentManager から Fork/Turn/Completion/Report を記録
|
||||
- **Prune**: 起動時 `store.Prune(7d)` + `flushLoop` 内 6h 定期 prune。`AgentLoop.gcLoop` で 30分毎に idle `sessionLocks` を GC
|
||||
- **CLI `/session` コマンド**: `list` / `graph` / `fork [label]` / `reset` サブコマンド。default は DAG summary + token stats
|
||||
- **Mini App Session Graph**: `/miniapp/api/sessions/graph` endpoint。SSE `session` event に `graph` 含む。フロントエンドで tree rendering
|
||||
|
||||
## コードの匂い — チェックリスト
|
||||
|
||||
新しいコードを書くとき・レビューするときの確認事項:
|
||||
|
||||
|
|
@ -495,37 +64,20 @@ canvas には全エージェントが統一して表示される。
|
|||
6. **`var x []T` から始まる容量なし append** → ソース長が既知なら `make([]T, 0, n)`
|
||||
7. **`[]rune(s)` 変換前に長さチェックなし** → `len(s) <= max` で ASCII fast path を先に
|
||||
|
||||
### ストレージ保護設計 (microSD 寿命)
|
||||
|
||||
| データ | 現状 | 推奨戦略 | 削減率 |
|
||||
|---|---|---|---|
|
||||
| `sessions/*.json` | メッセージ毎書き込み | write-behind (dirty flag + 5分タイマー) | 80% |
|
||||
| `state/stats.json` | LLM呼び出し毎 | 定期フラッシュのみ (5分) | 98% |
|
||||
| `memory/MEMORY.md` | 即時 (変えない) | ターンスコープキャッシュ (読み取りのみ最適化) | — |
|
||||
|
||||
**実装ポイント:**
|
||||
- `SessionManager` に `dirtyKeys map[string]bool` + バックグラウンドフラッシャー goroutine
|
||||
- `stats.Tracker` に `Close()` メソッド追加 (タイマー停止 + 最終 save)
|
||||
- SIGTERM/SIGINT でシャットダウンフック必須
|
||||
- MEMORY.md の edit_file 書き込み後にターンキャッシュを無効化 (`InvalidateCache()`)
|
||||
|
||||
---
|
||||
|
||||
## Session Management (Future)
|
||||
## 未実装タスク
|
||||
|
||||
現状の設計は「正確性」は成熟しているが「ライフサイクル」が欠落している。
|
||||
以下の `todo/` ファイルに分割。基本は別ブランチで並列実装可能(※ TASKS-2 は TASKS-1 の型変更前提あり)。
|
||||
|
||||
**近期:**
|
||||
- `SessionManager.Delete(key)` + TTL エビクション
|
||||
- `sessionLocks sync.Map` (loop.go) の GC
|
||||
- 起動時の `loadSessions()` を遅延ロード化
|
||||
| ファイル | 概要 |
|
||||
|---|---|
|
||||
| [`todo/TASKS-1.md`](todo/TASKS-1.md) | ~~**Memory & Performance Optimization**~~ ✅ 実装済み(MemoryStore キャッシュ+パース済み state、FunctionCall.Arguments map統一、ToolDefinition.Parameters RawMessage化、検索結果フォーマット共通化、stats 定期フラッシュ) |
|
||||
| [`todo/TASKS-2.md`](todo/TASKS-2.md) | ~~**Subagent Orchestration (Container Model)**~~ ✅ 実装済み(Container Q&A escalation、Deliberate Plan Mode、Environment injection、SessionRecorder 拡張) |
|
||||
| [`todo/TASKS-3.md`](todo/TASKS-3.md) | ~~**Session DAG (SQLite Store)**~~ ✅ 実装済み(Phase 0–3: SQLite SessionStore、LegacyAdapter、Fork/Report、CompactOldTurns、`/session` CLI コマンド、Mini App グラフ UI) |
|
||||
| [`todo/TASKS-4.md`](todo/TASKS-4.md) | ~~**Mini App & Static Serving**~~ ✅ 実装済み(`http.FileServer` 統合、テンプレート注入、Bun ビルド導線、frontend unit test + CI `pnpm test`) |
|
||||
| [`todo/TASKS-5.md`](todo/TASKS-5.md) | ~~**Heartbeat Worktree Management**~~ ✅ 実装済み(`/plan worktrees` の `list/inspect/merge/dispose`、安全化した `PruneOrphaned`、Mini App `/miniapp/api/worktrees` + Git タブ UI) |
|
||||
| [`todo/TASKS-6.md`](todo/TASKS-6.md) | **SOUL.md — AI Persona Evolution** — 睡眠フェーズで体験を統合・忘却し人格を再構成。TASKS-2 完了後に着手 |
|
||||
| [`todo/TASKS-7.md`](todo/TASKS-7.md) | **Provider Wire Compatibility Hardening** — openai_compat の provider 別 wire 分岐(OpenAI strict / Gemini)、thought_signature round-trip 保全、互換テスト追加 |
|
||||
|
||||
**中期:**
|
||||
- チェックポイント / ロールバック (`Session.Messages` を append-only immutable に)
|
||||
- 名前付きセッション (`/new-session`, `/switch-session`, `/list-sessions`)
|
||||
|
||||
**長期:**
|
||||
- `Session.ParentKey` でサブエージェントセッションをグラフ化
|
||||
- クロスセッション検索 (MEMORY.md の補完として)
|
||||
|
||||
設計の哲学: `Session.Messages` (履歴中心) と `MEMORY.md` (知識中心) が現状共存している。どちらを主軸にするかで発展方向が変わる。
|
||||
|
|
|
|||
|
|
@ -425,6 +425,58 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo {
|
|||
return result
|
||||
}
|
||||
|
||||
func (p *agentLoopDataProvider) GetSessionGraph() *miniapp.SessionGraphData {
|
||||
nodes := p.loop.GetSessionGraph()
|
||||
if len(nodes) == 0 {
|
||||
return &miniapp.SessionGraphData{
|
||||
Nodes: []miniapp.SessionGraphNode{},
|
||||
Edges: []miniapp.SessionGraphEdge{},
|
||||
}
|
||||
}
|
||||
|
||||
gNodes := make([]miniapp.SessionGraphNode, 0, len(nodes))
|
||||
var edges []miniapp.SessionGraphEdge
|
||||
|
||||
for _, n := range nodes {
|
||||
sk := gatewayShortKey(n.Key)
|
||||
label := n.Label
|
||||
if label == "" {
|
||||
label = sk
|
||||
}
|
||||
gNodes = append(gNodes, miniapp.SessionGraphNode{
|
||||
Key: n.Key,
|
||||
ShortKey: sk,
|
||||
Label: label,
|
||||
Status: n.Status,
|
||||
TurnCount: n.TurnCount,
|
||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: n.UpdatedAt.Format(time.RFC3339),
|
||||
Summary: n.Summary,
|
||||
ForkTurnID: n.ForkTurnID,
|
||||
})
|
||||
if n.ParentKey != "" {
|
||||
edges = append(edges, miniapp.SessionGraphEdge{
|
||||
From: n.ParentKey,
|
||||
To: n.Key,
|
||||
ForkTurnID: n.ForkTurnID,
|
||||
})
|
||||
}
|
||||
}
|
||||
if edges == nil {
|
||||
edges = []miniapp.SessionGraphEdge{}
|
||||
}
|
||||
return &miniapp.SessionGraphData{Nodes: gNodes, Edges: edges}
|
||||
}
|
||||
|
||||
// gatewayShortKey abbreviates long session keys for display.
|
||||
func gatewayShortKey(key string) string {
|
||||
parts := strings.Split(key, ":")
|
||||
if len(parts) > 2 {
|
||||
return strings.Join(parts[2:], ":")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func (p *agentLoopDataProvider) GetContextInfo() miniapp.ContextInfo {
|
||||
workDir, planWorkDir, workspace, bootstrap := p.loop.GetContextInfo()
|
||||
files := make([]miniapp.BootstrapFileInfo, len(bootstrap))
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -12,70 +12,103 @@ import (
|
|||
)
|
||||
|
||||
// setupWorkspace creates a temporary workspace with standard directories and optional files.
|
||||
|
||||
// Returns the tmpDir path; caller should defer os.RemoveAll(tmpDir).
|
||||
|
||||
func setupWorkspace(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "picoclaw-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
|
||||
|
||||
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
|
||||
|
||||
for name, content := range files {
|
||||
dir := filepath.Dir(filepath.Join(tmpDir, name))
|
||||
|
||||
os.MkdirAll(dir, 0o755)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
// TestSingleSystemMessage verifies that BuildMessages always produces exactly one
|
||||
|
||||
// system message regardless of summary/history variations.
|
||||
|
||||
// Fix: multiple system messages break Anthropic (top-level system param) and
|
||||
|
||||
// Codex (only reads last system message as instructions).
|
||||
|
||||
func TestSingleSystemMessage(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"IDENTITY.md": "# Identity\nTest agent.",
|
||||
})
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
history []providers.Message
|
||||
|
||||
summary string
|
||||
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "no summary, no history",
|
||||
|
||||
summary: "",
|
||||
|
||||
message: "hello",
|
||||
},
|
||||
|
||||
{
|
||||
name: "with summary",
|
||||
|
||||
summary: "Previous conversation discussed X",
|
||||
|
||||
message: "hello",
|
||||
},
|
||||
|
||||
{
|
||||
name: "with history and summary",
|
||||
|
||||
history: []providers.Message{
|
||||
{Role: "user", Content: "hi"},
|
||||
|
||||
{Role: "assistant", Content: "hello"},
|
||||
},
|
||||
|
||||
summary: strings.Repeat("Long summary text. ", 50),
|
||||
|
||||
message: "new message",
|
||||
},
|
||||
|
||||
{
|
||||
name: "system message in history is filtered",
|
||||
|
||||
history: []providers.Message{
|
||||
{Role: "system", Content: "stale system prompt from previous session"},
|
||||
|
||||
{Role: "user", Content: "hi"},
|
||||
|
||||
{Role: "assistant", Content: "hello"},
|
||||
},
|
||||
|
||||
summary: "",
|
||||
|
||||
message: "new message",
|
||||
},
|
||||
}
|
||||
|
|
@ -85,35 +118,44 @@ func TestSingleSystemMessage(t *testing.T) {
|
|||
msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1")
|
||||
|
||||
systemCount := 0
|
||||
|
||||
for _, m := range msgs {
|
||||
if m.Role == "system" {
|
||||
systemCount++
|
||||
}
|
||||
}
|
||||
|
||||
if systemCount != 1 {
|
||||
t.Errorf("expected exactly 1 system message, got %d", systemCount)
|
||||
}
|
||||
|
||||
if msgs[0].Role != "system" {
|
||||
t.Errorf("first message should be system, got %s", msgs[0].Role)
|
||||
}
|
||||
|
||||
if msgs[len(msgs)-1].Role != "user" {
|
||||
t.Errorf("last message should be user, got %s", msgs[len(msgs)-1].Role)
|
||||
}
|
||||
|
||||
// System message must contain identity (static) and time (dynamic)
|
||||
|
||||
sys := msgs[0].Content
|
||||
|
||||
if !strings.Contains(sys, "picoclaw") {
|
||||
t.Error("system message missing identity")
|
||||
}
|
||||
|
||||
if !strings.Contains(sys, "Current Time") {
|
||||
t.Error("system message missing dynamic time context")
|
||||
}
|
||||
|
||||
// Summary handling
|
||||
|
||||
if tt.summary != "" {
|
||||
if !strings.Contains(sys, "CONTEXT_SUMMARY:") {
|
||||
t.Error("summary present but CONTEXT_SUMMARY prefix missing")
|
||||
}
|
||||
|
||||
if !strings.Contains(sys, tt.summary[:20]) {
|
||||
t.Error("summary content not found in system message")
|
||||
}
|
||||
|
|
@ -127,29 +169,46 @@ func TestSingleSystemMessage(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestMtimeAutoInvalidation verifies that the cache detects source file changes
|
||||
|
||||
// via mtime without requiring explicit InvalidateCache().
|
||||
|
||||
// Fix: original implementation had no auto-invalidation — edits to bootstrap files,
|
||||
|
||||
// memory, or skills were invisible until process restart.
|
||||
|
||||
func TestMtimeAutoInvalidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
file string // relative path inside workspace
|
||||
|
||||
contentV1 string
|
||||
|
||||
contentV2 string
|
||||
|
||||
checkField string // substring to verify in rebuilt prompt
|
||||
}{
|
||||
{
|
||||
name: "bootstrap file change",
|
||||
|
||||
file: "IDENTITY.md",
|
||||
|
||||
contentV1: "# Original Identity",
|
||||
|
||||
contentV2: "# Updated Identity",
|
||||
|
||||
checkField: "Updated Identity",
|
||||
},
|
||||
|
||||
{
|
||||
name: "memory file change",
|
||||
|
||||
file: "memory/MEMORY.md",
|
||||
|
||||
contentV1: "# Memory\nUser likes Go.",
|
||||
|
||||
contentV2: "# Memory\nUser likes Rust.",
|
||||
|
||||
checkField: "User likes Rust",
|
||||
},
|
||||
}
|
||||
|
|
@ -157,6 +216,7 @@ func TestMtimeAutoInvalidation(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{tt.file: tt.contentV1})
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
|
|
@ -164,26 +224,39 @@ func TestMtimeAutoInvalidation(t *testing.T) {
|
|||
sp1 := cb.BuildSystemPromptWithCache()
|
||||
|
||||
// Overwrite file and set future mtime to ensure detection.
|
||||
|
||||
// Use 2s offset for filesystem mtime resolution safety (some FS
|
||||
|
||||
// have 1s or coarser granularity, especially in CI containers).
|
||||
|
||||
fullPath := filepath.Join(tmpDir, tt.file)
|
||||
|
||||
os.WriteFile(fullPath, []byte(tt.contentV2), 0o644)
|
||||
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
|
||||
os.Chtimes(fullPath, future, future)
|
||||
|
||||
// Verify sourceFilesChangedLocked detects the mtime change
|
||||
|
||||
cb.systemPromptMutex.RLock()
|
||||
|
||||
changed := cb.sourceFilesChangedLocked()
|
||||
|
||||
cb.systemPromptMutex.RUnlock()
|
||||
|
||||
if !changed {
|
||||
t.Fatalf("sourceFilesChangedLocked() should detect %s change", tt.file)
|
||||
}
|
||||
|
||||
// Should auto-rebuild without explicit InvalidateCache()
|
||||
|
||||
sp2 := cb.BuildSystemPromptWithCache()
|
||||
|
||||
if sp1 == sp2 {
|
||||
t.Errorf("cache not rebuilt after %s change", tt.file)
|
||||
}
|
||||
|
||||
if !strings.Contains(sp2, tt.checkField) {
|
||||
t.Errorf("rebuilt prompt missing expected content %q", tt.checkField)
|
||||
}
|
||||
|
|
@ -191,23 +264,34 @@ func TestMtimeAutoInvalidation(t *testing.T) {
|
|||
}
|
||||
|
||||
// Skills directory mtime change
|
||||
|
||||
t.Run("skills dir change", func(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, nil)
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
|
||||
_ = cb.BuildSystemPromptWithCache() // populate cache
|
||||
|
||||
// Touch skills directory (simulate new skill installed)
|
||||
|
||||
skillsDir := filepath.Join(tmpDir, "skills")
|
||||
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
|
||||
os.Chtimes(skillsDir, future, future)
|
||||
|
||||
// Verify sourceFilesChangedLocked detects it (cache is rebuilt)
|
||||
|
||||
// We confirm by checking internal state: a second call should rebuild.
|
||||
|
||||
cb.systemPromptMutex.RLock()
|
||||
|
||||
changed := cb.sourceFilesChangedLocked()
|
||||
|
||||
cb.systemPromptMutex.RUnlock()
|
||||
|
||||
if !changed {
|
||||
t.Error("sourceFilesChangedLocked() should detect skills dir mtime change")
|
||||
}
|
||||
|
|
@ -215,17 +299,22 @@ func TestMtimeAutoInvalidation(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestExplicitInvalidateCache verifies that InvalidateCache() forces a rebuild
|
||||
|
||||
// even when source files haven't changed (useful for tests and reload commands).
|
||||
|
||||
func TestExplicitInvalidateCache(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"IDENTITY.md": "# Test Identity",
|
||||
})
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
|
||||
sp1 := cb.BuildSystemPromptWithCache()
|
||||
|
||||
cb.InvalidateCache()
|
||||
|
||||
sp2 := cb.BuildSystemPromptWithCache()
|
||||
|
||||
if sp1 != sp2 {
|
||||
|
|
@ -233,29 +322,39 @@ func TestExplicitInvalidateCache(t *testing.T) {
|
|||
}
|
||||
|
||||
// Verify cachedAt was reset
|
||||
|
||||
cb.InvalidateCache()
|
||||
|
||||
cb.systemPromptMutex.RLock()
|
||||
|
||||
if !cb.cachedAt.IsZero() {
|
||||
t.Error("cachedAt should be zero after InvalidateCache()")
|
||||
}
|
||||
|
||||
cb.systemPromptMutex.RUnlock()
|
||||
}
|
||||
|
||||
// TestCacheStability verifies that the static prompt is stable across repeated calls
|
||||
|
||||
// when no files change (regression test for issue #607).
|
||||
|
||||
func TestCacheStability(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"IDENTITY.md": "# Identity\nContent",
|
||||
|
||||
"SOUL.md": "# Soul\nContent",
|
||||
})
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
|
||||
results := make([]string, 5)
|
||||
|
||||
for i := range results {
|
||||
results[i] = cb.BuildSystemPromptWithCache()
|
||||
}
|
||||
|
||||
for i := 1; i < len(results); i++ {
|
||||
if results[i] != results[0] {
|
||||
t.Errorf("cached prompt changed between call 0 and %d", i)
|
||||
|
|
@ -263,32 +362,47 @@ func TestCacheStability(t *testing.T) {
|
|||
}
|
||||
|
||||
// Static prompt must NOT contain per-request data
|
||||
|
||||
if strings.Contains(results[0], "Current Time") {
|
||||
t.Error("static cached prompt should not contain time (added dynamically)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewFileCreationInvalidatesCache verifies that creating a source file that
|
||||
|
||||
// did not exist when the cache was built triggers a cache rebuild.
|
||||
|
||||
// This catches the "from nothing to something" edge case that the old
|
||||
|
||||
// modifiedSince (return false on stat error) would miss.
|
||||
|
||||
func TestNewFileCreationInvalidatesCache(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
file string // relative path inside workspace
|
||||
|
||||
content string
|
||||
|
||||
checkField string // substring to verify in rebuilt prompt
|
||||
}{
|
||||
{
|
||||
name: "new bootstrap file",
|
||||
|
||||
file: "SOUL.md",
|
||||
|
||||
content: "# Soul\nBe kind and helpful.",
|
||||
|
||||
checkField: "Be kind and helpful",
|
||||
},
|
||||
|
||||
{
|
||||
name: "new memory file",
|
||||
|
||||
file: "memory/MEMORY.md",
|
||||
|
||||
content: "# Memory\nUser prefers dark mode.",
|
||||
|
||||
checkField: "User prefers dark mode",
|
||||
},
|
||||
}
|
||||
|
|
@ -296,29 +410,41 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Start with an empty workspace (no bootstrap/memory files)
|
||||
|
||||
tmpDir := setupWorkspace(t, nil)
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
|
||||
// Populate cache — file does not exist yet
|
||||
|
||||
sp1 := cb.BuildSystemPromptWithCache()
|
||||
|
||||
if strings.Contains(sp1, tt.checkField) {
|
||||
t.Fatalf("prompt should not contain %q before file is created", tt.checkField)
|
||||
}
|
||||
|
||||
// Create the file after cache was built
|
||||
|
||||
fullPath := filepath.Join(tmpDir, tt.file)
|
||||
|
||||
os.MkdirAll(filepath.Dir(fullPath), 0o755)
|
||||
|
||||
if err := os.WriteFile(fullPath, []byte(tt.content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Set future mtime to guarantee detection
|
||||
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
|
||||
os.Chtimes(fullPath, future, future)
|
||||
|
||||
// Cache should auto-invalidate because file went from absent -> present
|
||||
|
||||
sp2 := cb.BuildSystemPromptWithCache()
|
||||
|
||||
if !strings.Contains(sp2, tt.checkField) {
|
||||
t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField)
|
||||
}
|
||||
|
|
@ -327,110 +453,163 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestSkillFileContentChange verifies that modifying a skill file's content
|
||||
|
||||
// (not just the directory structure) invalidates the cache.
|
||||
|
||||
// This is the scenario where directory mtime alone is insufficient — on most
|
||||
|
||||
// filesystems, editing a file inside a directory does NOT update the parent
|
||||
|
||||
// directory's mtime.
|
||||
|
||||
func TestSkillFileContentChange(t *testing.T) {
|
||||
skillMD := `---
|
||||
|
||||
name: test-skill
|
||||
|
||||
description: "A test skill"
|
||||
|
||||
---
|
||||
|
||||
# Test Skill v1
|
||||
|
||||
Original content.`
|
||||
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"skills/test-skill/SKILL.md": skillMD,
|
||||
})
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
|
||||
// Populate cache
|
||||
|
||||
sp1 := cb.BuildSystemPromptWithCache()
|
||||
|
||||
_ = sp1 // cache is warm
|
||||
|
||||
// Modify the skill file content (without touching the skills/ directory)
|
||||
|
||||
updatedSkillMD := `---
|
||||
|
||||
name: test-skill
|
||||
|
||||
description: "An updated test skill"
|
||||
|
||||
---
|
||||
|
||||
# Test Skill v2
|
||||
|
||||
Updated content.`
|
||||
|
||||
skillPath := filepath.Join(tmpDir, "skills", "test-skill", "SKILL.md")
|
||||
|
||||
if err := os.WriteFile(skillPath, []byte(updatedSkillMD), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Set future mtime on the skill file only (NOT the directory)
|
||||
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
|
||||
os.Chtimes(skillPath, future, future)
|
||||
|
||||
// Verify that sourceFilesChangedLocked detects the content change
|
||||
|
||||
cb.systemPromptMutex.RLock()
|
||||
|
||||
changed := cb.sourceFilesChangedLocked()
|
||||
|
||||
cb.systemPromptMutex.RUnlock()
|
||||
|
||||
if !changed {
|
||||
t.Error("sourceFilesChangedLocked() should detect skill file content change")
|
||||
}
|
||||
|
||||
// Verify cache is actually rebuilt with new content
|
||||
|
||||
sp2 := cb.BuildSystemPromptWithCache()
|
||||
|
||||
if sp1 == sp2 && strings.Contains(sp1, "test-skill") {
|
||||
// If the skill appeared in the prompt and the prompt didn't change,
|
||||
|
||||
// the cache was not invalidated.
|
||||
|
||||
t.Error("cache should be invalidated when skill file content changes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
|
||||
|
||||
// can safely call BuildSystemPromptWithCache concurrently without producing
|
||||
|
||||
// empty results, panics, or data races.
|
||||
|
||||
// Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache
|
||||
|
||||
func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
||||
tmpDir := setupWorkspace(t, map[string]string{
|
||||
"IDENTITY.md": "# Identity\nConcurrency test agent.",
|
||||
|
||||
"SOUL.md": "# Soul\nBe helpful.",
|
||||
|
||||
"memory/MEMORY.md": "# Memory\nUser prefers Go.",
|
||||
|
||||
"skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo",
|
||||
})
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
|
||||
const goroutines = 20
|
||||
|
||||
const iterations = 50
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
errs := make(chan string, goroutines*iterations)
|
||||
|
||||
for g := range goroutines {
|
||||
wg.Add(1)
|
||||
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
|
||||
for i := range iterations {
|
||||
result := cb.BuildSystemPromptWithCache()
|
||||
|
||||
if result == "" {
|
||||
errs <- "empty prompt returned"
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "picoclaw") {
|
||||
errs <- "prompt missing identity"
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Also exercise BuildMessages concurrently
|
||||
|
||||
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat")
|
||||
|
||||
if len(msgs) < 2 {
|
||||
errs <- "BuildMessages returned fewer than 2 messages"
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if msgs[0].Role != "system" {
|
||||
errs <- "first message not system"
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Occasionally invalidate to exercise the write path
|
||||
|
||||
if i%10 == 0 {
|
||||
cb.InvalidateCache()
|
||||
}
|
||||
|
|
@ -439,6 +618,7 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
|||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
close(errs)
|
||||
|
||||
for errMsg := range errs {
|
||||
|
|
@ -449,64 +629,90 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
|||
// BenchmarkBuildMessagesWithCache measures caching performance.
|
||||
|
||||
// TestEmptyWorkspaceBaselineDetectsNewFiles verifies that when the cache is
|
||||
|
||||
// built on an empty workspace (no tracked files exist), creating a file
|
||||
|
||||
// afterwards still triggers cache invalidation. This validates the
|
||||
|
||||
// time.Unix(1, 0) fallback for maxMtime: any real file's mtime is after epoch,
|
||||
|
||||
// so fileChangedSince correctly detects the absent -> present transition AND
|
||||
|
||||
// the mtime comparison succeeds even without artificially inflated Chtimes.
|
||||
|
||||
func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) {
|
||||
// Empty workspace: no bootstrap files, no memory, no skills content.
|
||||
|
||||
tmpDir := setupWorkspace(t, nil)
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
|
||||
// Build cache — all tracked files are absent, maxMtime falls back to epoch.
|
||||
|
||||
sp1 := cb.BuildSystemPromptWithCache()
|
||||
|
||||
// Create a bootstrap file with natural mtime (no Chtimes manipulation).
|
||||
|
||||
// The file's mtime should be the current wall-clock time, which is
|
||||
|
||||
// strictly after time.Unix(1, 0).
|
||||
|
||||
soulPath := filepath.Join(tmpDir, "SOUL.md")
|
||||
|
||||
if err := os.WriteFile(soulPath, []byte("# Soul\nNewly created."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Cache should detect the new file via existedAtCache (absent -> present).
|
||||
|
||||
cb.systemPromptMutex.RLock()
|
||||
|
||||
changed := cb.sourceFilesChangedLocked()
|
||||
|
||||
cb.systemPromptMutex.RUnlock()
|
||||
|
||||
if !changed {
|
||||
t.Fatal("sourceFilesChangedLocked should detect newly created file on empty workspace")
|
||||
}
|
||||
|
||||
sp2 := cb.BuildSystemPromptWithCache()
|
||||
|
||||
if !strings.Contains(sp2, "Newly created") {
|
||||
t.Error("rebuilt prompt should contain new file content")
|
||||
}
|
||||
|
||||
if sp1 == sp2 {
|
||||
t.Error("cache should have been invalidated after file creation")
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuildMessagesWithCache measures caching performance.
|
||||
|
||||
func BenchmarkBuildMessagesWithCache(b *testing.B) {
|
||||
tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*")
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
|
||||
|
||||
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
|
||||
|
||||
for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} {
|
||||
os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644)
|
||||
}
|
||||
|
||||
cb := NewContextBuilder(tmpDir)
|
||||
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "previous message"},
|
||||
|
||||
{Role: "assistant", Content: "previous response"},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ func msg(role, content string) providers.Message {
|
|||
|
||||
func assistantWithTools(toolIDs ...string) providers.Message {
|
||||
calls := make([]providers.ToolCall, len(toolIDs))
|
||||
|
||||
for i, id := range toolIDs {
|
||||
calls[i] = providers.ToolCall{ID: id, Type: "function"}
|
||||
}
|
||||
|
||||
return providers.Message{Role: "assistant", ToolCalls: calls}
|
||||
}
|
||||
|
||||
|
|
@ -24,11 +26,13 @@ func toolResult(id string) providers.Message {
|
|||
|
||||
func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) {
|
||||
result := sanitizeHistoryForProvider(nil)
|
||||
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("expected empty, got %d messages", len(result))
|
||||
}
|
||||
|
||||
result = sanitizeHistoryForProvider([]providers.Message{})
|
||||
|
||||
if len(result) != 0 {
|
||||
t.Fatalf("expected empty, got %d messages", len(result))
|
||||
}
|
||||
|
|
@ -37,170 +41,228 @@ func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) {
|
|||
func TestSanitizeHistoryForProvider_SingleToolCall(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
msg("user", "hello"),
|
||||
|
||||
assistantWithTools("A"),
|
||||
|
||||
toolResult("A"),
|
||||
|
||||
msg("assistant", "done"),
|
||||
}
|
||||
|
||||
result := sanitizeHistoryForProvider(history)
|
||||
|
||||
if len(result) != 4 {
|
||||
t.Fatalf("expected 4 messages, got %d", len(result))
|
||||
}
|
||||
|
||||
assertRoles(t, result, "user", "assistant", "tool", "assistant")
|
||||
}
|
||||
|
||||
func TestSanitizeHistoryForProvider_MultiToolCalls(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
msg("user", "do two things"),
|
||||
|
||||
assistantWithTools("A", "B"),
|
||||
|
||||
toolResult("A"),
|
||||
|
||||
toolResult("B"),
|
||||
|
||||
msg("assistant", "both done"),
|
||||
}
|
||||
|
||||
result := sanitizeHistoryForProvider(history)
|
||||
|
||||
if len(result) != 5 {
|
||||
t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result))
|
||||
}
|
||||
|
||||
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant")
|
||||
}
|
||||
|
||||
func TestSanitizeHistoryForProvider_AssistantToolCallAfterPlainAssistant(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
msg("user", "hi"),
|
||||
|
||||
msg("assistant", "thinking"),
|
||||
|
||||
assistantWithTools("A"),
|
||||
|
||||
toolResult("A"),
|
||||
}
|
||||
|
||||
result := sanitizeHistoryForProvider(history)
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
|
||||
}
|
||||
|
||||
assertRoles(t, result, "user", "assistant")
|
||||
}
|
||||
|
||||
func TestSanitizeHistoryForProvider_OrphanedLeadingTool(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
toolResult("A"),
|
||||
|
||||
msg("user", "hello"),
|
||||
}
|
||||
|
||||
result := sanitizeHistoryForProvider(history)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
|
||||
}
|
||||
|
||||
assertRoles(t, result, "user")
|
||||
}
|
||||
|
||||
func TestSanitizeHistoryForProvider_ToolAfterUserDropped(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
msg("user", "hello"),
|
||||
|
||||
toolResult("A"),
|
||||
}
|
||||
|
||||
result := sanitizeHistoryForProvider(history)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
|
||||
}
|
||||
|
||||
assertRoles(t, result, "user")
|
||||
}
|
||||
|
||||
func TestSanitizeHistoryForProvider_ToolAfterAssistantNoToolCalls(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
msg("user", "hello"),
|
||||
|
||||
msg("assistant", "hi"),
|
||||
|
||||
toolResult("A"),
|
||||
}
|
||||
|
||||
result := sanitizeHistoryForProvider(history)
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
|
||||
}
|
||||
|
||||
assertRoles(t, result, "user", "assistant")
|
||||
}
|
||||
|
||||
func TestSanitizeHistoryForProvider_AssistantToolCallAtStart(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
assistantWithTools("A"),
|
||||
|
||||
toolResult("A"),
|
||||
|
||||
msg("user", "hello"),
|
||||
}
|
||||
|
||||
result := sanitizeHistoryForProvider(history)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
|
||||
}
|
||||
|
||||
assertRoles(t, result, "user")
|
||||
}
|
||||
|
||||
func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
msg("user", "do two things"),
|
||||
|
||||
assistantWithTools("A", "B"),
|
||||
|
||||
toolResult("A"),
|
||||
|
||||
toolResult("B"),
|
||||
|
||||
msg("assistant", "done"),
|
||||
|
||||
msg("user", "hi"),
|
||||
|
||||
assistantWithTools("C"),
|
||||
|
||||
toolResult("C"),
|
||||
|
||||
msg("assistant", "done again"),
|
||||
}
|
||||
|
||||
result := sanitizeHistoryForProvider(history)
|
||||
|
||||
if len(result) != 9 {
|
||||
t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
|
||||
}
|
||||
|
||||
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "user", "assistant", "tool", "assistant")
|
||||
}
|
||||
|
||||
func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
msg("user", "start"),
|
||||
|
||||
assistantWithTools("A", "B"),
|
||||
|
||||
toolResult("A"),
|
||||
|
||||
toolResult("B"),
|
||||
|
||||
assistantWithTools("C", "D"),
|
||||
|
||||
toolResult("C"),
|
||||
|
||||
toolResult("D"),
|
||||
|
||||
msg("assistant", "all done"),
|
||||
}
|
||||
|
||||
result := sanitizeHistoryForProvider(history)
|
||||
|
||||
if len(result) != 8 {
|
||||
t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result))
|
||||
}
|
||||
|
||||
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "tool", "tool", "assistant")
|
||||
}
|
||||
|
||||
func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
msg("user", "hello"),
|
||||
|
||||
msg("assistant", "hi"),
|
||||
|
||||
msg("user", "how are you"),
|
||||
|
||||
msg("assistant", "fine"),
|
||||
}
|
||||
|
||||
result := sanitizeHistoryForProvider(history)
|
||||
|
||||
if len(result) != 4 {
|
||||
t.Fatalf("expected 4 messages, got %d", len(result))
|
||||
}
|
||||
|
||||
assertRoles(t, result, "user", "assistant", "user", "assistant")
|
||||
}
|
||||
|
||||
func roles(msgs []providers.Message) []string {
|
||||
r := make([]string, len(msgs))
|
||||
|
||||
for i, m := range msgs {
|
||||
r[i] = m.Role
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
|
||||
t.Helper()
|
||||
|
||||
if len(msgs) != len(expected) {
|
||||
t.Fatalf("role count mismatch: got %v, want %v", roles(msgs), expected)
|
||||
}
|
||||
|
||||
for i, exp := range expected {
|
||||
if msgs[i].Role != exp {
|
||||
t.Errorf("message[%d]: got role %q, want %q", i, msgs[i].Role, exp)
|
||||
|
|
|
|||
|
|
@ -17,90 +17,162 @@ import (
|
|||
)
|
||||
|
||||
// AgentInstance represents a fully configured agent with its own workspace,
|
||||
|
||||
// session manager, context builder, and tool registry.
|
||||
|
||||
type AgentInstance struct {
|
||||
ID string
|
||||
|
||||
Name string
|
||||
|
||||
Model string
|
||||
|
||||
Fallbacks []string
|
||||
|
||||
Workspace string
|
||||
|
||||
MaxIterations int
|
||||
|
||||
TaskReminderInterval int
|
||||
|
||||
MaxTokens int
|
||||
|
||||
Temperature float64
|
||||
|
||||
ContextWindow int
|
||||
|
||||
Provider providers.LLMProvider
|
||||
Sessions *session.SessionManager
|
||||
|
||||
Sessions *session.LegacyAdapter
|
||||
|
||||
ContextBuilder *ContextBuilder
|
||||
|
||||
Tools *tools.ToolRegistry
|
||||
|
||||
Subagents *config.SubagentsConfig
|
||||
|
||||
SkillsFilter []string
|
||||
|
||||
Candidates []providers.FallbackCandidate
|
||||
|
||||
PlanModel string
|
||||
|
||||
PlanFallbacks []string
|
||||
|
||||
PlanCandidates []providers.FallbackCandidate
|
||||
|
||||
// SubagentMgr is set during registerSharedTools when orchestration is enabled.
|
||||
|
||||
// Used by runAgentLoop to wait for spawned subagents before worktree cleanup.
|
||||
|
||||
SubagentMgr *tools.SubagentManager
|
||||
|
||||
// Interview staleness tracking: consecutive turns where MEMORY.md was not updated.
|
||||
|
||||
interviewStaleCount int
|
||||
|
||||
interviewMemoryLen int
|
||||
|
||||
// Per-session worktree isolation
|
||||
|
||||
worktrees map[string]*git.WorktreeInfo // sessionKey → worktree
|
||||
|
||||
worktreeMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewAgentInstance creates an agent instance from config.
|
||||
|
||||
func NewAgentInstance(
|
||||
agentCfg *config.AgentConfig,
|
||||
|
||||
defaults *config.AgentDefaults,
|
||||
|
||||
cfg *config.Config,
|
||||
|
||||
provider providers.LLMProvider,
|
||||
) *AgentInstance {
|
||||
workspace := resolveAgentWorkspace(agentCfg, defaults)
|
||||
|
||||
os.MkdirAll(workspace, 0o755)
|
||||
|
||||
model := resolveAgentModel(agentCfg, defaults)
|
||||
|
||||
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
||||
|
||||
restrict := defaults.RestrictToWorkspace
|
||||
|
||||
toolsRegistry := tools.NewToolRegistry()
|
||||
|
||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
|
||||
|
||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
|
||||
|
||||
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
|
||||
|
||||
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
||||
}
|
||||
|
||||
toolsRegistry.Register(execTool)
|
||||
|
||||
toolsRegistry.Register(tools.NewBgMonitorTool(execTool))
|
||||
|
||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
|
||||
|
||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
|
||||
|
||||
toolsRegistry.Register(tools.NewLogsTool())
|
||||
|
||||
toolsRegistry.Register(tools.NewGitPushTool())
|
||||
|
||||
toolsRegistry.Register(tools.NewCreatePRTool())
|
||||
|
||||
sessionsDir := filepath.Join(workspace, "sessions")
|
||||
sessionsManager := session.NewSessionManager(sessionsDir)
|
||||
dbPath := filepath.Join(workspace, "sessions.db")
|
||||
|
||||
store, err := session.OpenSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open session store: %v", err)
|
||||
}
|
||||
|
||||
jsonDir := filepath.Join(workspace, "sessions")
|
||||
|
||||
if n, merr := session.MigrateJSONSessions(jsonDir, store); merr != nil {
|
||||
log.Printf("session migration: %d migrated, error: %v", n, merr)
|
||||
} else if n > 0 {
|
||||
log.Printf("session migration: %d sessions migrated to SQLite", n)
|
||||
}
|
||||
|
||||
if n, perr := store.Prune(session.DefaultPruneTTL); perr != nil {
|
||||
log.Printf("session prune error: %v", perr)
|
||||
} else if n > 0 {
|
||||
log.Printf("session prune: %d old sessions removed", n)
|
||||
}
|
||||
|
||||
sessionsManager := session.NewLegacyAdapter(store)
|
||||
|
||||
contextBuilder := NewContextBuilder(workspace)
|
||||
|
||||
agentID := routing.DefaultAgentID
|
||||
|
||||
agentName := ""
|
||||
|
||||
var subagents *config.SubagentsConfig
|
||||
|
||||
var skillsFilter []string
|
||||
|
||||
if agentCfg != nil {
|
||||
agentID = routing.NormalizeAgentID(agentCfg.ID)
|
||||
|
||||
agentName = agentCfg.Name
|
||||
|
||||
subagents = agentCfg.Subagents
|
||||
|
||||
skillsFilter = agentCfg.Skills
|
||||
}
|
||||
|
||||
// Apply defaults.Orchestration: if the flag is set, ensure orchestration is enabled.
|
||||
|
||||
if defaults.Orchestration {
|
||||
if subagents == nil {
|
||||
subagents = &config.SubagentsConfig{Enabled: true}
|
||||
|
|
@ -110,43 +182,54 @@ func NewAgentInstance(
|
|||
}
|
||||
|
||||
maxIter := defaults.MaxToolIterations
|
||||
|
||||
if maxIter == 0 {
|
||||
maxIter = 20
|
||||
}
|
||||
|
||||
reminderInterval := defaults.TaskReminderInterval
|
||||
|
||||
if reminderInterval == 0 {
|
||||
reminderInterval = 5
|
||||
}
|
||||
|
||||
maxTokens := defaults.MaxTokens
|
||||
|
||||
if maxTokens == 0 {
|
||||
maxTokens = 8192
|
||||
}
|
||||
|
||||
temperature := 0.7
|
||||
|
||||
if defaults.Temperature != nil {
|
||||
temperature = *defaults.Temperature
|
||||
}
|
||||
|
||||
// Resolve fallback candidates
|
||||
|
||||
modelCfg := providers.ModelConfig{
|
||||
Primary: model,
|
||||
|
||||
Fallbacks: fallbacks,
|
||||
}
|
||||
|
||||
resolveFromModelList := func(raw string) (string, bool) {
|
||||
ensureProtocol := func(model string) string {
|
||||
model = strings.TrimSpace(model)
|
||||
|
||||
if model == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.Contains(model, "/") {
|
||||
return model
|
||||
}
|
||||
|
||||
return "openai/" + model
|
||||
}
|
||||
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
|
|
@ -158,13 +241,17 @@ func NewAgentInstance(
|
|||
|
||||
for i := range cfg.ModelList {
|
||||
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
|
||||
|
||||
if fullModel == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if fullModel == raw {
|
||||
return ensureProtocol(fullModel), true
|
||||
}
|
||||
|
||||
_, modelID := providers.ExtractProtocol(fullModel)
|
||||
|
||||
if modelID == raw {
|
||||
return ensureProtocol(fullModel), true
|
||||
}
|
||||
|
|
@ -177,107 +264,155 @@ func NewAgentInstance(
|
|||
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
||||
|
||||
// Resolve plan model (for interviewing/review phases)
|
||||
|
||||
planModel := resolvePlanModel(agentCfg, defaults)
|
||||
|
||||
planFallbacks := resolvePlanFallbacks(agentCfg, defaults)
|
||||
|
||||
var planCandidates []providers.FallbackCandidate
|
||||
|
||||
if planModel != "" {
|
||||
planModelCfg := providers.ModelConfig{
|
||||
Primary: planModel,
|
||||
|
||||
Fallbacks: planFallbacks,
|
||||
}
|
||||
|
||||
planCandidates = providers.ResolveCandidates(planModelCfg, defaults.Provider)
|
||||
}
|
||||
|
||||
// Startup cleanup: prune orphaned worktrees
|
||||
|
||||
worktreesDir := filepath.Join(workspace, ".worktrees")
|
||||
|
||||
if repoRoot := git.FindRepoRoot(workspace); repoRoot != "" {
|
||||
git.PruneOrphaned(repoRoot, worktreesDir)
|
||||
}
|
||||
|
||||
return &AgentInstance{
|
||||
ID: agentID,
|
||||
|
||||
Name: agentName,
|
||||
|
||||
Model: model,
|
||||
|
||||
Fallbacks: fallbacks,
|
||||
|
||||
Workspace: workspace,
|
||||
|
||||
MaxIterations: maxIter,
|
||||
|
||||
TaskReminderInterval: reminderInterval,
|
||||
|
||||
MaxTokens: maxTokens,
|
||||
|
||||
Temperature: temperature,
|
||||
|
||||
ContextWindow: maxTokens,
|
||||
|
||||
Provider: provider,
|
||||
|
||||
Sessions: sessionsManager,
|
||||
|
||||
ContextBuilder: contextBuilder,
|
||||
|
||||
Tools: toolsRegistry,
|
||||
|
||||
Subagents: subagents,
|
||||
|
||||
SkillsFilter: skillsFilter,
|
||||
|
||||
Candidates: candidates,
|
||||
|
||||
PlanModel: planModel,
|
||||
|
||||
PlanFallbacks: planFallbacks,
|
||||
|
||||
PlanCandidates: planCandidates,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAgentWorkspace determines the workspace directory for an agent.
|
||||
|
||||
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
||||
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
||||
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
||||
}
|
||||
|
||||
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
|
||||
return expandHome(defaults.Workspace)
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
id := routing.NormalizeAgentID(agentCfg.ID)
|
||||
|
||||
return filepath.Join(home, ".picoclaw", "workspace-"+id)
|
||||
}
|
||||
|
||||
// resolveAgentModel resolves the primary model for an agent.
|
||||
|
||||
func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
||||
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
|
||||
return strings.TrimSpace(agentCfg.Model.Primary)
|
||||
}
|
||||
|
||||
return defaults.GetModelName()
|
||||
}
|
||||
|
||||
// resolveAgentFallbacks resolves the fallback models for an agent.
|
||||
|
||||
func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
|
||||
if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil {
|
||||
return agentCfg.Model.Fallbacks
|
||||
}
|
||||
|
||||
return defaults.ModelFallbacks
|
||||
}
|
||||
|
||||
// resolvePlanModel resolves the plan model for an agent (used during interviewing/review phases).
|
||||
|
||||
func resolvePlanModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
||||
if agentCfg != nil && agentCfg.PlanModel != nil && strings.TrimSpace(agentCfg.PlanModel.Primary) != "" {
|
||||
return strings.TrimSpace(agentCfg.PlanModel.Primary)
|
||||
}
|
||||
|
||||
return defaults.PlanModel
|
||||
}
|
||||
|
||||
// resolvePlanFallbacks resolves the plan model fallbacks for an agent.
|
||||
|
||||
func resolvePlanFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
|
||||
if agentCfg != nil && agentCfg.PlanModel != nil && agentCfg.PlanModel.Fallbacks != nil {
|
||||
return agentCfg.PlanModel.Fallbacks
|
||||
}
|
||||
|
||||
return defaults.PlanModelFallbacks
|
||||
}
|
||||
|
||||
// ActivateWorktree creates a worktree for a session.
|
||||
|
||||
// projectDir is the git repository to create the worktree in.
|
||||
|
||||
// If empty, falls back to ai.Workspace.
|
||||
|
||||
// Worktree path: <workspace>/.worktrees/<branch-basename>/
|
||||
|
||||
func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName, projectDir string) (*git.WorktreeInfo, error) {
|
||||
if projectDir == "" {
|
||||
projectDir = ai.Workspace
|
||||
}
|
||||
|
||||
repoRoot := git.FindRepoRoot(projectDir)
|
||||
|
||||
if repoRoot == "" {
|
||||
return nil, fmt.Errorf("directory is not a git repository: %s", projectDir)
|
||||
}
|
||||
|
||||
branchName := git.SanitizeBranchName(taskName)
|
||||
|
||||
baseName := git.BranchBaseName(branchName)
|
||||
|
||||
wtPath := filepath.Join(ai.Workspace, ".worktrees", baseName)
|
||||
|
||||
wt, err := git.CreateWorktree(repoRoot, wtPath, branchName)
|
||||
|
|
@ -286,22 +421,29 @@ func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName, projectDir strin
|
|||
}
|
||||
|
||||
ai.worktreeMu.Lock()
|
||||
|
||||
if ai.worktrees == nil {
|
||||
ai.worktrees = make(map[string]*git.WorktreeInfo)
|
||||
}
|
||||
|
||||
ai.worktrees[sessionKey] = wt
|
||||
|
||||
ai.worktreeMu.Unlock()
|
||||
|
||||
return wt, nil
|
||||
}
|
||||
|
||||
// DeactivateWorktree safe-disposes the session's worktree.
|
||||
|
||||
func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discard bool) (*git.DisposeResult, error) {
|
||||
ai.worktreeMu.Lock()
|
||||
|
||||
wt, ok := ai.worktrees[sessionKey]
|
||||
|
||||
if ok {
|
||||
delete(ai.worktrees, sessionKey)
|
||||
}
|
||||
|
||||
ai.worktreeMu.Unlock()
|
||||
|
||||
if !ok || wt == nil {
|
||||
|
|
@ -309,44 +451,55 @@ func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discar
|
|||
}
|
||||
|
||||
repoRoot := git.FindRepoRoot(ai.Workspace)
|
||||
|
||||
if repoRoot == "" {
|
||||
return nil, fmt.Errorf("workspace is not a git repository")
|
||||
}
|
||||
|
||||
// Even on discard, SafeDispose auto-commits first for safety
|
||||
|
||||
if commitMsg != "" && git.HasUncommittedChanges(wt.Path) {
|
||||
_ = git.AutoCommit(wt.Path, commitMsg)
|
||||
}
|
||||
|
||||
result := git.SafeDispose(repoRoot, wt)
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetWorktree returns the session's active worktree, or nil.
|
||||
|
||||
func (ai *AgentInstance) GetWorktree(sessionKey string) *git.WorktreeInfo {
|
||||
ai.worktreeMu.RLock()
|
||||
|
||||
defer ai.worktreeMu.RUnlock()
|
||||
|
||||
return ai.worktrees[sessionKey]
|
||||
}
|
||||
|
||||
// IsInWorktree returns true if the session has an active worktree.
|
||||
|
||||
func (ai *AgentInstance) IsInWorktree(sessionKey string) bool {
|
||||
return ai.GetWorktree(sessionKey) != nil
|
||||
}
|
||||
|
||||
// EffectiveWorkspace returns worktree path for session, or original Workspace.
|
||||
|
||||
func (ai *AgentInstance) EffectiveWorkspace(sessionKey string) string {
|
||||
if wt := ai.GetWorktree(sessionKey); wt != nil {
|
||||
return wt.Path
|
||||
}
|
||||
|
||||
return ai.Workspace
|
||||
}
|
||||
|
||||
// GetWorktreeBranch returns the branch name for the session's worktree, or "".
|
||||
|
||||
func (ai *AgentInstance) GetWorktreeBranch(sessionKey string) string {
|
||||
if wt := ai.GetWorktree(sessionKey); wt != nil {
|
||||
return wt.Branch
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
@ -354,12 +507,16 @@ func expandHome(path string) string {
|
|||
if path == "" {
|
||||
return path
|
||||
}
|
||||
|
||||
if path[0] == '~' {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
if len(path) > 1 && path[1] == '/' {
|
||||
return home + path[1:]
|
||||
}
|
||||
|
||||
return home
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,28 +12,35 @@ func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
|
||||
Model: "test-model",
|
||||
|
||||
MaxTokens: 1234,
|
||||
|
||||
MaxToolIterations: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
configuredTemp := 1.0
|
||||
|
||||
cfg.Agents.Defaults.Temperature = &configuredTemp
|
||||
|
||||
provider := &mockProvider{}
|
||||
|
||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||
|
||||
if agent.MaxTokens != 1234 {
|
||||
t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234)
|
||||
}
|
||||
|
||||
if agent.Temperature != 1.0 {
|
||||
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 1.0)
|
||||
}
|
||||
|
|
@ -44,23 +51,29 @@ func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
|
||||
Model: "test-model",
|
||||
|
||||
MaxTokens: 1234,
|
||||
|
||||
MaxToolIterations: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
configuredTemp := 0.0
|
||||
|
||||
cfg.Agents.Defaults.Temperature = &configuredTemp
|
||||
|
||||
provider := &mockProvider{}
|
||||
|
||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||
|
||||
if agent.Temperature != 0.0 {
|
||||
|
|
@ -73,20 +86,25 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
|
||||
Model: "test-model",
|
||||
|
||||
MaxTokens: 1234,
|
||||
|
||||
MaxToolIterations: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provider := &mockProvider{}
|
||||
|
||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||
|
||||
if agent.Temperature != 0.7 {
|
||||
|
|
@ -99,33 +117,41 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
|
||||
Model: "step-3.5-flash",
|
||||
},
|
||||
},
|
||||
|
||||
ModelList: []config.ModelConfig{
|
||||
{
|
||||
ModelName: "step-3.5-flash",
|
||||
|
||||
Model: "openrouter/stepfun/step-3.5-flash:free",
|
||||
|
||||
APIBase: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provider := &mockProvider{}
|
||||
|
||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||
|
||||
if len(agent.Candidates) != 1 {
|
||||
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||
}
|
||||
|
||||
if agent.Candidates[0].Provider != "openrouter" {
|
||||
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter")
|
||||
}
|
||||
|
||||
if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" {
|
||||
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free")
|
||||
}
|
||||
|
|
@ -136,33 +162,41 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *
|
|||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
|
||||
Model: "glm-5",
|
||||
},
|
||||
},
|
||||
|
||||
ModelList: []config.ModelConfig{
|
||||
{
|
||||
ModelName: "glm-5",
|
||||
|
||||
Model: "glm-5",
|
||||
|
||||
APIBase: "https://api.z.ai/api/coding/paas/v4",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provider := &mockProvider{}
|
||||
|
||||
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||
|
||||
if len(agent.Candidates) != 1 {
|
||||
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||
}
|
||||
|
||||
if agent.Candidates[0].Provider != "openai" {
|
||||
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
|
||||
}
|
||||
|
||||
if agent.Candidates[0].Model != "glm-5" {
|
||||
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
|
||||
}
|
||||
|
|
|
|||
2483
pkg/agent/loop.go
2483
pkg/agent/loop.go
File diff suppressed because it is too large
Load diff
|
|
@ -12,63 +12,93 @@ import (
|
|||
)
|
||||
|
||||
// makeOrchTestLoop creates a minimal AgentLoop with a temp workspace and
|
||||
|
||||
// a real Broadcaster wired as the reporter.
|
||||
|
||||
// Returns the loop, the broadcaster, and a cleanup function.
|
||||
|
||||
func makeOrchTestLoop(t *testing.T) (*AgentLoop, *orch.Broadcaster) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "agent-orch-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { os.RemoveAll(tmpDir) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
|
||||
Model: "test-model",
|
||||
|
||||
MaxTokens: 512,
|
||||
|
||||
MaxToolIterations: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
|
||||
|
||||
b := orch.NewBroadcaster()
|
||||
|
||||
al.SetOrchReporter(b)
|
||||
|
||||
return al, b
|
||||
}
|
||||
|
||||
// collectOrchEvents drains the subscriber channel until an agent_gc event
|
||||
|
||||
// arrives or the deadline is exceeded.
|
||||
|
||||
func collectOrchEvents(t *testing.T, ch <-chan orch.Event, timeout time.Duration) []orch.Event {
|
||||
t.Helper()
|
||||
|
||||
var events []orch.Event
|
||||
|
||||
deadline := time.After(timeout)
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev := <-ch:
|
||||
|
||||
events = append(events, ev)
|
||||
|
||||
if ev.Type == "agent_gc" {
|
||||
return events
|
||||
}
|
||||
|
||||
case <-deadline:
|
||||
|
||||
t.Fatalf("timed out waiting for agent_gc; events so far: %+v", events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentLoop_ProcessDirect_EmitsSpawnWaitingGC verifies that a main
|
||||
|
||||
// session processed via ProcessDirect emits the full lifecycle:
|
||||
|
||||
//
|
||||
|
||||
// agent_spawn(sessionKey) → agent_state(waiting) → agent_gc(completed)
|
||||
|
||||
//
|
||||
|
||||
// and that the Broadcaster snapshot is empty after the call returns.
|
||||
|
||||
func TestAgentLoop_ProcessDirect_EmitsSpawnWaitingGC(t *testing.T) {
|
||||
al, b := makeOrchTestLoop(t)
|
||||
|
||||
sub := b.Subscribe()
|
||||
|
||||
defer b.Unsubscribe(sub)
|
||||
|
||||
const sessionKey = "orch-test-session"
|
||||
|
||||
_, err := al.ProcessDirect(context.Background(), "hello", sessionKey)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirect: %v", err)
|
||||
|
|
@ -77,39 +107,51 @@ func TestAgentLoop_ProcessDirect_EmitsSpawnWaitingGC(t *testing.T) {
|
|||
events := collectOrchEvents(t, sub.Ch, 5*time.Second)
|
||||
|
||||
// First event: agent_spawn with correct ID.
|
||||
|
||||
if events[0].Type != "agent_spawn" || events[0].ID != sessionKey {
|
||||
t.Errorf("first event must be agent_spawn(%s), got: %+v", sessionKey, events[0])
|
||||
}
|
||||
|
||||
// At least one agent_state(waiting) for this session.
|
||||
|
||||
var hasWaiting bool
|
||||
|
||||
for _, ev := range events {
|
||||
if ev.Type == "agent_state" && ev.ID == sessionKey && ev.State == "waiting" {
|
||||
hasWaiting = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasWaiting {
|
||||
t.Errorf("missing agent_state(waiting) for %s; events: %+v", sessionKey, events)
|
||||
}
|
||||
|
||||
// Last event: agent_gc(completed) for this session.
|
||||
|
||||
last := events[len(events)-1]
|
||||
|
||||
if last.Type != "agent_gc" || last.ID != sessionKey || last.Reason != "completed" {
|
||||
t.Errorf("last event must be agent_gc(completed,%s), got: %+v", sessionKey, last)
|
||||
}
|
||||
|
||||
// Snapshot must be empty — session removed on GC.
|
||||
|
||||
if snap := b.Snapshot(); len(snap) != 0 {
|
||||
t.Errorf("snapshot must be empty after GC, got: %v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentLoop_ProcessHeartbeat_EmitsSpawnAndGC verifies that heartbeat
|
||||
|
||||
// sessions appear on canvas with sessionKey = "heartbeat".
|
||||
|
||||
func TestAgentLoop_ProcessHeartbeat_EmitsSpawnAndGC(t *testing.T) {
|
||||
al, b := makeOrchTestLoop(t)
|
||||
|
||||
sub := b.Subscribe()
|
||||
|
||||
defer b.Unsubscribe(sub)
|
||||
|
||||
_, err := al.ProcessHeartbeat(context.Background(), "check system", "heartbeat-chan", "none")
|
||||
|
|
@ -120,12 +162,15 @@ func TestAgentLoop_ProcessHeartbeat_EmitsSpawnAndGC(t *testing.T) {
|
|||
events := collectOrchEvents(t, sub.Ch, 5*time.Second)
|
||||
|
||||
// ProcessHeartbeat always uses sessionKey = "heartbeat".
|
||||
|
||||
const want = "heartbeat"
|
||||
|
||||
if events[0].Type != "agent_spawn" || events[0].ID != want {
|
||||
t.Errorf("first event must be agent_spawn(%s), got: %+v", want, events[0])
|
||||
}
|
||||
|
||||
last := events[len(events)-1]
|
||||
|
||||
if last.Type != "agent_gc" || last.ID != want || last.Reason != "completed" {
|
||||
t.Errorf("last event must be agent_gc(completed,%s), got: %+v", want, last)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -9,101 +9,172 @@ import (
|
|||
|
||||
func newTestMemoryStore(t *testing.T) (*MemoryStore, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "memory-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
ms := NewMemoryStore(tmpDir)
|
||||
|
||||
return ms, func() { os.RemoveAll(tmpDir) }
|
||||
}
|
||||
|
||||
const testPlanInterviewing = `# Active Plan
|
||||
|
||||
|
||||
|
||||
> Task: Set up server monitoring
|
||||
|
||||
> Status: interviewing
|
||||
|
||||
> Phase: 1
|
||||
|
||||
`
|
||||
|
||||
const testPlanExecuting = `# Active Plan
|
||||
|
||||
|
||||
|
||||
> Task: Set up server monitoring
|
||||
|
||||
> Status: executing
|
||||
|
||||
> Phase: 2
|
||||
|
||||
|
||||
|
||||
## Phase 1: Prometheus Install
|
||||
|
||||
- [x] Install Prometheus
|
||||
|
||||
- [x] Configure node_exporter
|
||||
|
||||
|
||||
|
||||
## Phase 2: Grafana Setup
|
||||
|
||||
- [ ] Install Grafana
|
||||
|
||||
- [ ] Create dashboard
|
||||
|
||||
|
||||
|
||||
## Phase 3: Alert Configuration
|
||||
|
||||
- [ ] Set up alert rules
|
||||
|
||||
- [ ] Configure Telegram notifications
|
||||
|
||||
|
||||
|
||||
## Commands
|
||||
|
||||
build: go build ./...
|
||||
|
||||
test: go test ./pkg/... -count=1
|
||||
|
||||
lint: golangci-lint run
|
||||
|
||||
|
||||
|
||||
## Context
|
||||
|
||||
Pi: Debian Bookworm arm64, ports: 3000/9090
|
||||
|
||||
`
|
||||
|
||||
const testPlanPhase1Complete = `# Active Plan
|
||||
|
||||
|
||||
|
||||
> Task: Set up server monitoring
|
||||
|
||||
> Status: executing
|
||||
|
||||
> Phase: 1
|
||||
|
||||
|
||||
|
||||
## Phase 1: Prometheus Install
|
||||
|
||||
- [x] Install Prometheus
|
||||
|
||||
- [x] Configure node_exporter
|
||||
|
||||
|
||||
|
||||
## Phase 2: Grafana Setup
|
||||
|
||||
- [ ] Install Grafana
|
||||
|
||||
- [ ] Create dashboard
|
||||
|
||||
|
||||
|
||||
## Context
|
||||
|
||||
Pi: Debian Bookworm arm64
|
||||
|
||||
`
|
||||
|
||||
const testPlanAllComplete = `# Active Plan
|
||||
|
||||
|
||||
|
||||
> Task: Set up server monitoring
|
||||
|
||||
> Status: executing
|
||||
|
||||
> Phase: 2
|
||||
|
||||
|
||||
|
||||
## Phase 1: Prometheus Install
|
||||
|
||||
- [x] Install Prometheus
|
||||
|
||||
- [x] Configure node_exporter
|
||||
|
||||
|
||||
|
||||
## Phase 2: Grafana Setup
|
||||
|
||||
- [x] Install Grafana
|
||||
|
||||
- [x] Create dashboard
|
||||
|
||||
|
||||
|
||||
## Context
|
||||
|
||||
Pi: Debian Bookworm arm64
|
||||
|
||||
`
|
||||
|
||||
func TestHasActivePlan(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
// No plan
|
||||
|
||||
if ms.HasActivePlan() {
|
||||
t.Error("expected no active plan for empty memory")
|
||||
}
|
||||
|
||||
// With regular content
|
||||
|
||||
ms.WriteLongTerm("Some random notes")
|
||||
|
||||
if ms.HasActivePlan() {
|
||||
t.Error("expected no active plan for regular content")
|
||||
}
|
||||
|
||||
// With active plan
|
||||
|
||||
ms.WriteLongTerm(testPlanExecuting)
|
||||
|
||||
if !ms.HasActivePlan() {
|
||||
t.Error("expected active plan to be detected")
|
||||
}
|
||||
|
|
@ -111,21 +182,27 @@ func TestHasActivePlan(t *testing.T) {
|
|||
|
||||
func TestGetPlanStatus(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
// No plan
|
||||
|
||||
if status := ms.GetPlanStatus(); status != "" {
|
||||
t.Errorf("expected empty status, got %q", status)
|
||||
}
|
||||
|
||||
// Interviewing
|
||||
|
||||
ms.WriteLongTerm(testPlanInterviewing)
|
||||
|
||||
if status := ms.GetPlanStatus(); status != "interviewing" {
|
||||
t.Errorf("expected 'interviewing', got %q", status)
|
||||
}
|
||||
|
||||
// Executing
|
||||
|
||||
ms.WriteLongTerm(testPlanExecuting)
|
||||
|
||||
if status := ms.GetPlanStatus(); status != "executing" {
|
||||
t.Errorf("expected 'executing', got %q", status)
|
||||
}
|
||||
|
|
@ -133,15 +210,19 @@ func TestGetPlanStatus(t *testing.T) {
|
|||
|
||||
func TestGetCurrentPhase(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
// No plan
|
||||
|
||||
if phase := ms.GetCurrentPhase(); phase != 0 {
|
||||
t.Errorf("expected phase 0, got %d", phase)
|
||||
}
|
||||
|
||||
// Phase 2
|
||||
|
||||
ms.WriteLongTerm(testPlanExecuting)
|
||||
|
||||
if phase := ms.GetCurrentPhase(); phase != 2 {
|
||||
t.Errorf("expected phase 2, got %d", phase)
|
||||
}
|
||||
|
|
@ -149,15 +230,19 @@ func TestGetCurrentPhase(t *testing.T) {
|
|||
|
||||
func TestGetTotalPhases(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
// No plan
|
||||
|
||||
if total := ms.GetTotalPhases(); total != 0 {
|
||||
t.Errorf("expected 0 phases, got %d", total)
|
||||
}
|
||||
|
||||
// 3 phases
|
||||
|
||||
ms.WriteLongTerm(testPlanExecuting)
|
||||
|
||||
if total := ms.GetTotalPhases(); total != 3 {
|
||||
t.Errorf("expected 3 phases, got %d", total)
|
||||
}
|
||||
|
|
@ -165,22 +250,29 @@ func TestGetTotalPhases(t *testing.T) {
|
|||
|
||||
func TestIsPlanComplete(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
// Not complete
|
||||
|
||||
ms.WriteLongTerm(testPlanExecuting)
|
||||
|
||||
if ms.IsPlanComplete() {
|
||||
t.Error("expected plan to be incomplete")
|
||||
}
|
||||
|
||||
// All complete
|
||||
|
||||
ms.WriteLongTerm(testPlanAllComplete)
|
||||
|
||||
if !ms.IsPlanComplete() {
|
||||
t.Error("expected plan to be complete")
|
||||
}
|
||||
|
||||
// No plan
|
||||
|
||||
ms.ClearLongTerm()
|
||||
|
||||
if ms.IsPlanComplete() {
|
||||
t.Error("expected false when no plan exists")
|
||||
}
|
||||
|
|
@ -188,16 +280,21 @@ func TestIsPlanComplete(t *testing.T) {
|
|||
|
||||
func TestIsCurrentPhaseComplete(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
// Phase 2 not complete
|
||||
|
||||
ms.WriteLongTerm(testPlanExecuting)
|
||||
|
||||
if ms.IsCurrentPhaseComplete() {
|
||||
t.Error("expected current phase to be incomplete")
|
||||
}
|
||||
|
||||
// Phase 1 complete (current=1)
|
||||
|
||||
ms.WriteLongTerm(testPlanPhase1Complete)
|
||||
|
||||
if !ms.IsCurrentPhaseComplete() {
|
||||
t.Error("expected phase 1 to be complete")
|
||||
}
|
||||
|
|
@ -205,12 +302,15 @@ func TestIsCurrentPhaseComplete(t *testing.T) {
|
|||
|
||||
func TestSetStatus(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
ms.WriteLongTerm(testPlanInterviewing)
|
||||
|
||||
if err := ms.SetStatus("executing"); err != nil {
|
||||
t.Fatalf("SetStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if status := ms.GetPlanStatus(); status != "executing" {
|
||||
t.Errorf("expected 'executing', got %q", status)
|
||||
}
|
||||
|
|
@ -218,12 +318,15 @@ func TestSetStatus(t *testing.T) {
|
|||
|
||||
func TestAdvancePhase(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
ms.WriteLongTerm(testPlanPhase1Complete)
|
||||
|
||||
if err := ms.AdvancePhase(); err != nil {
|
||||
t.Fatalf("AdvancePhase failed: %v", err)
|
||||
}
|
||||
|
||||
if phase := ms.GetCurrentPhase(); phase != 2 {
|
||||
t.Errorf("expected phase 2 after advance, got %d", phase)
|
||||
}
|
||||
|
|
@ -231,37 +334,49 @@ func TestAdvancePhase(t *testing.T) {
|
|||
|
||||
func TestMarkStep(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
ms.WriteLongTerm(testPlanExecuting)
|
||||
|
||||
// Mark step 1 in phase 2
|
||||
|
||||
if err := ms.MarkStep(2, 1); err != nil {
|
||||
t.Fatalf("MarkStep failed: %v", err)
|
||||
}
|
||||
|
||||
content := ms.ReadLongTerm()
|
||||
|
||||
// Phase 2 should have first step checked
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
foundChecked := false
|
||||
|
||||
inPhase2 := false
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "## Phase 2:") {
|
||||
inPhase2 = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if inPhase2 && strings.HasPrefix(line, "## ") {
|
||||
break
|
||||
}
|
||||
|
||||
if inPhase2 && strings.HasPrefix(line, "- [x] Install Grafana") {
|
||||
foundChecked = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundChecked {
|
||||
t.Error("expected 'Install Grafana' to be marked [x]")
|
||||
}
|
||||
|
||||
// Error case: invalid step
|
||||
|
||||
if err := ms.MarkStep(2, 99); err == nil {
|
||||
t.Error("expected error for invalid step number")
|
||||
}
|
||||
|
|
@ -269,23 +384,29 @@ func TestMarkStep(t *testing.T) {
|
|||
|
||||
func TestAddStep(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
ms.WriteLongTerm(testPlanExecuting)
|
||||
|
||||
// Add step to phase 2
|
||||
|
||||
if err := ms.AddStep(2, "Test dashboard"); err != nil {
|
||||
t.Fatalf("AddStep failed: %v", err)
|
||||
}
|
||||
|
||||
content := ms.ReadLongTerm()
|
||||
|
||||
if !strings.Contains(content, "- [ ] Test dashboard") {
|
||||
t.Error("expected new step to be added")
|
||||
}
|
||||
|
||||
// Verify it's in the right place (before Phase 3)
|
||||
|
||||
idx := strings.Index(content, "- [ ] Test dashboard")
|
||||
|
||||
phase3Idx := strings.Index(content, "## Phase 3:")
|
||||
|
||||
if idx > phase3Idx {
|
||||
t.Error("expected new step to be before Phase 3")
|
||||
}
|
||||
|
|
@ -293,17 +414,21 @@ func TestAddStep(t *testing.T) {
|
|||
|
||||
func TestClearLongTerm(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
ms.WriteLongTerm(testPlanExecuting)
|
||||
|
||||
if err := ms.ClearLongTerm(); err != nil {
|
||||
t.Fatalf("ClearLongTerm failed: %v", err)
|
||||
}
|
||||
|
||||
if content := ms.ReadLongTerm(); content != "" {
|
||||
t.Errorf("expected empty memory after clear, got %q", content)
|
||||
}
|
||||
|
||||
// Clearing again should not error
|
||||
|
||||
if err := ms.ClearLongTerm(); err != nil {
|
||||
t.Fatalf("ClearLongTerm (idempotent) failed: %v", err)
|
||||
}
|
||||
|
|
@ -311,34 +436,45 @@ func TestClearLongTerm(t *testing.T) {
|
|||
|
||||
func TestGetInterviewContext(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
ms.WriteLongTerm(testPlanInterviewing)
|
||||
|
||||
ctx := ms.GetInterviewContext()
|
||||
|
||||
if !strings.Contains(ctx, "Active Plan (interviewing)") {
|
||||
t.Error("expected 'Active Plan (interviewing)' header")
|
||||
}
|
||||
|
||||
if !strings.Contains(ctx, "Interview Guide") {
|
||||
t.Error("expected 'Interview Guide' section")
|
||||
}
|
||||
|
||||
if !strings.Contains(ctx, "Target Format") {
|
||||
t.Error("expected 'Target Format' section")
|
||||
}
|
||||
|
||||
if !strings.Contains(ctx, "Set up server monitoring") {
|
||||
t.Error("expected task description in context")
|
||||
}
|
||||
|
||||
// Should guide AI to ask about tooling
|
||||
|
||||
if !strings.Contains(ctx, "test framework") || !strings.Contains(ctx, "linter") {
|
||||
t.Error("expected interview guide to mention test framework and linter")
|
||||
}
|
||||
|
||||
// Target format should include Commands section example
|
||||
|
||||
if !strings.Contains(ctx, "## Commands") {
|
||||
t.Error("expected target format to include ## Commands section")
|
||||
}
|
||||
|
||||
if !strings.Contains(ctx, "project-specific test command") {
|
||||
t.Error("expected target format Commands to include test command placeholder")
|
||||
}
|
||||
|
||||
if !strings.Contains(ctx, "project-specific lint command") {
|
||||
t.Error("expected target format Commands to include lint command placeholder")
|
||||
}
|
||||
|
|
@ -346,46 +482,57 @@ func TestGetInterviewContext(t *testing.T) {
|
|||
|
||||
func TestGetPlanContext(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
ms.WriteLongTerm(testPlanExecuting)
|
||||
|
||||
ctx := ms.GetPlanContext()
|
||||
|
||||
// Should have task summary
|
||||
|
||||
if !strings.Contains(ctx, "Phase 2/3") {
|
||||
t.Error("expected 'Phase 2/3' in plan context")
|
||||
}
|
||||
|
||||
// Completed phase should be summarized
|
||||
|
||||
if !strings.Contains(ctx, "Done: Phase 1") {
|
||||
t.Error("expected completed phase summary")
|
||||
}
|
||||
|
||||
// Current phase should have full detail
|
||||
|
||||
if !strings.Contains(ctx, "Current: Phase 2") {
|
||||
t.Error("expected current phase detail")
|
||||
}
|
||||
|
||||
if !strings.Contains(ctx, "Install Grafana") {
|
||||
t.Error("expected current phase steps")
|
||||
}
|
||||
|
||||
// Future phases should NOT appear
|
||||
|
||||
if strings.Contains(ctx, "Phase 3") {
|
||||
t.Error("expected future phases to be omitted")
|
||||
}
|
||||
|
||||
// Commands should be included
|
||||
|
||||
if !strings.Contains(ctx, "### Commands") {
|
||||
t.Error("expected Commands section in plan context")
|
||||
}
|
||||
|
||||
if !strings.Contains(ctx, "go test") {
|
||||
t.Error("expected test command in Commands section")
|
||||
}
|
||||
|
||||
if !strings.Contains(ctx, "golangci-lint") {
|
||||
t.Error("expected lint command in Commands section")
|
||||
}
|
||||
|
||||
// Context should be included
|
||||
|
||||
if !strings.Contains(ctx, "Debian Bookworm") {
|
||||
t.Error("expected Context section")
|
||||
}
|
||||
|
|
@ -393,20 +540,27 @@ func TestGetPlanContext(t *testing.T) {
|
|||
|
||||
func TestGetMemoryContext_PlanActive_SuppressesDailyNotes(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
// Write a daily note
|
||||
|
||||
ms.AppendToday("Today's note")
|
||||
|
||||
// Without plan, daily notes should appear
|
||||
|
||||
ctx := ms.GetMemoryContext()
|
||||
|
||||
if !strings.Contains(ctx, "Recent Daily Notes") {
|
||||
t.Error("expected daily notes when no plan active")
|
||||
}
|
||||
|
||||
// With plan, daily notes should be suppressed
|
||||
|
||||
ms.WriteLongTerm(testPlanExecuting)
|
||||
|
||||
ctx = ms.GetMemoryContext()
|
||||
|
||||
if strings.Contains(ctx, "Recent Daily Notes") {
|
||||
t.Error("expected daily notes to be suppressed when plan is active")
|
||||
}
|
||||
|
|
@ -414,14 +568,17 @@ func TestGetMemoryContext_PlanActive_SuppressesDailyNotes(t *testing.T) {
|
|||
|
||||
func TestGetMemoryContext_InterviewingMode(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
ms.WriteLongTerm(testPlanInterviewing)
|
||||
|
||||
ctx := ms.GetMemoryContext()
|
||||
|
||||
if !strings.Contains(ctx, "interviewing") {
|
||||
t.Error("expected interviewing context")
|
||||
}
|
||||
|
||||
if !strings.Contains(ctx, "Interview Guide") {
|
||||
t.Error("expected interview guide in context")
|
||||
}
|
||||
|
|
@ -429,14 +586,17 @@ func TestGetMemoryContext_InterviewingMode(t *testing.T) {
|
|||
|
||||
func TestGetMemoryContext_ExecutingMode(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
ms.WriteLongTerm(testPlanExecuting)
|
||||
|
||||
ctx := ms.GetMemoryContext()
|
||||
|
||||
if !strings.Contains(ctx, "Active Plan") {
|
||||
t.Error("expected active plan in context")
|
||||
}
|
||||
|
||||
if !strings.Contains(ctx, "Current: Phase 2") {
|
||||
t.Error("expected current phase in context")
|
||||
}
|
||||
|
|
@ -444,14 +604,17 @@ func TestGetMemoryContext_ExecutingMode(t *testing.T) {
|
|||
|
||||
func TestGetMemoryContext_RegularMemory(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
ms.WriteLongTerm("Some notes about projects")
|
||||
|
||||
ctx := ms.GetMemoryContext()
|
||||
|
||||
if !strings.Contains(ctx, "Long-term Memory") {
|
||||
t.Error("expected regular long-term memory section")
|
||||
}
|
||||
|
||||
if !strings.Contains(ctx, "Some notes about projects") {
|
||||
t.Error("expected memory content")
|
||||
}
|
||||
|
|
@ -463,12 +626,15 @@ func TestBuildInterviewSeed(t *testing.T) {
|
|||
if !strings.Contains(seed, "# Active Plan") {
|
||||
t.Error("expected '# Active Plan' header")
|
||||
}
|
||||
|
||||
if !strings.Contains(seed, "Deploy monitoring stack") {
|
||||
t.Error("expected task description")
|
||||
}
|
||||
|
||||
if !strings.Contains(seed, "interviewing") {
|
||||
t.Error("expected interviewing status")
|
||||
}
|
||||
|
||||
if !strings.Contains(seed, "> Phase: 1") {
|
||||
t.Error("expected Phase: 1")
|
||||
}
|
||||
|
|
@ -476,28 +642,37 @@ func TestBuildInterviewSeed(t *testing.T) {
|
|||
|
||||
func TestFormatPlanDisplay(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
// No plan
|
||||
|
||||
display := ms.FormatPlanDisplay()
|
||||
|
||||
if display != "No active plan." {
|
||||
t.Errorf("expected 'No active plan.', got %q", display)
|
||||
}
|
||||
|
||||
// With plan
|
||||
|
||||
ms.WriteLongTerm(testPlanExecuting)
|
||||
|
||||
display = ms.FormatPlanDisplay()
|
||||
|
||||
if !strings.Contains(display, "Set up server monitoring") {
|
||||
t.Error("expected task name in display")
|
||||
}
|
||||
|
||||
if !strings.Contains(display, "Phase 2/3") {
|
||||
t.Error("expected phase count in display")
|
||||
}
|
||||
|
||||
// Commands section should be visible
|
||||
|
||||
if !strings.Contains(display, "Commands:") {
|
||||
t.Error("expected Commands section in display")
|
||||
}
|
||||
|
||||
if !strings.Contains(display, "go test") {
|
||||
t.Error("expected test command in display")
|
||||
}
|
||||
|
|
@ -506,117 +681,210 @@ func TestFormatPlanDisplay(t *testing.T) {
|
|||
func TestValidatePlanStructure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
content string
|
||||
|
||||
wantErr string // "" means nil error expected
|
||||
}{
|
||||
{
|
||||
name: "valid plan with 1 phase and 1 step",
|
||||
|
||||
content: `# Active Plan
|
||||
|
||||
|
||||
|
||||
> Task: Do something
|
||||
|
||||
> Status: executing
|
||||
|
||||
> Phase: 1
|
||||
|
||||
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [ ] Install deps
|
||||
|
||||
`,
|
||||
|
||||
wantErr: "",
|
||||
},
|
||||
|
||||
{
|
||||
name: "missing Active Plan header",
|
||||
|
||||
content: `> Status: executing`,
|
||||
|
||||
wantErr: "missing '# Active Plan' header",
|
||||
},
|
||||
|
||||
{
|
||||
name: "missing Status line",
|
||||
|
||||
content: `# Active Plan
|
||||
|
||||
|
||||
|
||||
> Phase: 1
|
||||
|
||||
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [ ] Install deps
|
||||
|
||||
`,
|
||||
|
||||
wantErr: "missing '> Status:' line",
|
||||
},
|
||||
|
||||
{
|
||||
name: "missing Phase line",
|
||||
|
||||
content: `# Active Plan
|
||||
|
||||
|
||||
|
||||
> Status: executing
|
||||
|
||||
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [ ] Install deps
|
||||
|
||||
`,
|
||||
|
||||
wantErr: "missing '> Phase:' line",
|
||||
},
|
||||
|
||||
{
|
||||
name: "no Phase sections",
|
||||
|
||||
content: `# Active Plan
|
||||
|
||||
|
||||
|
||||
> Task: Do something
|
||||
|
||||
> Status: executing
|
||||
|
||||
> Phase: 1
|
||||
|
||||
`,
|
||||
|
||||
wantErr: "no '## Phase N:' sections found",
|
||||
},
|
||||
|
||||
{
|
||||
name: "phase with no checkbox steps",
|
||||
|
||||
content: `# Active Plan
|
||||
|
||||
|
||||
|
||||
> Task: Do something
|
||||
|
||||
> Status: executing
|
||||
|
||||
> Phase: 1
|
||||
|
||||
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
Some description without checkboxes
|
||||
|
||||
`,
|
||||
|
||||
wantErr: "Phase 1 has no checkbox steps",
|
||||
},
|
||||
|
||||
{
|
||||
name: "all steps done is valid",
|
||||
|
||||
content: `# Active Plan
|
||||
|
||||
|
||||
|
||||
> Task: Do something
|
||||
|
||||
> Status: executing
|
||||
|
||||
> Phase: 1
|
||||
|
||||
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [x] Install deps
|
||||
|
||||
- [x] Configure
|
||||
|
||||
`,
|
||||
|
||||
wantErr: "",
|
||||
},
|
||||
|
||||
{
|
||||
name: "multi-phase valid",
|
||||
|
||||
content: `# Active Plan
|
||||
|
||||
|
||||
|
||||
> Task: Do something
|
||||
|
||||
> Status: executing
|
||||
|
||||
> Phase: 1
|
||||
|
||||
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [ ] Install deps
|
||||
|
||||
|
||||
|
||||
## Phase 2: Build
|
||||
|
||||
- [ ] Compile
|
||||
|
||||
- [ ] Test
|
||||
|
||||
`,
|
||||
|
||||
wantErr: "",
|
||||
},
|
||||
|
||||
{
|
||||
name: "second phase empty steps",
|
||||
|
||||
content: `# Active Plan
|
||||
|
||||
|
||||
|
||||
> Task: Do something
|
||||
|
||||
> Status: executing
|
||||
|
||||
> Phase: 1
|
||||
|
||||
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [ ] Install deps
|
||||
|
||||
|
||||
|
||||
## Phase 2: Build
|
||||
|
||||
No checkboxes here
|
||||
|
||||
`,
|
||||
|
||||
wantErr: "Phase 2 has no checkbox steps",
|
||||
},
|
||||
}
|
||||
|
|
@ -624,9 +892,11 @@ No checkboxes here
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ms, cleanup := newTestMemoryStore(t)
|
||||
|
||||
defer cleanup()
|
||||
|
||||
ms.WriteLongTerm(tt.content)
|
||||
|
||||
err := ms.ValidatePlanStructure()
|
||||
|
||||
if tt.wantErr == "" {
|
||||
|
|
@ -649,18 +919,23 @@ func TestMemoryStoreCreation(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ms := NewMemoryStore(tmpDir)
|
||||
|
||||
// Verify memory directory was created
|
||||
|
||||
memoryDir := filepath.Join(tmpDir, "memory")
|
||||
|
||||
if _, err := os.Stat(memoryDir); os.IsNotExist(err) {
|
||||
t.Error("expected memory directory to be created")
|
||||
}
|
||||
|
||||
// Verify memory file path
|
||||
|
||||
expectedFile := filepath.Join(memoryDir, "MEMORY.md")
|
||||
|
||||
if ms.memoryFile != expectedFile {
|
||||
t.Errorf("expected memory file %q, got %q", expectedFile, ms.memoryFile)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,18 @@ type mockProvider struct{}
|
|||
|
||||
func (m *mockProvider) Chat(
|
||||
ctx context.Context,
|
||||
|
||||
messages []providers.Message,
|
||||
|
||||
tools []providers.ToolDefinition,
|
||||
|
||||
model string,
|
||||
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
return &providers.LLMResponse{
|
||||
Content: "Mock response",
|
||||
|
||||
ToolCalls: []providers.ToolCall{},
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,42 +10,61 @@ import (
|
|||
)
|
||||
|
||||
// AgentRegistry manages multiple agent instances and routes messages to them.
|
||||
|
||||
type AgentRegistry struct {
|
||||
agents map[string]*AgentInstance
|
||||
|
||||
resolver *routing.RouteResolver
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewAgentRegistry creates a registry from config, instantiating all agents.
|
||||
|
||||
func NewAgentRegistry(
|
||||
cfg *config.Config,
|
||||
|
||||
provider providers.LLMProvider,
|
||||
) *AgentRegistry {
|
||||
registry := &AgentRegistry{
|
||||
agents: make(map[string]*AgentInstance),
|
||||
|
||||
resolver: routing.NewRouteResolver(cfg),
|
||||
}
|
||||
|
||||
agentConfigs := cfg.Agents.List
|
||||
|
||||
if len(agentConfigs) == 0 {
|
||||
implicitAgent := &config.AgentConfig{
|
||||
ID: "main",
|
||||
|
||||
Default: true,
|
||||
}
|
||||
|
||||
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
|
||||
|
||||
registry.agents["main"] = instance
|
||||
|
||||
logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil)
|
||||
} else {
|
||||
for i := range agentConfigs {
|
||||
ac := &agentConfigs[i]
|
||||
|
||||
id := routing.NormalizeAgentID(ac.ID)
|
||||
|
||||
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
|
||||
|
||||
registry.agents[id] = instance
|
||||
|
||||
logger.InfoCF("agent", "Registered agent",
|
||||
|
||||
map[string]any{
|
||||
"agent_id": id,
|
||||
|
||||
"name": ac.Name,
|
||||
|
||||
"workspace": instance.Workspace,
|
||||
|
||||
"model": instance.Model,
|
||||
})
|
||||
}
|
||||
|
|
@ -55,60 +74,83 @@ func NewAgentRegistry(
|
|||
}
|
||||
|
||||
// GetAgent returns the agent instance for a given ID.
|
||||
|
||||
func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) {
|
||||
r.mu.RLock()
|
||||
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
id := routing.NormalizeAgentID(agentID)
|
||||
|
||||
agent, ok := r.agents[id]
|
||||
|
||||
return agent, ok
|
||||
}
|
||||
|
||||
// ResolveRoute determines which agent handles the message.
|
||||
|
||||
func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute {
|
||||
return r.resolver.ResolveRoute(input)
|
||||
}
|
||||
|
||||
// ListAgentIDs returns all registered agent IDs.
|
||||
|
||||
func (r *AgentRegistry) ListAgentIDs() []string {
|
||||
r.mu.RLock()
|
||||
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
ids := make([]string, 0, len(r.agents))
|
||||
|
||||
for id := range r.agents {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID.
|
||||
|
||||
func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {
|
||||
parent, ok := r.GetAgent(parentAgentID)
|
||||
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if parent.Subagents == nil || parent.Subagents.AllowAgents == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
targetNorm := routing.NormalizeAgentID(targetAgentID)
|
||||
|
||||
for _, allowed := range parent.Subagents.AllowAgents {
|
||||
if allowed == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
if routing.NormalizeAgentID(allowed) == targetNorm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetDefaultAgent returns the default agent instance.
|
||||
|
||||
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
||||
r.mu.RLock()
|
||||
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if agent, ok := r.agents["main"]; ok {
|
||||
return agent
|
||||
}
|
||||
|
||||
for _, agent := range r.agents {
|
||||
return agent
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,13 @@ type mockRegistryProvider struct{}
|
|||
|
||||
func (m *mockRegistryProvider) Chat(
|
||||
ctx context.Context,
|
||||
|
||||
messages []providers.Message,
|
||||
|
||||
tools []providers.ToolDefinition,
|
||||
|
||||
model string,
|
||||
|
||||
options map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil
|
||||
|
|
@ -29,10 +33,14 @@ func testCfg(agents []config.AgentConfig) *config.Config {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: "/tmp/picoclaw-test-registry",
|
||||
|
||||
Model: "gpt-4",
|
||||
|
||||
MaxTokens: 8192,
|
||||
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
|
||||
List: agents,
|
||||
},
|
||||
}
|
||||
|
|
@ -40,17 +48,21 @@ func testCfg(agents []config.AgentConfig) *config.Config {
|
|||
|
||||
func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
|
||||
cfg := testCfg(nil)
|
||||
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
ids := registry.ListAgentIDs()
|
||||
|
||||
if len(ids) != 1 || ids[0] != "main" {
|
||||
t.Errorf("expected implicit main agent, got %v", ids)
|
||||
}
|
||||
|
||||
agent, ok := registry.GetAgent("main")
|
||||
|
||||
if !ok || agent == nil {
|
||||
t.Fatal("expected to find 'main' agent")
|
||||
}
|
||||
|
||||
if agent.ID != "main" {
|
||||
t.Errorf("agent.ID = %q, want 'main'", agent.ID)
|
||||
}
|
||||
|
|
@ -59,24 +71,30 @@ func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
|
|||
func TestNewAgentRegistry_ExplicitAgents(t *testing.T) {
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "sales", Default: true, Name: "Sales Bot"},
|
||||
|
||||
{ID: "support", Name: "Support Bot"},
|
||||
})
|
||||
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
ids := registry.ListAgentIDs()
|
||||
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids)
|
||||
}
|
||||
|
||||
sales, ok := registry.GetAgent("sales")
|
||||
|
||||
if !ok || sales == nil {
|
||||
t.Fatal("expected to find 'sales' agent")
|
||||
}
|
||||
|
||||
if sales.Name != "Sales Bot" {
|
||||
t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name)
|
||||
}
|
||||
|
||||
support, ok := registry.GetAgent("support")
|
||||
|
||||
if !ok || support == nil {
|
||||
t.Fatal("expected to find 'support' agent")
|
||||
}
|
||||
|
|
@ -86,12 +104,15 @@ func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
|
|||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "my-agent", Default: true},
|
||||
})
|
||||
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
agent, ok := registry.GetAgent("My-Agent")
|
||||
|
||||
if !ok || agent == nil {
|
||||
t.Fatal("expected to find agent with normalized ID")
|
||||
}
|
||||
|
||||
if agent.ID != "my-agent" {
|
||||
t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID)
|
||||
}
|
||||
|
|
@ -100,12 +121,16 @@ func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
|
|||
func TestAgentRegistry_GetDefaultAgent(t *testing.T) {
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "alpha"},
|
||||
|
||||
{ID: "beta", Default: true},
|
||||
})
|
||||
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
// GetDefaultAgent first checks for "main", then returns any
|
||||
|
||||
agent := registry.GetDefaultAgent()
|
||||
|
||||
if agent == nil {
|
||||
t.Fatal("expected a default agent")
|
||||
}
|
||||
|
|
@ -115,26 +140,35 @@ func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
|
|||
cfg := testCfg([]config.AgentConfig{
|
||||
{
|
||||
ID: "parent",
|
||||
|
||||
Default: true,
|
||||
|
||||
Subagents: &config.SubagentsConfig{
|
||||
AllowAgents: []string{"child1", "child2"},
|
||||
},
|
||||
},
|
||||
|
||||
{ID: "child1"},
|
||||
|
||||
{ID: "child2"},
|
||||
|
||||
{ID: "restricted"},
|
||||
})
|
||||
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
if !registry.CanSpawnSubagent("parent", "child1") {
|
||||
t.Error("expected parent to be allowed to spawn child1")
|
||||
}
|
||||
|
||||
if !registry.CanSpawnSubagent("parent", "child2") {
|
||||
t.Error("expected parent to be allowed to spawn child2")
|
||||
}
|
||||
|
||||
if registry.CanSpawnSubagent("parent", "restricted") {
|
||||
t.Error("expected parent to NOT be allowed to spawn restricted")
|
||||
}
|
||||
|
||||
if registry.CanSpawnSubagent("child1", "child2") {
|
||||
t.Error("expected child1 to NOT be allowed to spawn (no subagents config)")
|
||||
}
|
||||
|
|
@ -144,18 +178,23 @@ func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
|
|||
cfg := testCfg([]config.AgentConfig{
|
||||
{
|
||||
ID: "admin",
|
||||
|
||||
Default: true,
|
||||
|
||||
Subagents: &config.SubagentsConfig{
|
||||
AllowAgents: []string{"*"},
|
||||
},
|
||||
},
|
||||
|
||||
{ID: "any-agent"},
|
||||
})
|
||||
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
if !registry.CanSpawnSubagent("admin", "any-agent") {
|
||||
t.Error("expected wildcard to allow spawning any agent")
|
||||
}
|
||||
|
||||
if !registry.CanSpawnSubagent("admin", "nonexistent") {
|
||||
t.Error("expected wildcard to allow spawning even nonexistent agents")
|
||||
}
|
||||
|
|
@ -163,12 +202,15 @@ func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
|
|||
|
||||
func TestAgentInstance_Model(t *testing.T) {
|
||||
model := &config.AgentModelConfig{Primary: "claude-opus"}
|
||||
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "custom", Default: true, Model: model},
|
||||
})
|
||||
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
agent, _ := registry.GetAgent("custom")
|
||||
|
||||
if agent.Model != "claude-opus" {
|
||||
t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model)
|
||||
}
|
||||
|
|
@ -178,10 +220,13 @@ func TestAgentInstance_FallbackInheritance(t *testing.T) {
|
|||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "inherit", Default: true},
|
||||
})
|
||||
|
||||
cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"}
|
||||
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
agent, _ := registry.GetAgent("inherit")
|
||||
|
||||
if len(agent.Fallbacks) != 2 {
|
||||
t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks))
|
||||
}
|
||||
|
|
@ -190,15 +235,21 @@ func TestAgentInstance_FallbackInheritance(t *testing.T) {
|
|||
func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
|
||||
model := &config.AgentModelConfig{
|
||||
Primary: "gpt-4",
|
||||
|
||||
Fallbacks: []string{}, // explicitly empty = disable
|
||||
|
||||
}
|
||||
|
||||
cfg := testCfg([]config.AgentConfig{
|
||||
{ID: "no-fallback", Default: true, Model: model},
|
||||
})
|
||||
|
||||
cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"}
|
||||
|
||||
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
|
||||
|
||||
agent, _ := registry.GetAgent("no-fallback")
|
||||
|
||||
if len(agent.Fallbacks) != 0 {
|
||||
t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks)
|
||||
}
|
||||
|
|
|
|||
121
pkg/agent/session_recorder.go
Normal file
121
pkg/agent/session_recorder.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
// sessionRecorderImpl bridges tools.SessionRecorder → session.SessionStore.
|
||||
|
||||
type sessionRecorderImpl struct {
|
||||
adapter *session.LegacyAdapter
|
||||
}
|
||||
|
||||
var _ tools.SessionRecorder = (*sessionRecorderImpl)(nil)
|
||||
|
||||
func newSessionRecorder(adapter *session.LegacyAdapter) *sessionRecorderImpl {
|
||||
return &sessionRecorderImpl{adapter: adapter}
|
||||
}
|
||||
|
||||
func (r *sessionRecorderImpl) RecordFork(conductorKey, subagentKey, taskID, label string) error {
|
||||
store := r.adapter.Store()
|
||||
|
||||
return store.Fork(conductorKey, subagentKey, &session.CreateOpts{
|
||||
ForkTurnID: taskID,
|
||||
|
||||
Label: label,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *sessionRecorderImpl) RecordSubagentTurn(subagentKey string, messages []providers.Message) error {
|
||||
store := r.adapter.Store()
|
||||
|
||||
turn := &session.Turn{
|
||||
Kind: session.TurnNormal,
|
||||
|
||||
Messages: messages,
|
||||
}
|
||||
|
||||
return store.Append(subagentKey, turn)
|
||||
}
|
||||
|
||||
func (r *sessionRecorderImpl) RecordCompletion(subagentKey, status, result string) error {
|
||||
store := r.adapter.Store()
|
||||
|
||||
return store.SetStatus(subagentKey, status)
|
||||
}
|
||||
|
||||
func (r *sessionRecorderImpl) RecordReport(conductorKey, subagentKey, senderID, content string) error {
|
||||
store := r.adapter.Store()
|
||||
|
||||
turn := &session.Turn{
|
||||
Kind: session.TurnReport,
|
||||
|
||||
OriginKey: subagentKey,
|
||||
|
||||
Author: senderID,
|
||||
|
||||
Messages: []providers.Message{
|
||||
{Role: "user", Content: content},
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.Append(conductorKey, turn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Advance LegacyAdapter's stored counter so flush loop doesn't double-write.
|
||||
|
||||
r.adapter.AdvanceStored(conductorKey, 1)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sessionRecorderImpl) RecordQuestion(conductorKey, subagentKey, taskID, question string) error {
|
||||
store := r.adapter.Store()
|
||||
|
||||
turn := &session.Turn{
|
||||
Kind: session.TurnQuestion,
|
||||
|
||||
OriginKey: subagentKey,
|
||||
|
||||
Author: taskID,
|
||||
|
||||
Messages: []providers.Message{
|
||||
{Role: "user", Content: question},
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.Append(conductorKey, turn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.adapter.AdvanceStored(conductorKey, 1)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sessionRecorderImpl) RecordPlanSubmit(conductorKey, subagentKey, taskID, planText string) error {
|
||||
store := r.adapter.Store()
|
||||
|
||||
turn := &session.Turn{
|
||||
Kind: session.TurnPlanSubmit,
|
||||
|
||||
OriginKey: subagentKey,
|
||||
|
||||
Author: taskID,
|
||||
|
||||
Messages: []providers.Message{
|
||||
{Role: "user", Content: planText},
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.Append(conductorKey, turn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.adapter.AdvanceStored(conductorKey, 1)
|
||||
|
||||
return nil
|
||||
}
|
||||
384
pkg/agent/session_recorder_test.go
Normal file
384
pkg/agent/session_recorder_test.go
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
)
|
||||
|
||||
func newTestRecorder(t *testing.T) (*sessionRecorderImpl, *session.LegacyAdapter, session.SessionStore) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
store, err := session.OpenSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
|
||||
adapter := session.NewLegacyAdapter(store)
|
||||
|
||||
t.Cleanup(func() { adapter.Close() })
|
||||
|
||||
recorder := newSessionRecorder(adapter)
|
||||
|
||||
return recorder, adapter, store
|
||||
}
|
||||
|
||||
func TestRecordFork(t *testing.T) {
|
||||
rec, _, store := newTestRecorder(t)
|
||||
|
||||
// Create conductor session first.
|
||||
|
||||
if err := store.Create("conductor:main", nil); err != nil {
|
||||
t.Fatalf("create conductor session: %v", err)
|
||||
}
|
||||
|
||||
err := rec.RecordFork("conductor:main", "subagent:subagent-1", "subagent-1", "scout")
|
||||
if err != nil {
|
||||
t.Fatalf("RecordFork: %v", err)
|
||||
}
|
||||
|
||||
// Verify child session exists with correct parent.
|
||||
|
||||
info, err := store.Get("subagent:subagent-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Get child: %v", err)
|
||||
}
|
||||
|
||||
if info == nil {
|
||||
t.Fatal("child session not found")
|
||||
}
|
||||
|
||||
if info.ParentKey != "conductor:main" {
|
||||
t.Errorf("ParentKey = %q, want %q", info.ParentKey, "conductor:main")
|
||||
}
|
||||
|
||||
if info.ForkTurnID != "subagent-1" {
|
||||
t.Errorf("ForkTurnID = %q, want %q", info.ForkTurnID, "subagent-1")
|
||||
}
|
||||
|
||||
if info.Label != "scout" {
|
||||
t.Errorf("Label = %q, want %q", info.Label, "scout")
|
||||
}
|
||||
|
||||
// Verify parent lists child.
|
||||
|
||||
children, err := store.Children("conductor:main")
|
||||
if err != nil {
|
||||
t.Fatalf("Children: %v", err)
|
||||
}
|
||||
|
||||
if len(children) != 1 {
|
||||
t.Fatalf("children count = %d, want 1", len(children))
|
||||
}
|
||||
|
||||
if children[0].Key != "subagent:subagent-1" {
|
||||
t.Errorf("child key = %q, want %q", children[0].Key, "subagent:subagent-1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSubagentTurn(t *testing.T) {
|
||||
rec, _, store := newTestRecorder(t)
|
||||
|
||||
// Create subagent session.
|
||||
|
||||
if err := store.Create("subagent:subagent-1", nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
msgs := []providers.Message{
|
||||
{Role: "system", Content: "You are a scout."},
|
||||
|
||||
{Role: "user", Content: "Investigate X."},
|
||||
|
||||
{Role: "assistant", Content: "Found Y."},
|
||||
}
|
||||
|
||||
if err := rec.RecordSubagentTurn("subagent:subagent-1", msgs); err != nil {
|
||||
t.Fatalf("RecordSubagentTurn: %v", err)
|
||||
}
|
||||
|
||||
turns, err := store.Turns("subagent:subagent-1", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Turns: %v", err)
|
||||
}
|
||||
|
||||
if len(turns) != 1 {
|
||||
t.Fatalf("turns count = %d, want 1", len(turns))
|
||||
}
|
||||
|
||||
if turns[0].Kind != session.TurnNormal {
|
||||
t.Errorf("Kind = %d, want TurnNormal", turns[0].Kind)
|
||||
}
|
||||
|
||||
if len(turns[0].Messages) != 3 {
|
||||
t.Errorf("messages count = %d, want 3", len(turns[0].Messages))
|
||||
}
|
||||
|
||||
if turns[0].Messages[2].Content != "Found Y." {
|
||||
t.Errorf("last message = %q, want %q", turns[0].Messages[2].Content, "Found Y.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordCompletion(t *testing.T) {
|
||||
rec, _, store := newTestRecorder(t)
|
||||
|
||||
if err := store.Create("subagent:subagent-1", nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
if err := rec.RecordCompletion("subagent:subagent-1", "completed", "done"); err != nil {
|
||||
t.Fatalf("RecordCompletion: %v", err)
|
||||
}
|
||||
|
||||
info, err := store.Get("subagent:subagent-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
|
||||
if info.Status != "completed" {
|
||||
t.Errorf("Status = %q, want %q", info.Status, "completed")
|
||||
}
|
||||
|
||||
// Test failed status.
|
||||
|
||||
if err := store.Create("subagent:subagent-2", nil); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
if err := rec.RecordCompletion("subagent:subagent-2", "failed", "error"); err != nil {
|
||||
t.Fatalf("RecordCompletion failed: %v", err)
|
||||
}
|
||||
|
||||
info2, _ := store.Get("subagent:subagent-2")
|
||||
|
||||
if info2.Status != "failed" {
|
||||
t.Errorf("Status = %q, want %q", info2.Status, "failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordReport(t *testing.T) {
|
||||
rec, adapter, store := newTestRecorder(t)
|
||||
|
||||
// Create conductor session via adapter so it's in cache.
|
||||
|
||||
_ = adapter.GetOrCreate("conductor:main")
|
||||
|
||||
if err := store.Create("subagent:subagent-1", nil); err != nil {
|
||||
t.Fatalf("create subagent: %v", err)
|
||||
}
|
||||
|
||||
content := "[System: subagent:subagent-1] Task 'scout' completed.\n\nResult:\nFound Y."
|
||||
|
||||
if err := rec.RecordReport("conductor:main", "subagent:subagent-1", "subagent:subagent-1", content); err != nil {
|
||||
t.Fatalf("RecordReport: %v", err)
|
||||
}
|
||||
|
||||
// Verify TurnReport in store.
|
||||
|
||||
turns, err := store.Turns("conductor:main", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Turns: %v", err)
|
||||
}
|
||||
|
||||
if len(turns) != 1 {
|
||||
t.Fatalf("turns count = %d, want 1", len(turns))
|
||||
}
|
||||
|
||||
if turns[0].Kind != session.TurnReport {
|
||||
t.Errorf("Kind = %d, want TurnReport(%d)", turns[0].Kind, session.TurnReport)
|
||||
}
|
||||
|
||||
if turns[0].OriginKey != "subagent:subagent-1" {
|
||||
t.Errorf("OriginKey = %q, want %q", turns[0].OriginKey, "subagent:subagent-1")
|
||||
}
|
||||
|
||||
if turns[0].Author != "subagent:subagent-1" {
|
||||
t.Errorf("Author = %q, want %q", turns[0].Author, "subagent:subagent-1")
|
||||
}
|
||||
|
||||
if len(turns[0].Messages) != 1 || turns[0].Messages[0].Role != "user" {
|
||||
t.Errorf("unexpected messages: %v", turns[0].Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceStoredPreventsDoubleWrite(t *testing.T) {
|
||||
rec, adapter, store := newTestRecorder(t)
|
||||
|
||||
// Create conductor session via adapter.
|
||||
|
||||
_ = adapter.GetOrCreate("conductor:main")
|
||||
|
||||
if err := store.Create("subagent:subagent-1", nil); err != nil {
|
||||
t.Fatalf("create subagent: %v", err)
|
||||
}
|
||||
|
||||
// Simulate: conductor has 2 messages already flushed.
|
||||
|
||||
adapter.AddMessage("conductor:main", "user", "hello")
|
||||
|
||||
adapter.AddMessage("conductor:main", "assistant", "hi")
|
||||
|
||||
if err := adapter.Save("conductor:main"); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
// RecordReport writes directly to store and advances stored counter.
|
||||
|
||||
content := "[System: subagent:subagent-1] result"
|
||||
|
||||
if err := rec.RecordReport("conductor:main", "subagent:subagent-1", "subagent:subagent-1", content); err != nil {
|
||||
t.Fatalf("RecordReport: %v", err)
|
||||
}
|
||||
|
||||
// The in-memory cache should also be updated (by loop.go calling AddFullMessage).
|
||||
|
||||
// Simulate what loop.go does after RecordReport succeeds.
|
||||
|
||||
adapter.AddFullMessage("conductor:main", providers.Message{Role: "user", Content: content})
|
||||
|
||||
// AdvanceStored was already called by RecordReport, so stored = 3 + 1 = 4
|
||||
|
||||
// but we added 1 message to cache making it len=4 as well. No double write.
|
||||
|
||||
// Save should NOT re-write the report turn.
|
||||
|
||||
if err := adapter.Save("conductor:main"); err != nil {
|
||||
t.Fatalf("Save after report: %v", err)
|
||||
}
|
||||
|
||||
// Count all turns in store for conductor session.
|
||||
|
||||
turns, err := store.Turns("conductor:main", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Turns: %v", err)
|
||||
}
|
||||
|
||||
// Expected: turn 1 (initial 2 msgs), turn 2 (TurnReport from RecordReport)
|
||||
|
||||
// NOT turn 3 (duplicate from flush).
|
||||
|
||||
if len(turns) != 2 {
|
||||
t.Errorf("turns count = %d, want 2 (no double-write)", len(turns))
|
||||
|
||||
for i, turn := range turns {
|
||||
t.Logf(" turn[%d]: seq=%d kind=%d msgs=%d", i, turn.Seq, turn.Kind, len(turn.Messages))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordQuestion(t *testing.T) {
|
||||
rec, adapter, store := newTestRecorder(t)
|
||||
|
||||
// Create conductor session via adapter so it's in cache.
|
||||
|
||||
_ = adapter.GetOrCreate("conductor:main")
|
||||
|
||||
if err := store.Create("subagent:subagent-1", nil); err != nil {
|
||||
t.Fatalf("create subagent: %v", err)
|
||||
}
|
||||
|
||||
question := "What database schema should I use for the users table?"
|
||||
|
||||
if err := rec.RecordQuestion("conductor:main", "subagent:subagent-1", "subagent-1", question); err != nil {
|
||||
t.Fatalf("RecordQuestion: %v", err)
|
||||
}
|
||||
|
||||
turns, err := store.Turns("conductor:main", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Turns: %v", err)
|
||||
}
|
||||
|
||||
if len(turns) != 1 {
|
||||
t.Fatalf("turns count = %d, want 1", len(turns))
|
||||
}
|
||||
|
||||
if turns[0].Kind != session.TurnQuestion {
|
||||
t.Errorf("Kind = %d, want TurnQuestion(%d)", turns[0].Kind, session.TurnQuestion)
|
||||
}
|
||||
|
||||
if turns[0].OriginKey != "subagent:subagent-1" {
|
||||
t.Errorf("OriginKey = %q, want %q", turns[0].OriginKey, "subagent:subagent-1")
|
||||
}
|
||||
|
||||
if turns[0].Author != "subagent-1" {
|
||||
t.Errorf("Author = %q, want %q", turns[0].Author, "subagent-1")
|
||||
}
|
||||
|
||||
if len(turns[0].Messages) != 1 || turns[0].Messages[0].Content != question {
|
||||
t.Errorf("unexpected messages: %v", turns[0].Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPlanSubmit(t *testing.T) {
|
||||
rec, adapter, store := newTestRecorder(t)
|
||||
|
||||
_ = adapter.GetOrCreate("conductor:main")
|
||||
|
||||
if err := store.Create("subagent:subagent-1", nil); err != nil {
|
||||
t.Fatalf("create subagent: %v", err)
|
||||
}
|
||||
|
||||
planText := "Goal: Implement auth\nSteps:\n1. Add middleware\n2. Add JWT validation"
|
||||
|
||||
if err := rec.RecordPlanSubmit("conductor:main", "subagent:subagent-1", "subagent-1", planText); err != nil {
|
||||
t.Fatalf("RecordPlanSubmit: %v", err)
|
||||
}
|
||||
|
||||
turns, err := store.Turns("conductor:main", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Turns: %v", err)
|
||||
}
|
||||
|
||||
if len(turns) != 1 {
|
||||
t.Fatalf("turns count = %d, want 1", len(turns))
|
||||
}
|
||||
|
||||
if turns[0].Kind != session.TurnPlanSubmit {
|
||||
t.Errorf("Kind = %d, want TurnPlanSubmit(%d)", turns[0].Kind, session.TurnPlanSubmit)
|
||||
}
|
||||
|
||||
if turns[0].OriginKey != "subagent:subagent-1" {
|
||||
t.Errorf("OriginKey = %q, want %q", turns[0].OriginKey, "subagent:subagent-1")
|
||||
}
|
||||
|
||||
if len(turns[0].Messages) != 1 || turns[0].Messages[0].Content != planText {
|
||||
t.Errorf("unexpected messages: %v", turns[0].Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTaskID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
||||
want string
|
||||
}{
|
||||
{"subagent:subagent-1", "subagent-1"},
|
||||
|
||||
{"subagent:subagent-42", "subagent-42"},
|
||||
|
||||
{"plain-id", "plain-id"},
|
||||
|
||||
{"a:b:c", "c"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := extractTaskID(tt.input)
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("extractTaskID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Suppress log output in tests.
|
||||
|
||||
os.Setenv("PICOCLAW_LOG_LEVEL", "error")
|
||||
}
|
||||
|
|
@ -8,38 +8,55 @@ import (
|
|||
)
|
||||
|
||||
// SessionEntry represents an active or recently-active session.
|
||||
|
||||
type SessionEntry struct {
|
||||
SessionKey string `json:"session_key"`
|
||||
|
||||
Channel string `json:"channel"`
|
||||
|
||||
ChatID string `json:"chat_id"`
|
||||
|
||||
TouchDir string `json:"touch_dir"`
|
||||
|
||||
ProjectPath string `json:"project_path,omitempty"` // canonical project path
|
||||
|
||||
Purpose string `json:"purpose,omitempty"` // 1-line task description
|
||||
|
||||
Branch string `json:"branch,omitempty"` // git branch name
|
||||
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
// TouchMeta carries optional metadata for Touch calls.
|
||||
|
||||
type TouchMeta struct {
|
||||
ProjectPath string // canonical project path (always original workspace-relative)
|
||||
|
||||
Purpose string // 1-line task description
|
||||
|
||||
Branch string // git branch name
|
||||
}
|
||||
|
||||
// PeerInfo is the minimal info shared between sessions on the same project.
|
||||
|
||||
type PeerInfo struct {
|
||||
SessionKey string
|
||||
|
||||
Purpose string
|
||||
|
||||
Branch string
|
||||
}
|
||||
|
||||
// SessionTracker tracks per-session tool-call activity.
|
||||
|
||||
// Thread-safe; used by AgentLoop for plan coordination and by the mini app API for observability.
|
||||
|
||||
type SessionTracker struct {
|
||||
entries sync.Map // sessionKey → *SessionEntry
|
||||
}
|
||||
|
||||
// NewSessionTracker creates a new tracker.
|
||||
|
||||
func NewSessionTracker() *SessionTracker {
|
||||
return &SessionTracker{}
|
||||
}
|
||||
|
|
@ -47,121 +64,177 @@ func NewSessionTracker() *SessionTracker {
|
|||
const sessionActivityTimeout = 15 * time.Minute
|
||||
|
||||
// Touch records a tool-call activity for a session.
|
||||
|
||||
// dir is the workspace-relative directory the tool call targeted.
|
||||
|
||||
// If dir is empty, only LastSeenAt is updated.
|
||||
|
||||
// meta is optional and carries project coordination metadata.
|
||||
|
||||
func (st *SessionTracker) Touch(sessionKey, channel, chatID, dir string, meta *TouchMeta) {
|
||||
now := time.Now()
|
||||
|
||||
val, loaded := st.entries.Load(sessionKey)
|
||||
|
||||
if loaded {
|
||||
entry := val.(*SessionEntry)
|
||||
|
||||
entry.LastSeenAt = now
|
||||
|
||||
if dir != "" {
|
||||
entry.TouchDir = dir
|
||||
}
|
||||
|
||||
if channel != "" {
|
||||
entry.Channel = channel
|
||||
}
|
||||
|
||||
if chatID != "" {
|
||||
entry.ChatID = chatID
|
||||
}
|
||||
|
||||
if meta != nil {
|
||||
if meta.ProjectPath != "" {
|
||||
entry.ProjectPath = meta.ProjectPath
|
||||
}
|
||||
|
||||
if meta.Purpose != "" {
|
||||
entry.Purpose = meta.Purpose
|
||||
}
|
||||
|
||||
if meta.Branch != "" {
|
||||
entry.Branch = meta.Branch
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
entry := &SessionEntry{
|
||||
SessionKey: sessionKey,
|
||||
|
||||
Channel: channel,
|
||||
|
||||
ChatID: chatID,
|
||||
|
||||
TouchDir: dir,
|
||||
|
||||
LastSeenAt: now,
|
||||
}
|
||||
|
||||
if meta != nil {
|
||||
entry.ProjectPath = meta.ProjectPath
|
||||
|
||||
entry.Purpose = meta.Purpose
|
||||
|
||||
entry.Branch = meta.Branch
|
||||
}
|
||||
|
||||
st.entries.Store(sessionKey, entry)
|
||||
}
|
||||
|
||||
// IsActiveInDir returns true if any session (excluding those matching excludeKey)
|
||||
|
||||
// has touched a directory overlapping with dir within sessionActivityTimeout.
|
||||
|
||||
// Overlap = either is a prefix of the other (parent/child relationship).
|
||||
|
||||
func (st *SessionTracker) IsActiveInDir(dir, excludeKey string) bool {
|
||||
cutoff := time.Now().Add(-sessionActivityTimeout)
|
||||
|
||||
active := false
|
||||
|
||||
st.entries.Range(func(key, val any) bool {
|
||||
if key.(string) == excludeKey {
|
||||
return true
|
||||
}
|
||||
|
||||
entry := val.(*SessionEntry)
|
||||
|
||||
if entry.LastSeenAt.After(cutoff) && entry.TouchDir != "" &&
|
||||
|
||||
(strings.HasPrefix(entry.TouchDir, dir) || strings.HasPrefix(dir, entry.TouchDir)) {
|
||||
active = true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return active
|
||||
}
|
||||
|
||||
// ListActive returns all sessions seen within sessionActivityTimeout,
|
||||
|
||||
// sorted by LastSeenAt descending (most recent first).
|
||||
|
||||
func (st *SessionTracker) ListActive() []SessionEntry {
|
||||
cutoff := time.Now().Add(-sessionActivityTimeout)
|
||||
|
||||
var result []SessionEntry
|
||||
|
||||
st.entries.Range(func(key, val any) bool {
|
||||
entry := val.(*SessionEntry)
|
||||
|
||||
if entry.LastSeenAt.After(cutoff) {
|
||||
result = append(result, *entry) // copy
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].LastSeenAt.After(result[j].LastSeenAt)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetTouchDir returns the TouchDir for a given session key, or "" if not found.
|
||||
|
||||
func (st *SessionTracker) GetTouchDir(sessionKey string) string {
|
||||
val, ok := st.entries.Load(sessionKey)
|
||||
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return val.(*SessionEntry).TouchDir
|
||||
}
|
||||
|
||||
// GetPeerPurposes returns purposes of other active sessions targeting the same project.
|
||||
|
||||
// Used for lightweight coordination without context pollution.
|
||||
|
||||
func (st *SessionTracker) GetPeerPurposes(sessionKey, projectPath string) []PeerInfo {
|
||||
if projectPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-sessionActivityTimeout)
|
||||
|
||||
var result []PeerInfo
|
||||
|
||||
st.entries.Range(func(key, val any) bool {
|
||||
if key.(string) == sessionKey {
|
||||
return true
|
||||
}
|
||||
|
||||
entry := val.(*SessionEntry)
|
||||
|
||||
if entry.LastSeenAt.After(cutoff) && entry.ProjectPath == projectPath {
|
||||
result = append(result, PeerInfo{
|
||||
SessionKey: entry.SessionKey,
|
||||
|
||||
Purpose: entry.Purpose,
|
||||
|
||||
Branch: entry.Branch,
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,38 +9,53 @@ func TestTouch(t *testing.T) {
|
|||
st := NewSessionTracker()
|
||||
|
||||
// Basic touch creates entry
|
||||
|
||||
st.Touch("sess1", "telegram", "123", "projects/myapp", nil)
|
||||
|
||||
entries := st.ListActive()
|
||||
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
|
||||
if entries[0].SessionKey != "sess1" {
|
||||
t.Errorf("expected session_key=sess1, got %s", entries[0].SessionKey)
|
||||
}
|
||||
|
||||
if entries[0].Channel != "telegram" {
|
||||
t.Errorf("expected channel=telegram, got %s", entries[0].Channel)
|
||||
}
|
||||
|
||||
if entries[0].TouchDir != "projects/myapp" {
|
||||
t.Errorf("expected touch_dir=projects/myapp, got %s", entries[0].TouchDir)
|
||||
}
|
||||
|
||||
// Touch again with new dir overwrites TouchDir
|
||||
|
||||
st.Touch("sess1", "", "", "projects/other", nil)
|
||||
|
||||
entries = st.ListActive()
|
||||
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
|
||||
if entries[0].TouchDir != "projects/other" {
|
||||
t.Errorf("expected touch_dir=projects/other, got %s", entries[0].TouchDir)
|
||||
}
|
||||
|
||||
// Channel should remain from first touch
|
||||
|
||||
if entries[0].Channel != "telegram" {
|
||||
t.Errorf("expected channel=telegram (unchanged), got %s", entries[0].Channel)
|
||||
}
|
||||
|
||||
// Touch with empty dir does not overwrite TouchDir
|
||||
|
||||
st.Touch("sess1", "", "", "", nil)
|
||||
|
||||
entries = st.ListActive()
|
||||
|
||||
if entries[0].TouchDir != "projects/other" {
|
||||
t.Errorf("expected touch_dir unchanged, got %s", entries[0].TouchDir)
|
||||
}
|
||||
|
|
@ -50,36 +65,45 @@ func TestIsActiveInDir(t *testing.T) {
|
|||
st := NewSessionTracker()
|
||||
|
||||
// Setup: sess1 touches "projects/myapp"
|
||||
|
||||
st.Touch("sess1", "telegram", "123", "projects/myapp", nil)
|
||||
|
||||
// Same dir, excluding sess1 → false
|
||||
|
||||
if st.IsActiveInDir("projects/myapp", "sess1") {
|
||||
t.Error("expected false when excluding the only active session")
|
||||
}
|
||||
|
||||
// Same dir, excluding different key → true
|
||||
|
||||
if !st.IsActiveInDir("projects/myapp", "heartbeat") {
|
||||
t.Error("expected true for exact dir match")
|
||||
}
|
||||
|
||||
// Parent dir match: "projects" is prefix of "projects/myapp"
|
||||
|
||||
if !st.IsActiveInDir("projects", "heartbeat") {
|
||||
t.Error("expected true for parent dir match")
|
||||
}
|
||||
|
||||
// Child dir match: "projects/myapp/src" has prefix "projects/myapp"
|
||||
|
||||
if !st.IsActiveInDir("projects/myapp/src", "heartbeat") {
|
||||
t.Error("expected true for child dir match")
|
||||
}
|
||||
|
||||
// Unrelated dir → false
|
||||
|
||||
if st.IsActiveInDir("other/stuff", "heartbeat") {
|
||||
t.Error("expected false for unrelated dir")
|
||||
}
|
||||
|
||||
// Stale entry (manually set LastSeenAt to past)
|
||||
|
||||
val, _ := st.entries.Load("sess1")
|
||||
|
||||
entry := val.(*SessionEntry)
|
||||
|
||||
entry.LastSeenAt = time.Now().Add(-sessionActivityTimeout - time.Minute)
|
||||
|
||||
if st.IsActiveInDir("projects/myapp", "heartbeat") {
|
||||
|
|
@ -91,32 +115,43 @@ func TestListActive(t *testing.T) {
|
|||
st := NewSessionTracker()
|
||||
|
||||
// Add two sessions
|
||||
|
||||
st.Touch("sess1", "telegram", "123", "projects/a", nil)
|
||||
|
||||
time.Sleep(5 * time.Millisecond) // ensure different timestamps
|
||||
|
||||
st.Touch("sess2", "discord", "456", "projects/b", nil)
|
||||
|
||||
entries := st.ListActive()
|
||||
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Most recent first
|
||||
|
||||
if entries[0].SessionKey != "sess2" {
|
||||
t.Errorf("expected sess2 first (most recent), got %s", entries[0].SessionKey)
|
||||
}
|
||||
|
||||
if entries[1].SessionKey != "sess1" {
|
||||
t.Errorf("expected sess1 second, got %s", entries[1].SessionKey)
|
||||
}
|
||||
|
||||
// Make sess1 stale
|
||||
|
||||
val, _ := st.entries.Load("sess1")
|
||||
|
||||
entry := val.(*SessionEntry)
|
||||
|
||||
entry.LastSeenAt = time.Now().Add(-sessionActivityTimeout - time.Minute)
|
||||
|
||||
entries = st.ListActive()
|
||||
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 active entry after stale, got %d", len(entries))
|
||||
}
|
||||
|
||||
if entries[0].SessionKey != "sess2" {
|
||||
t.Errorf("expected only sess2, got %s", entries[0].SessionKey)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
package git
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// WorktreeInfo describes an active git worktree.
|
||||
|
|
@ -19,6 +23,24 @@ type WorktreeInfo struct {
|
|||
RepoRoot string // main repo root
|
||||
}
|
||||
|
||||
// ManagedWorktree is a user-facing summary for worktree management commands/UI.
|
||||
type ManagedWorktree struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"-"`
|
||||
Branch string `json:"branch"`
|
||||
LastCommitHash string `json:"last_commit_hash"`
|
||||
LastCommitSubject string `json:"last_commit_subject"`
|
||||
LastCommitAge string `json:"last_commit_age"`
|
||||
HasUncommitted bool `json:"has_uncommitted"`
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrInvalidWorktreeName is returned when the given worktree name is unsafe.
|
||||
ErrInvalidWorktreeName = errors.New("invalid worktree name")
|
||||
// ErrWorktreeNotFound is returned when the named worktree cannot be found.
|
||||
ErrWorktreeNotFound = errors.New("worktree not found")
|
||||
)
|
||||
|
||||
// DisposeResult describes what happened when a worktree was disposed.
|
||||
type DisposeResult struct {
|
||||
Branch string
|
||||
|
|
@ -221,30 +243,258 @@ func MergeWorktreeBranch(repoDir string, wt *WorktreeInfo) MergeResult {
|
|||
return result
|
||||
}
|
||||
|
||||
// PruneOrphaned runs git worktree prune and removes dirs in worktreesDir
|
||||
// that aren't valid git worktrees.
|
||||
// ListManagedWorktrees returns active worktree summaries under worktreesDir.
|
||||
func ListManagedWorktrees(repoDir, worktreesDir string) ([]ManagedWorktree, error) {
|
||||
entries, err := os.ReadDir(worktreesDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []ManagedWorktree{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]ManagedWorktree, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
wtPath := filepath.Join(worktreesDir, name)
|
||||
if !isLinkedWorktree(wtPath) {
|
||||
continue
|
||||
}
|
||||
result = append(result, buildManagedWorktree(repoDir, name, wtPath))
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetManagedWorktree resolves one managed worktree by name.
|
||||
func GetManagedWorktree(repoDir, worktreesDir, name string) (*ManagedWorktree, error) {
|
||||
if !isSafeWorktreeName(name) {
|
||||
return nil, ErrInvalidWorktreeName
|
||||
}
|
||||
wtPath := filepath.Join(worktreesDir, name)
|
||||
if !isLinkedWorktree(wtPath) {
|
||||
return nil, ErrWorktreeNotFound
|
||||
}
|
||||
wt := buildManagedWorktree(repoDir, name, wtPath)
|
||||
return &wt, nil
|
||||
}
|
||||
|
||||
// MergeManagedWorktree merges a named worktree branch into baseBranch.
|
||||
// When baseBranch is empty, DetectDefaultBranch(repoDir) is used.
|
||||
func MergeManagedWorktree(repoDir, worktreesDir, name, baseBranch string) (MergeResult, string, error) {
|
||||
var zero MergeResult
|
||||
|
||||
wt, err := GetManagedWorktree(repoDir, worktreesDir, name)
|
||||
if err != nil {
|
||||
return zero, "", err
|
||||
}
|
||||
if wt.Branch == "" || wt.Branch == "HEAD" {
|
||||
return zero, "", fmt.Errorf("worktree %q has no mergeable branch", name)
|
||||
}
|
||||
|
||||
if baseBranch == "" {
|
||||
baseBranch = DetectDefaultBranch(repoDir)
|
||||
}
|
||||
if baseBranch == "" {
|
||||
return zero, "", fmt.Errorf("failed to resolve base branch")
|
||||
}
|
||||
|
||||
current := CurrentBranch(repoDir)
|
||||
if current == "" {
|
||||
return zero, "", fmt.Errorf("failed to detect current branch")
|
||||
}
|
||||
|
||||
if current != baseBranch {
|
||||
if err := checkoutBranch(repoDir, baseBranch); err != nil {
|
||||
return zero, "", err
|
||||
}
|
||||
defer func() {
|
||||
if err := checkoutBranch(repoDir, current); err != nil {
|
||||
logger.WarnCF("git", "Failed to restore original branch after merge", map[string]any{
|
||||
"branch": current,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
res := MergeWorktreeBranch(repoDir, &WorktreeInfo{
|
||||
Path: wt.Path,
|
||||
Branch: wt.Branch,
|
||||
BaseBranch: baseBranch,
|
||||
RepoRoot: repoDir,
|
||||
})
|
||||
return res, baseBranch, nil
|
||||
}
|
||||
|
||||
// DisposeManagedWorktree removes a named worktree with SafeDispose.
|
||||
// When baseBranch is empty, DetectDefaultBranch(repoDir) is used.
|
||||
func DisposeManagedWorktree(repoDir, worktreesDir, name, baseBranch string) (DisposeResult, error) {
|
||||
var zero DisposeResult
|
||||
|
||||
wt, err := GetManagedWorktree(repoDir, worktreesDir, name)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
if wt.Branch == "" {
|
||||
return zero, fmt.Errorf("worktree %q has no branch", name)
|
||||
}
|
||||
if baseBranch == "" {
|
||||
baseBranch = DetectDefaultBranch(repoDir)
|
||||
}
|
||||
if baseBranch == "" {
|
||||
baseBranch = "main"
|
||||
}
|
||||
res := SafeDispose(repoDir, &WorktreeInfo{
|
||||
Path: wt.Path,
|
||||
Branch: wt.Branch,
|
||||
BaseBranch: baseBranch,
|
||||
RepoRoot: repoDir,
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// WorktreeStatusShort returns "git status --short" output for a worktree.
|
||||
func WorktreeStatusShort(worktreePath string) (string, error) {
|
||||
cmd := exec.Command("git", "status", "--short")
|
||||
cmd.Dir = worktreePath
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git status --short: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// WorktreeRecentLog returns recent oneline commits for a worktree branch.
|
||||
func WorktreeRecentLog(worktreePath string, n int) (string, error) {
|
||||
if n <= 0 {
|
||||
n = 10
|
||||
}
|
||||
cmd := exec.Command("git", "log", "--oneline", fmt.Sprintf("-%d", n))
|
||||
cmd.Dir = worktreePath
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git log --oneline: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// WorktreeDiffStat returns a compact diff stat for a worktree.
|
||||
func WorktreeDiffStat(worktreePath string) (string, error) {
|
||||
cmd := exec.Command("git", "diff", "--stat")
|
||||
cmd.Dir = worktreePath
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git diff --stat: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// DetectDefaultBranch returns the preferred base branch name for merges/dispose.
|
||||
func DetectDefaultBranch(repoDir string) string {
|
||||
if localBranchExists(repoDir, "main") {
|
||||
return "main"
|
||||
}
|
||||
if localBranchExists(repoDir, "master") {
|
||||
return "master"
|
||||
}
|
||||
|
||||
// Try origin/HEAD -> origin/<branch>
|
||||
cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD")
|
||||
cmd.Dir = repoDir
|
||||
if out, err := cmd.Output(); err == nil {
|
||||
ref := strings.TrimSpace(string(out))
|
||||
if idx := strings.LastIndex(ref, "/"); idx >= 0 && idx < len(ref)-1 {
|
||||
return ref[idx+1:]
|
||||
}
|
||||
}
|
||||
|
||||
current := CurrentBranch(repoDir)
|
||||
if current != "" && current != "HEAD" {
|
||||
return current
|
||||
}
|
||||
return "main"
|
||||
}
|
||||
|
||||
// PruneOrphaned removes stale worktree directories under worktreesDir.
|
||||
// For orphaned linked worktrees with uncommitted changes, it auto-commits before removal.
|
||||
func PruneOrphaned(repoDir, worktreesDir string) {
|
||||
entries, err := os.ReadDir(worktreesDir)
|
||||
if err != nil {
|
||||
// Still attempt git's own metadata prune.
|
||||
pruneCmd := exec.Command("git", "worktree", "prune")
|
||||
pruneCmd.Dir = repoDir
|
||||
pruneCmd.Run() // best-effort
|
||||
|
||||
entries, err := os.ReadDir(worktreesDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
active := map[string]bool{}
|
||||
activeKnown := false
|
||||
if activePaths, err := listGitWorktreePaths(repoDir); err == nil {
|
||||
activeKnown = true
|
||||
for _, p := range activePaths {
|
||||
active[filepath.Clean(p)] = true
|
||||
}
|
||||
} else {
|
||||
logger.WarnCF("git", "Skip linked-worktree prune: failed to enumerate active worktrees", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
wtPath := filepath.Join(worktreesDir, entry.Name())
|
||||
// Check if it's still a valid git worktree
|
||||
checkCmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||
checkCmd.Dir = wtPath
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
// Not a valid git worktree — remove
|
||||
os.RemoveAll(wtPath)
|
||||
wtPath := filepath.Clean(filepath.Join(worktreesDir, entry.Name()))
|
||||
|
||||
if !isLinkedWorktree(wtPath) {
|
||||
_ = os.RemoveAll(wtPath)
|
||||
continue
|
||||
}
|
||||
if !activeKnown {
|
||||
continue
|
||||
}
|
||||
if active[wtPath] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Orphaned linked worktree: protect changes before prune.
|
||||
if HasUncommittedChanges(wtPath) {
|
||||
if err := AutoCommit(wtPath, "auto-save before prune"); err != nil {
|
||||
logger.WarnCF("git", "Skip pruning orphaned worktree: auto-commit failed", map[string]any{
|
||||
"path": wtPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
logger.InfoCF("git", "Auto-committed orphaned worktree before prune", map[string]any{"path": wtPath})
|
||||
}
|
||||
|
||||
removeCmd := exec.Command("git", "worktree", "remove", "--force", wtPath)
|
||||
removeCmd.Dir = repoDir
|
||||
if out, err := removeCmd.CombinedOutput(); err != nil {
|
||||
logger.WarnCF(
|
||||
"git",
|
||||
"git worktree remove failed for orphaned worktree; removing directory directly",
|
||||
map[string]any{
|
||||
"path": wtPath,
|
||||
"error": strings.TrimSpace(string(out)),
|
||||
},
|
||||
)
|
||||
_ = os.RemoveAll(wtPath)
|
||||
continue
|
||||
}
|
||||
logger.InfoCF("git", "Pruned orphaned worktree", map[string]any{"path": wtPath})
|
||||
}
|
||||
|
||||
pruneCmd := exec.Command("git", "worktree", "prune")
|
||||
pruneCmd.Dir = repoDir
|
||||
if out, err := pruneCmd.CombinedOutput(); err != nil {
|
||||
logger.WarnCF("git", "git worktree prune failed", map[string]any{"error": strings.TrimSpace(string(out))})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -266,3 +516,100 @@ func BranchBaseName(branch string) string {
|
|||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isSafeWorktreeName(name string) bool {
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return false
|
||||
}
|
||||
if filepath.Base(name) != name {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsAny(name, `/\\`) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isLinkedWorktree(path string) bool {
|
||||
gitPath := filepath.Join(path, ".git")
|
||||
info, err := os.Stat(gitPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Linked worktrees have a .git file (not a directory).
|
||||
if info.IsDir() {
|
||||
return false
|
||||
}
|
||||
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||
cmd.Dir = path
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func buildManagedWorktree(repoDir, name, wtPath string) ManagedWorktree {
|
||||
hash, subject, age := lastCommitInfo(wtPath)
|
||||
return ManagedWorktree{
|
||||
Name: name,
|
||||
Path: wtPath,
|
||||
Branch: CurrentBranch(wtPath),
|
||||
LastCommitHash: hash,
|
||||
LastCommitSubject: subject,
|
||||
LastCommitAge: age,
|
||||
HasUncommitted: HasUncommittedChanges(wtPath),
|
||||
}
|
||||
}
|
||||
|
||||
func lastCommitInfo(dir string) (hash, subject, age string) {
|
||||
cmd := exec.Command("git", "log", "-1", "--pretty=format:%h\x1f%s\x1f%cr")
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", "", ""
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimSpace(string(out)), "\x1f", 3)
|
||||
if len(parts) > 0 {
|
||||
hash = parts[0]
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
subject = parts[1]
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
age = parts[2]
|
||||
}
|
||||
return hash, subject, age
|
||||
}
|
||||
|
||||
func localBranchExists(repoDir, name string) bool {
|
||||
cmd := exec.Command("git", "rev-parse", "--verify", "refs/heads/"+name)
|
||||
cmd.Dir = repoDir
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func checkoutBranch(repoDir, branch string) error {
|
||||
cmd := exec.Command("git", "checkout", branch)
|
||||
cmd.Dir = repoDir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git checkout %s: %s: %w", branch, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func listGitWorktreePaths(repoDir string) ([]string, error) {
|
||||
cmd := exec.Command("git", "worktree", "list", "--porcelain")
|
||||
cmd.Dir = repoDir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(string(out), "\n")
|
||||
paths := make([]string, 0)
|
||||
for _, line := range lines {
|
||||
if !strings.HasPrefix(line, "worktree ") {
|
||||
continue
|
||||
}
|
||||
p := strings.TrimSpace(strings.TrimPrefix(line, "worktree "))
|
||||
if p != "" {
|
||||
paths = append(paths, filepath.Clean(p))
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package git
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -297,3 +298,135 @@ func TestPruneOrphaned(t *testing.T) {
|
|||
t.Error("orphaned dir should have been removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedWorktree_ListAndGet(t *testing.T) {
|
||||
dir := initTestRepo(t)
|
||||
worktreesDir := filepath.Join(dir, ".worktrees")
|
||||
wtPath := filepath.Join(worktreesDir, "managed-list")
|
||||
|
||||
if _, err := CreateWorktree(dir, wtPath, "plan/managed-list"); err != nil {
|
||||
t.Fatalf("CreateWorktree: %v", err)
|
||||
}
|
||||
|
||||
items, err := ListManagedWorktrees(dir, worktreesDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ListManagedWorktrees: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 managed worktree, got %d", len(items))
|
||||
}
|
||||
if items[0].Name != "managed-list" {
|
||||
t.Errorf("Name = %q, want %q", items[0].Name, "managed-list")
|
||||
}
|
||||
if items[0].Branch != "plan/managed-list" {
|
||||
t.Errorf("Branch = %q, want %q", items[0].Branch, "plan/managed-list")
|
||||
}
|
||||
if items[0].Path != wtPath {
|
||||
t.Errorf("Path = %q, want %q", items[0].Path, wtPath)
|
||||
}
|
||||
if items[0].HasUncommitted {
|
||||
t.Error("HasUncommitted should be false for clean worktree")
|
||||
}
|
||||
|
||||
if _, err := GetManagedWorktree(dir, worktreesDir, "../bad"); !errors.Is(err, ErrInvalidWorktreeName) {
|
||||
t.Fatalf("expected ErrInvalidWorktreeName, got %v", err)
|
||||
}
|
||||
if _, err := GetManagedWorktree(dir, worktreesDir, "missing"); !errors.Is(err, ErrWorktreeNotFound) {
|
||||
t.Fatalf("expected ErrWorktreeNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeManagedWorktree(t *testing.T) {
|
||||
dir := initTestRepo(t)
|
||||
baseBranch := CurrentBranch(dir)
|
||||
worktreesDir := filepath.Join(dir, ".worktrees")
|
||||
wtPath := filepath.Join(worktreesDir, "managed-merge")
|
||||
|
||||
if _, err := CreateWorktree(dir, wtPath, "plan/managed-merge"); err != nil {
|
||||
t.Fatalf("CreateWorktree: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(wtPath, "merged-managed.txt"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if err := AutoCommit(wtPath, "add merged-managed.txt"); err != nil {
|
||||
t.Fatalf("AutoCommit: %v", err)
|
||||
}
|
||||
|
||||
res, usedBase, err := MergeManagedWorktree(dir, worktreesDir, "managed-merge", "")
|
||||
if err != nil {
|
||||
t.Fatalf("MergeManagedWorktree: %v", err)
|
||||
}
|
||||
if usedBase == "" {
|
||||
t.Fatal("used base branch should not be empty")
|
||||
}
|
||||
if !res.Merged {
|
||||
t.Fatal("expected Merged=true")
|
||||
}
|
||||
if res.Conflict {
|
||||
t.Fatal("expected Conflict=false")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "merged-managed.txt")); os.IsNotExist(err) {
|
||||
t.Fatal("merged-managed.txt should exist after merge")
|
||||
}
|
||||
if branch := CurrentBranch(dir); branch != baseBranch {
|
||||
t.Errorf("CurrentBranch after merge = %q, want %q", branch, baseBranch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisposeManagedWorktree(t *testing.T) {
|
||||
dir := initTestRepo(t)
|
||||
worktreesDir := filepath.Join(dir, ".worktrees")
|
||||
wtPath := filepath.Join(worktreesDir, "managed-dispose")
|
||||
|
||||
if _, err := CreateWorktree(dir, wtPath, "plan/managed-dispose"); err != nil {
|
||||
t.Fatalf("CreateWorktree: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(wtPath, "dirty.txt"), []byte("dirty"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
res, err := DisposeManagedWorktree(dir, worktreesDir, "managed-dispose", "")
|
||||
if err != nil {
|
||||
t.Fatalf("DisposeManagedWorktree: %v", err)
|
||||
}
|
||||
if !res.AutoCommitted {
|
||||
t.Error("AutoCommitted should be true")
|
||||
}
|
||||
if res.CommitsAhead != 1 {
|
||||
t.Errorf("CommitsAhead = %d, want 1", res.CommitsAhead)
|
||||
}
|
||||
if res.BranchDeleted {
|
||||
t.Error("BranchDeleted should be false when branch has unique commits")
|
||||
}
|
||||
if _, err := os.Stat(wtPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("worktree dir should be removed, stat err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneOrphaned_AutoCommitBeforeRemoval(t *testing.T) {
|
||||
dir := initTestRepo(t)
|
||||
baseBranch := CurrentBranch(dir)
|
||||
worktreesDir := filepath.Join(dir, ".worktrees")
|
||||
wtPath := filepath.Join(worktreesDir, "prune-autosave")
|
||||
|
||||
wt, err := CreateWorktree(dir, wtPath, "plan/prune-autosave")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorktree: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(wtPath, "autosave.txt"), []byte("autosave"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if !HasUncommittedChanges(wtPath) {
|
||||
t.Fatal("worktree should have uncommitted changes")
|
||||
}
|
||||
|
||||
otherRepo := initTestRepo(t)
|
||||
PruneOrphaned(otherRepo, worktreesDir)
|
||||
|
||||
if _, err := os.Stat(wtPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("worktree dir should be removed, stat err: %v", err)
|
||||
}
|
||||
if ahead := CommitsAhead(dir, baseBranch, wt.Branch); ahead != 1 {
|
||||
t.Fatalf("CommitsAhead = %d, want 1 (auto-commit should be preserved)", ahead)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@ package miniapp
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/git"
|
||||
)
|
||||
|
||||
func (h *Handler) apiSkills(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -54,6 +58,120 @@ func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *Handler) apiWorktrees(w http.ResponseWriter, r *http.Request) {
|
||||
repoRoot := git.FindRepoRoot(h.workspace)
|
||||
if repoRoot == "" {
|
||||
http.Error(w, `{"error":"workspace is not a git repository"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
worktreesDir := filepath.Join(h.workspace, ".worktrees")
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
items, err := git.ListManagedWorktrees(repoRoot, worktreesDir)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to list worktrees"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, items)
|
||||
|
||||
case http.MethodPost:
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
Name string `json:"name"`
|
||||
Force bool `json:"force"`
|
||||
BaseBranch string `json:"base_branch"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
req.Action = strings.ToLower(strings.TrimSpace(req.Action))
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.BaseBranch = strings.TrimSpace(req.BaseBranch)
|
||||
if req.Action == "" || req.Name == "" {
|
||||
http.Error(w, `{"error":"action and name are required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Action {
|
||||
case "merge":
|
||||
res, baseBranch, err := git.MergeManagedWorktree(repoRoot, worktreesDir, req.Name, req.BaseBranch)
|
||||
if err != nil {
|
||||
if writeWorktreeAPIError(w, err) {
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"merge failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"status": "ok",
|
||||
"action": "merge",
|
||||
"name": req.Name,
|
||||
"base_branch": baseBranch,
|
||||
"result": res,
|
||||
})
|
||||
|
||||
case "dispose":
|
||||
wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, req.Name)
|
||||
if err != nil {
|
||||
if writeWorktreeAPIError(w, err) {
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"failed to inspect worktree"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if wt.HasUncommitted && !req.Force {
|
||||
http.Error(
|
||||
w,
|
||||
`{"error":"worktree has uncommitted changes; retry with force=true"}`,
|
||||
http.StatusConflict,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := git.DisposeManagedWorktree(repoRoot, worktreesDir, req.Name, req.BaseBranch)
|
||||
if err != nil {
|
||||
if writeWorktreeAPIError(w, err) {
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"dispose failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"status": "ok",
|
||||
"action": "dispose",
|
||||
"name": req.Name,
|
||||
"result": res,
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, `{"error":"unknown action"}`, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
default:
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) apiSessionGraph(w http.ResponseWriter, r *http.Request) {
|
||||
graph := h.provider.GetSessionGraph()
|
||||
if graph == nil {
|
||||
graph = &SessionGraphData{
|
||||
Nodes: []SessionGraphNode{},
|
||||
Edges: []SessionGraphEdge{},
|
||||
}
|
||||
}
|
||||
writeJSON(w, graph)
|
||||
}
|
||||
|
||||
func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
|
|
@ -112,9 +230,17 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// Send initial state immediately
|
||||
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
|
||||
sendSSEIfChanged(w, flusher, "session",
|
||||
map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()},
|
||||
&lastSession)
|
||||
sendSSEIfChanged(
|
||||
w,
|
||||
flusher,
|
||||
"session",
|
||||
map[string]any{
|
||||
"stats": h.provider.GetSessionStats(),
|
||||
"sessions": h.provider.GetActiveSessions(),
|
||||
"graph": h.provider.GetSessionGraph(),
|
||||
},
|
||||
&lastSession,
|
||||
)
|
||||
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
||||
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
|
||||
sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext)
|
||||
|
|
@ -128,9 +254,17 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
case <-ch:
|
||||
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
|
||||
sendSSEIfChanged(w, flusher, "session",
|
||||
map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()},
|
||||
&lastSession)
|
||||
sendSSEIfChanged(
|
||||
w,
|
||||
flusher,
|
||||
"session",
|
||||
map[string]any{
|
||||
"stats": h.provider.GetSessionStats(),
|
||||
"sessions": h.provider.GetActiveSessions(),
|
||||
"graph": h.provider.GetSessionGraph(),
|
||||
},
|
||||
&lastSession,
|
||||
)
|
||||
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
||||
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
|
||||
sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext)
|
||||
|
|
@ -159,4 +293,17 @@ func writeJSON(w http.ResponseWriter, v any) {
|
|||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeWorktreeAPIError(w http.ResponseWriter, err error) bool {
|
||||
switch {
|
||||
case errors.Is(err, git.ErrInvalidWorktreeName):
|
||||
http.Error(w, `{"error":"invalid worktree name"}`, http.StatusBadRequest)
|
||||
return true
|
||||
case errors.Is(err, git.ErrWorktreeNotFound):
|
||||
http.Error(w, `{"error":"worktree not found"}`, http.StatusNotFound)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// apiDevConsole receives console output from dev preview iframes.
|
||||
|
|
|
|||
27
pkg/miniapp/frontend/build.mjs
Normal file
27
pkg/miniapp/frontend/build.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { copyFile, mkdir, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const rootDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const srcDir = path.join(rootDir, 'src');
|
||||
const outDir = path.join(rootDir, '..', 'static', 'dist');
|
||||
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [path.join(srcDir, 'app.js')],
|
||||
outdir: outDir,
|
||||
target: 'browser',
|
||||
format: 'iife',
|
||||
sourcemap: 'none',
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
for (const log of result.logs) {
|
||||
console.error(log);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await copyFile(path.join(srcDir, 'map.js'), path.join(outDir, 'map.js'));
|
||||
14
pkg/miniapp/frontend/package.json
Normal file
14
pkg/miniapp/frontend/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "miniapp-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun run ./build.mjs",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "^16.8.1",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
900
pkg/miniapp/frontend/pnpm-lock.yaml
generated
Normal file
900
pkg/miniapp/frontend/pnpm-lock.yaml
generated
Normal file
|
|
@ -0,0 +1,900 @@
|
|||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
happy-dom:
|
||||
specifier: ^16.8.1
|
||||
version: 16.8.1
|
||||
vitest:
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9(happy-dom@16.8.1)
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.21.5':
|
||||
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.21.5':
|
||||
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.21.5':
|
||||
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.21.5':
|
||||
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.21.5':
|
||||
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.21.5':
|
||||
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.21.5':
|
||||
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.21.5':
|
||||
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.21.5':
|
||||
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.21.5':
|
||||
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.21.5':
|
||||
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.21.5':
|
||||
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-x64@0.21.5':
|
||||
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.21.5':
|
||||
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/sunos-x64@0.21.5':
|
||||
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.21.5':
|
||||
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.21.5':
|
||||
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.59.0':
|
||||
resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
|
||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.59.0':
|
||||
resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.59.0':
|
||||
resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||
resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||
|
||||
'@vitest/mocker@2.1.9':
|
||||
resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@2.1.9':
|
||||
resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
|
||||
|
||||
'@vitest/runner@2.1.9':
|
||||
resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
|
||||
|
||||
'@vitest/snapshot@2.1.9':
|
||||
resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
|
||||
|
||||
'@vitest/spy@2.1.9':
|
||||
resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
|
||||
|
||||
'@vitest/utils@2.1.9':
|
||||
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
cac@6.7.14:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
check-error@2.1.3:
|
||||
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
deep-eql@5.0.2:
|
||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
esbuild@0.21.5:
|
||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
happy-dom@16.8.1:
|
||||
resolution: {integrity: sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
pathe@1.1.2:
|
||||
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
||||
|
||||
pathval@2.0.1:
|
||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||
engines: {node: '>= 14.16'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
postcss@8.5.8:
|
||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
rollup@4.59.0:
|
||||
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@0.3.2:
|
||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||
|
||||
tinypool@1.1.1:
|
||||
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
|
||||
tinyrainbow@1.2.0:
|
||||
resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tinyspy@3.0.2:
|
||||
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
vite-node@2.1.9:
|
||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
|
||||
vite@5.4.21:
|
||||
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^18.0.0 || >=20.0.0
|
||||
less: '*'
|
||||
lightningcss: ^1.21.0
|
||||
sass: '*'
|
||||
sass-embedded: '*'
|
||||
stylus: '*'
|
||||
sugarss: '*'
|
||||
terser: ^5.4.0
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
lightningcss:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
|
||||
vitest@2.1.9:
|
||||
resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@types/node': ^18.0.0 || >=20.0.0
|
||||
'@vitest/browser': 2.1.9
|
||||
'@vitest/ui': 2.1.9
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
webidl-conversions@7.0.0:
|
||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
whatwg-mimetype@3.0.0:
|
||||
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
'@vitest/mocker@2.1.9(vite@5.4.21)':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 5.4.21
|
||||
|
||||
'@vitest/pretty-format@2.1.9':
|
||||
dependencies:
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
'@vitest/runner@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/utils': 2.1.9
|
||||
pathe: 1.1.2
|
||||
|
||||
'@vitest/snapshot@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
|
||||
'@vitest/spy@2.1.9':
|
||||
dependencies:
|
||||
tinyspy: 3.0.2
|
||||
|
||||
'@vitest/utils@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
loupe: 3.2.1
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
check-error: 2.1.3
|
||||
deep-eql: 5.0.2
|
||||
loupe: 3.2.1
|
||||
pathval: 2.0.1
|
||||
|
||||
check-error@2.1.3: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
deep-eql@5.0.2: {}
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
esbuild@0.21.5:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.21.5
|
||||
'@esbuild/android-arm': 0.21.5
|
||||
'@esbuild/android-arm64': 0.21.5
|
||||
'@esbuild/android-x64': 0.21.5
|
||||
'@esbuild/darwin-arm64': 0.21.5
|
||||
'@esbuild/darwin-x64': 0.21.5
|
||||
'@esbuild/freebsd-arm64': 0.21.5
|
||||
'@esbuild/freebsd-x64': 0.21.5
|
||||
'@esbuild/linux-arm': 0.21.5
|
||||
'@esbuild/linux-arm64': 0.21.5
|
||||
'@esbuild/linux-ia32': 0.21.5
|
||||
'@esbuild/linux-loong64': 0.21.5
|
||||
'@esbuild/linux-mips64el': 0.21.5
|
||||
'@esbuild/linux-ppc64': 0.21.5
|
||||
'@esbuild/linux-riscv64': 0.21.5
|
||||
'@esbuild/linux-s390x': 0.21.5
|
||||
'@esbuild/linux-x64': 0.21.5
|
||||
'@esbuild/netbsd-x64': 0.21.5
|
||||
'@esbuild/openbsd-x64': 0.21.5
|
||||
'@esbuild/sunos-x64': 0.21.5
|
||||
'@esbuild/win32-arm64': 0.21.5
|
||||
'@esbuild/win32-ia32': 0.21.5
|
||||
'@esbuild/win32-x64': 0.21.5
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
happy-dom@16.8.1:
|
||||
dependencies:
|
||||
webidl-conversions: 7.0.0
|
||||
whatwg-mimetype: 3.0.0
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
pathe@1.1.2: {}
|
||||
|
||||
pathval@2.0.1: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
postcss@8.5.8:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
rollup@4.59.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.59.0
|
||||
'@rollup/rollup-android-arm64': 4.59.0
|
||||
'@rollup/rollup-darwin-arm64': 4.59.0
|
||||
'@rollup/rollup-darwin-x64': 4.59.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.59.0
|
||||
'@rollup/rollup-freebsd-x64': 4.59.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.59.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.59.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.59.0
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-loong64-musl': 4.59.0
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-ppc64-musl': 4.59.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.59.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.59.0
|
||||
'@rollup/rollup-openbsd-x64': 4.59.0
|
||||
'@rollup/rollup-openharmony-arm64': 4.59.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.59.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.59.0
|
||||
'@rollup/rollup-win32-x64-gnu': 4.59.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.59.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
|
||||
tinypool@1.1.1: {}
|
||||
|
||||
tinyrainbow@1.2.0: {}
|
||||
|
||||
tinyspy@3.0.2: {}
|
||||
|
||||
vite-node@2.1.9:
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.21
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vite@5.4.21:
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.8
|
||||
rollup: 4.59.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitest@2.1.9(happy-dom@16.8.1):
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.9
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.21)
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
'@vitest/runner': 2.1.9
|
||||
'@vitest/snapshot': 2.1.9
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 1.2.0
|
||||
vite: 5.4.21
|
||||
vite-node: 2.1.9
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
happy-dom: 16.8.1
|
||||
transitivePeerDependencies:
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
webidl-conversions@7.0.0: {}
|
||||
|
||||
whatwg-mimetype@3.0.0: {}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
1341
pkg/miniapp/frontend/src/app.js
Normal file
1341
pkg/miniapp/frontend/src/app.js
Normal file
File diff suppressed because it is too large
Load diff
106
pkg/miniapp/frontend/src/logs_view.js
Normal file
106
pkg/miniapp/frontend/src/logs_view.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
const SAFE_LEVELS = new Set(['debug', 'info', 'warn', 'error']);
|
||||
|
||||
function stringifyFieldValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function renderFields(fields) {
|
||||
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
|
||||
return '';
|
||||
}
|
||||
const keys = Object.keys(fields);
|
||||
if (keys.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = keys.map((key) => `${key}=${stringifyFieldValue(fields[key])}`);
|
||||
return ` <span class="log-fields">{${escapeHtml(parts.join(', '))}}</span>`;
|
||||
}
|
||||
|
||||
export function filterLogs(entries, component = '') {
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (!component) {
|
||||
return entries.slice();
|
||||
}
|
||||
return entries.filter((entry) => (entry?.component || '') === component);
|
||||
}
|
||||
|
||||
export function paginateLogs(entries, page = 1, pageSize = 100) {
|
||||
const list = Array.isArray(entries) ? entries : [];
|
||||
const size = Math.max(1, Number(pageSize) || 100);
|
||||
const totalPages = Math.max(1, Math.ceil(list.length / size));
|
||||
const currentPage = Math.min(Math.max(1, Number(page) || 1), totalPages);
|
||||
|
||||
// Page 1 is the newest page (tail of the list).
|
||||
const end = list.length - (currentPage - 1) * size;
|
||||
const start = Math.max(0, end - size);
|
||||
return {
|
||||
items: list.slice(start, Math.max(start, end)),
|
||||
currentPage,
|
||||
totalPages,
|
||||
pageSize: size,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderLogs(entries, options = {}) {
|
||||
const component = options.component || '';
|
||||
const filtered = filterLogs(entries, component);
|
||||
const paged = paginateLogs(filtered, options.page, options.pageSize);
|
||||
|
||||
let html = '';
|
||||
for (const entry of paged.items) {
|
||||
const levelRaw = String(entry?.level || 'info').toLowerCase();
|
||||
const level = SAFE_LEVELS.has(levelRaw) ? levelRaw : 'info';
|
||||
const ts = entry?.timestamp ? String(entry.timestamp).substring(11, 19) : '';
|
||||
const componentHTML = entry?.component
|
||||
? `<span class="log-comp">${escapeHtml(entry.component)}</span>`
|
||||
: '';
|
||||
const fieldsHTML = renderFields(entry?.fields);
|
||||
const message = escapeHtml(entry?.message || '');
|
||||
|
||||
html += '<div class="log-entry">' +
|
||||
`<span class="log-ts">${ts}</span>` +
|
||||
`<span class="log-badge ${level}">${level}</span>` +
|
||||
componentHTML +
|
||||
`<span class="log-msg">${message}${fieldsHTML}</span>` +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
return {
|
||||
html,
|
||||
totalItems: filtered.length,
|
||||
currentPage: paged.currentPage,
|
||||
totalPages: paged.totalPages,
|
||||
pageSize: paged.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderLogsInto(container, entries, options = {}) {
|
||||
const view = renderLogs(entries, options);
|
||||
container.innerHTML = view.html;
|
||||
return view;
|
||||
}
|
||||
70
pkg/miniapp/frontend/src/logs_view.test.js
Normal file
70
pkg/miniapp/frontend/src/logs_view.test.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { escapeHtml, filterLogs, paginateLogs, renderFields, renderLogs, renderLogsInto } from './logs_view.js';
|
||||
|
||||
describe('logs_view', () => {
|
||||
it('renders a normal log message row', () => {
|
||||
const view = renderLogs([
|
||||
{
|
||||
timestamp: '2026-03-05T12:34:56Z',
|
||||
level: 'INFO',
|
||||
component: 'telego',
|
||||
message: 'connected',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(view.totalItems).toBe(1);
|
||||
expect(view.html).toContain('12:34:56');
|
||||
expect(view.html).toContain('log-badge info');
|
||||
expect(view.html).toContain('connected');
|
||||
expect(view.html).toContain('telego');
|
||||
});
|
||||
|
||||
it('renders fields for empty/single/multiple cases', () => {
|
||||
expect(renderFields(null)).toBe('');
|
||||
expect(renderFields({})).toBe('');
|
||||
expect(renderFields({ req_id: 42 })).toContain('{req_id=42}');
|
||||
|
||||
const multi = renderFields({ a: 'x', b: 2 });
|
||||
expect(multi).toContain('a=x');
|
||||
expect(multi).toContain('b=2');
|
||||
});
|
||||
|
||||
it('sanitizes potentially dangerous HTML', () => {
|
||||
const xss = '<img src=x onerror=alert(1) />';
|
||||
const escaped = escapeHtml(xss);
|
||||
expect(escaped).not.toContain('<img');
|
||||
expect(escaped).toContain('<img');
|
||||
|
||||
const view = renderLogs([{ message: xss, level: 'warn' }]);
|
||||
const container = document.createElement('div');
|
||||
renderLogsInto(container, [{ message: xss, level: 'warn' }]);
|
||||
expect(container.querySelector('img')).toBeNull();
|
||||
expect(view.html).toContain('<img');
|
||||
});
|
||||
|
||||
it('supports filtering and pagination', () => {
|
||||
const entries = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
entries.push({
|
||||
level: 'debug',
|
||||
component: i % 2 === 0 ? 'telego' : 'dev-console',
|
||||
message: `entry-${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
const filtered = filterLogs(entries, 'telego');
|
||||
expect(filtered.length).toBe(13);
|
||||
|
||||
const pageInfo = paginateLogs(filtered, 2, 5);
|
||||
expect(pageInfo.totalPages).toBe(3);
|
||||
expect(pageInfo.items.length).toBe(5);
|
||||
|
||||
const page1 = renderLogs(entries, { component: 'telego', page: 1, pageSize: 5 });
|
||||
const page2 = renderLogs(entries, { component: 'telego', page: 2, pageSize: 5 });
|
||||
expect(page1.totalPages).toBe(3);
|
||||
expect(page1.html).not.toBe(page2.html);
|
||||
expect(page1.html).toContain('entry-24');
|
||||
expect(page2.html).toContain('entry-14');
|
||||
});
|
||||
});
|
||||
|
|
@ -203,3 +203,10 @@ function _drawMapFallback(ctx) {
|
|||
_r(ctx, '#4a2408', 162, 284, 12, 16);
|
||||
_r(ctx, _C.doorGold, 170, 291, 5, 5); // handle
|
||||
}
|
||||
|
||||
// Expose map helpers for app.js runtime.
|
||||
globalThis.MAP_POSITIONS = MAP_POSITIONS;
|
||||
globalThis.loadMapAsset = loadMapAsset;
|
||||
globalThis.drawMap = drawMap;
|
||||
|
||||
|
||||
912
pkg/miniapp/frontend/src/styles.css
Normal file
912
pkg/miniapp/frontend/src/styles.css
Normal file
|
|
@ -0,0 +1,912 @@
|
|||
:root {
|
||||
--bg: var(--tg-theme-bg-color, #ffffff);
|
||||
--text: var(--tg-theme-text-color, #000000);
|
||||
--hint: var(--tg-theme-hint-color, #999999);
|
||||
--link: var(--tg-theme-link-color, #2481cc);
|
||||
--btn: var(--tg-theme-button-color, #2481cc);
|
||||
--btn-text: var(--tg-theme-button-text-color, #ffffff);
|
||||
--secondary-bg: var(--tg-theme-secondary-bg-color, #f0f0f0);
|
||||
--done: #34c759;
|
||||
--current: var(--btn);
|
||||
--pending-phase: var(--hint);
|
||||
/* Liquid Glass */
|
||||
--glass-bg: rgba(255, 255, 255, 0.55);
|
||||
--glass-border: rgba(0, 0, 0, 0.08);
|
||||
--glass-border-interactive: rgba(0, 0, 0, 0.15);
|
||||
--glass-shadow: rgba(0, 0, 0, 0.06);
|
||||
--glass-divider: rgba(0, 0, 0, 0.06);
|
||||
--tab-bar-bg: rgba(255, 255, 255, 0.72);
|
||||
--tab-track-bg: rgba(0, 0, 0, 0.06);
|
||||
--tab-pill-bg: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: var(--tg-theme-bg-color, #1c1c1e);
|
||||
--text: var(--tg-theme-text-color, #ffffff);
|
||||
--hint: var(--tg-theme-hint-color, #8e8e93);
|
||||
--link: var(--tg-theme-link-color, #5ac8fa);
|
||||
--btn: var(--tg-theme-button-color, #5ac8fa);
|
||||
--btn-text: var(--tg-theme-button-text-color, #ffffff);
|
||||
--secondary-bg: var(--tg-theme-secondary-bg-color, #2c2c2e);
|
||||
/* Liquid Glass — dark */
|
||||
--glass-bg: rgba(255, 255, 255, 0.08);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
--glass-border-interactive: rgba(255, 255, 255, 0.22);
|
||||
--glass-shadow: rgba(0, 0, 0, 0.2);
|
||||
--glass-divider: rgba(255, 255, 255, 0.08);
|
||||
--tab-bar-bg: rgba(28, 28, 30, 0.72);
|
||||
--tab-track-bg: rgba(255, 255, 255, 0.1);
|
||||
--tab-pill-bg: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding: 8px 12px;
|
||||
background: var(--tab-bar-bg);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
}
|
||||
|
||||
.tabs-inner {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: var(--tab-track-bg);
|
||||
border-radius: 10px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.tab-indicator {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
left: 2px;
|
||||
width: calc(100% / var(--tab-count, 7) - 2px);
|
||||
border-radius: 8px;
|
||||
background: var(--tab-pill-bg);
|
||||
box-shadow: 0 0.5px 2px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08);
|
||||
transition: transform 0.38s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 7px 4px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--hint);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.25s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 1px 3px var(--glass-shadow);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.glass-interactive { border-color: var(--glass-border-interactive); }
|
||||
|
||||
.panel { display: none; padding: 16px; }
|
||||
.panel.active { display: block; }
|
||||
|
||||
.card {
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 12px;
|
||||
color: var(--hint);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Plan - phase/step list */
|
||||
.phase {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.phase-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0 8px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.phase-indicator {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.phase-indicator.done { background: var(--done); }
|
||||
.phase-indicator.current { background: var(--current); }
|
||||
.phase-indicator.pending { background: var(--glass-divider); color: var(--hint); }
|
||||
|
||||
.phase-title { flex: 1; }
|
||||
.phase-progress {
|
||||
font-size: 12px;
|
||||
color: var(--hint);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px 12px 34px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1), box-shadow 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.step:active { transform: scale(0.97); }
|
||||
.step:not(.step-done) {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border-interactive);
|
||||
box-shadow: 0 0.5px 2px var(--glass-shadow);
|
||||
}
|
||||
|
||||
.step-check {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--hint);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.step-check.done {
|
||||
background: var(--done);
|
||||
border-color: var(--done);
|
||||
}
|
||||
|
||||
.step-check.done::after {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
border: solid #fff;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.step-text.done {
|
||||
color: var(--hint);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Skills */
|
||||
.skill-item {
|
||||
padding: 14px 14px 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1), box-shadow 0.2s, border-color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.skill-item:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.skill-item.selected {
|
||||
border-color: var(--btn);
|
||||
box-shadow: 0 2px 8px var(--glass-shadow);
|
||||
}
|
||||
|
||||
.skill-body { flex: 1; min-width: 0; }
|
||||
|
||||
.skill-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.skill-desc {
|
||||
font-size: 13px;
|
||||
color: var(--hint);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.skill-source {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: var(--tab-track-bg);
|
||||
color: var(--hint);
|
||||
margin-top: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.skill-arrow {
|
||||
color: var(--hint);
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.skill-item.selected .skill-arrow { color: var(--btn); }
|
||||
|
||||
/* Stats */
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid var(--glass-divider);
|
||||
}
|
||||
|
||||
.stat-row:last-child { border-bottom: none; }
|
||||
.stat-label { color: var(--hint); font-size: 14px; }
|
||||
.stat-value { font-weight: 600; font-size: 14px; }
|
||||
|
||||
/* Send bar */
|
||||
.send-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--tab-bar-bg);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--glass-divider);
|
||||
}
|
||||
|
||||
.send-input {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
border-radius: 20px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.send-input:focus { border-color: var(--btn); }
|
||||
|
||||
.send-btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
background: var(--btn);
|
||||
color: var(--btn-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1), background 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.send-btn:active { transform: scale(0.95); }
|
||||
.send-btn:disabled { opacity: 0.5; }
|
||||
.send-btn.sent { background: var(--done); }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--hint);
|
||||
padding: 48px 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: var(--hint);
|
||||
padding: 48px 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.cmd-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cmd-tile {
|
||||
padding: 16px 14px;
|
||||
border-radius: 14px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1), background 0.15s;
|
||||
text-align: center;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.cmd-tile:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.cmd-tile.sent {
|
||||
background: var(--btn);
|
||||
color: var(--btn-text);
|
||||
border-color: var(--btn);
|
||||
}
|
||||
|
||||
/* MEMORY.md raw display */
|
||||
.memory-view {
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.memory-view .md-h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 16px 0 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.memory-view .md-h1:first-child { margin-top: 0; }
|
||||
|
||||
.memory-view .md-h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin: 14px 0 6px;
|
||||
color: var(--link);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.memory-view .md-h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 12px 0 4px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.memory-view .md-checkbox {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.memory-view .md-checkbox-icon {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1.5px solid var(--hint);
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.memory-view .md-checkbox-icon.checked {
|
||||
background: var(--done);
|
||||
border-color: var(--done);
|
||||
}
|
||||
|
||||
.memory-view .md-checkbox-icon.checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid #fff;
|
||||
border-width: 0 1.5px 1.5px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.memory-view .md-quote {
|
||||
border-left: 3px solid var(--hint);
|
||||
padding-left: 10px;
|
||||
color: var(--hint);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.memory-view .md-bullet {
|
||||
margin: 2px 0;
|
||||
padding-left: 12px;
|
||||
text-indent: -12px;
|
||||
}
|
||||
|
||||
.memory-view .md-bullet::before {
|
||||
content: '\2022 ';
|
||||
color: var(--hint);
|
||||
}
|
||||
|
||||
/* Slide to Approve */
|
||||
.slide-approve-wrap {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.slide-approve-track {
|
||||
position: relative;
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
border: 1.5px solid;
|
||||
border-image: linear-gradient(135deg, var(--btn), var(--glass-border)) 1;
|
||||
border-image: none;
|
||||
border-color: var(--btn);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
transition: background 0.3s, border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.slide-approve-track.approved {
|
||||
background: var(--done);
|
||||
border-color: var(--done);
|
||||
box-shadow: 0 0 16px rgba(76,175,80,0.4), 0 2px 8px rgba(0,0,0,0.18);
|
||||
}
|
||||
|
||||
.slide-approve-thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background: var(--btn);
|
||||
color: var(--btn-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
transition: left 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
z-index: 1;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
.slide-approve-thumb svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.slide-approve-thumb.dragging {
|
||||
transition: none;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% { opacity: 0.7; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.slide-approve-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: color 0.3s;
|
||||
animation: shimmer 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.slide-approve-track.approved .slide-approve-label {
|
||||
color: #fff;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Git log */
|
||||
.git-commit {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.git-commit:last-child { border-bottom: none; }
|
||||
.git-hash {
|
||||
font-family: monospace;
|
||||
color: var(--btn);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.git-subject { flex: 1; color: var(--text); }
|
||||
.git-meta { color: var(--hint); font-size: 11px; flex-shrink: 0; text-align: right; }
|
||||
.git-status {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.git-status-m { color: #e2b93d; }
|
||||
.git-status-a { color: #4caf50; }
|
||||
.git-status-d { color: #ef5350; }
|
||||
.git-status-u { color: var(--hint); }
|
||||
|
||||
.git-repo-item {
|
||||
padding: 14px 14px 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1), box-shadow 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.git-repo-item:active { transform: scale(0.98); }
|
||||
.git-repo-body { flex: 1; min-width: 0; }
|
||||
.git-repo-name { font-weight: 600; font-size: 15px; margin-bottom: 4px; }
|
||||
.git-repo-branch {
|
||||
font-size: 13px;
|
||||
color: var(--hint);
|
||||
font-family: monospace;
|
||||
}
|
||||
.git-repo-arrow {
|
||||
color: var(--hint);
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.git-back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--btn);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 8px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.git-back-btn:active { opacity: 0.6; }
|
||||
|
||||
.worktree-list {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.worktree-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: var(--glass-bg);
|
||||
}
|
||||
.worktree-item.dirty {
|
||||
border-color: rgba(255, 152, 0, 0.45);
|
||||
}
|
||||
.worktree-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.worktree-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.worktree-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
.worktree-branch {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--hint);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.worktree-last {
|
||||
font-size: 11px;
|
||||
color: var(--hint);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.worktree-dirty,
|
||||
.worktree-clean {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.worktree-dirty {
|
||||
color: #c26b00;
|
||||
background: rgba(255, 152, 0, 0.2);
|
||||
}
|
||||
.worktree-clean {
|
||||
color: #1b8f3a;
|
||||
background: rgba(76, 175, 80, 0.18);
|
||||
}
|
||||
.worktree-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.worktree-btn {
|
||||
border: 1px solid var(--glass-border-interactive);
|
||||
background: var(--glass-bg);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
min-width: 74px;
|
||||
}
|
||||
.worktree-btn.merge { color: var(--btn); }
|
||||
.worktree-btn.dispose { color: #d14b4b; }
|
||||
.worktree-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
/* Dev header */
|
||||
.dev-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dev-header-title { font-weight: 600; font-size: 15px; }
|
||||
.dev-header-target {
|
||||
color: var(--hint);
|
||||
font-size: 13px;
|
||||
margin-left: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Dev target cards */
|
||||
.dev-target-item {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1), box-shadow 0.2s, border-color 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.dev-target-item:active { transform: scale(0.98); }
|
||||
.dev-target-item.active {
|
||||
border-color: var(--btn);
|
||||
box-shadow: 0 2px 8px var(--glass-shadow);
|
||||
}
|
||||
.dev-target-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--hint);
|
||||
}
|
||||
.dev-target-dot.on { background: var(--done); }
|
||||
.dev-target-name { font-weight: 600; font-size: 14px; }
|
||||
.dev-target-url {
|
||||
color: var(--hint);
|
||||
font-size: 13px;
|
||||
margin-left: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dev-target-delete {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 6px;
|
||||
margin: -6px;
|
||||
margin-left: 8px;
|
||||
border-radius: 50%;
|
||||
color: var(--hint);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.dev-target-delete:active {
|
||||
color: #ff3b30;
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
}
|
||||
|
||||
/* Log viewer */
|
||||
.log-filter-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.log-filter-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--glass-border-interactive);
|
||||
background: var(--glass-bg);
|
||||
color: var(--hint);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.log-filter-chip.active {
|
||||
background: var(--btn);
|
||||
color: var(--btn-text);
|
||||
border-color: var(--btn);
|
||||
}
|
||||
|
||||
#logs-content {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.log-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
color: var(--hint);
|
||||
font-size: 11px;
|
||||
}
|
||||
.log-page-btn {
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--glass-border-interactive);
|
||||
background: var(--glass-bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.log-page-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.log-entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 3px 0;
|
||||
font-size: 11px;
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
line-height: 1.4;
|
||||
border-bottom: 1px solid var(--glass-divider);
|
||||
}
|
||||
.log-ts { color: var(--hint); flex-shrink: 0; white-space: nowrap; }
|
||||
.log-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
line-height: 16px;
|
||||
}
|
||||
.log-badge.error { background: rgba(255,59,48,0.15); color: #ff3b30; }
|
||||
.log-badge.warn { background: rgba(255,204,0,0.15); color: #cc9900; }
|
||||
.log-badge.info { background: rgba(52,199,89,0.12); color: #34c759; }
|
||||
.log-badge.debug { background: rgba(142,142,147,0.12); color: #8e8e93; }
|
||||
.log-comp { color: var(--link); flex-shrink: 0; font-size: 10px; }
|
||||
.log-msg { flex: 1; word-break: break-all; color: var(--text); }
|
||||
.log-fields { color: var(--hint); font-size: 10px; }
|
||||
.log-actions { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.log-snap-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border-interactive);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Orch panel ── */
|
||||
.orch-room-row { display:flex; align-items:flex-start; padding:12px 0; }
|
||||
.orch-side { display:flex; flex-direction:column; align-items:center; gap:12px; padding-top:20px; width:40px; flex-shrink:0; }
|
||||
.orch-badge { display:flex; flex-direction:column; align-items:center; gap:3px; opacity:0.3; transition:opacity 0.3s; }
|
||||
.orch-badge.alive { opacity:1; }
|
||||
.orch-badge.alive .orch-badge-label { color:#4a6ac0; }
|
||||
.orch-badge.alive .orch-badge-dot { background:#4ade80; }
|
||||
.orch-badge.toolcall .orch-badge-dot { background:#fb923c; animation:orch-blink 0.2s step-end infinite; }
|
||||
.orch-badge.waiting .orch-badge-dot { background:#60a5fa; }
|
||||
.orch-badge.talking .orch-badge-label{ color:#facc15; }
|
||||
.orch-badge.talking .orch-badge-dot { background:#facc15; animation:orch-blink 0.6s step-end infinite; }
|
||||
.orch-badge-emoji { font-size:16px; line-height:1; }
|
||||
.orch-badge-label { font-size:7px; color:var(--hint); letter-spacing:0.05em; text-transform:uppercase; }
|
||||
.orch-badge-dot { width:4px; height:4px; border-radius:50%; background:var(--hint); }
|
||||
@keyframes orch-blink { 50% { opacity:0; } }
|
||||
.orch-canvas-wrap { flex:1; min-width:0; border-radius:12px; overflow:hidden; background:#060810; }
|
||||
.orch-canvas-wrap canvas { image-rendering:pixelated; image-rendering:crisp-edges; display:block; width:100%; aspect-ratio:1/1; }
|
||||
.orch-status { text-align:center; font-size:11px; color:var(--hint); padding:6px 0 4px; }
|
||||
.orch-dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--hint); margin-right:4px; vertical-align:middle; }
|
||||
.orch-dot.on { background:#4ade80; }
|
||||
|
||||
/* Session graph tree */
|
||||
.session-tree { padding:0; margin:0; list-style:none; }
|
||||
.session-tree-node { position:relative; padding:4px 0 4px 20px; font-size:13px; }
|
||||
.session-tree-node::before {
|
||||
content:''; position:absolute; left:0; top:0; bottom:0;
|
||||
border-left:1px solid var(--secondary-bg);
|
||||
}
|
||||
.session-tree-node::after {
|
||||
content:''; position:absolute; left:0; top:14px; width:16px;
|
||||
border-top:1px solid var(--secondary-bg);
|
||||
}
|
||||
.session-tree-node:last-child::before { height:14px; }
|
||||
.session-tree-children { padding-left:20px; margin:0; list-style:none; }
|
||||
.session-tree-label { font-weight:600; }
|
||||
.session-tree-meta { color:var(--hint); font-size:11px; margin-left:6px; }
|
||||
.session-tree-icon { font-size:10px; margin-right:4px; }
|
||||
.session-tree-icon.active { color:var(--done); }
|
||||
.session-tree-icon.completed { color:var(--hint); }
|
||||
|
||||
|
||||
|
||||
8
pkg/miniapp/frontend/vitest.config.mjs
Normal file
8
pkg/miniapp/frontend/vitest.config.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.test.js'],
|
||||
},
|
||||
});
|
||||
|
|
@ -2,18 +2,33 @@ package miniapp
|
|||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/orch"
|
||||
)
|
||||
|
||||
//go:embed static/index.html static/map.js
|
||||
//go:generate bun run --cwd frontend build
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
var (
|
||||
miniappStaticFS = mustMiniappStaticFS()
|
||||
miniappTemplate = template.Must(template.ParseFS(staticFS, "static/index.html"))
|
||||
)
|
||||
|
||||
func mustMiniappStaticFS() fs.FS {
|
||||
sub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
panic("miniapp: failed to create static sub filesystem: " + err.Error())
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
// Handler serves the Mini App HTML and API endpoints.
|
||||
type Handler struct {
|
||||
provider DataProvider
|
||||
|
|
@ -68,44 +83,45 @@ func (h *Handler) SetOrchBroadcaster(b *orch.Broadcaster) {
|
|||
// RegisterRoutes registers Mini App routes on the given mux.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/miniapp", h.serveIndex)
|
||||
mux.HandleFunc("/miniapp/index.html", h.serveIndex)
|
||||
mux.HandleFunc("/miniapp/", h.serveStatic)
|
||||
mux.HandleFunc("/miniapp/api/skills", h.requireAuth(h.apiSkills))
|
||||
mux.HandleFunc("/miniapp/api/plan", h.requireAuth(h.apiPlan))
|
||||
mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession))
|
||||
mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions))
|
||||
mux.HandleFunc("/miniapp/api/sessions/graph", h.requireAuth(h.apiSessionGraph))
|
||||
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand))
|
||||
mux.HandleFunc("/miniapp/api/context", h.requireAuth(h.apiContext))
|
||||
mux.HandleFunc("/miniapp/api/prompt", h.requireAuth(h.apiPrompt))
|
||||
mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit))
|
||||
mux.HandleFunc("/miniapp/api/worktrees", h.requireAuth(h.apiWorktrees))
|
||||
mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev))
|
||||
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
|
||||
mux.HandleFunc("/miniapp/api/logs/ws", h.requireAuth(h.wsLogs))
|
||||
mux.HandleFunc("/miniapp/api/logs/snapshot", h.requireAuth(h.apiLogsSnapshot))
|
||||
mux.HandleFunc("/miniapp/api/logs/snapshot/", h.requireAuth(h.apiLogsSnapshotDownload))
|
||||
mux.HandleFunc("/miniapp/api/orchestration/ws", h.requireAuth(h.wsOrchestration))
|
||||
mux.HandleFunc("/miniapp/map.js", h.serveMapJS)
|
||||
mux.HandleFunc("/miniapp/dev/console", h.apiDevConsole)
|
||||
mux.HandleFunc("/miniapp/dev/", h.serveDevProxy)
|
||||
}
|
||||
|
||||
func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := staticFS.ReadFile("static/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
data := struct {
|
||||
OrchEnabled bool
|
||||
}{
|
||||
OrchEnabled: h.orchBroadcaster != nil,
|
||||
}
|
||||
if err := miniappTemplate.Execute(w, data); err != nil {
|
||||
http.Error(w, "failed to render template", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if h.orchBroadcaster != nil {
|
||||
data = []byte(strings.Replace(string(data), "</head>", "<script>var ORCH_ENABLED=true;</script></head>", 1))
|
||||
}
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (h *Handler) serveMapJS(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := staticFS.ReadFile("static/map.js")
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
func (h *Handler) serveStatic(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/miniapp/" {
|
||||
h.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Write(data)
|
||||
http.StripPrefix("/miniapp/", http.FileServer(http.FS(miniappStaticFS))).ServeHTTP(w, r)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
|
@ -19,6 +22,8 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
gitpkg "github.com/sipeed/picoclaw/pkg/git"
|
||||
"github.com/sipeed/picoclaw/pkg/orch"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/sipeed/picoclaw/pkg/stats"
|
||||
)
|
||||
|
|
@ -188,6 +193,8 @@ func (m *mockDataProvider) GetActiveSessions() []SessionInfo {
|
|||
return []SessionInfo{}
|
||||
}
|
||||
|
||||
func (m *mockDataProvider) GetSessionGraph() *SessionGraphData { return nil }
|
||||
|
||||
func (m *mockDataProvider) GetGitRepos() []GitRepoSummary {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -221,6 +228,61 @@ func testInitData() string {
|
|||
}, testBotToken)
|
||||
}
|
||||
|
||||
func TestMiniApp_IndexTemplateInjectsOrchFlag(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest("GET", "/miniapp", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "window.ORCH_ENABLED = false;") {
|
||||
t.Fatalf("expected ORCH_ENABLED=false in rendered template")
|
||||
}
|
||||
|
||||
h.SetOrchBroadcaster(orch.NewBroadcaster())
|
||||
w2 := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w2, httptest.NewRequest("GET", "/miniapp", nil))
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 with broadcaster, got %d", w2.Code)
|
||||
}
|
||||
if !strings.Contains(w2.Body.String(), "window.ORCH_ENABLED = true;") {
|
||||
t.Fatalf("expected ORCH_ENABLED=true in rendered template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniApp_StaticFileServerServesAssets(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{path: "/miniapp/map-preview.html", want: "Orchestration Room"},
|
||||
{path: "/miniapp/dist/map.js", want: "MAP_POSITIONS"},
|
||||
{path: "/miniapp/dist/app.js", want: "renderLogs"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for %s, got %d", tc.path, w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), tc.want) {
|
||||
t.Fatalf("expected %q in %s", tc.want, tc.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSE_AuthRequired(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
|
|
@ -483,6 +545,8 @@ func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo {
|
|||
return []SessionInfo{}
|
||||
}
|
||||
|
||||
func (m *mutatingDataProvider) GetSessionGraph() *SessionGraphData { return nil }
|
||||
|
||||
func (m *mutatingDataProvider) GetGitRepos() []GitRepoSummary {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -2072,3 +2136,135 @@ func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Du
|
|||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func initMiniAppGitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
repo := t.TempDir()
|
||||
|
||||
runGit := func(dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %s: %s: %v", strings.Join(args, " "), strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
}
|
||||
|
||||
runGit(repo, "init")
|
||||
runGit(repo, "config", "user.email", "test@test.com")
|
||||
runGit(repo, "config", "user.name", "Test")
|
||||
|
||||
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# Test\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
runGit(repo, "add", "-A")
|
||||
runGit(repo, "commit", "-m", "initial")
|
||||
|
||||
return repo
|
||||
}
|
||||
|
||||
func TestAPIWorktrees_List(t *testing.T) {
|
||||
repo := initMiniAppGitRepo(t)
|
||||
wtPath := filepath.Join(repo, ".worktrees", "api-list")
|
||||
if _, err := gitpkg.CreateWorktree(repo, wtPath, "plan/api-list"); err != nil {
|
||||
t.Fatalf("CreateWorktree: %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, repo)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest("GET", "/miniapp/api/worktrees?initData="+url.QueryEscape(testInitData()), nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var items []struct {
|
||||
Name string `json:"name"`
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 worktree, got %d", len(items))
|
||||
}
|
||||
if items[0].Name != "api-list" {
|
||||
t.Errorf("Name = %q, want %q", items[0].Name, "api-list")
|
||||
}
|
||||
if items[0].Branch != "plan/api-list" {
|
||||
t.Errorf("Branch = %q, want %q", items[0].Branch, "plan/api-list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIWorktrees_MergeAndDispose(t *testing.T) {
|
||||
repo := initMiniAppGitRepo(t)
|
||||
|
||||
mergePath := filepath.Join(repo, ".worktrees", "api-merge")
|
||||
if _, err := gitpkg.CreateWorktree(repo, mergePath, "plan/api-merge"); err != nil {
|
||||
t.Fatalf("CreateWorktree merge: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mergePath, "merged.txt"), []byte("from worktree"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile merge: %v", err)
|
||||
}
|
||||
if err := gitpkg.AutoCommit(mergePath, "add merged.txt"); err != nil {
|
||||
t.Fatalf("AutoCommit merge: %v", err)
|
||||
}
|
||||
|
||||
disposePath := filepath.Join(repo, ".worktrees", "api-dispose")
|
||||
if _, err := gitpkg.CreateWorktree(repo, disposePath, "plan/api-dispose"); err != nil {
|
||||
t.Fatalf("CreateWorktree dispose: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(disposePath, "dirty.txt"), []byte("dirty"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile dispose: %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, repo)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
mergeReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/miniapp/api/worktrees?initData="+url.QueryEscape(testInitData()),
|
||||
strings.NewReader(`{"action":"merge","name":"api-merge"}`),
|
||||
)
|
||||
mergeReq.Header.Set("Content-Type", "application/json")
|
||||
mergeW := httptest.NewRecorder()
|
||||
mux.ServeHTTP(mergeW, mergeReq)
|
||||
if mergeW.Code != http.StatusOK {
|
||||
t.Fatalf("merge expected 200, got %d: %s", mergeW.Code, mergeW.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(repo, "merged.txt")); os.IsNotExist(err) {
|
||||
t.Fatal("merged.txt should exist after merge")
|
||||
}
|
||||
|
||||
disposeReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/miniapp/api/worktrees?initData="+url.QueryEscape(testInitData()),
|
||||
strings.NewReader(`{"action":"dispose","name":"api-dispose"}`),
|
||||
)
|
||||
disposeReq.Header.Set("Content-Type", "application/json")
|
||||
disposeW := httptest.NewRecorder()
|
||||
mux.ServeHTTP(disposeW, disposeReq)
|
||||
if disposeW.Code != http.StatusConflict {
|
||||
t.Fatalf("dispose without force expected 409, got %d: %s", disposeW.Code, disposeW.Body.String())
|
||||
}
|
||||
|
||||
disposeForceReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/miniapp/api/worktrees?initData="+url.QueryEscape(testInitData()),
|
||||
strings.NewReader(`{"action":"dispose","name":"api-dispose","force":true}`),
|
||||
)
|
||||
disposeForceReq.Header.Set("Content-Type", "application/json")
|
||||
disposeForceW := httptest.NewRecorder()
|
||||
mux.ServeHTTP(disposeForceW, disposeForceReq)
|
||||
if disposeForceW.Code != http.StatusOK {
|
||||
t.Fatalf("dispose with force expected 200, got %d: %s", disposeForceW.Code, disposeForceW.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(disposePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("worktree dir should be removed, stat err: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1230
pkg/miniapp/static/dist/app.css
vendored
Normal file
1230
pkg/miniapp/static/dist/app.css
vendored
Normal file
File diff suppressed because it is too large
Load diff
1459
pkg/miniapp/static/dist/app.js
vendored
Normal file
1459
pkg/miniapp/static/dist/app.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
212
pkg/miniapp/static/dist/map.js
vendored
Normal file
212
pkg/miniapp/static/dist/map.js
vendored
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// map.js — Orchestration Room
|
||||
//
|
||||
// External asset: drop map.png (320×320px) next to index.html to replace
|
||||
// the procedural fallback. Character positions (MAP_POSITIONS) are defined
|
||||
// in canvas-pixel coordinates and remain valid regardless of which rendering
|
||||
// path is used — just make sure your map.png matches them.
|
||||
//
|
||||
// Usage:
|
||||
// loadMapAsset(function() { drawMap(ctx); }); // call once on init
|
||||
// drawMap(ctx); // call each frame
|
||||
|
||||
// ─── Character home positions (px, canvas 320×320) ─────────────────────────
|
||||
//
|
||||
// ┌──────────────────────────────┐
|
||||
// │ [conductor desk] │ y ≈ 20–50
|
||||
// │ 👑(160,58) 👩💼(108,58) │
|
||||
// │ [carpet] │
|
||||
// │ [WS1] [WS2] [WS3] │ y ≈ 80–100
|
||||
// │ 🔍40 💻144 📊248 │ y = 106
|
||||
// │ [meeting area] │ y ≈ 130–192
|
||||
// │ [WS4] [WS5] │ y ≈ 200–220
|
||||
// │ 🔧40 🎯144 │ y = 222
|
||||
// │ 🚪(160,308) │ door
|
||||
// └──────────────────────────────┘
|
||||
|
||||
var MAP_POSITIONS = {
|
||||
door: { x: 160, y: 314 }, // entry / exit point
|
||||
conductor: { x: 160, y: 58 },
|
||||
secretary: { x: 108, y: 58 },
|
||||
heartbeat: { x: 230, y: 58 }, // pigeon messenger — periodic heartbeat agent
|
||||
meeting: { x: 160, y: 161 }, // neutral zone for conversations
|
||||
stations: [
|
||||
{ x: 40, y: 106 }, // S0 scout
|
||||
{ x: 144, y: 106 }, // S1 analyst
|
||||
{ x: 248, y: 106 }, // S2 coder
|
||||
{ x: 40, y: 222 }, // S3 worker
|
||||
{ x: 144, y: 222 }, // S4 coordinator
|
||||
],
|
||||
};
|
||||
|
||||
// ─── Asset loading ──────────────────────────────────────────────────────────
|
||||
|
||||
var _mapImage = null;
|
||||
|
||||
// Call once before first draw. cb() is invoked when ready (image or fallback).
|
||||
function loadMapAsset(cb) {
|
||||
var img = new Image();
|
||||
img.onload = function() { _mapImage = img; cb(); };
|
||||
img.onerror = function() { cb(); }; // no map.png → use fallback
|
||||
img.src = './map.png';
|
||||
}
|
||||
|
||||
// ─── Public draw entry point ────────────────────────────────────────────────
|
||||
|
||||
function drawMap(ctx) {
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
if (_mapImage) {
|
||||
ctx.drawImage(_mapImage, 0, 0, 320, 320);
|
||||
} else {
|
||||
_drawMapFallback(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Procedural fallback ────────────────────────────────────────────────────
|
||||
|
||||
var _C = {
|
||||
wallDark: '#0c1018',
|
||||
wallHighlight: '#252d3f',
|
||||
floorA: '#171b2c',
|
||||
floorB: '#1b2033',
|
||||
carpetBase: '#1a2050',
|
||||
carpetBorder: '#2a3480',
|
||||
deskBack: '#2c3e6b',
|
||||
deskTop: '#3a50a0',
|
||||
deskEdge: '#4a6ac0',
|
||||
deskShadow: '#1a2448',
|
||||
monitorFrame: '#070b14',
|
||||
monitorBlue: '#1040a0',
|
||||
monitorGlow: '#4488ff',
|
||||
wsBase: '#162818',
|
||||
wsTop: '#1e3822',
|
||||
wsEdge: '#2a5030',
|
||||
termGlow: '#00dd55',
|
||||
rugFill: '#1c2248',
|
||||
rugBorder: '#283070',
|
||||
doorMid: '#8a5818',
|
||||
doorLight: '#a06820',
|
||||
doorGold: '#c8940a',
|
||||
};
|
||||
|
||||
function _r(ctx, color, x, y, w, h, alpha) {
|
||||
ctx.globalAlpha = alpha === undefined ? 1 : alpha;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y, w, h);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function _b(ctx, color, x, y, w, h) {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
||||
}
|
||||
|
||||
function _dot(ctx, color, x, y) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y, 2, 2);
|
||||
}
|
||||
|
||||
function _workstation(ctx, x, y) {
|
||||
_r(ctx, _C.wsBase, x, y, 48, 20);
|
||||
_r(ctx, _C.wsTop, x, y, 48, 8);
|
||||
_r(ctx, _C.wsEdge, x, y, 2, 20);
|
||||
_r(ctx, _C.wsEdge, x+46, y, 2, 20);
|
||||
_r(ctx, _C.wsEdge, x, y, 48, 2);
|
||||
// terminal screen
|
||||
_r(ctx, _C.monitorFrame, x+16, y+2, 16, 12);
|
||||
_r(ctx, '#041008', x+17, y+3, 14, 10);
|
||||
_r(ctx, '#003315', x+18, y+4, 12, 8);
|
||||
_r(ctx, _C.termGlow, x+20, y+6, 8, 3);
|
||||
_dot(ctx, '#00ff88', x+22, y+6);
|
||||
}
|
||||
|
||||
function _drawMapFallback(ctx) {
|
||||
var T = 16;
|
||||
|
||||
// floor tiles
|
||||
for (var ty = 0; ty < 20; ty++) {
|
||||
for (var tx = 0; tx < 20; tx++) {
|
||||
ctx.fillStyle = (tx + ty) % 2 === 0 ? _C.floorA : _C.floorB;
|
||||
ctx.fillRect(tx * T, ty * T, T, T);
|
||||
}
|
||||
}
|
||||
|
||||
// conductor carpet
|
||||
_r(ctx, _C.carpetBase, 16, 16, 288, 50);
|
||||
_b(ctx, _C.carpetBorder, 18, 18, 284, 46);
|
||||
|
||||
// conductor desk
|
||||
_r(ctx, _C.deskBack, 96, 20, 128, 30);
|
||||
_r(ctx, _C.deskTop, 96, 20, 128, 12);
|
||||
_r(ctx, _C.deskEdge, 96, 20, 128, 2);
|
||||
_r(ctx, _C.deskEdge, 96, 20, 2, 30);
|
||||
_r(ctx, _C.deskEdge, 222, 20, 2, 30);
|
||||
_r(ctx, _C.deskShadow,96,48, 128, 4);
|
||||
// monitor
|
||||
_r(ctx, _C.monitorFrame, 138, 22, 44, 14);
|
||||
_r(ctx, _C.monitorBlue, 140, 23, 40, 12);
|
||||
_r(ctx, _C.monitorGlow, 156, 26, 8, 6);
|
||||
_r(ctx, '#6699ff', 158, 27, 4, 3);
|
||||
|
||||
// workstations
|
||||
_workstation(ctx, 16, 80); // S0
|
||||
_workstation(ctx, 128, 80); // S1 (x+24 = 152 ≈ 144 center)
|
||||
_workstation(ctx, 224, 80); // S2
|
||||
_workstation(ctx, 16, 200); // S3
|
||||
_workstation(ctx, 128, 200); // S4
|
||||
|
||||
// meeting rug
|
||||
_r(ctx, _C.rugFill, 64, 130, 192, 62, 0.55);
|
||||
_b(ctx, _C.rugBorder, 66, 132, 188, 58);
|
||||
_b(ctx, '#202860', 70, 136, 180, 50);
|
||||
|
||||
// bulletin board (left wall)
|
||||
_r(ctx, '#2c1a06', 18, 148, 36, 44);
|
||||
_r(ctx, '#3a2508', 20, 150, 32, 40);
|
||||
_r(ctx, '#cc9900', 22, 153, 12, 8);
|
||||
_r(ctx, '#dd8800', 22, 164, 10, 6);
|
||||
_r(ctx, '#bb7700', 34, 155, 13, 8);
|
||||
_r(ctx, '#ccaa00', 33, 165, 11, 6);
|
||||
_dot(ctx, '#ff4444', 28, 153);
|
||||
_dot(ctx, '#44aaff', 41, 158);
|
||||
_dot(ctx, '#44ff88', 27, 165);
|
||||
|
||||
// server rack (right wall)
|
||||
_r(ctx, '#111122', 285, 80, 18, 112);
|
||||
_r(ctx, '#181830', 287, 82, 14, 108);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
var ry = 85 + i * 10;
|
||||
_r(ctx, '#0a0a12', 288, ry, 12, 8);
|
||||
var lc = ['#00ff44','#0044ff','#ff3300','#111111'][i % 4];
|
||||
_r(ctx, lc, 296, ry + 2, 3, 4);
|
||||
}
|
||||
|
||||
// walls (drawn last to cover any overruns)
|
||||
_r(ctx, _C.wallDark, 0, 0, 320, 16);
|
||||
_r(ctx, _C.wallHighlight, 0, 14, 320, 2);
|
||||
_r(ctx, _C.wallDark, 0, 0, 16, 320);
|
||||
_r(ctx, _C.wallHighlight,14, 0, 2, 320);
|
||||
_r(ctx, _C.wallDark, 304, 0, 16, 320);
|
||||
_r(ctx, _C.wallHighlight,304, 0, 2, 320);
|
||||
_r(ctx, _C.wallDark, 0, 304, 144, 16);
|
||||
_r(ctx, _C.wallDark, 176, 304, 144, 16);
|
||||
_r(ctx, _C.wallHighlight, 0, 304, 144, 2);
|
||||
_r(ctx, _C.wallHighlight,176, 304, 144, 2);
|
||||
|
||||
// door
|
||||
_r(ctx, '#0a0808', 144, 292, 32, 12); // outside (dark)
|
||||
_r(ctx, _C.doorMid, 144, 280, 32, 24);
|
||||
_r(ctx, _C.doorLight, 144, 280, 32, 3);
|
||||
_r(ctx, _C.doorLight, 144, 280, 3, 24);
|
||||
_r(ctx, _C.doorLight, 173, 280, 3, 24);
|
||||
_r(ctx, '#4a2408', 146, 284, 12, 16); // door panels
|
||||
_r(ctx, '#4a2408', 162, 284, 12, 16);
|
||||
_r(ctx, _C.doorGold, 170, 291, 5, 5); // handle
|
||||
}
|
||||
|
||||
// Expose map helpers for app.js runtime.
|
||||
globalThis.MAP_POSITIONS = MAP_POSITIONS;
|
||||
globalThis.loadMapAsset = loadMapAsset;
|
||||
globalThis.drawMap = drawMap;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -96,7 +96,7 @@
|
|||
.demo-bar span { color: #3a4a80; }
|
||||
.demo-bar span.on { color: #4ade80; }
|
||||
</style>
|
||||
<script src="map.js"></script>
|
||||
<script src="dist/map.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -456,3 +456,4 @@ loadMapAsset(function() {
|
|||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
|
|||
|
|
@ -88,12 +88,39 @@ type ContextInfo struct {
|
|||
Bootstrap []BootstrapFileInfo `json:"bootstrap"`
|
||||
}
|
||||
|
||||
// SessionGraphData holds the full session DAG for the Mini App.
|
||||
type SessionGraphData struct {
|
||||
Nodes []SessionGraphNode `json:"nodes"`
|
||||
Edges []SessionGraphEdge `json:"edges"`
|
||||
}
|
||||
|
||||
// SessionGraphNode represents a single session in the graph.
|
||||
type SessionGraphNode struct {
|
||||
Key string `json:"key"`
|
||||
ShortKey string `json:"short_key"`
|
||||
Label string `json:"label"`
|
||||
Status string `json:"status"`
|
||||
TurnCount int `json:"turn_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
ForkTurnID string `json:"fork_turn_id,omitempty"`
|
||||
}
|
||||
|
||||
// SessionGraphEdge represents a parent→child fork relationship.
|
||||
type SessionGraphEdge struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
ForkTurnID string `json:"fork_turn_id,omitempty"`
|
||||
}
|
||||
|
||||
// DataProvider is the read-only interface to agent state for the Mini App API.
|
||||
type DataProvider interface {
|
||||
ListSkills() []skills.SkillInfo
|
||||
GetPlanInfo() PlanInfo
|
||||
GetSessionStats() *stats.Stats
|
||||
GetActiveSessions() []SessionInfo
|
||||
GetSessionGraph() *SessionGraphData
|
||||
GetGitRepos() []GitRepoSummary
|
||||
GetGitRepoDetail(name string) GitInfo
|
||||
GetContextInfo() ContextInfo
|
||||
|
|
|
|||
|
|
@ -188,16 +188,19 @@ func buildParams(
|
|||
func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
||||
result := make([]anthropic.ToolUnionParam, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
params := t.Function.ParametersMap()
|
||||
tool := anthropic.ToolParam{
|
||||
Name: t.Function.Name,
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: t.Function.Parameters["properties"],
|
||||
Properties: params["properties"],
|
||||
},
|
||||
}
|
||||
if desc := t.Function.Description; desc != "" {
|
||||
tool.Description = anthropic.String(desc)
|
||||
}
|
||||
if req, ok := t.Function.Parameters["required"].([]any); ok {
|
||||
|
||||
switch req := params["required"].(type) {
|
||||
case []any:
|
||||
required := make([]string, 0, len(req))
|
||||
for _, r := range req {
|
||||
if s, ok := r.(string); ok {
|
||||
|
|
@ -205,7 +208,10 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
|||
}
|
||||
}
|
||||
tool.InputSchema.Required = required
|
||||
case []string:
|
||||
tool.InputSchema.Required = append([]string(nil), req...)
|
||||
}
|
||||
|
||||
result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
|
||||
}
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import (
|
|||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
func TestBuildParams_BasicMessage(t *testing.T) {
|
||||
|
|
@ -84,13 +86,13 @@ func TestBuildParams_WithTools(t *testing.T) {
|
|||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather for a city",
|
||||
Parameters: map[string]any{
|
||||
Parameters: protocoltypes.MustMarshalParameters(map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"city": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"city"},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ func (p *AntigravityProvider) buildRequest(
|
|||
if t.Type != "function" {
|
||||
continue
|
||||
}
|
||||
params := sanitizeSchemaForGemini(t.Function.Parameters)
|
||||
params := sanitizeSchemaForGemini(t.Function.ParametersMap())
|
||||
funcDecls = append(funcDecls, antigravityFuncDecl{
|
||||
Name: t.Function.Name,
|
||||
Description: t.Function.Description,
|
||||
|
|
@ -340,17 +340,13 @@ func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) {
|
|||
thoughtSignature = tc.Function.ThoughtSignature
|
||||
}
|
||||
|
||||
if len(args) == 0 && tc.Function != nil && len(tc.Function.Arguments) > 0 {
|
||||
args = cloneToolArgs(tc.Function.Arguments)
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
|
||||
if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" {
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil {
|
||||
args = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return name, args, thoughtSignature
|
||||
}
|
||||
|
||||
|
|
@ -436,14 +432,13 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
|
|||
contentParts = append(contentParts, part.Text)
|
||||
}
|
||||
if part.FunctionCall != nil {
|
||||
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: part.FunctionCall.Args,
|
||||
Function: &FunctionCall{
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: string(argumentsJSON),
|
||||
Arguments: cloneToolArgs(part.FunctionCall.Args),
|
||||
ThoughtSignature: extractPartThoughtSignature(
|
||||
part.ThoughtSignature,
|
||||
part.ThoughtSignatureSnake,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) {
|
|||
ID: "call_read_file_123",
|
||||
Function: &FunctionCall{
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"README.md"}`,
|
||||
Arguments: map[string]any{"path": "README.md"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -129,9 +129,8 @@ func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
|
|||
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
|
||||
}
|
||||
if len(tool.Function.Parameters) > 0 {
|
||||
paramsJSON, _ := json.Marshal(tool.Function.Parameters)
|
||||
sb.WriteString("Parameters:\n```json\n")
|
||||
sb.Write(paramsJSON)
|
||||
sb.Write(tool.Function.Parameters)
|
||||
sb.WriteString("\n```\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
|
|
|||
|
|
@ -619,12 +619,12 @@ func TestBuildSystemPrompt_WithTools(t *testing.T) {
|
|||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather for a location",
|
||||
Parameters: map[string]any{
|
||||
Parameters: MustMarshalParameters(map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"location": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -920,9 +920,9 @@ func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) {
|
|||
if got[0].Arguments["name"] != "test" {
|
||||
t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"])
|
||||
}
|
||||
// Verify raw arguments string is preserved in FunctionCall
|
||||
if got[0].Function.Arguments == "" {
|
||||
t.Error("Function.Arguments should contain raw JSON string")
|
||||
// Verify parsed arguments are also set on FunctionCall
|
||||
if len(got[0].Function.Arguments) == 0 {
|
||||
t.Error("Function.Arguments should contain parsed JSON arguments")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -151,9 +151,8 @@ func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
|
|||
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
|
||||
}
|
||||
if len(tool.Function.Parameters) > 0 {
|
||||
paramsJSON, _ := json.Marshal(tool.Function.Parameters)
|
||||
sb.WriteString("Parameters:\n```json\n")
|
||||
sb.Write(paramsJSON)
|
||||
sb.Write(tool.Function.Parameters)
|
||||
sb.WriteString("\n```\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
|
|
|||
|
|
@ -76,8 +76,8 @@ func TestParseJSONLEvents_ToolCallExtraction(t *testing.T) {
|
|||
if resp.ToolCalls[0].ID != "call_1" {
|
||||
t.Errorf("ToolCalls[0].ID = %q, want %q", resp.ToolCalls[0].ID, "call_1")
|
||||
}
|
||||
if resp.ToolCalls[0].Function.Arguments != `{"path":"/tmp/test.txt"}` {
|
||||
t.Errorf("ToolCalls[0].Function.Arguments = %q", resp.ToolCalls[0].Function.Arguments)
|
||||
if resp.ToolCalls[0].Function.Arguments["path"] != "/tmp/test.txt" {
|
||||
t.Errorf("ToolCalls[0].Function.Arguments[path] = %v", resp.ToolCalls[0].Function.Arguments["path"])
|
||||
}
|
||||
// Content should have the tool call JSON stripped
|
||||
if strings.Contains(resp.Content, "tool_calls") {
|
||||
|
|
@ -292,12 +292,12 @@ func TestBuildPrompt_WithTools(t *testing.T) {
|
|||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get current weather",
|
||||
Parameters: map[string]any{
|
||||
Parameters: MustMarshalParameters(map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"city": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -317,21 +317,21 @@ func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool)
|
|||
return "", "", false
|
||||
}
|
||||
|
||||
if len(tc.Arguments) > 0 {
|
||||
argsJSON, err := json.Marshal(tc.Arguments)
|
||||
args := tc.Arguments
|
||||
if len(args) == 0 && tc.Function != nil {
|
||||
args = tc.Function.Arguments
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return name, "{}", true
|
||||
}
|
||||
|
||||
argsJSON, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
return name, string(argsJSON), true
|
||||
}
|
||||
|
||||
if tc.Function != nil && tc.Function.Arguments != "" {
|
||||
return name, tc.Function.Arguments, true
|
||||
}
|
||||
|
||||
return name, "{}", true
|
||||
}
|
||||
|
||||
func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam {
|
||||
capHint := len(tools)
|
||||
if enableWebSearch {
|
||||
|
|
@ -345,9 +345,13 @@ func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []resp
|
|||
if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") {
|
||||
continue
|
||||
}
|
||||
params := t.Function.ParametersMap()
|
||||
if params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
ft := responses.FunctionToolParam{
|
||||
Name: t.Function.Name,
|
||||
Parameters: t.Function.Parameters,
|
||||
Parameters: params,
|
||||
Strict: openai.Opt(false),
|
||||
}
|
||||
if t.Function.Description != "" {
|
||||
|
|
@ -382,6 +386,10 @@ func parseCodexResponse(resp *responses.Response) *LLMResponse {
|
|||
ID: item.CallID,
|
||||
Name: item.Name,
|
||||
Arguments: args,
|
||||
Function: &FunctionCall{
|
||||
Name: item.Name,
|
||||
Arguments: cloneToolArgs(args),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) {
|
|||
Type: "function",
|
||||
Function: &FunctionCall{
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"README.md"}`,
|
||||
Arguments: map[string]any{"path": "README.md"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -114,12 +114,12 @@ func TestBuildCodexParams_WithTools(t *testing.T) {
|
|||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather",
|
||||
Parameters: map[string]any{
|
||||
Parameters: MustMarshalParameters(map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"city": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -166,9 +166,9 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
|
|||
Function: ToolFunctionDefinition{
|
||||
Name: "web_search",
|
||||
Description: "local web search",
|
||||
Parameters: map[string]any{
|
||||
Parameters: MustMarshalParameters(map[string]any{
|
||||
"type": "object",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -176,9 +176,9 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
|
|||
Function: ToolFunctionDefinition{
|
||||
Name: "read_file",
|
||||
Description: "read file",
|
||||
Parameters: map[string]any{
|
||||
Parameters: MustMarshalParameters(map[string]any{
|
||||
"type": "object",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -462,6 +462,10 @@ func AccumulateStream(ch <-chan protocoltypes.StreamEvent) (*LLMResponse, error)
|
|||
ID: tc.ID,
|
||||
Name: tc.Name,
|
||||
Arguments: arguments,
|
||||
Function: &FunctionCall{
|
||||
Name: tc.Name,
|
||||
Arguments: cloneOpenAIToolArgs(arguments),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -534,6 +538,11 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
|||
Name: name,
|
||||
Arguments: arguments,
|
||||
ThoughtSignature: thoughtSignature,
|
||||
Function: &FunctionCall{
|
||||
Name: name,
|
||||
Arguments: cloneOpenAIToolArgs(arguments),
|
||||
ThoughtSignature: thoughtSignature,
|
||||
},
|
||||
}
|
||||
|
||||
if thoughtSignature != "" {
|
||||
|
|
@ -564,10 +573,21 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
|||
type openaiMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
type openaiToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Function *openaiFunctionCall `json:"function,omitempty"`
|
||||
}
|
||||
|
||||
type openaiFunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
// stripSystemParts converts []Message to []openaiMessage, dropping the
|
||||
// SystemParts field so it doesn't leak into the JSON payload sent to
|
||||
// OpenAI-compatible APIs (some strict endpoints reject unknown fields).
|
||||
|
|
@ -577,13 +597,74 @@ func stripSystemParts(messages []Message) []openaiMessage {
|
|||
out[i] = openaiMessage{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
ToolCalls: m.ToolCalls,
|
||||
ToolCalls: toOpenAIWireToolCalls(m.ToolCalls),
|
||||
ToolCallID: m.ToolCallID,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toOpenAIWireToolCalls(toolCalls []ToolCall) []openaiToolCall {
|
||||
if len(toolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]openaiToolCall, 0, len(toolCalls))
|
||||
for _, tc := range toolCalls {
|
||||
name, args := normalizeOpenAIWireToolCall(tc)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
argsJSON, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
argsJSON = []byte(`{}`)
|
||||
}
|
||||
|
||||
wire := openaiToolCall{
|
||||
ID: tc.ID,
|
||||
Type: tc.Type,
|
||||
Function: &openaiFunctionCall{
|
||||
Name: name,
|
||||
Arguments: string(argsJSON),
|
||||
},
|
||||
}
|
||||
out = append(out, wire)
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeOpenAIWireToolCall(tc ToolCall) (name string, args map[string]any) {
|
||||
name = tc.Name
|
||||
if name == "" && tc.Function != nil {
|
||||
name = tc.Function.Name
|
||||
}
|
||||
|
||||
args = tc.Arguments
|
||||
if len(args) == 0 && tc.Function != nil {
|
||||
args = tc.Function.Arguments
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
return name, args
|
||||
}
|
||||
|
||||
func cloneOpenAIToolArgs(src map[string]any) map[string]any {
|
||||
if len(src) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
dst := make(map[string]any, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func normalizeModel(model, apiBase string) string {
|
||||
before, after, ok := strings.Cut(model, "/")
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
package protocoltypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type,omitempty"`
|
||||
|
|
@ -19,9 +24,75 @@ type GoogleExtra struct {
|
|||
}
|
||||
|
||||
type FunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"-"`
|
||||
ThoughtSignature string `json:"thought_signature,omitempty"`
|
||||
}
|
||||
|
||||
func (f *FunctionCall) UnmarshalJSON(data []byte) error {
|
||||
var wire struct {
|
||||
Name string `json:"name"`
|
||||
Arguments any `json:"arguments"`
|
||||
ThoughtSignature string `json:"thought_signature,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &wire); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f.Name = wire.Name
|
||||
f.ThoughtSignature = wire.ThoughtSignature
|
||||
f.Arguments = decodeFunctionArguments(wire.Arguments)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FunctionCall) MarshalJSON() ([]byte, error) {
|
||||
args := "{}"
|
||||
if len(f.Arguments) > 0 {
|
||||
payload, err := json.Marshal(f.Arguments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = string(payload)
|
||||
}
|
||||
|
||||
wire := struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
ThoughtSignature string `json:"thought_signature,omitempty"`
|
||||
}{
|
||||
Name: f.Name,
|
||||
Arguments: args,
|
||||
ThoughtSignature: f.ThoughtSignature,
|
||||
}
|
||||
return json.Marshal(wire)
|
||||
}
|
||||
|
||||
func decodeFunctionArguments(raw any) map[string]any {
|
||||
switch v := raw.(type) {
|
||||
case nil:
|
||||
return map[string]any{}
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil || parsed == nil {
|
||||
return map[string]any{"raw": v}
|
||||
}
|
||||
return parsed
|
||||
case map[string]any:
|
||||
if v == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return v
|
||||
default:
|
||||
payload, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{"raw": string(payload)}
|
||||
}
|
||||
}
|
||||
|
||||
type LLMResponse struct {
|
||||
|
|
@ -79,7 +150,42 @@ type ToolDefinition struct {
|
|||
type ToolFunctionDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
Parameters json.RawMessage `json:"parameters"`
|
||||
}
|
||||
|
||||
func (t *ToolFunctionDefinition) ParametersMap() map[string]any {
|
||||
if t == nil || len(t.Parameters) == 0 {
|
||||
return nil
|
||||
}
|
||||
var params map[string]any
|
||||
if err := json.Unmarshal(t.Parameters, ¶ms); err != nil {
|
||||
return nil
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func (t *ToolFunctionDefinition) SetParametersMap(params map[string]any) error {
|
||||
if len(params) == 0 {
|
||||
t.Parameters = json.RawMessage(`{}`)
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Parameters = json.RawMessage(payload)
|
||||
return nil
|
||||
}
|
||||
|
||||
func MustMarshalParameters(params map[string]any) json.RawMessage {
|
||||
if len(params) == 0 {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
payload, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
return json.RawMessage(payload)
|
||||
}
|
||||
|
||||
// StreamEvent represents a single chunk from an SSE streaming response.
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ func extractToolCallsFromText(text string) []ToolCall {
|
|||
var result []ToolCall
|
||||
for _, tc := range wrapper.ToolCalls {
|
||||
var args map[string]any
|
||||
json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil || args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
|
||||
result = append(result, ToolCall{
|
||||
ID: tc.ID,
|
||||
|
|
@ -50,7 +52,7 @@ func extractToolCallsFromText(text string) []ToolCall {
|
|||
Arguments: args,
|
||||
Function: &FunctionCall{
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
Arguments: cloneToolArgs(args),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -278,7 +280,6 @@ func parseInvokeElements(text string, callIdx *int) []ToolCall {
|
|||
paramRemaining = paramRemaining[valueStart+valueEnd+len("</parameter>"):]
|
||||
}
|
||||
|
||||
argsJSON, _ := json.Marshal(args)
|
||||
*callIdx++
|
||||
result = append(result, ToolCall{
|
||||
ID: fmt.Sprintf("xmltc_%d", *callIdx),
|
||||
|
|
@ -287,7 +288,7 @@ func parseInvokeElements(text string, callIdx *int) []ToolCall {
|
|||
Arguments: args,
|
||||
Function: &FunctionCall{
|
||||
Name: toolName,
|
||||
Arguments: string(argsJSON),
|
||||
Arguments: cloneToolArgs(args),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,38 +5,32 @@
|
|||
|
||||
package providers
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated.
|
||||
// It handles cases where Name/Arguments might be in different locations (top-level vs Function)
|
||||
// and ensures both are populated consistently.
|
||||
func NormalizeToolCall(tc ToolCall) ToolCall {
|
||||
normalized := tc
|
||||
|
||||
// Ensure Name is populated from Function if not set
|
||||
// Ensure Name is populated from Function if not set.
|
||||
if normalized.Name == "" && normalized.Function != nil {
|
||||
normalized.Name = normalized.Function.Name
|
||||
}
|
||||
|
||||
// Ensure Arguments is not nil
|
||||
// Ensure Arguments is not nil.
|
||||
if normalized.Arguments == nil {
|
||||
normalized.Arguments = map[string]any{}
|
||||
}
|
||||
|
||||
// Parse Arguments from Function.Arguments if not already set
|
||||
if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" {
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil {
|
||||
normalized.Arguments = parsed
|
||||
}
|
||||
// Populate top-level arguments from Function arguments when needed.
|
||||
if len(normalized.Arguments) == 0 && normalized.Function != nil && len(normalized.Function.Arguments) > 0 {
|
||||
normalized.Arguments = cloneToolArgs(normalized.Function.Arguments)
|
||||
}
|
||||
|
||||
// Ensure Function is populated with consistent values
|
||||
argsJSON, _ := json.Marshal(normalized.Arguments)
|
||||
// Ensure Function is populated with consistent values.
|
||||
if normalized.Function == nil {
|
||||
normalized.Function = &FunctionCall{
|
||||
Name: normalized.Name,
|
||||
Arguments: string(argsJSON),
|
||||
Arguments: cloneToolArgs(normalized.Arguments),
|
||||
}
|
||||
} else {
|
||||
if normalized.Function.Name == "" {
|
||||
|
|
@ -45,10 +39,21 @@ func NormalizeToolCall(tc ToolCall) ToolCall {
|
|||
if normalized.Name == "" {
|
||||
normalized.Name = normalized.Function.Name
|
||||
}
|
||||
if normalized.Function.Arguments == "" {
|
||||
normalized.Function.Arguments = string(argsJSON)
|
||||
if len(normalized.Function.Arguments) == 0 {
|
||||
normalized.Function.Arguments = cloneToolArgs(normalized.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func cloneToolArgs(src map[string]any) map[string]any {
|
||||
if len(src) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
dst := make(map[string]any, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package providers
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
|
|
@ -97,3 +98,7 @@ type ModelConfig struct {
|
|||
Primary string
|
||||
Fallbacks []string
|
||||
}
|
||||
|
||||
func MustMarshalParameters(params map[string]any) json.RawMessage {
|
||||
return protocoltypes.MustMarshalParameters(params)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ func BuildAgentMainSessionKey(agentID string) string {
|
|||
return fmt.Sprintf("agent:%s:%s", NormalizeAgentID(agentID), DefaultMainKey)
|
||||
}
|
||||
|
||||
// BuildSubagentSessionKey returns "subagent:<taskID>" for subagent sessions.
|
||||
func BuildSubagentSessionKey(taskID string) string {
|
||||
return fmt.Sprintf("subagent:%s", taskID)
|
||||
}
|
||||
|
||||
// BuildAgentPeerSessionKey constructs a session key based on agent, channel, peer, and DM scope.
|
||||
func BuildAgentPeerSessionKey(params SessionKeyParams) string {
|
||||
agentID := NormalizeAgentID(params.AgentID)
|
||||
|
|
|
|||
143
pkg/session/graph.go
Normal file
143
pkg/session/graph.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// SessionGraph is a thin wrapper around SessionStore that provides
|
||||
|
||||
// structured turn-writing via BeginTurn/TurnWriter.
|
||||
|
||||
// It does NOT replace LegacyAdapter — existing call sites remain unchanged.
|
||||
|
||||
// Future phases will migrate callers to use SessionGraph directly.
|
||||
|
||||
type SessionGraph struct {
|
||||
store SessionStore
|
||||
}
|
||||
|
||||
// NewSessionGraph creates a SessionGraph backed by the given store.
|
||||
|
||||
func NewSessionGraph(store SessionStore) *SessionGraph {
|
||||
return &SessionGraph{store: store}
|
||||
}
|
||||
|
||||
// Messages returns all messages for the session by reading turns from the store.
|
||||
|
||||
func (g *SessionGraph) Messages(sessionKey string) ([]providers.Message, error) {
|
||||
turns, err := g.store.Turns(sessionKey, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var msgs []providers.Message
|
||||
|
||||
for _, t := range turns {
|
||||
msgs = append(msgs, t.Messages...)
|
||||
}
|
||||
|
||||
if msgs == nil {
|
||||
msgs = []providers.Message{}
|
||||
}
|
||||
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// BeginTurn starts a new turn that can be built up incrementally
|
||||
|
||||
// and committed atomically.
|
||||
|
||||
func (g *SessionGraph) BeginTurn(sessionKey string, kind TurnKind) *TurnWriter {
|
||||
return &TurnWriter{
|
||||
store: g.store,
|
||||
|
||||
sessionKey: sessionKey,
|
||||
|
||||
turn: Turn{
|
||||
SessionKey: sessionKey,
|
||||
|
||||
Kind: kind,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TurnWriter accumulates messages for a single turn and commits them atomically.
|
||||
|
||||
type TurnWriter struct {
|
||||
mu sync.Mutex
|
||||
|
||||
store SessionStore
|
||||
|
||||
sessionKey string
|
||||
|
||||
turn Turn
|
||||
|
||||
committed bool
|
||||
|
||||
discarded bool
|
||||
}
|
||||
|
||||
// Add appends a message to the pending turn.
|
||||
|
||||
func (tw *TurnWriter) Add(msg providers.Message) {
|
||||
tw.mu.Lock()
|
||||
|
||||
defer tw.mu.Unlock()
|
||||
|
||||
tw.turn.Messages = append(tw.turn.Messages, msg)
|
||||
}
|
||||
|
||||
// SetOrigin sets the origin session key for this turn (e.g. subagent source).
|
||||
|
||||
func (tw *TurnWriter) SetOrigin(sessionKey string) {
|
||||
tw.mu.Lock()
|
||||
|
||||
defer tw.mu.Unlock()
|
||||
|
||||
tw.turn.OriginKey = sessionKey
|
||||
}
|
||||
|
||||
// SetAuthor sets the author field for this turn.
|
||||
|
||||
func (tw *TurnWriter) SetAuthor(author string) {
|
||||
tw.mu.Lock()
|
||||
|
||||
defer tw.mu.Unlock()
|
||||
|
||||
tw.turn.Author = author
|
||||
}
|
||||
|
||||
// Commit writes the accumulated turn to the store.
|
||||
|
||||
// Returns an error if already committed or discarded.
|
||||
|
||||
func (tw *TurnWriter) Commit() error {
|
||||
tw.mu.Lock()
|
||||
|
||||
defer tw.mu.Unlock()
|
||||
|
||||
if tw.committed {
|
||||
return errors.New("turn already committed")
|
||||
}
|
||||
|
||||
if tw.discarded {
|
||||
return errors.New("turn already discarded")
|
||||
}
|
||||
|
||||
tw.committed = true
|
||||
|
||||
return tw.store.Append(tw.sessionKey, &tw.turn)
|
||||
}
|
||||
|
||||
// Discard marks the turn as abandoned — nothing is written.
|
||||
|
||||
func (tw *TurnWriter) Discard() {
|
||||
tw.mu.Lock()
|
||||
|
||||
defer tw.mu.Unlock()
|
||||
|
||||
tw.discarded = true
|
||||
}
|
||||
181
pkg/session/graph_test.go
Normal file
181
pkg/session/graph_test.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func TestSessionGraph_Messages(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create("g1", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := store.Append("g1", &Turn{
|
||||
Kind: TurnNormal,
|
||||
|
||||
Messages: []providers.Message{{Role: "user", Content: "hello"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := store.Append("g1", &Turn{
|
||||
Kind: TurnNormal,
|
||||
|
||||
Messages: []providers.Message{
|
||||
{Role: "assistant", Content: "hi"},
|
||||
|
||||
{Role: "user", Content: "how are you"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
g := NewSessionGraph(store)
|
||||
|
||||
msgs, err := g.Messages("g1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(msgs) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(msgs))
|
||||
}
|
||||
|
||||
if msgs[0].Content != "hello" || msgs[1].Content != "hi" || msgs[2].Content != "how are you" {
|
||||
t.Errorf("unexpected messages: %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionGraph_Messages_Empty(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create("empty", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
g := NewSessionGraph(store)
|
||||
|
||||
msgs, err := g.Messages("empty")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if msgs == nil || len(msgs) != 0 {
|
||||
t.Errorf("expected empty slice, got %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnWriter_Commit(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create("tw1", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
g := NewSessionGraph(store)
|
||||
|
||||
tw := g.BeginTurn("tw1", TurnNormal)
|
||||
|
||||
tw.Add(providers.Message{Role: "user", Content: "msg1"})
|
||||
|
||||
tw.Add(providers.Message{Role: "assistant", Content: "msg2"})
|
||||
|
||||
tw.SetOrigin("parent-key")
|
||||
|
||||
tw.SetAuthor("agent-1")
|
||||
|
||||
if err := tw.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
turns, err := store.Turns("tw1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(turns) != 1 {
|
||||
t.Fatalf("expected 1 turn, got %d", len(turns))
|
||||
}
|
||||
|
||||
if len(turns[0].Messages) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(turns[0].Messages))
|
||||
}
|
||||
|
||||
if turns[0].OriginKey != "parent-key" {
|
||||
t.Errorf("expected origin 'parent-key', got %q", turns[0].OriginKey)
|
||||
}
|
||||
|
||||
if turns[0].Author != "agent-1" {
|
||||
t.Errorf("expected author 'agent-1', got %q", turns[0].Author)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnWriter_Discard(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create("tw2", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
g := NewSessionGraph(store)
|
||||
|
||||
tw := g.BeginTurn("tw2", TurnNormal)
|
||||
|
||||
tw.Add(providers.Message{Role: "user", Content: "should not persist"})
|
||||
|
||||
tw.Discard()
|
||||
|
||||
turns, err := store.Turns("tw2", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(turns) != 0 {
|
||||
t.Errorf("expected 0 turns after discard, got %d", len(turns))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnWriter_DoubleCommit(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create("tw3", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
g := NewSessionGraph(store)
|
||||
|
||||
tw := g.BeginTurn("tw3", TurnNormal)
|
||||
|
||||
tw.Add(providers.Message{Role: "user", Content: "once"})
|
||||
|
||||
if err := tw.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := tw.Commit(); err == nil {
|
||||
t.Error("expected error on double commit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnWriter_CommitAfterDiscard(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create("tw4", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
g := NewSessionGraph(store)
|
||||
|
||||
tw := g.BeginTurn("tw4", TurnNormal)
|
||||
|
||||
tw.Add(providers.Message{Role: "user", Content: "x"})
|
||||
|
||||
tw.Discard()
|
||||
|
||||
if err := tw.Commit(); err == nil {
|
||||
t.Error("expected error on commit after discard")
|
||||
}
|
||||
}
|
||||
641
pkg/session/legacy_adapter.go
Normal file
641
pkg/session/legacy_adapter.go
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// LegacyAdapter wraps a SessionStore and exposes the same public API as
|
||||
|
||||
// SessionManager so that all existing call sites (loop.go, etc.) work
|
||||
|
||||
// without modification.
|
||||
|
||||
type LegacyAdapter struct {
|
||||
store SessionStore
|
||||
|
||||
mu sync.RWMutex
|
||||
|
||||
cache map[string]*sessionCache
|
||||
|
||||
dirtyMu sync.Mutex
|
||||
|
||||
dirtyKeys map[string]bool
|
||||
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type sessionCache struct {
|
||||
messages []providers.Message
|
||||
|
||||
summary string
|
||||
|
||||
created time.Time
|
||||
|
||||
updated time.Time
|
||||
|
||||
dirty bool
|
||||
|
||||
replaced bool // SetHistory/TruncateHistory set this; Save does full rewrite
|
||||
|
||||
stored int // number of messages already persisted in the store
|
||||
}
|
||||
|
||||
// NewLegacyAdapter creates a LegacyAdapter backed by the given store.
|
||||
|
||||
func NewLegacyAdapter(store SessionStore) *LegacyAdapter {
|
||||
la := &LegacyAdapter{
|
||||
store: store,
|
||||
|
||||
cache: make(map[string]*sessionCache),
|
||||
|
||||
dirtyKeys: make(map[string]bool),
|
||||
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
go la.flushLoop()
|
||||
|
||||
return la
|
||||
}
|
||||
|
||||
// getOrLoad returns the cache entry for key, loading from the store if needed.
|
||||
|
||||
// Caller must hold la.mu (write lock).
|
||||
|
||||
func (la *LegacyAdapter) getOrLoad(key string) *sessionCache {
|
||||
if c, ok := la.cache[key]; ok {
|
||||
return c
|
||||
}
|
||||
|
||||
// Try loading from store
|
||||
|
||||
info, err := la.store.Get(key)
|
||||
|
||||
if err != nil || info == nil {
|
||||
// Create in store
|
||||
|
||||
_ = la.store.Create(key, nil)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
c := &sessionCache{
|
||||
messages: []providers.Message{},
|
||||
|
||||
created: now,
|
||||
|
||||
updated: now,
|
||||
}
|
||||
|
||||
la.cache[key] = c
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// Load all turns and reconstruct messages
|
||||
|
||||
turns, _ := la.store.Turns(key, 0)
|
||||
|
||||
var msgs []providers.Message
|
||||
|
||||
for _, t := range turns {
|
||||
msgs = append(msgs, t.Messages...)
|
||||
}
|
||||
|
||||
if msgs == nil {
|
||||
msgs = []providers.Message{}
|
||||
}
|
||||
|
||||
c := &sessionCache{
|
||||
messages: msgs,
|
||||
|
||||
summary: info.Summary,
|
||||
|
||||
created: info.CreatedAt,
|
||||
|
||||
updated: info.UpdatedAt,
|
||||
|
||||
stored: len(msgs),
|
||||
}
|
||||
|
||||
la.cache[key] = c
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// GetOrCreate returns a Session-compatible object for the given key.
|
||||
|
||||
// Creates the session if it doesn't exist.
|
||||
|
||||
func (la *LegacyAdapter) GetOrCreate(key string) *Session {
|
||||
la.mu.Lock()
|
||||
|
||||
defer la.mu.Unlock()
|
||||
|
||||
c := la.getOrLoad(key)
|
||||
|
||||
return &Session{
|
||||
Key: key,
|
||||
|
||||
Messages: c.messages,
|
||||
|
||||
Summary: c.summary,
|
||||
|
||||
Created: c.created,
|
||||
|
||||
Updated: c.updated,
|
||||
}
|
||||
}
|
||||
|
||||
// AddMessage adds a simple message to the session.
|
||||
|
||||
func (la *LegacyAdapter) AddMessage(sessionKey, role, content string) {
|
||||
la.AddFullMessage(sessionKey, providers.Message{
|
||||
Role: role,
|
||||
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
// AddFullMessage adds a complete message with tool calls to the session.
|
||||
|
||||
func (la *LegacyAdapter) AddFullMessage(sessionKey string, msg providers.Message) {
|
||||
la.mu.Lock()
|
||||
|
||||
defer la.mu.Unlock()
|
||||
|
||||
c := la.getOrLoad(sessionKey)
|
||||
|
||||
c.messages = append(c.messages, msg)
|
||||
|
||||
c.updated = time.Now()
|
||||
|
||||
c.dirty = true
|
||||
}
|
||||
|
||||
// GetHistory returns a defensive copy of the session messages.
|
||||
|
||||
func (la *LegacyAdapter) GetHistory(key string) []providers.Message {
|
||||
la.mu.RLock()
|
||||
|
||||
c, ok := la.cache[key]
|
||||
|
||||
la.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
// Try lazy load
|
||||
|
||||
la.mu.Lock()
|
||||
|
||||
c, ok = la.cache[key]
|
||||
|
||||
if !ok {
|
||||
// Check if it exists in the store
|
||||
|
||||
info, _ := la.store.Get(key)
|
||||
|
||||
if info == nil {
|
||||
la.mu.Unlock()
|
||||
|
||||
return []providers.Message{}
|
||||
}
|
||||
|
||||
c = la.getOrLoad(key)
|
||||
}
|
||||
|
||||
la.mu.Unlock()
|
||||
}
|
||||
|
||||
la.mu.RLock()
|
||||
|
||||
defer la.mu.RUnlock()
|
||||
|
||||
history := make([]providers.Message, len(c.messages))
|
||||
|
||||
copy(history, c.messages)
|
||||
|
||||
return history
|
||||
}
|
||||
|
||||
// SetHistory replaces the session's message history entirely.
|
||||
|
||||
func (la *LegacyAdapter) SetHistory(key string, history []providers.Message) {
|
||||
la.mu.Lock()
|
||||
|
||||
defer la.mu.Unlock()
|
||||
|
||||
c, ok := la.cache[key]
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
msgs := make([]providers.Message, len(history))
|
||||
|
||||
copy(msgs, history)
|
||||
|
||||
c.messages = msgs
|
||||
|
||||
c.updated = time.Now()
|
||||
|
||||
c.replaced = true
|
||||
|
||||
c.dirty = true
|
||||
}
|
||||
|
||||
// GetSummary returns the session summary.
|
||||
|
||||
func (la *LegacyAdapter) GetSummary(key string) string {
|
||||
la.mu.RLock()
|
||||
|
||||
c, ok := la.cache[key]
|
||||
|
||||
la.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
la.mu.Lock()
|
||||
|
||||
c, ok = la.cache[key]
|
||||
|
||||
if !ok {
|
||||
info, _ := la.store.Get(key)
|
||||
|
||||
if info == nil {
|
||||
la.mu.Unlock()
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
c = la.getOrLoad(key)
|
||||
}
|
||||
|
||||
la.mu.Unlock()
|
||||
}
|
||||
|
||||
la.mu.RLock()
|
||||
|
||||
defer la.mu.RUnlock()
|
||||
|
||||
return c.summary
|
||||
}
|
||||
|
||||
// SetSummary updates the session summary in cache and store.
|
||||
|
||||
func (la *LegacyAdapter) SetSummary(key string, summary string) {
|
||||
la.mu.Lock()
|
||||
|
||||
defer la.mu.Unlock()
|
||||
|
||||
c, ok := la.cache[key]
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
c.summary = summary
|
||||
|
||||
c.updated = time.Now()
|
||||
|
||||
_ = la.store.SetSummary(key, summary)
|
||||
}
|
||||
|
||||
// TruncateHistory keeps only the last n messages.
|
||||
|
||||
func (la *LegacyAdapter) TruncateHistory(key string, keepLast int) {
|
||||
la.mu.Lock()
|
||||
|
||||
defer la.mu.Unlock()
|
||||
|
||||
c, ok := la.cache[key]
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if keepLast <= 0 {
|
||||
c.messages = []providers.Message{}
|
||||
|
||||
c.updated = time.Now()
|
||||
|
||||
c.replaced = true
|
||||
|
||||
c.dirty = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(c.messages) <= keepLast {
|
||||
return
|
||||
}
|
||||
|
||||
c.messages = c.messages[len(c.messages)-keepLast:]
|
||||
|
||||
c.updated = time.Now()
|
||||
|
||||
c.replaced = true
|
||||
|
||||
c.dirty = true
|
||||
}
|
||||
|
||||
// MarkDirty marks a session key for deferred persistence.
|
||||
|
||||
func (la *LegacyAdapter) MarkDirty(key string) {
|
||||
la.dirtyMu.Lock()
|
||||
|
||||
la.dirtyKeys[key] = true
|
||||
|
||||
la.dirtyMu.Unlock()
|
||||
}
|
||||
|
||||
// FlushDirty writes all dirty sessions to the store.
|
||||
|
||||
func (la *LegacyAdapter) FlushDirty() {
|
||||
la.dirtyMu.Lock()
|
||||
|
||||
keys := make([]string, 0, len(la.dirtyKeys))
|
||||
|
||||
for k := range la.dirtyKeys {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
la.dirtyKeys = make(map[string]bool)
|
||||
|
||||
la.dirtyMu.Unlock()
|
||||
|
||||
for _, k := range keys {
|
||||
la.Save(k)
|
||||
}
|
||||
}
|
||||
|
||||
// Save persists the session to the store.
|
||||
|
||||
func (la *LegacyAdapter) Save(key string) error {
|
||||
la.mu.RLock()
|
||||
|
||||
c, ok := la.cache[key]
|
||||
|
||||
if !ok {
|
||||
la.mu.RUnlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot under read lock
|
||||
|
||||
replaced := c.replaced
|
||||
|
||||
stored := c.stored
|
||||
|
||||
msgs := make([]providers.Message, len(c.messages))
|
||||
|
||||
copy(msgs, c.messages)
|
||||
|
||||
la.mu.RUnlock()
|
||||
|
||||
if replaced {
|
||||
// Full rewrite: compact all existing turns then write the whole history
|
||||
|
||||
if err := la.store.Compact(key, 1<<31, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(msgs) > 0 {
|
||||
turn := &Turn{
|
||||
SessionKey: key,
|
||||
|
||||
Kind: TurnNormal,
|
||||
|
||||
Messages: msgs,
|
||||
}
|
||||
|
||||
if err := la.store.Append(key, turn); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
la.mu.Lock()
|
||||
|
||||
if cc, ok := la.cache[key]; ok {
|
||||
cc.replaced = false
|
||||
|
||||
cc.stored = len(msgs)
|
||||
|
||||
cc.dirty = false
|
||||
}
|
||||
|
||||
la.mu.Unlock()
|
||||
} else {
|
||||
// Incremental: only append new messages
|
||||
|
||||
newMsgs := msgs[stored:]
|
||||
|
||||
if len(newMsgs) > 0 {
|
||||
turn := &Turn{
|
||||
SessionKey: key,
|
||||
|
||||
Kind: TurnNormal,
|
||||
|
||||
Messages: newMsgs,
|
||||
}
|
||||
|
||||
if err := la.store.Append(key, turn); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
la.mu.Lock()
|
||||
|
||||
if cc, ok := la.cache[key]; ok {
|
||||
cc.stored = len(msgs)
|
||||
|
||||
cc.dirty = false
|
||||
}
|
||||
|
||||
la.mu.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultPruneTTL is the default time-to-live for session pruning.
|
||||
|
||||
const DefaultPruneTTL = 7 * 24 * time.Hour
|
||||
|
||||
// CompactOldTurns flushes pending writes, then compacts SQLite turns
|
||||
|
||||
// keeping only the last keepLast messages. Sets session summary to the given value.
|
||||
|
||||
func (la *LegacyAdapter) CompactOldTurns(key string, keepLast int, summary string) error {
|
||||
// 1. Flush pending messages to SQLite
|
||||
|
||||
if err := la.Save(key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Query all turns
|
||||
|
||||
turns, err := la.store.Turns(key, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Count total messages, find cut point
|
||||
|
||||
totalMsgs := 0
|
||||
|
||||
for _, t := range turns {
|
||||
totalMsgs += len(t.Messages)
|
||||
}
|
||||
|
||||
if keepLast >= totalMsgs {
|
||||
// Nothing to compact, just update summary
|
||||
|
||||
if err := la.store.SetSummary(key, summary); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
la.mu.Lock()
|
||||
|
||||
if c, ok := la.cache[key]; ok {
|
||||
c.summary = summary
|
||||
}
|
||||
|
||||
la.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
dropCount := totalMsgs - keepLast
|
||||
|
||||
accumulated := 0
|
||||
|
||||
cutSeq := 0
|
||||
|
||||
for _, t := range turns {
|
||||
accumulated += len(t.Messages)
|
||||
|
||||
if accumulated <= dropCount {
|
||||
cutSeq = t.Seq
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cutSeq == 0 {
|
||||
if err := la.store.SetSummary(key, summary); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
la.mu.Lock()
|
||||
|
||||
if c, ok := la.cache[key]; ok {
|
||||
c.summary = summary
|
||||
}
|
||||
|
||||
la.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. Compact in SQLite
|
||||
|
||||
if err := la.store.Compact(key, cutSeq, summary); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. Update in-memory cache
|
||||
|
||||
la.mu.Lock()
|
||||
|
||||
defer la.mu.Unlock()
|
||||
|
||||
if c, ok := la.cache[key]; ok {
|
||||
if keepLast < len(c.messages) {
|
||||
c.messages = c.messages[len(c.messages)-keepLast:]
|
||||
}
|
||||
|
||||
c.stored = len(c.messages)
|
||||
|
||||
c.replaced = false
|
||||
|
||||
c.dirty = false
|
||||
|
||||
c.summary = summary
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store returns the underlying SessionStore for direct DAG operations.
|
||||
|
||||
func (la *LegacyAdapter) Store() SessionStore {
|
||||
return la.store
|
||||
}
|
||||
|
||||
// Graph returns a SessionGraph backed by the underlying store.
|
||||
|
||||
func (la *LegacyAdapter) Graph() *SessionGraph {
|
||||
return NewSessionGraph(la.store)
|
||||
}
|
||||
|
||||
// AdvanceStored increments the stored counter for a session by delta,
|
||||
|
||||
// preventing the flush loop from re-persisting messages already written
|
||||
|
||||
// directly to the store (e.g. TurnReport).
|
||||
|
||||
func (la *LegacyAdapter) AdvanceStored(key string, delta int) {
|
||||
la.mu.Lock()
|
||||
|
||||
defer la.mu.Unlock()
|
||||
|
||||
if c, ok := la.cache[key]; ok {
|
||||
c.stored += delta
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the background flush loop and persists all dirty sessions.
|
||||
|
||||
func (la *LegacyAdapter) Close() {
|
||||
select {
|
||||
case <-la.done:
|
||||
|
||||
return // already closed
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
close(la.done)
|
||||
|
||||
la.FlushDirty()
|
||||
|
||||
la.store.Close()
|
||||
}
|
||||
|
||||
func (la *LegacyAdapter) flushLoop() {
|
||||
flushTicker := time.NewTicker(5 * time.Minute)
|
||||
|
||||
pruneTicker := time.NewTicker(6 * time.Hour)
|
||||
|
||||
defer flushTicker.Stop()
|
||||
|
||||
defer pruneTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-flushTicker.C:
|
||||
|
||||
la.FlushDirty()
|
||||
|
||||
case <-pruneTicker.C:
|
||||
|
||||
_, _ = la.store.Prune(DefaultPruneTTL)
|
||||
|
||||
case <-la.done:
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
669
pkg/session/legacy_adapter_test.go
Normal file
669
pkg/session/legacy_adapter_test.go
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// sessionBackend abstracts the common API shared by SessionManager and LegacyAdapter.
|
||||
|
||||
type sessionBackend interface { //nolint:interfacebloat // test helper mirrors SessionManager API
|
||||
|
||||
GetOrCreate(key string) *Session
|
||||
|
||||
AddMessage(sessionKey, role, content string)
|
||||
|
||||
AddFullMessage(sessionKey string, msg providers.Message)
|
||||
|
||||
GetHistory(key string) []providers.Message
|
||||
|
||||
SetHistory(key string, history []providers.Message)
|
||||
|
||||
GetSummary(key string) string
|
||||
|
||||
SetSummary(key string, summary string)
|
||||
|
||||
TruncateHistory(key string, keepLast int)
|
||||
|
||||
MarkDirty(key string)
|
||||
|
||||
FlushDirty()
|
||||
|
||||
Save(key string) error
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
func backends(t *testing.T) map[string]sessionBackend {
|
||||
t.Helper()
|
||||
|
||||
jsonDir := t.TempDir()
|
||||
|
||||
sm := NewSessionManager(jsonDir)
|
||||
|
||||
t.Cleanup(func() { sm.Close() })
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, err := OpenSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLiteStore: %v", err)
|
||||
}
|
||||
|
||||
la := NewLegacyAdapter(store)
|
||||
|
||||
t.Cleanup(func() { la.Close() })
|
||||
|
||||
return map[string]sessionBackend{
|
||||
"json": sm,
|
||||
|
||||
"sqlite": la,
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_GetOrCreate(t *testing.T) {
|
||||
for name, be := range backends(t) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s := be.GetOrCreate("k1")
|
||||
|
||||
if s.Key != "k1" {
|
||||
t.Errorf("expected key k1, got %s", s.Key)
|
||||
}
|
||||
|
||||
if len(s.Messages) != 0 {
|
||||
t.Errorf("expected empty messages, got %d", len(s.Messages))
|
||||
}
|
||||
|
||||
// Second call returns existing
|
||||
|
||||
be.AddMessage("k1", "user", "hello")
|
||||
|
||||
s2 := be.GetOrCreate("k1")
|
||||
|
||||
if s2.Key != "k1" {
|
||||
t.Errorf("expected key k1 on second call")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_AddMessageAndGetHistory(t *testing.T) {
|
||||
for name, be := range backends(t) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
be.GetOrCreate("k1")
|
||||
|
||||
be.AddMessage("k1", "user", "hello")
|
||||
|
||||
be.AddMessage("k1", "assistant", "hi")
|
||||
|
||||
history := be.GetHistory("k1")
|
||||
|
||||
if len(history) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(history))
|
||||
}
|
||||
|
||||
if history[0].Role != "user" || history[0].Content != "hello" {
|
||||
t.Errorf("unexpected first message: %+v", history[0])
|
||||
}
|
||||
|
||||
if history[1].Role != "assistant" || history[1].Content != "hi" {
|
||||
t.Errorf("unexpected second message: %+v", history[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_AddFullMessage(t *testing.T) {
|
||||
for name, be := range backends(t) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
be.GetOrCreate("k1")
|
||||
|
||||
be.AddFullMessage("k1", providers.Message{
|
||||
Role: "assistant",
|
||||
|
||||
Content: "sure",
|
||||
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{Name: "exec", Arguments: map[string]any{}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
be.AddFullMessage("k1", providers.Message{
|
||||
Role: "tool",
|
||||
|
||||
Content: "ok",
|
||||
|
||||
ToolCallID: "call_1",
|
||||
})
|
||||
|
||||
history := be.GetHistory("k1")
|
||||
|
||||
if len(history) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(history))
|
||||
}
|
||||
|
||||
if history[0].ToolCalls[0].ID != "call_1" {
|
||||
t.Errorf("tool call ID mismatch")
|
||||
}
|
||||
|
||||
if history[1].ToolCallID != "call_1" {
|
||||
t.Errorf("tool call result ID mismatch")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_AddFullMessage_AutoCreates(t *testing.T) {
|
||||
for name, be := range backends(t) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// AddFullMessage without prior GetOrCreate should still work
|
||||
|
||||
be.AddFullMessage("auto", providers.Message{Role: "user", Content: "hi"})
|
||||
|
||||
history := be.GetHistory("auto")
|
||||
|
||||
if len(history) != 1 {
|
||||
t.Fatalf("expected 1, got %d", len(history))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_SetHistory(t *testing.T) {
|
||||
for name, be := range backends(t) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
be.GetOrCreate("k1")
|
||||
|
||||
be.AddMessage("k1", "user", "old")
|
||||
|
||||
newHistory := []providers.Message{
|
||||
{Role: "user", Content: "new1"},
|
||||
|
||||
{Role: "assistant", Content: "new2"},
|
||||
}
|
||||
|
||||
be.SetHistory("k1", newHistory)
|
||||
|
||||
got := be.GetHistory("k1")
|
||||
|
||||
if len(got) != 2 || got[0].Content != "new1" || got[1].Content != "new2" {
|
||||
t.Errorf("unexpected history after SetHistory: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_GetSetSummary(t *testing.T) {
|
||||
for name, be := range backends(t) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
be.GetOrCreate("k1")
|
||||
|
||||
if s := be.GetSummary("k1"); s != "" {
|
||||
t.Errorf("expected empty summary, got %q", s)
|
||||
}
|
||||
|
||||
be.SetSummary("k1", "test summary")
|
||||
|
||||
if s := be.GetSummary("k1"); s != "test summary" {
|
||||
t.Errorf("expected 'test summary', got %q", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_TruncateHistory(t *testing.T) {
|
||||
for name, be := range backends(t) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
be.GetOrCreate("k1")
|
||||
|
||||
for i := range 10 {
|
||||
be.AddMessage("k1", "user", string(rune('a'+i)))
|
||||
}
|
||||
|
||||
be.TruncateHistory("k1", 3)
|
||||
|
||||
got := be.GetHistory("k1")
|
||||
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(got))
|
||||
}
|
||||
|
||||
if got[0].Content != "h" {
|
||||
t.Errorf("expected 'h', got %q", got[0].Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_TruncateHistory_Zero(t *testing.T) {
|
||||
for name, be := range backends(t) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
be.GetOrCreate("k1")
|
||||
|
||||
be.AddMessage("k1", "user", "hello")
|
||||
|
||||
be.TruncateHistory("k1", 0)
|
||||
|
||||
got := be.GetHistory("k1")
|
||||
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected 0, got %d", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_TruncateHistory_LargerThanLen(t *testing.T) {
|
||||
for name, be := range backends(t) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
be.GetOrCreate("k1")
|
||||
|
||||
be.AddMessage("k1", "user", "hello")
|
||||
|
||||
be.TruncateHistory("k1", 100)
|
||||
|
||||
got := be.GetHistory("k1")
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Errorf("expected 1, got %d", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_GetHistory_DefensiveCopy(t *testing.T) {
|
||||
for name, be := range backends(t) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
be.GetOrCreate("k1")
|
||||
|
||||
be.AddMessage("k1", "user", "hello")
|
||||
|
||||
h1 := be.GetHistory("k1")
|
||||
|
||||
h1[0].Content = "modified"
|
||||
|
||||
h2 := be.GetHistory("k1")
|
||||
|
||||
if h2[0].Content != "hello" {
|
||||
t.Errorf("defensive copy failed: %q", h2[0].Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_GetHistory_NonExistent(t *testing.T) {
|
||||
for name, be := range backends(t) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := be.GetHistory("nope")
|
||||
|
||||
if got == nil || len(got) != 0 {
|
||||
t.Errorf("expected empty slice, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_MarkDirtyAndFlush(t *testing.T) {
|
||||
for name, be := range backends(t) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
be.GetOrCreate("k1")
|
||||
|
||||
be.AddMessage("k1", "user", "hello")
|
||||
|
||||
be.MarkDirty("k1")
|
||||
|
||||
be.FlushDirty()
|
||||
|
||||
// Should not panic or error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_SaveAndReload(t *testing.T) {
|
||||
// Test that Save persists data that can be reloaded.
|
||||
|
||||
// For JSON backend, we reload via new SessionManager.
|
||||
|
||||
// For SQLite, we reload via new LegacyAdapter on same DB.
|
||||
|
||||
t.Run("json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
sm := NewSessionManager(dir)
|
||||
|
||||
sm.GetOrCreate("k1")
|
||||
|
||||
sm.AddMessage("k1", "user", "hello")
|
||||
|
||||
sm.SetSummary("k1", "test")
|
||||
|
||||
sm.Save("k1")
|
||||
|
||||
sm.Close()
|
||||
|
||||
sm2 := NewSessionManager(dir)
|
||||
|
||||
defer sm2.Close()
|
||||
|
||||
h := sm2.GetHistory("k1")
|
||||
|
||||
if len(h) != 1 || h[0].Content != "hello" {
|
||||
t.Errorf("json reload: expected [hello], got %+v", h)
|
||||
}
|
||||
|
||||
if s := sm2.GetSummary("k1"); s != "test" {
|
||||
t.Errorf("json reload summary: expected 'test', got %q", s)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, _ := OpenSQLiteStore(dbPath)
|
||||
|
||||
la := NewLegacyAdapter(store)
|
||||
|
||||
la.GetOrCreate("k1")
|
||||
|
||||
la.AddMessage("k1", "user", "hello")
|
||||
|
||||
la.SetSummary("k1", "test")
|
||||
|
||||
la.Save("k1")
|
||||
|
||||
la.Close()
|
||||
|
||||
store2, _ := OpenSQLiteStore(dbPath)
|
||||
|
||||
la2 := NewLegacyAdapter(store2)
|
||||
|
||||
defer la2.Close()
|
||||
|
||||
h := la2.GetHistory("k1")
|
||||
|
||||
if len(h) != 1 || h[0].Content != "hello" {
|
||||
t.Errorf("sqlite reload: expected [hello], got %+v", h)
|
||||
}
|
||||
|
||||
if s := la2.GetSummary("k1"); s != "test" {
|
||||
t.Errorf("sqlite reload summary: expected 'test', got %q", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackend_SaveAfterSetHistory(t *testing.T) {
|
||||
// Verify that Save after SetHistory (full replacement) works correctly
|
||||
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, _ := OpenSQLiteStore(dbPath)
|
||||
|
||||
la := NewLegacyAdapter(store)
|
||||
|
||||
la.GetOrCreate("k1")
|
||||
|
||||
la.AddMessage("k1", "user", "old1")
|
||||
|
||||
la.AddMessage("k1", "user", "old2")
|
||||
|
||||
la.Save("k1")
|
||||
|
||||
// Replace history
|
||||
|
||||
la.SetHistory("k1", []providers.Message{
|
||||
{Role: "user", Content: "new1"},
|
||||
})
|
||||
|
||||
la.Save("k1")
|
||||
|
||||
la.Close()
|
||||
|
||||
// Reload and verify
|
||||
|
||||
store2, _ := OpenSQLiteStore(dbPath)
|
||||
|
||||
la2 := NewLegacyAdapter(store2)
|
||||
|
||||
defer la2.Close()
|
||||
|
||||
h := la2.GetHistory("k1")
|
||||
|
||||
if len(h) != 1 || h[0].Content != "new1" {
|
||||
t.Errorf("expected [new1], got %+v", h)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackend_IncrementalSave(t *testing.T) {
|
||||
// Verify that incremental saves only add new messages
|
||||
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, _ := OpenSQLiteStore(dbPath)
|
||||
|
||||
la := NewLegacyAdapter(store)
|
||||
|
||||
la.GetOrCreate("k1")
|
||||
|
||||
la.AddMessage("k1", "user", "msg1")
|
||||
|
||||
la.Save("k1")
|
||||
|
||||
la.AddMessage("k1", "user", "msg2")
|
||||
|
||||
la.Save("k1")
|
||||
|
||||
la.Close()
|
||||
|
||||
// Verify 2 turns were created (one per save)
|
||||
|
||||
store2, _ := OpenSQLiteStore(dbPath)
|
||||
|
||||
defer store2.Close()
|
||||
|
||||
turns, _ := store2.Turns("k1", 0)
|
||||
|
||||
if len(turns) != 2 {
|
||||
t.Errorf("expected 2 turns (incremental), got %d", len(turns))
|
||||
}
|
||||
|
||||
// But total messages should be 2
|
||||
|
||||
la2 := NewLegacyAdapter(store2)
|
||||
|
||||
h := la2.GetHistory("k1")
|
||||
|
||||
if len(h) != 2 {
|
||||
t.Errorf("expected 2 messages total, got %d", len(h))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompactOldTurns(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, err := OpenSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
la := NewLegacyAdapter(store)
|
||||
|
||||
defer la.Close()
|
||||
|
||||
la.GetOrCreate("k1")
|
||||
|
||||
// Turn 1: 2 messages
|
||||
|
||||
la.AddMessage("k1", "user", "a")
|
||||
|
||||
la.AddMessage("k1", "assistant", "b")
|
||||
|
||||
la.Save("k1")
|
||||
|
||||
// Turn 2: 3 messages
|
||||
|
||||
la.AddMessage("k1", "user", "c")
|
||||
|
||||
la.AddMessage("k1", "assistant", "d")
|
||||
|
||||
la.AddMessage("k1", "user", "e")
|
||||
|
||||
la.Save("k1")
|
||||
|
||||
// Turn 3: 2 messages
|
||||
|
||||
la.AddMessage("k1", "user", "f")
|
||||
|
||||
la.AddMessage("k1", "assistant", "g")
|
||||
|
||||
la.Save("k1")
|
||||
|
||||
// Total: 7 messages across 3 turns. keepLast=2 → drop 5 → compact turns 1+2 (5 msgs)
|
||||
|
||||
if err := la.CompactOldTurns("k1", 2, "test summary"); err != nil {
|
||||
t.Fatalf("CompactOldTurns: %v", err)
|
||||
}
|
||||
|
||||
h := la.GetHistory("k1")
|
||||
|
||||
if len(h) != 2 {
|
||||
t.Fatalf("expected 2 messages in cache, got %d", len(h))
|
||||
}
|
||||
|
||||
if h[0].Content != "f" || h[1].Content != "g" {
|
||||
t.Errorf("unexpected messages: %+v", h)
|
||||
}
|
||||
|
||||
if s := la.GetSummary("k1"); s != "test summary" {
|
||||
t.Errorf("expected summary 'test summary', got %q", s)
|
||||
}
|
||||
|
||||
// Verify in SQLite: only turn 3 remains
|
||||
|
||||
turns, _ := store.Turns("k1", 0)
|
||||
|
||||
if len(turns) != 1 {
|
||||
t.Fatalf("expected 1 turn in SQLite, got %d", len(turns))
|
||||
}
|
||||
|
||||
if len(turns[0].Messages) != 2 {
|
||||
t.Errorf("expected 2 messages in remaining turn, got %d", len(turns[0].Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactOldTurns_NothingToCompact(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, err := OpenSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
la := NewLegacyAdapter(store)
|
||||
|
||||
defer la.Close()
|
||||
|
||||
la.GetOrCreate("k1")
|
||||
|
||||
la.AddMessage("k1", "user", "a")
|
||||
|
||||
la.AddMessage("k1", "assistant", "b")
|
||||
|
||||
la.Save("k1")
|
||||
|
||||
// keepLast=10 >= total 2 → nothing compacted, summary still updated
|
||||
|
||||
if err := la.CompactOldTurns("k1", 10, "new summary"); err != nil {
|
||||
t.Fatalf("CompactOldTurns: %v", err)
|
||||
}
|
||||
|
||||
h := la.GetHistory("k1")
|
||||
|
||||
if len(h) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(h))
|
||||
}
|
||||
|
||||
if s := la.GetSummary("k1"); s != "new summary" {
|
||||
t.Errorf("expected 'new summary', got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactOldTurns_SingleTurn(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, err := OpenSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
la := NewLegacyAdapter(store)
|
||||
|
||||
defer la.Close()
|
||||
|
||||
la.GetOrCreate("k1")
|
||||
|
||||
la.AddMessage("k1", "user", "a")
|
||||
|
||||
la.AddMessage("k1", "assistant", "b")
|
||||
|
||||
la.AddMessage("k1", "user", "c")
|
||||
|
||||
la.Save("k1")
|
||||
|
||||
// Single turn with 3 messages, keepLast=2 → dropCount=1, but first turn has 3 msgs
|
||||
|
||||
// accumulated(3) > dropCount(1) on first turn → cutSeq=0 → no compaction
|
||||
|
||||
if err := la.CompactOldTurns("k1", 2, "sum"); err != nil {
|
||||
t.Fatalf("CompactOldTurns: %v", err)
|
||||
}
|
||||
|
||||
h := la.GetHistory("k1")
|
||||
|
||||
if len(h) != 3 {
|
||||
t.Fatalf("expected 3 messages (no compaction), got %d", len(h))
|
||||
}
|
||||
|
||||
if s := la.GetSummary("k1"); s != "sum" {
|
||||
t.Errorf("expected 'sum', got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactOldTurns_Graph(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, err := OpenSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
la := NewLegacyAdapter(store)
|
||||
|
||||
defer la.Close()
|
||||
|
||||
la.GetOrCreate("k1")
|
||||
|
||||
la.AddMessage("k1", "user", "hello")
|
||||
|
||||
la.Save("k1")
|
||||
|
||||
g := la.Graph()
|
||||
|
||||
msgs, err := g.Messages("k1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(msgs) != 1 || msgs[0].Content != "hello" {
|
||||
t.Errorf("unexpected graph messages: %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,55 +13,75 @@ import (
|
|||
|
||||
type Session struct {
|
||||
Key string `json:"key"`
|
||||
|
||||
Messages []providers.Message `json:"messages"`
|
||||
|
||||
Summary string `json:"summary,omitempty"`
|
||||
|
||||
Created time.Time `json:"created"`
|
||||
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
type SessionManager struct {
|
||||
sessions map[string]*Session
|
||||
|
||||
mu sync.RWMutex
|
||||
|
||||
storage string
|
||||
|
||||
// Write-behind: dirty keys are flushed periodically to reduce disk writes.
|
||||
|
||||
dirtyMu sync.Mutex
|
||||
|
||||
dirtyKeys map[string]bool
|
||||
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func NewSessionManager(storage string) *SessionManager {
|
||||
sm := &SessionManager{
|
||||
sessions: make(map[string]*Session),
|
||||
|
||||
storage: storage,
|
||||
|
||||
dirtyKeys: make(map[string]bool),
|
||||
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
if storage != "" {
|
||||
os.MkdirAll(storage, 0o755)
|
||||
|
||||
sm.loadSessions()
|
||||
}
|
||||
|
||||
go sm.flushLoop()
|
||||
|
||||
return sm
|
||||
}
|
||||
|
||||
func (sm *SessionManager) GetOrCreate(key string) *Session {
|
||||
sm.mu.Lock()
|
||||
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
session, ok := sm.sessions[key]
|
||||
|
||||
if ok {
|
||||
return session
|
||||
}
|
||||
|
||||
session = &Session{
|
||||
Key: key,
|
||||
|
||||
Messages: []providers.Message{},
|
||||
|
||||
Created: time.Now(),
|
||||
|
||||
Updated: time.Now(),
|
||||
}
|
||||
|
||||
sm.sessions[key] = session
|
||||
|
||||
return session
|
||||
|
|
@ -70,78 +90,101 @@ func (sm *SessionManager) GetOrCreate(key string) *Session {
|
|||
func (sm *SessionManager) AddMessage(sessionKey, role, content string) {
|
||||
sm.AddFullMessage(sessionKey, providers.Message{
|
||||
Role: role,
|
||||
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
// AddFullMessage adds a complete message with tool calls and tool call ID to the session.
|
||||
|
||||
// This is used to save the full conversation flow including tool calls and tool results.
|
||||
|
||||
func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) {
|
||||
sm.mu.Lock()
|
||||
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
session, ok := sm.sessions[sessionKey]
|
||||
|
||||
if !ok {
|
||||
session = &Session{
|
||||
Key: sessionKey,
|
||||
|
||||
Messages: []providers.Message{},
|
||||
|
||||
Created: time.Now(),
|
||||
}
|
||||
|
||||
sm.sessions[sessionKey] = session
|
||||
}
|
||||
|
||||
session.Messages = append(session.Messages, msg)
|
||||
|
||||
session.Updated = time.Now()
|
||||
}
|
||||
|
||||
func (sm *SessionManager) GetHistory(key string) []providers.Message {
|
||||
sm.mu.RLock()
|
||||
|
||||
defer sm.mu.RUnlock()
|
||||
|
||||
session, ok := sm.sessions[key]
|
||||
|
||||
if !ok {
|
||||
return []providers.Message{}
|
||||
}
|
||||
|
||||
history := make([]providers.Message, len(session.Messages))
|
||||
|
||||
copy(history, session.Messages)
|
||||
|
||||
return history
|
||||
}
|
||||
|
||||
func (sm *SessionManager) GetSummary(key string) string {
|
||||
sm.mu.RLock()
|
||||
|
||||
defer sm.mu.RUnlock()
|
||||
|
||||
session, ok := sm.sessions[key]
|
||||
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return session.Summary
|
||||
}
|
||||
|
||||
func (sm *SessionManager) SetSummary(key string, summary string) {
|
||||
sm.mu.Lock()
|
||||
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
session, ok := sm.sessions[key]
|
||||
|
||||
if ok {
|
||||
session.Summary = summary
|
||||
|
||||
session.Updated = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
|
||||
sm.mu.Lock()
|
||||
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
session, ok := sm.sessions[key]
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if keepLast <= 0 {
|
||||
session.Messages = []providers.Message{}
|
||||
|
||||
session.Updated = time.Now()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -150,14 +193,20 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
|
|||
}
|
||||
|
||||
session.Messages = session.Messages[len(session.Messages)-keepLast:]
|
||||
|
||||
session.Updated = time.Now()
|
||||
}
|
||||
|
||||
// sanitizeFilename converts a session key into a cross-platform safe filename.
|
||||
|
||||
// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the
|
||||
|
||||
// volume separator on Windows, so filepath.Base would misinterpret the key.
|
||||
|
||||
// We replace it with '_'. The original key is preserved inside the JSON file,
|
||||
|
||||
// so loadSessions still maps back to the right in-memory key.
|
||||
|
||||
func sanitizeFilename(key string) string {
|
||||
return strings.ReplaceAll(key, ":", "_")
|
||||
}
|
||||
|
|
@ -170,33 +219,47 @@ func (sm *SessionManager) Save(key string) error {
|
|||
filename := sanitizeFilename(key)
|
||||
|
||||
// filepath.IsLocal rejects empty names, "..", absolute paths, and
|
||||
|
||||
// OS-reserved device names (NUL, COM1 … on Windows).
|
||||
|
||||
// The extra checks reject "." and any directory separators so that
|
||||
|
||||
// the session file is always written directly inside sm.storage.
|
||||
|
||||
if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
// Snapshot under read lock, then perform slow file I/O after unlock.
|
||||
|
||||
sm.mu.RLock()
|
||||
|
||||
stored, ok := sm.sessions[key]
|
||||
|
||||
if !ok {
|
||||
sm.mu.RUnlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
snapshot := Session{
|
||||
Key: stored.Key,
|
||||
|
||||
Summary: stored.Summary,
|
||||
|
||||
Created: stored.Created,
|
||||
|
||||
Updated: stored.Updated,
|
||||
}
|
||||
|
||||
if len(stored.Messages) > 0 {
|
||||
snapshot.Messages = make([]providers.Message, len(stored.Messages))
|
||||
|
||||
copy(snapshot.Messages, stored.Messages)
|
||||
} else {
|
||||
snapshot.Messages = []providers.Message{}
|
||||
}
|
||||
|
||||
sm.mu.RUnlock()
|
||||
|
||||
data, err := json.MarshalIndent(snapshot, "", " ")
|
||||
|
|
@ -205,13 +268,16 @@ func (sm *SessionManager) Save(key string) error {
|
|||
}
|
||||
|
||||
sessionPath := filepath.Join(sm.storage, filename+".json")
|
||||
|
||||
tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
cleanup := true
|
||||
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpPath)
|
||||
|
|
@ -220,16 +286,22 @@ func (sm *SessionManager) Save(key string) error {
|
|||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tmpFile.Chmod(0o644); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tmpFile.Sync(); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -237,7 +309,9 @@ func (sm *SessionManager) Save(key string) error {
|
|||
if err := os.Rename(tmpPath, sessionPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cleanup = false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -257,12 +331,14 @@ func (sm *SessionManager) loadSessions() error {
|
|||
}
|
||||
|
||||
sessionPath := filepath.Join(sm.storage, file.Name())
|
||||
|
||||
data, err := os.ReadFile(sessionPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var session Session
|
||||
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
continue
|
||||
}
|
||||
|
|
@ -274,57 +350,84 @@ func (sm *SessionManager) loadSessions() error {
|
|||
}
|
||||
|
||||
// SanitizeHistory rebuilds session history to ensure valid tool-call ordering.
|
||||
|
||||
// LLM APIs require that every assistant message with ToolCalls is immediately
|
||||
|
||||
// followed by exactly the matching tool-result messages (role="tool"), with no
|
||||
|
||||
// other messages in between. Violations can happen from session collisions or
|
||||
|
||||
// mid-execution crashes.
|
||||
|
||||
//
|
||||
|
||||
// The function walks the full history and copies only well-formed groups:
|
||||
|
||||
// - user/system messages are always kept
|
||||
|
||||
// - assistant messages without tool calls are always kept
|
||||
|
||||
// - assistant messages WITH tool calls are kept only if the immediately
|
||||
|
||||
// following messages are the complete set of matching tool results
|
||||
|
||||
//
|
||||
|
||||
// Returns the sanitized history and the number of messages removed.
|
||||
|
||||
func SanitizeHistory(history []providers.Message) ([]providers.Message, int) {
|
||||
if len(history) == 0 {
|
||||
return history, 0
|
||||
}
|
||||
|
||||
result := make([]providers.Message, 0, len(history))
|
||||
|
||||
i := 0
|
||||
|
||||
for i < len(history) {
|
||||
msg := history[i]
|
||||
|
||||
// Non-assistant messages or assistant without tool calls: keep
|
||||
|
||||
if msg.Role != "assistant" || len(msg.ToolCalls) == 0 {
|
||||
// Skip stray tool results not preceded by their assistant
|
||||
|
||||
if msg.Role == "tool" {
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, msg)
|
||||
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Assistant with tool calls: validate the immediately following messages
|
||||
|
||||
expectedIDs := make(map[string]bool, len(msg.ToolCalls))
|
||||
|
||||
for _, tc := range msg.ToolCalls {
|
||||
expectedIDs[tc.ID] = true
|
||||
}
|
||||
|
||||
needed := len(expectedIDs)
|
||||
|
||||
// Peek ahead: the next `needed` messages must all be tool results with matching IDs
|
||||
|
||||
groupOK := true
|
||||
|
||||
if i+needed >= len(history) {
|
||||
groupOK = false
|
||||
} else {
|
||||
for j := 0; j < needed; j++ {
|
||||
next := history[i+1+j]
|
||||
|
||||
if next.Role != "tool" || !expectedIDs[next.ToolCallID] {
|
||||
groupOK = false
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -332,14 +435,19 @@ func SanitizeHistory(history []providers.Message) ([]providers.Message, int) {
|
|||
|
||||
if groupOK {
|
||||
// Copy assistant + all tool results
|
||||
|
||||
result = append(result, msg)
|
||||
|
||||
for j := 0; j < needed; j++ {
|
||||
result = append(result, history[i+1+j])
|
||||
}
|
||||
|
||||
i += 1 + needed
|
||||
} else {
|
||||
// Skip the broken assistant message; tool results will be skipped
|
||||
|
||||
// individually when encountered (the "stray tool result" check above)
|
||||
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
|
@ -348,37 +456,54 @@ func SanitizeHistory(history []providers.Message) ([]providers.Message, int) {
|
|||
}
|
||||
|
||||
// SetHistory updates the messages of a session.
|
||||
|
||||
func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
|
||||
sm.mu.Lock()
|
||||
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
session, ok := sm.sessions[key]
|
||||
|
||||
if ok {
|
||||
// Create a deep copy to strictly isolate internal state
|
||||
|
||||
// from the caller's slice.
|
||||
|
||||
msgs := make([]providers.Message, len(history))
|
||||
|
||||
copy(msgs, history)
|
||||
|
||||
session.Messages = msgs
|
||||
|
||||
session.Updated = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// MarkDirty marks a session key for deferred persistence.
|
||||
|
||||
// The session will be written to disk on the next periodic flush or on Close().
|
||||
|
||||
func (sm *SessionManager) MarkDirty(key string) {
|
||||
sm.dirtyMu.Lock()
|
||||
|
||||
sm.dirtyKeys[key] = true
|
||||
|
||||
sm.dirtyMu.Unlock()
|
||||
}
|
||||
|
||||
// FlushDirty writes all dirty sessions to disk.
|
||||
|
||||
func (sm *SessionManager) FlushDirty() {
|
||||
sm.dirtyMu.Lock()
|
||||
|
||||
keys := make([]string, 0, len(sm.dirtyKeys))
|
||||
|
||||
for k := range sm.dirtyKeys {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
sm.dirtyKeys = make(map[string]bool)
|
||||
|
||||
sm.dirtyMu.Unlock()
|
||||
|
||||
for _, k := range keys {
|
||||
|
|
@ -387,24 +512,34 @@ func (sm *SessionManager) FlushDirty() {
|
|||
}
|
||||
|
||||
// Close stops the background flush goroutine and writes all dirty sessions.
|
||||
|
||||
func (sm *SessionManager) Close() {
|
||||
select {
|
||||
case <-sm.done:
|
||||
|
||||
return // already closed
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
close(sm.done)
|
||||
|
||||
sm.FlushDirty()
|
||||
}
|
||||
|
||||
func (sm *SessionManager) flushLoop() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
|
||||
sm.FlushDirty()
|
||||
|
||||
case <-sm.done:
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,19 +11,26 @@ import (
|
|||
func TestSanitizeFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
||||
expected string
|
||||
}{
|
||||
{"simple", "simple"},
|
||||
|
||||
{"telegram:123456", "telegram_123456"},
|
||||
|
||||
{"discord:987654321", "discord_987654321"},
|
||||
|
||||
{"slack:C01234", "slack_C01234"},
|
||||
|
||||
{"no-colons-here", "no-colons-here"},
|
||||
|
||||
{"multiple:colons:here", "multiple_colons_here"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := sanitizeFilename(tt.input)
|
||||
|
||||
if got != tt.expected {
|
||||
t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
|
|
@ -33,30 +40,41 @@ func TestSanitizeFilename(t *testing.T) {
|
|||
|
||||
func TestSave_WithColonInKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
sm := NewSessionManager(tmpDir)
|
||||
|
||||
// Create a session with a key containing colon (typical channel session key).
|
||||
|
||||
key := "telegram:123456"
|
||||
|
||||
sm.GetOrCreate(key)
|
||||
|
||||
sm.AddMessage(key, "user", "hello")
|
||||
|
||||
// Save should succeed even though the key contains ':'
|
||||
|
||||
if err := sm.Save(key); err != nil {
|
||||
t.Fatalf("Save(%q) failed: %v", key, err)
|
||||
}
|
||||
|
||||
// The file on disk should use sanitized name.
|
||||
|
||||
expectedFile := filepath.Join(tmpDir, "telegram_123456.json")
|
||||
|
||||
if _, err := os.Stat(expectedFile); os.IsNotExist(err) {
|
||||
t.Fatalf("expected session file %s to exist", expectedFile)
|
||||
}
|
||||
|
||||
// Load into a fresh manager and verify the session round-trips.
|
||||
|
||||
sm2 := NewSessionManager(tmpDir)
|
||||
|
||||
history := sm2.GetHistory(key)
|
||||
|
||||
if len(history) != 1 {
|
||||
t.Fatalf("expected 1 message after reload, got %d", len(history))
|
||||
}
|
||||
|
||||
if history[0].Content != "hello" {
|
||||
t.Errorf("expected message content %q, got %q", "hello", history[0].Content)
|
||||
}
|
||||
|
|
@ -65,19 +83,27 @@ func TestSave_WithColonInKey(t *testing.T) {
|
|||
func TestSanitizeHistory_OrphanedToolCall(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
|
||||
{Role: "assistant", Content: "sure", ToolCalls: []providers.ToolCall{
|
||||
{ID: "call_1", Name: "exec"},
|
||||
|
||||
{ID: "call_2", Name: "list_dir"},
|
||||
}},
|
||||
|
||||
{Role: "tool", Content: "ok", ToolCallID: "call_1"},
|
||||
|
||||
// Missing tool result for call_2 → orphaned
|
||||
|
||||
}
|
||||
|
||||
sanitized, removed := SanitizeHistory(history)
|
||||
|
||||
if removed == 0 {
|
||||
t.Fatal("expected orphaned messages to be removed")
|
||||
}
|
||||
|
||||
// After sanitization, only the user message should remain
|
||||
|
||||
if len(sanitized) != 1 || sanitized[0].Role != "user" {
|
||||
t.Errorf("expected [user], got %d messages", len(sanitized))
|
||||
}
|
||||
|
|
@ -85,25 +111,36 @@ func TestSanitizeHistory_OrphanedToolCall(t *testing.T) {
|
|||
|
||||
func TestSanitizeHistory_InterleavedMessages(t *testing.T) {
|
||||
// Simulates session collision: a user message got interleaved between
|
||||
|
||||
// an assistant tool call and its tool result
|
||||
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "first"},
|
||||
|
||||
{Role: "assistant", Content: "ok", ToolCalls: []providers.ToolCall{
|
||||
{ID: "call_1", Name: "exec"},
|
||||
}},
|
||||
|
||||
{Role: "user", Content: "collision!"}, // ← interleaved from other session
|
||||
|
||||
{Role: "tool", Content: "ok", ToolCallID: "call_1"}, // ← out of order
|
||||
|
||||
{Role: "assistant", Content: "done"},
|
||||
}
|
||||
|
||||
sanitized, removed := SanitizeHistory(history)
|
||||
|
||||
if removed == 0 {
|
||||
t.Fatal("expected interleaved messages to be removed")
|
||||
}
|
||||
|
||||
// Should keep: user("first"), user("collision!"), assistant("done")
|
||||
|
||||
// Should remove: assistant(call_1), tool(call_1)
|
||||
|
||||
if len(sanitized) != 3 {
|
||||
t.Errorf("expected 3 messages, got %d", len(sanitized))
|
||||
|
||||
for i, m := range sanitized {
|
||||
t.Logf(" [%d] role=%s content=%q", i, m.Role, m.Content)
|
||||
}
|
||||
|
|
@ -113,17 +150,22 @@ func TestSanitizeHistory_InterleavedMessages(t *testing.T) {
|
|||
func TestSanitizeHistory_CleanHistory(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
|
||||
{Role: "assistant", Content: "sure", ToolCalls: []providers.ToolCall{
|
||||
{ID: "call_1", Name: "exec"},
|
||||
}},
|
||||
|
||||
{Role: "tool", Content: "ok", ToolCallID: "call_1"},
|
||||
|
||||
{Role: "assistant", Content: "done"},
|
||||
}
|
||||
|
||||
sanitized, removed := SanitizeHistory(history)
|
||||
|
||||
if removed != 0 {
|
||||
t.Errorf("expected 0 removed, got %d", removed)
|
||||
}
|
||||
|
||||
if len(sanitized) != 4 {
|
||||
t.Errorf("expected 4 messages, got %d", len(sanitized))
|
||||
}
|
||||
|
|
@ -132,19 +174,26 @@ func TestSanitizeHistory_CleanHistory(t *testing.T) {
|
|||
func TestSanitizeHistory_MultipleToolCalls(t *testing.T) {
|
||||
history := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
|
||||
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{
|
||||
{ID: "call_1", Name: "exec"},
|
||||
|
||||
{ID: "call_2", Name: "read_file"},
|
||||
}},
|
||||
|
||||
{Role: "tool", Content: "ok", ToolCallID: "call_1"},
|
||||
|
||||
{Role: "tool", Content: "content", ToolCallID: "call_2"},
|
||||
|
||||
{Role: "assistant", Content: "all done"},
|
||||
}
|
||||
|
||||
sanitized, removed := SanitizeHistory(history)
|
||||
|
||||
if removed != 0 {
|
||||
t.Errorf("expected 0 removed, got %d", removed)
|
||||
}
|
||||
|
||||
if len(sanitized) != 5 {
|
||||
t.Errorf("expected 5 messages, got %d", len(sanitized))
|
||||
}
|
||||
|
|
@ -152,6 +201,7 @@ func TestSanitizeHistory_MultipleToolCalls(t *testing.T) {
|
|||
|
||||
func TestSanitizeHistory_Empty(t *testing.T) {
|
||||
sanitized, removed := SanitizeHistory(nil)
|
||||
|
||||
if removed != 0 || sanitized != nil {
|
||||
t.Errorf("expected nil/0, got %v/%d", sanitized, removed)
|
||||
}
|
||||
|
|
@ -159,11 +209,14 @@ func TestSanitizeHistory_Empty(t *testing.T) {
|
|||
|
||||
func TestSave_RejectsPathTraversal(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
sm := NewSessionManager(tmpDir)
|
||||
|
||||
badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"}
|
||||
|
||||
for _, key := range badKeys {
|
||||
sm.GetOrCreate(key)
|
||||
|
||||
if err := sm.Save(key); err == nil {
|
||||
t.Errorf("Save(%q) should have failed but didn't", key)
|
||||
}
|
||||
|
|
|
|||
123
pkg/session/migrate.go
Normal file
123
pkg/session/migrate.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MigrateJSONSessions reads JSON session files from jsonDir and imports them
|
||||
|
||||
// into the given SessionStore. Successfully migrated files are renamed to
|
||||
|
||||
// .json.migrated so they are skipped on subsequent runs.
|
||||
|
||||
//
|
||||
|
||||
// Individual file errors are logged and skipped (the file remains for retry).
|
||||
|
||||
// Returns the number of sessions migrated and the first error encountered, if any.
|
||||
|
||||
func MigrateJSONSessions(jsonDir string, store SessionStore) (int, error) {
|
||||
entries, err := os.ReadDir(jsonDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return 0, err
|
||||
}
|
||||
|
||||
migrated := 0
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
|
||||
if !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(jsonDir, name)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("session migrate: read %s: %v", name, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var sess Session
|
||||
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
log.Printf("session migrate: parse %s: %v", name, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if sess.Key == "" {
|
||||
log.Printf("session migrate: skip %s: empty key", name)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Create session in store (skip if already exists)
|
||||
|
||||
if existing, _ := store.Get(sess.Key); existing != nil {
|
||||
// Already migrated (perhaps from a previous partial run)
|
||||
|
||||
_ = os.Rename(path, path+".migrated")
|
||||
|
||||
migrated++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := store.Create(sess.Key, nil); err != nil {
|
||||
log.Printf("session migrate: create %s: %v", sess.Key, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Import messages as a single turn
|
||||
|
||||
if len(sess.Messages) > 0 {
|
||||
turn := &Turn{
|
||||
SessionKey: sess.Key,
|
||||
|
||||
Kind: TurnNormal,
|
||||
|
||||
Messages: sess.Messages,
|
||||
|
||||
CreatedAt: sess.Created,
|
||||
}
|
||||
|
||||
if err := store.Append(sess.Key, turn); err != nil {
|
||||
log.Printf("session migrate: append %s: %v", sess.Key, err)
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Set summary if present
|
||||
|
||||
if sess.Summary != "" {
|
||||
_ = store.SetSummary(sess.Key, sess.Summary)
|
||||
}
|
||||
|
||||
// Mark as migrated
|
||||
|
||||
if err := os.Rename(path, path+".migrated"); err != nil {
|
||||
log.Printf("session migrate: rename %s: %v", name, err)
|
||||
}
|
||||
|
||||
migrated++
|
||||
}
|
||||
|
||||
return migrated, nil
|
||||
}
|
||||
276
pkg/session/migrate_test.go
Normal file
276
pkg/session/migrate_test.go
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func writeJSONSession(t *testing.T, dir string, sess Session) {
|
||||
t.Helper()
|
||||
|
||||
data, err := json.MarshalIndent(sess, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(sess.Key) + ".json"
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_Basic(t *testing.T) {
|
||||
jsonDir := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, err := OpenSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer store.Close()
|
||||
|
||||
writeJSONSession(t, jsonDir, Session{
|
||||
Key: "telegram:123",
|
||||
|
||||
Messages: []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
|
||||
{Role: "assistant", Content: "hi"},
|
||||
},
|
||||
|
||||
Summary: "greeting",
|
||||
})
|
||||
|
||||
writeJSONSession(t, jsonDir, Session{
|
||||
Key: "discord:456",
|
||||
|
||||
Messages: []providers.Message{
|
||||
{Role: "user", Content: "test"},
|
||||
},
|
||||
})
|
||||
|
||||
migrated, err := MigrateJSONSessions(jsonDir, store)
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateJSONSessions: %v", err)
|
||||
}
|
||||
|
||||
if migrated != 2 {
|
||||
t.Errorf("expected 2 migrated, got %d", migrated)
|
||||
}
|
||||
|
||||
// Verify sessions exist
|
||||
|
||||
info, _ := store.Get("telegram:123")
|
||||
|
||||
if info == nil || info.Summary != "greeting" {
|
||||
t.Errorf("telegram:123 not found or wrong summary")
|
||||
}
|
||||
|
||||
turns, _ := store.Turns("telegram:123", 0)
|
||||
|
||||
if len(turns) != 1 || len(turns[0].Messages) != 2 {
|
||||
t.Errorf("expected 1 turn with 2 messages, got %+v", turns)
|
||||
}
|
||||
|
||||
info2, _ := store.Get("discord:456")
|
||||
|
||||
if info2 == nil {
|
||||
t.Error("discord:456 not found")
|
||||
}
|
||||
|
||||
// Verify .json files were renamed
|
||||
|
||||
entries, _ := os.ReadDir(jsonDir)
|
||||
|
||||
for _, e := range entries {
|
||||
if filepath.Ext(e.Name()) == ".json" {
|
||||
t.Errorf("expected .json.migrated, found %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_EmptyMessages(t *testing.T) {
|
||||
jsonDir := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, _ := OpenSQLiteStore(dbPath)
|
||||
|
||||
defer store.Close()
|
||||
|
||||
writeJSONSession(t, jsonDir, Session{
|
||||
Key: "empty:1",
|
||||
|
||||
Messages: []providers.Message{},
|
||||
})
|
||||
|
||||
migrated, err := MigrateJSONSessions(jsonDir, store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if migrated != 1 {
|
||||
t.Errorf("expected 1, got %d", migrated)
|
||||
}
|
||||
|
||||
// Should exist but have no turns
|
||||
|
||||
count, _ := store.TurnCount("empty:1")
|
||||
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 turns, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_EmptySummary(t *testing.T) {
|
||||
jsonDir := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, _ := OpenSQLiteStore(dbPath)
|
||||
|
||||
defer store.Close()
|
||||
|
||||
writeJSONSession(t, jsonDir, Session{
|
||||
Key: "nosummary:1",
|
||||
|
||||
Messages: []providers.Message{{Role: "user", Content: "hi"}},
|
||||
})
|
||||
|
||||
MigrateJSONSessions(jsonDir, store)
|
||||
|
||||
info, _ := store.Get("nosummary:1")
|
||||
|
||||
if info.Summary != "" {
|
||||
t.Errorf("expected empty summary, got %q", info.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_InvalidJSON(t *testing.T) {
|
||||
jsonDir := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, _ := OpenSQLiteStore(dbPath)
|
||||
|
||||
defer store.Close()
|
||||
|
||||
// Write invalid JSON
|
||||
|
||||
os.WriteFile(filepath.Join(jsonDir, "bad.json"), []byte("{invalid"), 0o644)
|
||||
|
||||
// Write a valid one too
|
||||
|
||||
writeJSONSession(t, jsonDir, Session{
|
||||
Key: "good:1",
|
||||
|
||||
Messages: []providers.Message{{Role: "user", Content: "hi"}},
|
||||
})
|
||||
|
||||
migrated, err := MigrateJSONSessions(jsonDir, store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if migrated != 1 {
|
||||
t.Errorf("expected 1 (skipped bad), got %d", migrated)
|
||||
}
|
||||
|
||||
// Bad file should still be .json (not renamed)
|
||||
|
||||
if _, err := os.Stat(filepath.Join(jsonDir, "bad.json")); os.IsNotExist(err) {
|
||||
t.Error("bad.json should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_Idempotent(t *testing.T) {
|
||||
jsonDir := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, _ := OpenSQLiteStore(dbPath)
|
||||
|
||||
defer store.Close()
|
||||
|
||||
writeJSONSession(t, jsonDir, Session{
|
||||
Key: "k1",
|
||||
|
||||
Messages: []providers.Message{{Role: "user", Content: "hello"}},
|
||||
})
|
||||
|
||||
n1, _ := MigrateJSONSessions(jsonDir, store)
|
||||
|
||||
if n1 != 1 {
|
||||
t.Fatalf("first run: expected 1, got %d", n1)
|
||||
}
|
||||
|
||||
// Second run should find no .json files (all renamed)
|
||||
|
||||
n2, _ := MigrateJSONSessions(jsonDir, store)
|
||||
|
||||
if n2 != 0 {
|
||||
t.Errorf("second run: expected 0, got %d", n2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_NonExistentDir(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, _ := OpenSQLiteStore(dbPath)
|
||||
|
||||
defer store.Close()
|
||||
|
||||
n, err := MigrateJSONSessions("/nonexistent/path", store)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for non-existent dir, got %v", err)
|
||||
}
|
||||
|
||||
if n != 0 {
|
||||
t.Errorf("expected 0, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_AlreadyExistsInStore(t *testing.T) {
|
||||
jsonDir := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, _ := OpenSQLiteStore(dbPath)
|
||||
|
||||
defer store.Close()
|
||||
|
||||
// Pre-create session in store
|
||||
|
||||
store.Create("k1", nil)
|
||||
|
||||
// Write JSON for same key
|
||||
|
||||
writeJSONSession(t, jsonDir, Session{
|
||||
Key: "k1",
|
||||
|
||||
Messages: []providers.Message{{Role: "user", Content: "hello"}},
|
||||
})
|
||||
|
||||
n, _ := MigrateJSONSessions(jsonDir, store)
|
||||
|
||||
if n != 1 {
|
||||
t.Errorf("expected 1, got %d", n)
|
||||
}
|
||||
|
||||
// File should still be renamed
|
||||
|
||||
entries, _ := os.ReadDir(jsonDir)
|
||||
|
||||
for _, e := range entries {
|
||||
if filepath.Ext(e.Name()) == ".json" {
|
||||
t.Errorf("expected .json.migrated, found %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
557
pkg/session/sqlite.go
Normal file
557
pkg/session/sqlite.go
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const sqliteDriver = "sqlite"
|
||||
|
||||
const schema = `
|
||||
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
|
||||
|
||||
|
||||
key TEXT PRIMARY KEY,
|
||||
|
||||
|
||||
|
||||
parent_key TEXT NOT NULL DEFAULT '',
|
||||
|
||||
|
||||
|
||||
fork_turn_id TEXT NOT NULL DEFAULT '',
|
||||
|
||||
|
||||
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
|
||||
|
||||
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
|
||||
|
||||
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
|
||||
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
|
||||
|
||||
|
||||
updated_at TEXT NOT NULL
|
||||
|
||||
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS turns (
|
||||
|
||||
|
||||
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
|
||||
|
||||
session_key TEXT NOT NULL REFERENCES sessions(key) ON DELETE CASCADE,
|
||||
|
||||
|
||||
|
||||
seq INTEGER NOT NULL,
|
||||
|
||||
|
||||
|
||||
kind INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
|
||||
|
||||
messages TEXT NOT NULL DEFAULT '[]',
|
||||
|
||||
|
||||
|
||||
origin_key TEXT NOT NULL DEFAULT '',
|
||||
|
||||
|
||||
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
|
||||
|
||||
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
|
||||
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
|
||||
|
||||
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
|
||||
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_turns_session_seq ON turns(session_key, seq);
|
||||
|
||||
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_key);
|
||||
|
||||
|
||||
|
||||
`
|
||||
|
||||
// SQLiteStore implements SessionStore backed by a single SQLite file.
|
||||
|
||||
type SQLiteStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// OpenSQLiteStore opens (or creates) a SQLite session database at dbPath.
|
||||
|
||||
func OpenSQLiteStore(dbPath string) (*SQLiteStore, error) {
|
||||
connStr := "file:" + dbPath + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
|
||||
|
||||
db, err := sql.Open(sqliteDriver, connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open session store: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
// Ensure foreign keys are enabled (connection string param may not suffice).
|
||||
|
||||
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||
_ = db.Close()
|
||||
|
||||
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
_ = db.Close()
|
||||
|
||||
return nil, fmt.Errorf("create schema: %w", err)
|
||||
}
|
||||
|
||||
return &SQLiteStore{db: db}, nil
|
||||
}
|
||||
|
||||
func nowUTC() string {
|
||||
return time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func parseTime(s string) time.Time {
|
||||
t, _ := time.Parse(time.RFC3339Nano, s)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// --- Session CRUD ---
|
||||
|
||||
func (s *SQLiteStore) Create(key string, opts *CreateOpts) error {
|
||||
now := nowUTC()
|
||||
|
||||
parentKey, forkTurnID, label := "", "", ""
|
||||
|
||||
if opts != nil {
|
||||
parentKey = opts.ParentKey
|
||||
|
||||
forkTurnID = opts.ForkTurnID
|
||||
|
||||
label = opts.Label
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
|
||||
`INSERT INTO sessions (key, parent_key, fork_turn_id, label, created_at, updated_at)
|
||||
|
||||
|
||||
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
|
||||
key, parentKey, forkTurnID, label, now, now,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) Get(key string) (*SessionInfo, error) {
|
||||
row := s.db.QueryRow(
|
||||
|
||||
`SELECT key, parent_key, fork_turn_id, status, label, summary, created_at, updated_at
|
||||
|
||||
|
||||
|
||||
FROM sessions WHERE key = ?`, key,
|
||||
)
|
||||
|
||||
return scanSessionInfo(row)
|
||||
}
|
||||
|
||||
func scanSessionInfo(row *sql.Row) (*SessionInfo, error) {
|
||||
var info SessionInfo
|
||||
|
||||
var createdAt, updatedAt string
|
||||
|
||||
err := row.Scan(&info.Key, &info.ParentKey, &info.ForkTurnID, &info.Status,
|
||||
|
||||
&info.Label, &info.Summary, &createdAt, &updatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info.CreatedAt = parseTime(createdAt)
|
||||
|
||||
info.UpdatedAt = parseTime(updatedAt)
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) List(filter *ListFilter) ([]*SessionInfo, error) {
|
||||
query := `SELECT key, parent_key, fork_turn_id, status, label, summary, created_at, updated_at FROM sessions WHERE 1=1`
|
||||
|
||||
var args []any
|
||||
|
||||
if filter != nil {
|
||||
if filter.ParentKey != "" {
|
||||
query += ` AND parent_key = ?`
|
||||
|
||||
args = append(args, filter.ParentKey)
|
||||
}
|
||||
|
||||
if filter.Status != "" {
|
||||
query += ` AND status = ?`
|
||||
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
}
|
||||
|
||||
query += ` ORDER BY created_at`
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
|
||||
var result []*SessionInfo
|
||||
|
||||
for rows.Next() {
|
||||
var info SessionInfo
|
||||
|
||||
var createdAt, updatedAt string
|
||||
|
||||
if err := rows.Scan(&info.Key, &info.ParentKey, &info.ForkTurnID, &info.Status,
|
||||
|
||||
&info.Label, &info.Summary, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info.CreatedAt = parseTime(createdAt)
|
||||
|
||||
info.UpdatedAt = parseTime(updatedAt)
|
||||
|
||||
result = append(result, &info)
|
||||
}
|
||||
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SetStatus(key, status string) error {
|
||||
res, err := s.db.Exec(`UPDATE sessions SET status = ?, updated_at = ? WHERE key = ?`, status, nowUTC(), key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return checkRowAffected(res, key)
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SetSummary(key, summary string) error {
|
||||
res, err := s.db.Exec(`UPDATE sessions SET summary = ?, updated_at = ? WHERE key = ?`, summary, nowUTC(), key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return checkRowAffected(res, key)
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) Delete(key string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM sessions WHERE key = ?`, key)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) Children(key string) ([]*SessionInfo, error) {
|
||||
return s.List(&ListFilter{ParentKey: key})
|
||||
}
|
||||
|
||||
// --- Turn operations ---
|
||||
|
||||
func (s *SQLiteStore) Append(sessionKey string, turn *Turn) error {
|
||||
if turn.ID == "" {
|
||||
turn.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
if turn.CreatedAt.IsZero() {
|
||||
turn.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
messagesJSON, err := json.Marshal(turn.Messages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal messages: %w", err)
|
||||
}
|
||||
|
||||
metaJSON, err := json.Marshal(turn.Meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal meta: %w", err)
|
||||
}
|
||||
|
||||
// Auto-assign seq if not set
|
||||
|
||||
if turn.Seq == 0 {
|
||||
var maxSeq sql.NullInt64
|
||||
|
||||
_ = s.db.QueryRow(`SELECT MAX(seq) FROM turns WHERE session_key = ?`, sessionKey).Scan(&maxSeq)
|
||||
|
||||
if maxSeq.Valid {
|
||||
turn.Seq = int(maxSeq.Int64) + 1
|
||||
} else {
|
||||
turn.Seq = 1
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
|
||||
`INSERT INTO turns (id, session_key, seq, kind, messages, origin_key, summary, author, created_at, meta)
|
||||
|
||||
|
||||
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
|
||||
turn.ID, sessionKey, turn.Seq, int(turn.Kind),
|
||||
|
||||
string(messagesJSON), turn.OriginKey, turn.Summary, turn.Author,
|
||||
|
||||
turn.CreatedAt.UTC().Format(time.RFC3339Nano), string(metaJSON),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update session's updated_at
|
||||
|
||||
_, _ = s.db.Exec(`UPDATE sessions SET updated_at = ? WHERE key = ?`, nowUTC(), sessionKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) Turns(sessionKey string, sinceSeq int) ([]*Turn, error) {
|
||||
rows, err := s.db.Query(
|
||||
|
||||
`SELECT id, session_key, seq, kind, messages, origin_key, summary, author, created_at, meta
|
||||
|
||||
|
||||
|
||||
FROM turns WHERE session_key = ? AND seq > ? ORDER BY seq`,
|
||||
|
||||
sessionKey, sinceSeq,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
|
||||
var result []*Turn
|
||||
|
||||
for rows.Next() {
|
||||
t, err := scanTurn(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = append(result, t)
|
||||
}
|
||||
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanTurn(row scanner) (*Turn, error) {
|
||||
var t Turn
|
||||
|
||||
var kind int
|
||||
|
||||
var messagesJSON, metaJSON, createdAt string
|
||||
|
||||
err := row.Scan(&t.ID, &t.SessionKey, &t.Seq, &kind, &messagesJSON,
|
||||
|
||||
&t.OriginKey, &t.Summary, &t.Author, &createdAt, &metaJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t.Kind = TurnKind(kind)
|
||||
|
||||
t.CreatedAt = parseTime(createdAt)
|
||||
|
||||
if err := json.Unmarshal([]byte(messagesJSON), &t.Messages); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal messages: %w", err)
|
||||
}
|
||||
|
||||
if metaJSON != "" && metaJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(metaJSON), &t.Meta); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal meta: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) LastTurn(sessionKey string) (*Turn, error) {
|
||||
row := s.db.QueryRow(
|
||||
|
||||
`SELECT id, session_key, seq, kind, messages, origin_key, summary, author, created_at, meta
|
||||
|
||||
|
||||
|
||||
FROM turns WHERE session_key = ? ORDER BY seq DESC LIMIT 1`,
|
||||
|
||||
sessionKey,
|
||||
)
|
||||
|
||||
var t Turn
|
||||
|
||||
var kind int
|
||||
|
||||
var messagesJSON, metaJSON, createdAt string
|
||||
|
||||
err := row.Scan(&t.ID, &t.SessionKey, &t.Seq, &kind, &messagesJSON,
|
||||
|
||||
&t.OriginKey, &t.Summary, &t.Author, &createdAt, &metaJSON)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t.Kind = TurnKind(kind)
|
||||
|
||||
t.CreatedAt = parseTime(createdAt)
|
||||
|
||||
if err := json.Unmarshal([]byte(messagesJSON), &t.Messages); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal messages: %w", err)
|
||||
}
|
||||
|
||||
if metaJSON != "" && metaJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(metaJSON), &t.Meta); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal meta: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) TurnCount(sessionKey string) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM turns WHERE session_key = ?`, sessionKey).Scan(&count)
|
||||
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) Compact(sessionKey string, upToSeq int, summary string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(`DELETE FROM turns WHERE session_key = ? AND seq <= ?`, sessionKey, upToSeq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if summary != "" {
|
||||
_, err = tx.Exec(`UPDATE sessions SET summary = ?, updated_at = ? WHERE key = ?`,
|
||||
|
||||
summary, nowUTC(), sessionKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// --- DAG operations ---
|
||||
|
||||
func (s *SQLiteStore) Fork(parentKey, childKey string, opts *CreateOpts) error {
|
||||
if opts == nil {
|
||||
opts = &CreateOpts{}
|
||||
}
|
||||
|
||||
opts.ParentKey = parentKey
|
||||
|
||||
return s.Create(childKey, opts)
|
||||
}
|
||||
|
||||
// --- Maintenance ---
|
||||
|
||||
func (s *SQLiteStore) Prune(olderThan time.Duration) (int, error) {
|
||||
cutoff := time.Now().UTC().Add(-olderThan).Format(time.RFC3339Nano)
|
||||
|
||||
res, err := s.db.Exec(`DELETE FROM sessions WHERE updated_at < ?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n, _ := res.RowsAffected()
|
||||
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func checkRowAffected(res sql.Result, key string) error {
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return fmt.Errorf("session not found: %s", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
481
pkg/session/sqlite_test.go
Normal file
481
pkg/session/sqlite_test.go
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *SQLiteStore {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
store, err := OpenSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLiteStore: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { store.Close() })
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func TestSQLite_CreateGetDelete(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
// Create
|
||||
|
||||
if err := store.Create("s1", nil); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// Get
|
||||
|
||||
info, err := store.Get("s1")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
|
||||
if info == nil {
|
||||
t.Fatal("expected session, got nil")
|
||||
}
|
||||
|
||||
if info.Key != "s1" || info.Status != "active" {
|
||||
t.Errorf("unexpected session: %+v", info)
|
||||
}
|
||||
|
||||
// Get non-existent
|
||||
|
||||
info, err = store.Get("nope")
|
||||
if err != nil {
|
||||
t.Fatalf("Get non-existent: %v", err)
|
||||
}
|
||||
|
||||
if info != nil {
|
||||
t.Errorf("expected nil for non-existent session")
|
||||
}
|
||||
|
||||
// Delete
|
||||
|
||||
if delErr := store.Delete("s1"); delErr != nil {
|
||||
t.Fatalf("Delete: %v", delErr)
|
||||
}
|
||||
|
||||
info, err = store.Get("s1")
|
||||
if err != nil {
|
||||
t.Fatalf("Get after delete: %v", err)
|
||||
}
|
||||
|
||||
if info != nil {
|
||||
t.Errorf("expected nil after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_CreateWithOpts(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create("parent", nil); err != nil {
|
||||
t.Fatalf("Create parent: %v", err)
|
||||
}
|
||||
|
||||
if err := store.Create("child", &CreateOpts{
|
||||
ParentKey: "parent",
|
||||
|
||||
ForkTurnID: "turn-1",
|
||||
|
||||
Label: "test child",
|
||||
}); err != nil {
|
||||
t.Fatalf("Create child: %v", err)
|
||||
}
|
||||
|
||||
info, _ := store.Get("child")
|
||||
|
||||
if info.ParentKey != "parent" || info.ForkTurnID != "turn-1" || info.Label != "test child" {
|
||||
t.Errorf("unexpected opts: %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_List(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
store.Create("a", nil)
|
||||
|
||||
store.Create("b", &CreateOpts{ParentKey: "a"})
|
||||
|
||||
store.Create("c", nil)
|
||||
|
||||
all, _ := store.List(nil)
|
||||
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("expected 3 sessions, got %d", len(all))
|
||||
}
|
||||
|
||||
children, _ := store.List(&ListFilter{ParentKey: "a"})
|
||||
|
||||
if len(children) != 1 || children[0].Key != "b" {
|
||||
t.Errorf("unexpected children: %+v", children)
|
||||
}
|
||||
|
||||
store.SetStatus("c", "archived")
|
||||
|
||||
active, _ := store.List(&ListFilter{Status: "active"})
|
||||
|
||||
if len(active) != 2 {
|
||||
t.Errorf("expected 2 active, got %d", len(active))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_SetStatusSummary(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
store.Create("s1", nil)
|
||||
|
||||
if err := store.SetStatus("s1", "archived"); err != nil {
|
||||
t.Fatalf("SetStatus: %v", err)
|
||||
}
|
||||
|
||||
info, _ := store.Get("s1")
|
||||
|
||||
if info.Status != "archived" {
|
||||
t.Errorf("expected archived, got %s", info.Status)
|
||||
}
|
||||
|
||||
if err := store.SetSummary("s1", "test summary"); err != nil {
|
||||
t.Fatalf("SetSummary: %v", err)
|
||||
}
|
||||
|
||||
info, _ = store.Get("s1")
|
||||
|
||||
if info.Summary != "test summary" {
|
||||
t.Errorf("expected 'test summary', got %q", info.Summary)
|
||||
}
|
||||
|
||||
// Non-existent session
|
||||
|
||||
if err := store.SetStatus("nope", "active"); err == nil {
|
||||
t.Error("expected error for non-existent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_Children(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
store.Create("p", nil)
|
||||
|
||||
store.Create("c1", &CreateOpts{ParentKey: "p"})
|
||||
|
||||
store.Create("c2", &CreateOpts{ParentKey: "p"})
|
||||
|
||||
store.Create("other", nil)
|
||||
|
||||
children, err := store.Children("p")
|
||||
if err != nil {
|
||||
t.Fatalf("Children: %v", err)
|
||||
}
|
||||
|
||||
if len(children) != 2 {
|
||||
t.Errorf("expected 2 children, got %d", len(children))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_AppendAndTurns(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
store.Create("s1", nil)
|
||||
|
||||
turn1 := &Turn{
|
||||
SessionKey: "s1",
|
||||
|
||||
Kind: TurnNormal,
|
||||
|
||||
Messages: []providers.Message{{Role: "user", Content: "hello"}},
|
||||
|
||||
Author: "user",
|
||||
}
|
||||
|
||||
if err := store.Append("s1", turn1); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
|
||||
if turn1.ID == "" {
|
||||
t.Error("expected ID to be assigned")
|
||||
}
|
||||
|
||||
if turn1.Seq != 1 {
|
||||
t.Errorf("expected seq 1, got %d", turn1.Seq)
|
||||
}
|
||||
|
||||
turn2 := &Turn{
|
||||
SessionKey: "s1",
|
||||
|
||||
Kind: TurnNormal,
|
||||
|
||||
Messages: []providers.Message{{Role: "assistant", Content: "hi"}},
|
||||
|
||||
Author: "assistant",
|
||||
}
|
||||
|
||||
store.Append("s1", turn2)
|
||||
|
||||
if turn2.Seq != 2 {
|
||||
t.Errorf("expected seq 2, got %d", turn2.Seq)
|
||||
}
|
||||
|
||||
// Get all turns
|
||||
|
||||
turns, err := store.Turns("s1", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Turns: %v", err)
|
||||
}
|
||||
|
||||
if len(turns) != 2 {
|
||||
t.Fatalf("expected 2 turns, got %d", len(turns))
|
||||
}
|
||||
|
||||
// sinceSeq filter
|
||||
|
||||
turns, _ = store.Turns("s1", 1)
|
||||
|
||||
if len(turns) != 1 || turns[0].Seq != 2 {
|
||||
t.Errorf("expected 1 turn with seq 2, got %+v", turns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_LastTurn(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
store.Create("s1", nil)
|
||||
|
||||
// No turns
|
||||
|
||||
last, err := store.LastTurn("s1")
|
||||
if err != nil {
|
||||
t.Fatalf("LastTurn empty: %v", err)
|
||||
}
|
||||
|
||||
if last != nil {
|
||||
t.Error("expected nil for empty session")
|
||||
}
|
||||
|
||||
store.Append("s1", &Turn{Messages: []providers.Message{{Role: "user", Content: "a"}}})
|
||||
|
||||
store.Append("s1", &Turn{Messages: []providers.Message{{Role: "user", Content: "b"}}})
|
||||
|
||||
last, _ = store.LastTurn("s1")
|
||||
|
||||
if last == nil || last.Messages[0].Content != "b" {
|
||||
t.Errorf("expected last message 'b', got %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_TurnCount(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
store.Create("s1", nil)
|
||||
|
||||
count, _ := store.TurnCount("s1")
|
||||
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0, got %d", count)
|
||||
}
|
||||
|
||||
store.Append("s1", &Turn{Messages: []providers.Message{{Role: "user", Content: "a"}}})
|
||||
|
||||
store.Append("s1", &Turn{Messages: []providers.Message{{Role: "user", Content: "b"}}})
|
||||
|
||||
count, _ = store.TurnCount("s1")
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_Compact(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
store.Create("s1", nil)
|
||||
|
||||
for i := range 5 {
|
||||
store.Append("s1", &Turn{
|
||||
Messages: []providers.Message{{Role: "user", Content: string(rune('a' + i))}},
|
||||
})
|
||||
}
|
||||
|
||||
// Compact up to seq 3
|
||||
|
||||
if err := store.Compact("s1", 3, "summary of first 3 turns"); err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
|
||||
turns, _ := store.Turns("s1", 0)
|
||||
|
||||
if len(turns) != 2 {
|
||||
t.Errorf("expected 2 remaining turns, got %d", len(turns))
|
||||
}
|
||||
|
||||
if turns[0].Seq != 4 {
|
||||
t.Errorf("expected first remaining seq 4, got %d", turns[0].Seq)
|
||||
}
|
||||
|
||||
info, _ := store.Get("s1")
|
||||
|
||||
if info.Summary != "summary of first 3 turns" {
|
||||
t.Errorf("expected compacted summary, got %q", info.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_Fork(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
store.Create("parent", nil)
|
||||
|
||||
store.Append("parent", &Turn{
|
||||
Messages: []providers.Message{{Role: "user", Content: "hello"}},
|
||||
})
|
||||
|
||||
last, _ := store.LastTurn("parent")
|
||||
|
||||
if err := store.Fork("parent", "child", &CreateOpts{ForkTurnID: last.ID}); err != nil {
|
||||
t.Fatalf("Fork: %v", err)
|
||||
}
|
||||
|
||||
child, _ := store.Get("child")
|
||||
|
||||
if child.ParentKey != "parent" || child.ForkTurnID != last.ID {
|
||||
t.Errorf("unexpected fork result: %+v", child)
|
||||
}
|
||||
|
||||
children, _ := store.Children("parent")
|
||||
|
||||
if len(children) != 1 || children[0].Key != "child" {
|
||||
t.Errorf("expected 1 child, got %+v", children)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_Prune(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
// Create an old session by manipulating updated_at directly
|
||||
|
||||
store.Create("old", nil)
|
||||
|
||||
store.Create("new", nil)
|
||||
|
||||
old := time.Now().UTC().Add(-48 * time.Hour).Format(time.RFC3339Nano)
|
||||
|
||||
store.db.Exec(`UPDATE sessions SET updated_at = ? WHERE key = ?`, old, "old")
|
||||
|
||||
pruned, err := store.Prune(24 * time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Prune: %v", err)
|
||||
}
|
||||
|
||||
if pruned != 1 {
|
||||
t.Errorf("expected 1 pruned, got %d", pruned)
|
||||
}
|
||||
|
||||
info, _ := store.Get("old")
|
||||
|
||||
if info != nil {
|
||||
t.Error("expected old session to be pruned")
|
||||
}
|
||||
|
||||
info, _ = store.Get("new")
|
||||
|
||||
if info == nil {
|
||||
t.Error("expected new session to survive prune")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_MessagesRoundTrip(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
store.Create("s1", nil)
|
||||
|
||||
msgs := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
|
||||
{
|
||||
Role: "assistant",
|
||||
|
||||
Content: "sure",
|
||||
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
|
||||
Type: "function",
|
||||
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "exec",
|
||||
|
||||
Arguments: map[string]any{"cmd": "ls"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{Role: "tool", Content: "file1\nfile2", ToolCallID: "call_1"},
|
||||
|
||||
{Role: "assistant", Content: "done"},
|
||||
}
|
||||
|
||||
store.Append("s1", &Turn{Messages: msgs})
|
||||
|
||||
turns, _ := store.Turns("s1", 0)
|
||||
|
||||
if len(turns) != 1 {
|
||||
t.Fatalf("expected 1 turn, got %d", len(turns))
|
||||
}
|
||||
|
||||
got := turns[0].Messages
|
||||
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("expected 4 messages, got %d", len(got))
|
||||
}
|
||||
|
||||
// Check tool call round-trip
|
||||
|
||||
if got[1].ToolCalls[0].ID != "call_1" {
|
||||
t.Errorf("tool call ID mismatch: %s", got[1].ToolCalls[0].ID)
|
||||
}
|
||||
|
||||
if got[1].ToolCalls[0].Function.Name != "exec" {
|
||||
t.Errorf("tool call function name mismatch: %s", got[1].ToolCalls[0].Function.Name)
|
||||
}
|
||||
|
||||
if got[1].ToolCalls[0].Function.Arguments["cmd"] != "ls" {
|
||||
t.Errorf("tool call arguments mismatch: %v", got[1].ToolCalls[0].Function.Arguments)
|
||||
}
|
||||
|
||||
if got[2].ToolCallID != "call_1" {
|
||||
t.Errorf("tool call ID mismatch on result: %s", got[2].ToolCallID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLite_CascadeDelete(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
store.Create("s1", nil)
|
||||
|
||||
store.Append("s1", &Turn{Messages: []providers.Message{{Role: "user", Content: "a"}}})
|
||||
|
||||
store.Append("s1", &Turn{Messages: []providers.Message{{Role: "user", Content: "b"}}})
|
||||
|
||||
count, _ := store.TurnCount("s1")
|
||||
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 turns before delete, got %d", count)
|
||||
}
|
||||
|
||||
store.Delete("s1")
|
||||
|
||||
count, _ = store.TurnCount("s1")
|
||||
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 turns after cascade delete, got %d", count)
|
||||
}
|
||||
}
|
||||
31
pkg/session/store.go
Normal file
31
pkg/session/store.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package session
|
||||
|
||||
import "time"
|
||||
|
||||
// SessionStore is the storage interface for sessions and turns.
|
||||
// Phase 0 provides a SQLite implementation; LegacyAdapter wraps it to
|
||||
// expose the same API as SessionManager.
|
||||
type SessionStore interface { //nolint:interfacebloat // storage facade — methods are logically grouped
|
||||
// Session CRUD
|
||||
Create(key string, opts *CreateOpts) error
|
||||
Get(key string) (*SessionInfo, error)
|
||||
List(filter *ListFilter) ([]*SessionInfo, error)
|
||||
SetStatus(key, status string) error
|
||||
SetSummary(key, summary string) error
|
||||
Delete(key string) error
|
||||
Children(key string) ([]*SessionInfo, error)
|
||||
|
||||
// Turn operations
|
||||
Append(sessionKey string, turn *Turn) error
|
||||
Turns(sessionKey string, sinceSeq int) ([]*Turn, error)
|
||||
LastTurn(sessionKey string) (*Turn, error)
|
||||
TurnCount(sessionKey string) (int, error)
|
||||
Compact(sessionKey string, upToSeq int, summary string) error
|
||||
|
||||
// DAG operations
|
||||
Fork(parentKey, childKey string, opts *CreateOpts) error
|
||||
|
||||
// Maintenance
|
||||
Prune(olderThan time.Duration) (int, error)
|
||||
Close() error
|
||||
}
|
||||
93
pkg/session/types.go
Normal file
93
pkg/session/types.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// TurnKind classifies a turn within a session.
|
||||
|
||||
type TurnKind int
|
||||
|
||||
const (
|
||||
TurnNormal TurnKind = iota // Regular conversation turn
|
||||
|
||||
TurnReport // Subagent report turn
|
||||
|
||||
TurnForkPoint // Fork point for child sessions
|
||||
|
||||
)
|
||||
|
||||
// Escalation turn kinds — explicit values to keep stable across versions.
|
||||
|
||||
const (
|
||||
TurnQuestion TurnKind = 10 // Subagent → conductor question (escalation)
|
||||
|
||||
TurnPlanSubmit TurnKind = 11 // Subagent plan submission for review
|
||||
|
||||
)
|
||||
|
||||
// Turn represents a single conversation turn persisted in the store.
|
||||
|
||||
type Turn struct {
|
||||
ID string
|
||||
|
||||
SessionKey string
|
||||
|
||||
OriginKey string
|
||||
|
||||
Summary string
|
||||
|
||||
Author string
|
||||
|
||||
Seq int
|
||||
|
||||
Kind TurnKind
|
||||
|
||||
Messages []providers.Message
|
||||
|
||||
CreatedAt time.Time
|
||||
|
||||
Meta map[string]string
|
||||
}
|
||||
|
||||
// SessionInfo holds metadata about a session.
|
||||
|
||||
type SessionInfo struct {
|
||||
Key string
|
||||
|
||||
ParentKey string
|
||||
|
||||
ForkTurnID string
|
||||
|
||||
Status string
|
||||
|
||||
Label string
|
||||
|
||||
Summary string
|
||||
|
||||
TurnCount int
|
||||
|
||||
CreatedAt time.Time
|
||||
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateOpts are options for creating a new session.
|
||||
|
||||
type CreateOpts struct {
|
||||
ParentKey string
|
||||
|
||||
ForkTurnID string
|
||||
|
||||
Label string
|
||||
}
|
||||
|
||||
// ListFilter constrains which sessions are returned by List.
|
||||
|
||||
type ListFilter struct {
|
||||
ParentKey string
|
||||
|
||||
Status string
|
||||
}
|
||||
138
pkg/tools/answer_subagent.go
Normal file
138
pkg/tools/answer_subagent.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AnswerSubagentTool allows the conductor to answer a subagent's question.
|
||||
|
||||
type AnswerSubagentTool struct {
|
||||
manager *SubagentManager
|
||||
}
|
||||
|
||||
func NewAnswerSubagentTool(manager *SubagentManager) *AnswerSubagentTool {
|
||||
return &AnswerSubagentTool{manager: manager}
|
||||
}
|
||||
|
||||
func (t *AnswerSubagentTool) Name() string { return "answer_subagent" }
|
||||
|
||||
func (t *AnswerSubagentTool) Description() string {
|
||||
return "Answer a subagent's question or escalation. The subagent is blocked waiting for your response."
|
||||
}
|
||||
|
||||
func (t *AnswerSubagentTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"task_id": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "The task ID of the subagent (e.g. subagent-1)",
|
||||
},
|
||||
|
||||
"answer": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Your answer to the subagent's question",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"task_id", "answer"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AnswerSubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
taskID, _ := args["task_id"].(string)
|
||||
|
||||
if taskID == "" {
|
||||
return ErrorResult("required parameter \"task_id\" (string) is missing")
|
||||
}
|
||||
|
||||
answer, _ := args["answer"].(string)
|
||||
|
||||
if answer == "" {
|
||||
return ErrorResult("required parameter \"answer\" (string) is missing")
|
||||
}
|
||||
|
||||
if t.manager == nil {
|
||||
return ErrorResult("subagent manager not available")
|
||||
}
|
||||
|
||||
if err := t.manager.AnswerQuestion(taskID, answer); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to answer subagent: %v", err))
|
||||
}
|
||||
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("Answer sent to %s.", taskID),
|
||||
|
||||
ForUser: fmt.Sprintf("Answered %s", taskID),
|
||||
}
|
||||
}
|
||||
|
||||
// ReviewSubagentPlanTool allows the conductor to approve/reject a subagent's plan.
|
||||
|
||||
type ReviewSubagentPlanTool struct {
|
||||
manager *SubagentManager
|
||||
}
|
||||
|
||||
func NewReviewSubagentPlanTool(manager *SubagentManager) *ReviewSubagentPlanTool {
|
||||
return &ReviewSubagentPlanTool{manager: manager}
|
||||
}
|
||||
|
||||
func (t *ReviewSubagentPlanTool) Name() string { return "review_subagent_plan" }
|
||||
|
||||
func (t *ReviewSubagentPlanTool) Description() string {
|
||||
return "Approve or reject a subagent's execution plan. Use decision 'approved' to approve, or provide rejection feedback."
|
||||
}
|
||||
|
||||
func (t *ReviewSubagentPlanTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"task_id": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "The task ID of the subagent (e.g. subagent-1)",
|
||||
},
|
||||
|
||||
"decision": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Decision: 'approved' to approve, or rejection feedback text",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"task_id", "decision"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ReviewSubagentPlanTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
taskID, _ := args["task_id"].(string)
|
||||
|
||||
if taskID == "" {
|
||||
return ErrorResult("required parameter \"task_id\" (string) is missing")
|
||||
}
|
||||
|
||||
decision, _ := args["decision"].(string)
|
||||
|
||||
if decision == "" {
|
||||
return ErrorResult("required parameter \"decision\" (string) is missing")
|
||||
}
|
||||
|
||||
if t.manager == nil {
|
||||
return ErrorResult("subagent manager not available")
|
||||
}
|
||||
|
||||
if err := t.manager.AnswerQuestion(taskID, decision); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to send review decision: %v", err))
|
||||
}
|
||||
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("Review decision '%s' sent to %s.", decision, taskID),
|
||||
|
||||
ForUser: fmt.Sprintf("Reviewed %s: %s", taskID, decision),
|
||||
}
|
||||
}
|
||||
110
pkg/tools/ask_conductor.go
Normal file
110
pkg/tools/ask_conductor.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AskConductorTool allows a subagent to ask the conductor a question.
|
||||
|
||||
// The subagent blocks until the conductor answers via AnswerSubagentTool.
|
||||
|
||||
type AskConductorTool struct {
|
||||
taskID string
|
||||
|
||||
conductorKey string
|
||||
|
||||
subagentKey string
|
||||
|
||||
outCh chan<- ContainerMessage
|
||||
|
||||
inCh <-chan string
|
||||
|
||||
recorder SessionRecorder
|
||||
}
|
||||
|
||||
func NewAskConductorTool(
|
||||
taskID, conductorKey, subagentKey string,
|
||||
|
||||
outCh chan<- ContainerMessage,
|
||||
|
||||
inCh <-chan string,
|
||||
|
||||
recorder SessionRecorder,
|
||||
) *AskConductorTool {
|
||||
return &AskConductorTool{
|
||||
taskID: taskID,
|
||||
|
||||
conductorKey: conductorKey,
|
||||
|
||||
subagentKey: subagentKey,
|
||||
|
||||
outCh: outCh,
|
||||
|
||||
inCh: inCh,
|
||||
|
||||
recorder: recorder,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AskConductorTool) Name() string { return "ask_conductor" }
|
||||
|
||||
func (t *AskConductorTool) Description() string {
|
||||
return "Ask the conductor a clarifying question. Blocks until the conductor responds. Use when you need guidance or a decision before proceeding."
|
||||
}
|
||||
|
||||
func (t *AskConductorTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"question": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "The question to ask the conductor",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"question"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AskConductorTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
question, ok := args["question"].(string)
|
||||
|
||||
if !ok || question == "" {
|
||||
return ErrorResult("required parameter \"question\" (string) is missing")
|
||||
}
|
||||
|
||||
// Fire-and-forget: record question in session DAG.
|
||||
|
||||
if t.recorder != nil {
|
||||
_ = t.recorder.RecordQuestion(t.conductorKey, t.subagentKey, t.taskID, question)
|
||||
}
|
||||
|
||||
// Send question to conductor (blocking with ctx).
|
||||
|
||||
select {
|
||||
case t.outCh <- ContainerMessage{Type: "question", Content: question, TaskID: t.taskID}:
|
||||
|
||||
case <-ctx.Done():
|
||||
|
||||
return ErrorResult(fmt.Sprintf("context canceled while sending question: %v", ctx.Err()))
|
||||
}
|
||||
|
||||
// Wait for conductor's answer.
|
||||
|
||||
select {
|
||||
case answer := <-t.inCh:
|
||||
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("Conductor answered: %s", answer),
|
||||
|
||||
ForUser: answer,
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
|
||||
return ErrorResult(fmt.Sprintf("context canceled while waiting for answer: %v", ctx.Err()))
|
||||
}
|
||||
}
|
||||
77
pkg/tools/ask_conductor_test.go
Normal file
77
pkg/tools/ask_conductor_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAskConductorTool_Execute(t *testing.T) {
|
||||
outCh := make(chan ContainerMessage, 4)
|
||||
|
||||
inCh := make(chan string, 1)
|
||||
|
||||
tool := NewAskConductorTool("subagent-1", "conductor:main", "subagent:subagent-1", outCh, inCh, nil)
|
||||
|
||||
if tool.Name() != "ask_conductor" {
|
||||
t.Errorf("Name() = %q, want %q", tool.Name(), "ask_conductor")
|
||||
}
|
||||
|
||||
// Simulate conductor answering in background.
|
||||
|
||||
go func() {
|
||||
msg := <-outCh
|
||||
|
||||
if msg.Type != "question" {
|
||||
t.Errorf("msg.Type = %q, want %q", msg.Type, "question")
|
||||
}
|
||||
|
||||
if msg.Content != "What port?" {
|
||||
t.Errorf("msg.Content = %q, want %q", msg.Content, "What port?")
|
||||
}
|
||||
|
||||
inCh <- "Use port 8080"
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
|
||||
defer cancel()
|
||||
|
||||
result := tool.Execute(ctx, map[string]any{"question": "What port?"})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if result.ForUser != "Use port 8080" {
|
||||
t.Errorf("ForUser = %q, want %q", result.ForUser, "Use port 8080")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskConductorTool_MissingQuestion(t *testing.T) {
|
||||
tool := NewAskConductorTool("subagent-1", "conductor:main", "subagent:subagent-1", nil, nil, nil)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{})
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("expected error for missing question")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskConductorTool_ContextCanceled(t *testing.T) {
|
||||
outCh := make(chan ContainerMessage) // unbuffered, will block
|
||||
|
||||
inCh := make(chan string)
|
||||
|
||||
tool := NewAskConductorTool("subagent-1", "conductor:main", "subagent:subagent-1", outCh, inCh, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
cancel() // cancel immediately
|
||||
|
||||
result := tool.Execute(ctx, map[string]any{"question": "test?"})
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("expected error on canceled context")
|
||||
}
|
||||
}
|
||||
|
|
@ -11,16 +11,20 @@ import (
|
|||
|
||||
const (
|
||||
bgWatchPollInterval = 100 * time.Millisecond
|
||||
|
||||
bgWatchDefaultTimeout = 30 * time.Second
|
||||
|
||||
bgTailDefaultLines = 20
|
||||
)
|
||||
|
||||
// BgMonitorTool monitors and inspects background processes managed by ExecTool.
|
||||
|
||||
type BgMonitorTool struct {
|
||||
exec *ExecTool
|
||||
}
|
||||
|
||||
// NewBgMonitorTool creates a new BgMonitorTool that accesses bg processes from the given ExecTool.
|
||||
|
||||
func NewBgMonitorTool(exec *ExecTool) *BgMonitorTool {
|
||||
return &BgMonitorTool{exec: exec}
|
||||
}
|
||||
|
|
@ -36,77 +40,109 @@ func (t *BgMonitorTool) Description() string {
|
|||
func (t *BgMonitorTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"enum": []string{"list", "watch", "tail"},
|
||||
|
||||
"description": "Action: 'list' all bg processes, 'watch' for a pattern in output, 'tail' recent output lines.",
|
||||
},
|
||||
|
||||
"bg_id": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Background process ID (e.g. 'bg-1'). Required for watch and tail.",
|
||||
},
|
||||
|
||||
"pattern": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Regex pattern to watch for in output (used with action='watch').",
|
||||
},
|
||||
|
||||
"lines": map[string]any{
|
||||
"type": "number",
|
||||
|
||||
"description": "Number of recent lines to return (used with action='tail', default 20).",
|
||||
},
|
||||
|
||||
"watch_timeout": map[string]any{
|
||||
"type": "number",
|
||||
|
||||
"description": "Timeout in seconds for watch action (default 30).",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"action"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BgMonitorTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
action, _ := args["action"].(string)
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
|
||||
return t.actionList()
|
||||
|
||||
case "watch":
|
||||
|
||||
return t.actionWatch(ctx, args)
|
||||
|
||||
case "tail":
|
||||
|
||||
return t.actionTail(args)
|
||||
|
||||
default:
|
||||
|
||||
return ErrorResult(fmt.Sprintf("unknown action %q (use 'list', 'watch', or 'tail')", action))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BgMonitorTool) actionList() *ToolResult {
|
||||
procs := t.exec.BgProcesses()
|
||||
|
||||
if len(procs) == 0 {
|
||||
return &ToolResult{
|
||||
ForLLM: "No background processes.",
|
||||
|
||||
ForUser: "No background processes.",
|
||||
}
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(procs))
|
||||
|
||||
for id := range procs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
sort.Strings(ids)
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("Background Processes:\n\n")
|
||||
|
||||
for _, id := range ids {
|
||||
bp := procs[id]
|
||||
|
||||
if bp.isRunning() {
|
||||
uptime := time.Since(bp.startedAt).Truncate(time.Second)
|
||||
|
||||
fmt.Fprintf(&sb, " [%s] pid=%d running (uptime: %s, max: %s) %s\n",
|
||||
|
||||
id, bp.pid, uptime, getBgMaxLifetime(), bp.command)
|
||||
} else {
|
||||
ran := time.Since(bp.startedAt).Truncate(time.Second)
|
||||
|
||||
if bp.exitErr != nil {
|
||||
fmt.Fprintf(&sb, " [%s] pid=%d exited=err (ran: %s) %s\n",
|
||||
|
||||
id, bp.pid, ran, bp.command)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, " [%s] pid=%d exited=0 (ran: %s) %s\n",
|
||||
|
||||
id, bp.pid, ran, bp.command)
|
||||
}
|
||||
}
|
||||
|
|
@ -114,17 +150,20 @@ func (t *BgMonitorTool) actionList() *ToolResult {
|
|||
|
||||
return &ToolResult{
|
||||
ForLLM: sb.String(),
|
||||
|
||||
ForUser: sb.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BgMonitorTool) actionWatch(ctx context.Context, args map[string]any) *ToolResult {
|
||||
bgID, _ := args["bg_id"].(string)
|
||||
|
||||
if bgID == "" {
|
||||
return ErrorResult("bg_id is required for watch action")
|
||||
}
|
||||
|
||||
patternStr, _ := args["pattern"].(string)
|
||||
|
||||
if patternStr == "" {
|
||||
return ErrorResult("pattern is required for watch action")
|
||||
}
|
||||
|
|
@ -135,64 +174,93 @@ func (t *BgMonitorTool) actionWatch(ctx context.Context, args map[string]any) *T
|
|||
}
|
||||
|
||||
timeout := bgWatchDefaultTimeout
|
||||
|
||||
if t, ok := args["watch_timeout"].(float64); ok && t > 0 {
|
||||
timeout = time.Duration(t) * time.Second
|
||||
}
|
||||
|
||||
procs := t.exec.BgProcesses()
|
||||
|
||||
bp, ok := procs[bgID]
|
||||
|
||||
if !ok {
|
||||
return ErrorResult(fmt.Sprintf("background process %q not found", bgID))
|
||||
}
|
||||
|
||||
deadline := time.After(timeout)
|
||||
|
||||
ticker := time.NewTicker(bgWatchPollInterval)
|
||||
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
// Check for pattern match
|
||||
|
||||
if match := bp.output.Match(pattern); match != "" {
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("Match found in [%s]: %s", bgID, match),
|
||||
|
||||
ForUser: fmt.Sprintf("Match found in [%s]: %s", bgID, match),
|
||||
}
|
||||
}
|
||||
|
||||
// Check if process exited
|
||||
|
||||
if !bp.isRunning() {
|
||||
output := bp.output.String()
|
||||
|
||||
tail := lastNLines(output, 10)
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "Process %s exited before pattern matched.\n", bgID)
|
||||
|
||||
if bp.exitErr != nil {
|
||||
fmt.Fprintf(&sb, "Exit: %v\n", bp.exitErr)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "Exit: 0\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "\nLast output:\n%s", tail)
|
||||
|
||||
return &ToolResult{
|
||||
ForLLM: sb.String(),
|
||||
|
||||
ForUser: sb.String(),
|
||||
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-deadline:
|
||||
|
||||
// Timeout
|
||||
|
||||
output := bp.output.String()
|
||||
|
||||
tail := lastNLines(output, 10)
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "Watch timed out after %s waiting for pattern %q in [%s].\n", timeout, patternStr, bgID)
|
||||
|
||||
fmt.Fprintf(&sb, "\nLast output:\n%s", tail)
|
||||
|
||||
return &ToolResult{
|
||||
ForLLM: sb.String(),
|
||||
|
||||
ForUser: sb.String(),
|
||||
|
||||
IsError: true,
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
|
||||
return ErrorResult("watch canceled")
|
||||
|
||||
case <-ticker.C:
|
||||
|
||||
// Continue polling
|
||||
}
|
||||
}
|
||||
|
|
@ -200,17 +268,21 @@ func (t *BgMonitorTool) actionWatch(ctx context.Context, args map[string]any) *T
|
|||
|
||||
func (t *BgMonitorTool) actionTail(args map[string]any) *ToolResult {
|
||||
bgID, _ := args["bg_id"].(string)
|
||||
|
||||
if bgID == "" {
|
||||
return ErrorResult("bg_id is required for tail action")
|
||||
}
|
||||
|
||||
n := bgTailDefaultLines
|
||||
|
||||
if lines, ok := args["lines"].(float64); ok && lines > 0 {
|
||||
n = int(lines)
|
||||
}
|
||||
|
||||
procs := t.exec.BgProcesses()
|
||||
|
||||
bp, ok := procs[bgID]
|
||||
|
||||
if !ok {
|
||||
return ErrorResult(fmt.Sprintf("background process %q not found", bgID))
|
||||
}
|
||||
|
|
@ -218,7 +290,9 @@ func (t *BgMonitorTool) actionTail(args map[string]any) *ToolResult {
|
|||
lines := bp.output.Lines(n)
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "[%s] pid=%d %s\n", bp.id, bp.pid, bp.command)
|
||||
|
||||
if bp.isRunning() {
|
||||
fmt.Fprintf(&sb, "Status: running\n")
|
||||
} else {
|
||||
|
|
@ -228,7 +302,9 @@ func (t *BgMonitorTool) actionTail(args map[string]any) *ToolResult {
|
|||
fmt.Fprintf(&sb, "Status: exited=0\n")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "\nLast %d lines:\n", n)
|
||||
|
||||
for _, line := range lines {
|
||||
fmt.Fprintf(&sb, "%s\n", line)
|
||||
}
|
||||
|
|
@ -239,18 +315,23 @@ func (t *BgMonitorTool) actionTail(args map[string]any) *ToolResult {
|
|||
|
||||
return &ToolResult{
|
||||
ForLLM: sb.String(),
|
||||
|
||||
ForUser: sb.String(),
|
||||
}
|
||||
}
|
||||
|
||||
// lastNLines returns the last n lines from a string.
|
||||
|
||||
func lastNLines(s string, n int) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
|
||||
if n >= len(lines) {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
return strings.Join(lines[len(lines)-n:], "\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,64 +10,83 @@ import (
|
|||
|
||||
func TestBgMonitor_List(t *testing.T) {
|
||||
tool, _ := NewExecTool("", false)
|
||||
|
||||
monitor := NewBgMonitorTool(tool)
|
||||
|
||||
// List with no processes
|
||||
|
||||
result := monitor.Execute(context.Background(), map[string]any{"action": "list"})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "No background") {
|
||||
t.Errorf("expected 'No background' message, got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Start two bg processes
|
||||
|
||||
var cmd1, cmd2 string
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd1 = "Start-Sleep -Seconds 30"
|
||||
|
||||
cmd2 = "Start-Sleep -Seconds 30"
|
||||
} else {
|
||||
cmd1 = "sleep 30"
|
||||
|
||||
cmd2 = "sleep 30"
|
||||
}
|
||||
|
||||
r1 := tool.Execute(context.Background(), map[string]any{
|
||||
"command": cmd1,
|
||||
|
||||
"background": true,
|
||||
})
|
||||
|
||||
if r1.IsError {
|
||||
t.Fatalf("failed to start bg-1: %s", r1.ForLLM)
|
||||
}
|
||||
|
||||
r2 := tool.Execute(context.Background(), map[string]any{
|
||||
"command": cmd2,
|
||||
|
||||
"background": true,
|
||||
})
|
||||
|
||||
if r2.IsError {
|
||||
t.Fatalf("failed to start bg-2: %s", r2.ForLLM)
|
||||
}
|
||||
|
||||
// List should show both
|
||||
|
||||
result = monitor.Execute(context.Background(), map[string]any{"action": "list"})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "bg-1") {
|
||||
t.Errorf("expected bg-1 in list, got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "bg-2") {
|
||||
t.Errorf("expected bg-2 in list, got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
|
||||
tool.Shutdown()
|
||||
}
|
||||
|
||||
func TestBgMonitor_Watch_Match(t *testing.T) {
|
||||
tool, _ := NewExecTool("", false)
|
||||
|
||||
monitor := NewBgMonitorTool(tool)
|
||||
|
||||
var cmd string
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = "Write-Output 'Server ready on port 3000'; Start-Sleep -Seconds 30"
|
||||
} else {
|
||||
|
|
@ -76,25 +95,34 @@ func TestBgMonitor_Watch_Match(t *testing.T) {
|
|||
|
||||
r := tool.Execute(context.Background(), map[string]any{
|
||||
"command": cmd,
|
||||
|
||||
"background": true,
|
||||
})
|
||||
|
||||
if r.IsError {
|
||||
t.Fatalf("failed to start bg: %s", r.ForLLM)
|
||||
}
|
||||
|
||||
// Watch for "ready" pattern — should match quickly
|
||||
|
||||
result := monitor.Execute(context.Background(), map[string]any{
|
||||
"action": "watch",
|
||||
|
||||
"bg_id": "bg-1",
|
||||
|
||||
"pattern": "ready",
|
||||
|
||||
"watch_timeout": float64(10),
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected watch to match, got error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "Match found") {
|
||||
t.Errorf("expected 'Match found' message, got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "ready") {
|
||||
t.Errorf("expected match to contain 'ready', got: %s", result.ForLLM)
|
||||
}
|
||||
|
|
@ -104,9 +132,11 @@ func TestBgMonitor_Watch_Match(t *testing.T) {
|
|||
|
||||
func TestBgMonitor_Watch_Timeout(t *testing.T) {
|
||||
tool, _ := NewExecTool("", false)
|
||||
|
||||
monitor := NewBgMonitorTool(tool)
|
||||
|
||||
var cmd string
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = "Start-Sleep -Seconds 30"
|
||||
} else {
|
||||
|
|
@ -115,22 +145,30 @@ func TestBgMonitor_Watch_Timeout(t *testing.T) {
|
|||
|
||||
r := tool.Execute(context.Background(), map[string]any{
|
||||
"command": cmd,
|
||||
|
||||
"background": true,
|
||||
})
|
||||
|
||||
if r.IsError {
|
||||
t.Fatalf("failed to start bg: %s", r.ForLLM)
|
||||
}
|
||||
|
||||
// Watch for a pattern that won't appear, with short timeout
|
||||
|
||||
result := monitor.Execute(context.Background(), map[string]any{
|
||||
"action": "watch",
|
||||
|
||||
"bg_id": "bg-1",
|
||||
|
||||
"pattern": "never_going_to_match",
|
||||
|
||||
"watch_timeout": float64(1),
|
||||
})
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatalf("expected watch to timeout with error, got success: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "timed out") {
|
||||
t.Errorf("expected 'timed out' message, got: %s", result.ForLLM)
|
||||
}
|
||||
|
|
@ -140,9 +178,11 @@ func TestBgMonitor_Watch_Timeout(t *testing.T) {
|
|||
|
||||
func TestBgMonitor_Watch_ProcessExit(t *testing.T) {
|
||||
tool, _ := NewExecTool("", false)
|
||||
|
||||
monitor := NewBgMonitorTool(tool)
|
||||
|
||||
var cmd string
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = "Write-Output 'done quickly'"
|
||||
} else {
|
||||
|
|
@ -151,25 +191,34 @@ func TestBgMonitor_Watch_ProcessExit(t *testing.T) {
|
|||
|
||||
r := tool.Execute(context.Background(), map[string]any{
|
||||
"command": cmd,
|
||||
|
||||
"background": true,
|
||||
})
|
||||
|
||||
if r.IsError {
|
||||
t.Fatalf("failed to start bg: %s", r.ForLLM)
|
||||
}
|
||||
|
||||
// Wait a bit for the process to exit
|
||||
|
||||
time.Sleep(4 * time.Second)
|
||||
|
||||
// Watch for a pattern that doesn't match — process should have exited
|
||||
|
||||
result := monitor.Execute(context.Background(), map[string]any{
|
||||
"action": "watch",
|
||||
|
||||
"bg_id": "bg-1",
|
||||
|
||||
"pattern": "never_match",
|
||||
|
||||
"watch_timeout": float64(5),
|
||||
})
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatalf("expected error when process exits, got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "exited") {
|
||||
t.Errorf("expected 'exited' message, got: %s", result.ForLLM)
|
||||
}
|
||||
|
|
@ -179,9 +228,11 @@ func TestBgMonitor_Watch_ProcessExit(t *testing.T) {
|
|||
|
||||
func TestBgMonitor_Tail(t *testing.T) {
|
||||
tool, _ := NewExecTool("", false)
|
||||
|
||||
monitor := NewBgMonitorTool(tool)
|
||||
|
||||
var cmd string
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = "1..5 | ForEach-Object { Write-Output \"line $_\" }; Start-Sleep -Seconds 30"
|
||||
} else {
|
||||
|
|
@ -190,24 +241,32 @@ func TestBgMonitor_Tail(t *testing.T) {
|
|||
|
||||
r := tool.Execute(context.Background(), map[string]any{
|
||||
"command": cmd,
|
||||
|
||||
"background": true,
|
||||
})
|
||||
|
||||
if r.IsError {
|
||||
t.Fatalf("failed to start bg: %s", r.ForLLM)
|
||||
}
|
||||
|
||||
// Wait for initial output to be captured
|
||||
|
||||
time.Sleep(4 * time.Second)
|
||||
|
||||
// Tail last 3 lines
|
||||
|
||||
result := monitor.Execute(context.Background(), map[string]any{
|
||||
"action": "tail",
|
||||
|
||||
"bg_id": "bg-1",
|
||||
|
||||
"lines": float64(3),
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "line 5") {
|
||||
t.Errorf("expected tail to contain 'line 5', got: %s", result.ForLLM)
|
||||
}
|
||||
|
|
@ -217,12 +276,15 @@ func TestBgMonitor_Tail(t *testing.T) {
|
|||
|
||||
func TestBgMonitor_InvalidAction(t *testing.T) {
|
||||
tool, _ := NewExecTool("", false)
|
||||
|
||||
monitor := NewBgMonitorTool(tool)
|
||||
|
||||
result := monitor.Execute(context.Background(), map[string]any{"action": "invalid"})
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatalf("expected error for invalid action")
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "unknown action") {
|
||||
t.Errorf("expected 'unknown action' message, got: %s", result.ForLLM)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,27 +10,42 @@ import (
|
|||
|
||||
const (
|
||||
ciPollInterval = 30 * time.Second
|
||||
|
||||
ciPollTimeout = 15 * time.Minute
|
||||
)
|
||||
|
||||
// CreatePRTool creates a GitHub pull request from the current worktree branch.
|
||||
|
||||
//
|
||||
|
||||
// Safety invariants:
|
||||
|
||||
// - Only works inside a worktree (WorktreeInfo must be in context)
|
||||
|
||||
// - Base branch is auto-detected from WorktreeInfo.BaseBranch
|
||||
|
||||
// - Requires the branch to be already pushed (use git_push first)
|
||||
|
||||
// - Checks for merge conflicts with base before creating
|
||||
|
||||
// - Uses `gh pr create` under the hood
|
||||
|
||||
//
|
||||
|
||||
// Async behavior:
|
||||
|
||||
// - PR creation itself is synchronous and returns immediately with the PR URL
|
||||
|
||||
// - If CI runs are triggered, a background goroutine polls `gh pr checks`
|
||||
|
||||
// and calls the AsyncCallback when CI completes (pass or fail)
|
||||
|
||||
type CreatePRTool struct {
|
||||
callback AsyncCallback
|
||||
}
|
||||
|
||||
// NewCreatePRTool creates a CreatePRTool.
|
||||
|
||||
func NewCreatePRTool() *CreatePRTool {
|
||||
return &CreatePRTool{}
|
||||
}
|
||||
|
|
@ -38,122 +53,187 @@ func NewCreatePRTool() *CreatePRTool {
|
|||
func (t *CreatePRTool) Name() string { return "create_pr" }
|
||||
|
||||
// SetCallback implements AsyncTool for CI completion notification.
|
||||
|
||||
func (t *CreatePRTool) SetCallback(cb AsyncCallback) {
|
||||
t.callback = cb
|
||||
}
|
||||
|
||||
func (t *CreatePRTool) Description() string {
|
||||
return "Create a GitHub pull request from the current worktree branch. " +
|
||||
|
||||
"The base branch is auto-detected from the worktree's parent branch. " +
|
||||
|
||||
"The branch must be pushed to origin first (use git_push). " +
|
||||
|
||||
"Checks for merge conflicts with the base branch before creating. " +
|
||||
|
||||
"After PR creation, polls CI status in the background and notifies when complete. " +
|
||||
|
||||
"Requires the `gh` CLI to be installed and authenticated."
|
||||
}
|
||||
|
||||
func (t *CreatePRTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"title": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Pull request title",
|
||||
},
|
||||
|
||||
"body": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Pull request body/description (supports markdown)",
|
||||
},
|
||||
|
||||
"draft": map[string]any{
|
||||
"type": "boolean",
|
||||
|
||||
"description": "Create as draft PR (default: false)",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"title"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *CreatePRTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
wt := WorktreeInfoFromCtx(ctx)
|
||||
|
||||
if wt == nil {
|
||||
return ErrorResult(
|
||||
|
||||
"create_pr requires an active worktree.\n" +
|
||||
|
||||
"This tool can only be used during worktree-based sessions " +
|
||||
|
||||
"(e.g., heartbeat tasks or plan executing phase).\n" +
|
||||
|
||||
"The worktree provides the branch name and base branch for the PR.")
|
||||
}
|
||||
|
||||
branch := wt.Branch
|
||||
|
||||
if branch == "" {
|
||||
return ErrorResult(
|
||||
|
||||
"worktree has no branch name.\n" +
|
||||
|
||||
"The WorktreeInfo was set but Branch is empty. " +
|
||||
|
||||
"This is an internal error — the worktree may not have been created correctly.")
|
||||
}
|
||||
|
||||
baseBranch := wt.BaseBranch
|
||||
|
||||
if baseBranch == "" {
|
||||
baseBranch = "main"
|
||||
}
|
||||
|
||||
title, ok := args["title"].(string)
|
||||
|
||||
if !ok || strings.TrimSpace(title) == "" {
|
||||
return ErrorResult(
|
||||
|
||||
"title is required.\n" +
|
||||
|
||||
"Provide a concise PR title describing the change (e.g., \"Add rate limiter to API endpoints\").")
|
||||
}
|
||||
|
||||
// Verify the branch has been pushed by checking if the remote ref exists
|
||||
|
||||
checkCtx, checkCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
|
||||
defer checkCancel()
|
||||
|
||||
checkCmd := exec.CommandContext(checkCtx, "git", "ls-remote", "--exit-code", "origin", branch)
|
||||
|
||||
checkCmd.Dir = wt.Path
|
||||
|
||||
if err := checkCmd.Run(); err != nil {
|
||||
return ErrorResult(fmt.Sprintf(
|
||||
|
||||
"branch %q not found on origin.\n"+
|
||||
|
||||
"The branch must be pushed before creating a PR. Use the git_push tool first.\n"+
|
||||
|
||||
"git_push will auto-commit uncommitted changes and push the worktree branch to origin.",
|
||||
|
||||
branch))
|
||||
}
|
||||
|
||||
// Fetch latest base branch and check for merge conflicts
|
||||
|
||||
fetchCtx, fetchCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
|
||||
defer fetchCancel()
|
||||
|
||||
fetchCmd := exec.CommandContext(fetchCtx, "git", "fetch", "origin", baseBranch)
|
||||
|
||||
fetchCmd.Dir = wt.Path
|
||||
|
||||
if out, err := fetchCmd.CombinedOutput(); err != nil {
|
||||
return ErrorResult(fmt.Sprintf(
|
||||
|
||||
"failed to fetch origin/%s: %s\n%s\n"+
|
||||
|
||||
"Cannot verify merge compatibility without the latest base branch. "+
|
||||
|
||||
"Check network connectivity and that the base branch %q exists on origin.",
|
||||
|
||||
baseBranch, err, strings.TrimSpace(string(out)), baseBranch))
|
||||
}
|
||||
|
||||
// Try a merge dry-run to detect conflicts.
|
||||
|
||||
// merge-tree --write-tree is a plumbing command (Git 2.38+) that performs a
|
||||
|
||||
// three-way merge entirely in-memory without touching the working tree.
|
||||
|
||||
// Exit code 0 = clean merge, non-zero = conflicts detected.
|
||||
|
||||
mergeCtx, mergeCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
|
||||
defer mergeCancel()
|
||||
|
||||
mergeCmd := exec.CommandContext(mergeCtx, "git", "merge-tree",
|
||||
|
||||
"--write-tree", "--no-messages",
|
||||
|
||||
branch, "origin/"+baseBranch)
|
||||
|
||||
mergeCmd.Dir = wt.RepoRoot
|
||||
|
||||
mergeOut, mergeErr := mergeCmd.CombinedOutput()
|
||||
|
||||
if mergeErr != nil {
|
||||
conflictInfo := strings.TrimSpace(string(mergeOut))
|
||||
|
||||
return ErrorResult(fmt.Sprintf(
|
||||
|
||||
"merge conflict detected between %q and %s.\n"+
|
||||
|
||||
"The PR cannot be created cleanly. Resolve the conflicts in the worktree first, "+
|
||||
|
||||
"then use git_push to push the resolution before retrying create_pr.\n"+
|
||||
|
||||
"Conflict details:\n%s",
|
||||
|
||||
branch, baseBranch, conflictInfo))
|
||||
}
|
||||
|
||||
// Build gh pr create command
|
||||
|
||||
ghArgs := []string{
|
||||
"pr", "create",
|
||||
|
||||
"--base", baseBranch,
|
||||
|
||||
"--head", branch,
|
||||
|
||||
"--title", title,
|
||||
}
|
||||
|
||||
|
|
@ -168,92 +248,143 @@ func (t *CreatePRTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
|||
}
|
||||
|
||||
prCtx, prCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
|
||||
defer prCancel()
|
||||
|
||||
cmd := exec.CommandContext(prCtx, "gh", ghArgs...)
|
||||
|
||||
cmd.Dir = wt.RepoRoot
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
output := strings.TrimSpace(string(out))
|
||||
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf(
|
||||
|
||||
"gh pr create failed: %s\n%s\n"+
|
||||
|
||||
"Possible causes:\n"+
|
||||
|
||||
"- gh CLI not installed or not authenticated (run `gh auth login`)\n"+
|
||||
|
||||
"- A PR already exists for branch %q (check with `gh pr list`)\n"+
|
||||
|
||||
"- Repository not configured as a GitHub remote",
|
||||
|
||||
err, output, branch))
|
||||
}
|
||||
|
||||
prURL := output // gh pr create outputs the PR URL
|
||||
|
||||
// Start background CI polling if callback is set
|
||||
|
||||
if t.callback != nil && prURL != "" {
|
||||
cb := t.callback
|
||||
|
||||
repoRoot := wt.RepoRoot
|
||||
|
||||
go pollCIStatus(repoRoot, prURL, cb)
|
||||
}
|
||||
|
||||
return AsyncResult(fmt.Sprintf(
|
||||
|
||||
"Pull request created: %s\n"+
|
||||
|
||||
"Branch: %s -> %s\n"+
|
||||
|
||||
"CI status will be reported asynchronously when checks complete.",
|
||||
|
||||
prURL, branch, baseBranch))
|
||||
}
|
||||
|
||||
// pollCIStatus polls `gh pr checks` in the background until all checks
|
||||
|
||||
// pass, fail, or the timeout is reached. Reports back via AsyncCallback.
|
||||
|
||||
func pollCIStatus(repoRoot, prURL string, callback AsyncCallback) {
|
||||
// Detached context with hard timeout — this goroutine outlives the tool call.
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), ciPollTimeout)
|
||||
|
||||
defer cancel()
|
||||
|
||||
// Initial wait: CI runs take a few seconds to register after PR creation
|
||||
|
||||
select {
|
||||
case <-time.After(10 * time.Second):
|
||||
|
||||
case <-ctx.Done():
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(ciPollInterval)
|
||||
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
status, detail := checkPRChecks(ctx, repoRoot, prURL)
|
||||
|
||||
switch status {
|
||||
case ciStatusPass:
|
||||
|
||||
callback(ctx, NewToolResult(fmt.Sprintf(
|
||||
|
||||
"CI passed for %s\n%s",
|
||||
|
||||
prURL, detail)))
|
||||
|
||||
return
|
||||
|
||||
case ciStatusFail:
|
||||
|
||||
callback(ctx, &ToolResult{
|
||||
ForLLM: fmt.Sprintf(
|
||||
|
||||
"CI failed for %s\n%s\n"+
|
||||
|
||||
"Run `gh run view` for detailed logs.",
|
||||
|
||||
prURL, detail),
|
||||
|
||||
IsError: true,
|
||||
})
|
||||
|
||||
return
|
||||
|
||||
case ciStatusNone:
|
||||
|
||||
callback(ctx, NewToolResult(fmt.Sprintf(
|
||||
|
||||
"No CI checks configured for %s. PR is ready for review.",
|
||||
|
||||
prURL)))
|
||||
|
||||
return
|
||||
|
||||
case ciStatusPending:
|
||||
|
||||
// Still running, continue polling
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
|
||||
case <-ctx.Done():
|
||||
|
||||
callback(ctx, &ToolResult{
|
||||
ForLLM: fmt.Sprintf(
|
||||
|
||||
"CI polling timed out after %s for %s.\n"+
|
||||
|
||||
"Checks may still be running. Run `gh pr checks %s` to check.",
|
||||
|
||||
ciPollTimeout, prURL, prURL),
|
||||
|
||||
IsError: true,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -263,36 +394,51 @@ type ciStatus int
|
|||
|
||||
const (
|
||||
ciStatusPending ciStatus = iota
|
||||
|
||||
ciStatusPass
|
||||
|
||||
ciStatusFail
|
||||
|
||||
ciStatusNone
|
||||
)
|
||||
|
||||
// checkPRChecks runs `gh pr checks` and parses the result.
|
||||
|
||||
// Returns the aggregate status and raw output for the caller to include.
|
||||
|
||||
func checkPRChecks(ctx context.Context, repoRoot, prURL string) (ciStatus, string) {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(checkCtx, "gh", "pr", "checks", prURL)
|
||||
|
||||
cmd.Dir = repoRoot
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
output := strings.TrimSpace(string(out))
|
||||
|
||||
if err != nil {
|
||||
// gh pr checks exits 1 when any check has failed
|
||||
|
||||
if strings.Contains(output, "fail") || strings.Contains(output, "X ") {
|
||||
return ciStatusFail, output
|
||||
}
|
||||
|
||||
// "no checks" case
|
||||
|
||||
if strings.Contains(output, "no checks") || output == "" {
|
||||
return ciStatusNone, ""
|
||||
}
|
||||
|
||||
// Transient error or still pending — keep polling
|
||||
|
||||
return ciStatusPending, output
|
||||
}
|
||||
|
||||
// Exit 0: all checks completed. Check for pending.
|
||||
|
||||
if strings.Contains(output, "pending") || strings.Contains(output, "- ") {
|
||||
return ciStatusPending, output
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,191 +9,259 @@ import (
|
|||
)
|
||||
|
||||
// TestCreatePRTool_NoWorktree verifies that create_pr fails without worktree context.
|
||||
|
||||
func TestCreatePRTool_NoWorktree(t *testing.T) {
|
||||
tool := NewCreatePRTool()
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"title": "Test PR",
|
||||
})
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error when no worktree in context")
|
||||
}
|
||||
|
||||
assertContains(t, result.ForLLM, "worktree")
|
||||
|
||||
assertContains(t, result.ForLLM, "heartbeat")
|
||||
}
|
||||
|
||||
// TestCreatePRTool_EmptyBranch verifies that empty branch name is rejected.
|
||||
|
||||
func TestCreatePRTool_EmptyBranch(t *testing.T) {
|
||||
tool := NewCreatePRTool()
|
||||
|
||||
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
|
||||
Branch: "",
|
||||
|
||||
BaseBranch: "main",
|
||||
|
||||
Path: t.TempDir(),
|
||||
|
||||
RepoRoot: t.TempDir(),
|
||||
})
|
||||
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"title": "Test PR",
|
||||
})
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error for empty branch")
|
||||
}
|
||||
|
||||
assertContains(t, result.ForLLM, "no branch name")
|
||||
}
|
||||
|
||||
// TestCreatePRTool_MissingTitle verifies that missing title is rejected.
|
||||
|
||||
func TestCreatePRTool_MissingTitle(t *testing.T) {
|
||||
tool := NewCreatePRTool()
|
||||
|
||||
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
|
||||
Branch: "plan/test",
|
||||
|
||||
BaseBranch: "main",
|
||||
|
||||
Path: t.TempDir(),
|
||||
|
||||
RepoRoot: t.TempDir(),
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
args map[string]any
|
||||
}{
|
||||
{"no title key", map[string]any{}},
|
||||
|
||||
{"empty title", map[string]any{"title": ""}},
|
||||
|
||||
{"whitespace title", map[string]any{"title": " "}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tool.Execute(ctx, tt.args)
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error for missing/empty title")
|
||||
}
|
||||
|
||||
assertContains(t, result.ForLLM, "title is required")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreatePRTool_BranchNotPushed verifies the tool checks for remote branch existence.
|
||||
|
||||
func TestCreatePRTool_BranchNotPushed(t *testing.T) {
|
||||
tool := NewCreatePRTool()
|
||||
|
||||
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
|
||||
Branch: "plan/not-pushed",
|
||||
|
||||
BaseBranch: "main",
|
||||
|
||||
Path: t.TempDir(),
|
||||
|
||||
RepoRoot: t.TempDir(),
|
||||
})
|
||||
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"title": "Test PR",
|
||||
})
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error for unpushed branch")
|
||||
}
|
||||
|
||||
// Should mention git_push as the remedy
|
||||
|
||||
assertContains(t, result.ForLLM, "git_push")
|
||||
}
|
||||
|
||||
// TestCreatePRTool_DefaultBaseBranch verifies fallback to "main" when BaseBranch is empty.
|
||||
|
||||
func TestCreatePRTool_DefaultBaseBranch(t *testing.T) {
|
||||
tool := NewCreatePRTool()
|
||||
|
||||
// With empty BaseBranch, tool should default to "main"
|
||||
|
||||
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
|
||||
Branch: "plan/test",
|
||||
|
||||
BaseBranch: "",
|
||||
|
||||
Path: t.TempDir(),
|
||||
|
||||
RepoRoot: t.TempDir(),
|
||||
})
|
||||
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"title": "Test PR",
|
||||
})
|
||||
|
||||
// Will fail at ls-remote (no real repo), but should not fail at baseBranch validation
|
||||
|
||||
if result.IsError && strings.Contains(result.ForLLM, "base branch") {
|
||||
t.Fatal("should not fail on base branch when defaulting to main")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreatePRTool_Interface verifies the tool satisfies both Tool and AsyncTool interfaces.
|
||||
|
||||
func TestCreatePRTool_Interface(t *testing.T) {
|
||||
var _ Tool = (*CreatePRTool)(nil)
|
||||
|
||||
var _ AsyncTool = (*CreatePRTool)(nil)
|
||||
|
||||
tool := NewCreatePRTool()
|
||||
|
||||
if tool.Name() != "create_pr" {
|
||||
t.Errorf("Name: got %q, want %q", tool.Name(), "create_pr")
|
||||
}
|
||||
|
||||
if tool.Description() == "" {
|
||||
t.Error("Description should not be empty")
|
||||
}
|
||||
|
||||
params := tool.Parameters()
|
||||
|
||||
if params == nil {
|
||||
t.Fatal("Parameters should not be nil")
|
||||
}
|
||||
|
||||
// Verify "title" is required
|
||||
|
||||
required, ok := params["required"].([]string)
|
||||
|
||||
if !ok {
|
||||
t.Fatal("required should be []string")
|
||||
}
|
||||
|
||||
foundTitle := false
|
||||
|
||||
for _, r := range required {
|
||||
if r == "title" {
|
||||
foundTitle = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundTitle {
|
||||
t.Error("title should be in required parameters")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreatePRTool_SetCallback verifies callback is stored.
|
||||
|
||||
func TestCreatePRTool_SetCallback(t *testing.T) {
|
||||
tool := NewCreatePRTool()
|
||||
|
||||
if tool.callback != nil {
|
||||
t.Fatal("callback should be nil initially")
|
||||
}
|
||||
|
||||
called := false
|
||||
|
||||
tool.SetCallback(func(ctx context.Context, result *ToolResult) {
|
||||
called = true
|
||||
})
|
||||
|
||||
if tool.callback == nil {
|
||||
t.Fatal("callback should be set after SetCallback")
|
||||
}
|
||||
|
||||
// Verify it's callable (doesn't panic)
|
||||
|
||||
tool.callback(context.Background(), NewToolResult("test"))
|
||||
|
||||
if !called {
|
||||
t.Fatal("callback was not invoked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckPRChecks_ParseResults tests CI status parsing logic.
|
||||
|
||||
func TestCheckPRChecks_ParseResults(t *testing.T) {
|
||||
// This tests the parsing logic conceptually — actual `gh` calls
|
||||
|
||||
// would need integration tests. We verify the status constants exist
|
||||
|
||||
// and the type is usable.
|
||||
|
||||
if ciStatusPending != 0 {
|
||||
t.Error("ciStatusPending should be 0 (default)")
|
||||
}
|
||||
|
||||
if ciStatusPass == ciStatusFail {
|
||||
t.Error("ciStatusPass and ciStatusFail should differ")
|
||||
}
|
||||
|
||||
if ciStatusNone == ciStatusPending {
|
||||
t.Error("ciStatusNone and ciStatusPending should differ")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowedToolsForPreset_GitTools checks git tools are correctly assigned to presets.
|
||||
|
||||
func TestAllowedToolsForPreset_GitTools(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
preset Preset
|
||||
|
||||
wantGitPush bool
|
||||
|
||||
wantCreatePR bool
|
||||
}{
|
||||
{"scout", PresetScout, false, false},
|
||||
|
||||
{"analyst", PresetAnalyst, false, false},
|
||||
|
||||
{"coder", PresetCoder, true, false},
|
||||
|
||||
{"worker", PresetWorker, true, true},
|
||||
|
||||
{"coordinator", PresetCoordinator, true, true},
|
||||
}
|
||||
|
||||
|
|
@ -204,6 +272,7 @@ func TestAllowedToolsForPreset_GitTools(t *testing.T) {
|
|||
if got := allowed["git_push"]; got != tt.wantGitPush {
|
||||
t.Errorf("git_push: got %v, want %v", got, tt.wantGitPush)
|
||||
}
|
||||
|
||||
if got := allowed["create_pr"]; got != tt.wantCreatePR {
|
||||
t.Errorf("create_pr: got %v, want %v", got, tt.wantCreatePR)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,25 +14,36 @@ import (
|
|||
)
|
||||
|
||||
// JobExecutor is the interface for executing cron jobs through the agent
|
||||
|
||||
type JobExecutor interface {
|
||||
ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error)
|
||||
}
|
||||
|
||||
// CronTool provides scheduling capabilities for the agent
|
||||
|
||||
type CronTool struct {
|
||||
cronService *cron.CronService
|
||||
|
||||
executor JobExecutor
|
||||
|
||||
msgBus *bus.MessageBus
|
||||
|
||||
execTool *ExecTool
|
||||
|
||||
channel string
|
||||
|
||||
chatID string
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewCronTool creates a new CronTool
|
||||
|
||||
// execTimeout: 0 means no timeout, >0 sets the timeout duration
|
||||
|
||||
func NewCronTool(
|
||||
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
|
||||
|
||||
execTimeout time.Duration, config *config.Config,
|
||||
) (*CronTool, error) {
|
||||
execTool, err := NewExecToolWithConfig(workspace, restrict, config)
|
||||
|
|
@ -41,102 +52,147 @@ func NewCronTool(
|
|||
}
|
||||
|
||||
execTool.SetTimeout(execTimeout)
|
||||
|
||||
return &CronTool{
|
||||
cronService: cronService,
|
||||
|
||||
executor: executor,
|
||||
|
||||
msgBus: msgBus,
|
||||
|
||||
execTool: execTool,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the tool name
|
||||
|
||||
func (t *CronTool) Name() string {
|
||||
return "cron"
|
||||
}
|
||||
|
||||
// Description returns the tool description
|
||||
|
||||
func (t *CronTool) Description() string {
|
||||
return "Schedule reminders, tasks, or system commands. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules. Use 'command' to execute shell commands directly."
|
||||
}
|
||||
|
||||
// Parameters returns the tool parameters schema
|
||||
|
||||
func (t *CronTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"enum": []string{"add", "list", "remove", "enable", "disable"},
|
||||
|
||||
"description": "Action to perform. Use 'add' when user wants to schedule a reminder or task.",
|
||||
},
|
||||
|
||||
"message": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "The reminder/task message to display when triggered. If 'command' is used, this describes what the command does.",
|
||||
},
|
||||
|
||||
"command": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.",
|
||||
},
|
||||
|
||||
"at_seconds": map[string]any{
|
||||
"type": "integer",
|
||||
|
||||
"description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.",
|
||||
},
|
||||
|
||||
"every_seconds": map[string]any{
|
||||
"type": "integer",
|
||||
|
||||
"description": "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.",
|
||||
},
|
||||
|
||||
"cron_expr": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.",
|
||||
},
|
||||
|
||||
"job_id": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Job ID (for remove/enable/disable)",
|
||||
},
|
||||
|
||||
"deliver": map[string]any{
|
||||
"type": "boolean",
|
||||
|
||||
"description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"action"},
|
||||
}
|
||||
}
|
||||
|
||||
// SetContext sets the current session context for job creation
|
||||
|
||||
func (t *CronTool) SetContext(channel, chatID string) {
|
||||
t.mu.Lock()
|
||||
|
||||
defer t.mu.Unlock()
|
||||
|
||||
t.channel = channel
|
||||
|
||||
t.chatID = chatID
|
||||
}
|
||||
|
||||
// Execute runs the tool with the given arguments
|
||||
|
||||
func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
action, ok := args["action"].(string)
|
||||
|
||||
if !ok {
|
||||
return ErrorResult("action is required")
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
|
||||
return t.addJob(args)
|
||||
|
||||
case "list":
|
||||
|
||||
return t.listJobs()
|
||||
|
||||
case "remove":
|
||||
|
||||
return t.removeJob(args)
|
||||
|
||||
case "enable":
|
||||
|
||||
return t.enableJob(args, true)
|
||||
|
||||
case "disable":
|
||||
|
||||
return t.enableJob(args, false)
|
||||
|
||||
default:
|
||||
|
||||
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *CronTool) addJob(args map[string]any) *ToolResult {
|
||||
t.mu.RLock()
|
||||
|
||||
channel := t.channel
|
||||
|
||||
chatID := t.chatID
|
||||
|
||||
t.mu.RUnlock()
|
||||
|
||||
if channel == "" || chatID == "" {
|
||||
|
|
@ -144,6 +200,7 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
|
|||
}
|
||||
|
||||
message, ok := args["message"].(string)
|
||||
|
||||
if !ok || message == "" {
|
||||
return ErrorResult("message is required for add")
|
||||
}
|
||||
|
|
@ -151,26 +208,35 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
|
|||
var schedule cron.CronSchedule
|
||||
|
||||
// Check for at_seconds (one-time), every_seconds (recurring), or cron_expr
|
||||
|
||||
atSeconds, hasAt := args["at_seconds"].(float64)
|
||||
|
||||
everySeconds, hasEvery := args["every_seconds"].(float64)
|
||||
|
||||
cronExpr, hasCron := args["cron_expr"].(string)
|
||||
|
||||
// Priority: at_seconds > every_seconds > cron_expr
|
||||
|
||||
if hasAt {
|
||||
atMS := time.Now().UnixMilli() + int64(atSeconds)*1000
|
||||
|
||||
schedule = cron.CronSchedule{
|
||||
Kind: "at",
|
||||
|
||||
AtMS: &atMS,
|
||||
}
|
||||
} else if hasEvery {
|
||||
everyMS := int64(everySeconds) * 1000
|
||||
|
||||
schedule = cron.CronSchedule{
|
||||
Kind: "every",
|
||||
|
||||
EveryMS: &everyMS,
|
||||
}
|
||||
} else if hasCron {
|
||||
schedule = cron.CronSchedule{
|
||||
Kind: "cron",
|
||||
|
||||
Expr: cronExpr,
|
||||
}
|
||||
} else {
|
||||
|
|
@ -178,29 +244,43 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
|
|||
}
|
||||
|
||||
// Read deliver parameter, default to true
|
||||
|
||||
deliver := true
|
||||
|
||||
if d, ok := args["deliver"].(bool); ok {
|
||||
deliver = d
|
||||
}
|
||||
|
||||
command, _ := args["command"].(string)
|
||||
|
||||
if command != "" {
|
||||
// Commands must be processed by agent/exec tool, so deliver must be false (or handled specifically)
|
||||
|
||||
// Actually, let's keep deliver=false to let the system know it's not a simple chat message
|
||||
|
||||
// But for our new logic in ExecuteJob, we can handle it regardless of deliver flag if Payload.Command is set.
|
||||
|
||||
// However, logically, it's not "delivered" to chat directly as is.
|
||||
|
||||
deliver = false
|
||||
}
|
||||
|
||||
// Truncate message for job name (max 30 chars)
|
||||
|
||||
messagePreview := utils.Truncate(message, 30)
|
||||
|
||||
job, err := t.cronService.AddJob(
|
||||
|
||||
messagePreview,
|
||||
|
||||
schedule,
|
||||
|
||||
message,
|
||||
|
||||
deliver,
|
||||
|
||||
channel,
|
||||
|
||||
chatID,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -209,7 +289,9 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
|
|||
|
||||
if command != "" {
|
||||
job.Payload.Command = command
|
||||
|
||||
// Need to save the updated payload
|
||||
|
||||
t.cronService.UpdateJob(job)
|
||||
}
|
||||
|
||||
|
|
@ -224,9 +306,12 @@ func (t *CronTool) listJobs() *ToolResult {
|
|||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("Scheduled jobs:\n")
|
||||
|
||||
for _, j := range jobs {
|
||||
var scheduleInfo string
|
||||
|
||||
if j.Schedule.Kind == "every" && j.Schedule.EveryMS != nil {
|
||||
scheduleInfo = fmt.Sprintf("every %ds", *j.Schedule.EveryMS/1000)
|
||||
} else if j.Schedule.Kind == "cron" {
|
||||
|
|
@ -236,6 +321,7 @@ func (t *CronTool) listJobs() *ToolResult {
|
|||
} else {
|
||||
scheduleInfo = "unknown"
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo)
|
||||
}
|
||||
|
||||
|
|
@ -244,6 +330,7 @@ func (t *CronTool) listJobs() *ToolResult {
|
|||
|
||||
func (t *CronTool) removeJob(args map[string]any) *ToolResult {
|
||||
jobID, ok := args["job_id"].(string)
|
||||
|
||||
if !ok || jobID == "" {
|
||||
return ErrorResult("job_id is required for remove")
|
||||
}
|
||||
|
|
@ -251,49 +338,62 @@ func (t *CronTool) removeJob(args map[string]any) *ToolResult {
|
|||
if t.cronService.RemoveJob(jobID) {
|
||||
return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID))
|
||||
}
|
||||
|
||||
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
||||
}
|
||||
|
||||
func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult {
|
||||
jobID, ok := args["job_id"].(string)
|
||||
|
||||
if !ok || jobID == "" {
|
||||
return ErrorResult("job_id is required for enable/disable")
|
||||
}
|
||||
|
||||
job := t.cronService.EnableJob(jobID, enable)
|
||||
|
||||
if job == nil {
|
||||
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
||||
}
|
||||
|
||||
status := "enabled"
|
||||
|
||||
if !enable {
|
||||
status = "disabled"
|
||||
}
|
||||
|
||||
return SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status))
|
||||
}
|
||||
|
||||
// ExecuteJob executes a cron job through the agent
|
||||
|
||||
func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
||||
// Get channel/chatID from job payload
|
||||
|
||||
channel := job.Payload.Channel
|
||||
|
||||
chatID := job.Payload.To
|
||||
|
||||
// Default values if not set
|
||||
|
||||
if channel == "" {
|
||||
channel = "cli"
|
||||
}
|
||||
|
||||
if chatID == "" {
|
||||
chatID = "direct"
|
||||
}
|
||||
|
||||
// Execute command if present
|
||||
|
||||
if job.Payload.Command != "" {
|
||||
args := map[string]any{
|
||||
"command": job.Payload.Command,
|
||||
}
|
||||
|
||||
result := t.execTool.Execute(ctx, args)
|
||||
|
||||
var output string
|
||||
|
||||
if result.IsError {
|
||||
output = fmt.Sprintf("Error executing scheduled command: %s", result.ForLLM)
|
||||
} else {
|
||||
|
|
@ -301,36 +401,54 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
|||
}
|
||||
|
||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
|
||||
defer pubCancel()
|
||||
|
||||
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||
Channel: channel,
|
||||
|
||||
ChatID: chatID,
|
||||
|
||||
Content: output,
|
||||
})
|
||||
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// If deliver=true, send message directly without agent processing
|
||||
|
||||
if job.Payload.Deliver {
|
||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
|
||||
defer pubCancel()
|
||||
|
||||
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||
Channel: channel,
|
||||
|
||||
ChatID: chatID,
|
||||
|
||||
Content: job.Payload.Message,
|
||||
})
|
||||
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// For deliver=false, process through agent (for complex tasks)
|
||||
|
||||
sessionKey := fmt.Sprintf("cron-%s", job.ID)
|
||||
|
||||
// Call agent with job's message
|
||||
|
||||
response, err := t.executor.ProcessDirectWithChannel(
|
||||
|
||||
ctx,
|
||||
|
||||
job.Payload.Message,
|
||||
|
||||
sessionKey,
|
||||
|
||||
channel,
|
||||
|
||||
chatID,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -338,6 +456,8 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
|||
}
|
||||
|
||||
// Response is automatically sent via MessageBus by AgentLoop
|
||||
|
||||
_ = response // Will be sent by AgentLoop
|
||||
|
||||
return "ok"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ import (
|
|||
)
|
||||
|
||||
// DevPreviewTool allows the agent to control the Mini App dev reverse proxy.
|
||||
|
||||
type DevPreviewTool struct {
|
||||
manager miniapp.DevTargetManager
|
||||
}
|
||||
|
||||
// NewDevPreviewTool creates a new DevPreviewTool.
|
||||
|
||||
func NewDevPreviewTool(manager miniapp.DevTargetManager) *DevPreviewTool {
|
||||
return &DevPreviewTool{manager: manager}
|
||||
}
|
||||
|
|
@ -28,113 +30,157 @@ func (t *DevPreviewTool) Description() string {
|
|||
func (t *DevPreviewTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"enum": []string{"start", "stop", "unregister", "status"},
|
||||
|
||||
"description": "Action to perform: start (register + activate target), stop (deactivate proxy), unregister (remove a registered target), status (list all targets).",
|
||||
},
|
||||
|
||||
"target": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Target URL for the dev server (e.g. http://localhost:3000). Required for 'start' action. Must be a localhost URL.",
|
||||
},
|
||||
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Display name for the target (e.g. 'frontend'). Optional for 'start' action; auto-generated from host:port if omitted.",
|
||||
},
|
||||
|
||||
"id": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Target ID. Required for 'unregister' action.",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"action"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DevPreviewTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
action, ok := args["action"].(string)
|
||||
|
||||
if !ok {
|
||||
return ErrorResult("action is required")
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "start":
|
||||
|
||||
target, _ := args["target"].(string)
|
||||
|
||||
if target == "" {
|
||||
return ErrorResult("target is required for start action")
|
||||
}
|
||||
|
||||
name, _ := args["name"].(string)
|
||||
|
||||
if name == "" {
|
||||
name = inferName(target)
|
||||
}
|
||||
|
||||
id, err := t.manager.RegisterDevTarget(name, target)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to register dev target: %v", err))
|
||||
}
|
||||
|
||||
if err := t.manager.ActivateDevTarget(id); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to activate dev target: %v", err))
|
||||
}
|
||||
|
||||
return SilentResult(
|
||||
|
||||
fmt.Sprintf(
|
||||
|
||||
"Dev preview started (id=%s, name=%s). Target: %s\nUsers can view it in the Mini App Dev tab.",
|
||||
|
||||
id,
|
||||
|
||||
name,
|
||||
|
||||
target,
|
||||
),
|
||||
)
|
||||
|
||||
case "stop":
|
||||
|
||||
if err := t.manager.DeactivateDevTarget(); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to stop dev preview: %v", err))
|
||||
}
|
||||
|
||||
return SilentResult("Dev preview stopped.")
|
||||
|
||||
case "unregister":
|
||||
|
||||
id, _ := args["id"].(string)
|
||||
|
||||
if id == "" {
|
||||
return ErrorResult("id is required for unregister action")
|
||||
}
|
||||
|
||||
if err := t.manager.UnregisterDevTarget(id); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to unregister target: %v", err))
|
||||
}
|
||||
|
||||
return SilentResult(fmt.Sprintf("Dev target %s unregistered.", id))
|
||||
|
||||
case "status":
|
||||
|
||||
targets := t.manager.ListDevTargets()
|
||||
|
||||
active := t.manager.GetDevTarget()
|
||||
|
||||
if len(targets) == 0 {
|
||||
if active == "" {
|
||||
return SilentResult("Dev preview is not active. No targets registered.")
|
||||
}
|
||||
|
||||
return SilentResult(fmt.Sprintf("Dev preview is active. Target: %s\nNo registered targets.", active))
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
if active != "" {
|
||||
sb.WriteString(fmt.Sprintf("Dev preview is active. Target: %s\n", active))
|
||||
} else {
|
||||
sb.WriteString("Dev preview is not active.\n")
|
||||
}
|
||||
|
||||
sb.WriteString("Registered targets:\n")
|
||||
|
||||
for _, dt := range targets {
|
||||
sb.WriteString(fmt.Sprintf(" [%s] %s → %s\n", dt.ID, dt.Name, dt.Target))
|
||||
}
|
||||
|
||||
return SilentResult(sb.String())
|
||||
|
||||
default:
|
||||
|
||||
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
|
||||
}
|
||||
}
|
||||
|
||||
// inferName generates a display name from a target URL (e.g. "localhost:3000").
|
||||
|
||||
func inferName(target string) string {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return target
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
|
||||
port := u.Port()
|
||||
|
||||
if port != "" {
|
||||
return host + ":" + port
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,16 @@ import (
|
|||
)
|
||||
|
||||
// mockDevTargetManager implements miniapp.DevTargetManager for testing.
|
||||
|
||||
type mockDevTargetManager struct {
|
||||
targets map[string]*miniapp.DevTarget
|
||||
|
||||
nextID int
|
||||
|
||||
activeID string
|
||||
|
||||
active string // active target URL
|
||||
|
||||
regErr error
|
||||
}
|
||||
|
||||
|
|
@ -26,9 +31,13 @@ func (m *mockDevTargetManager) RegisterDevTarget(name, target string) (string, e
|
|||
if m.regErr != nil {
|
||||
return "", m.regErr
|
||||
}
|
||||
|
||||
m.nextID++
|
||||
|
||||
id := fmt.Sprintf("%d", m.nextID)
|
||||
|
||||
m.targets[id] = &miniapp.DevTarget{ID: id, Name: name, Target: target}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
|
|
@ -36,27 +45,37 @@ func (m *mockDevTargetManager) UnregisterDevTarget(id string) error {
|
|||
if _, ok := m.targets[id]; !ok {
|
||||
return fmt.Errorf("target %q not found", id)
|
||||
}
|
||||
|
||||
delete(m.targets, id)
|
||||
|
||||
if m.activeID == id {
|
||||
m.activeID = ""
|
||||
|
||||
m.active = ""
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDevTargetManager) ActivateDevTarget(id string) error {
|
||||
dt, ok := m.targets[id]
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("target %q not found", id)
|
||||
}
|
||||
|
||||
m.activeID = id
|
||||
|
||||
m.active = dt.Target
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDevTargetManager) DeactivateDevTarget() error {
|
||||
m.activeID = ""
|
||||
|
||||
m.active = ""
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -66,34 +85,43 @@ func (m *mockDevTargetManager) GetDevTarget() string {
|
|||
|
||||
func (m *mockDevTargetManager) ListDevTargets() []miniapp.DevTarget {
|
||||
out := make([]miniapp.DevTarget, 0, len(m.targets))
|
||||
|
||||
for _, dt := range m.targets {
|
||||
out = append(out, *dt)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func TestDevPreviewTool_Start(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "start",
|
||||
|
||||
"target": "http://localhost:3000",
|
||||
|
||||
"name": "frontend",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if len(mgr.targets) != 1 {
|
||||
t.Errorf("expected 1 registered target, got %d", len(mgr.targets))
|
||||
}
|
||||
|
||||
if mgr.active != "http://localhost:3000" {
|
||||
t.Errorf("expected active target http://localhost:3000, got %q", mgr.active)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "started") {
|
||||
t.Errorf("expected result to contain 'started', got %q", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "frontend") {
|
||||
t.Errorf("expected result to contain 'frontend', got %q", result.ForLLM)
|
||||
}
|
||||
|
|
@ -101,17 +129,21 @@ func TestDevPreviewTool_Start(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_StartAutoName(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "start",
|
||||
|
||||
"target": "http://localhost:3000",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Auto-generated name should be "localhost:3000"
|
||||
|
||||
for _, dt := range mgr.targets {
|
||||
if dt.Name != "localhost:3000" {
|
||||
t.Errorf("expected auto-name 'localhost:3000', got %q", dt.Name)
|
||||
|
|
@ -121,6 +153,7 @@ func TestDevPreviewTool_StartAutoName(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_StartMissingTarget(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
|
|
@ -134,11 +167,14 @@ func TestDevPreviewTool_StartMissingTarget(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_StartError(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
mgr.regErr = fmt.Errorf("only localhost")
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "start",
|
||||
|
||||
"target": "http://example.com:3000",
|
||||
})
|
||||
|
||||
|
|
@ -149,7 +185,9 @@ func TestDevPreviewTool_StartError(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_Stop(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
mgr.active = "http://localhost:3000"
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
|
|
@ -159,6 +197,7 @@ func TestDevPreviewTool_Stop(t *testing.T) {
|
|||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if mgr.active != "" {
|
||||
t.Errorf("expected empty active target after stop, got %q", mgr.active)
|
||||
}
|
||||
|
|
@ -166,19 +205,23 @@ func TestDevPreviewTool_Stop(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_Unregister(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
// Register a target first
|
||||
|
||||
id, _ := mgr.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "unregister",
|
||||
|
||||
"id": id,
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if len(mgr.targets) != 0 {
|
||||
t.Errorf("expected 0 targets after unregister, got %d", len(mgr.targets))
|
||||
}
|
||||
|
|
@ -186,6 +229,7 @@ func TestDevPreviewTool_Unregister(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_UnregisterMissingID(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
|
|
@ -199,10 +243,12 @@ func TestDevPreviewTool_UnregisterMissingID(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_UnregisterNotFound(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "unregister",
|
||||
|
||||
"id": "999",
|
||||
})
|
||||
|
||||
|
|
@ -213,10 +259,13 @@ func TestDevPreviewTool_UnregisterNotFound(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_Status(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
mgr.RegisterDevTarget("api", "http://localhost:8080")
|
||||
|
||||
mgr.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||
|
||||
mgr.active = "http://localhost:8080"
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
|
|
@ -226,15 +275,19 @@ func TestDevPreviewTool_Status(t *testing.T) {
|
|||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "active") {
|
||||
t.Errorf("expected 'active' in result, got %q", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "http://localhost:8080") {
|
||||
t.Errorf("expected target URL in result, got %q", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "api") {
|
||||
t.Errorf("expected 'api' in result, got %q", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "frontend") {
|
||||
t.Errorf("expected 'frontend' in result, got %q", result.ForLLM)
|
||||
}
|
||||
|
|
@ -242,6 +295,7 @@ func TestDevPreviewTool_Status(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_StatusInactive(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
|
|
@ -251,6 +305,7 @@ func TestDevPreviewTool_StatusInactive(t *testing.T) {
|
|||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "not active") {
|
||||
t.Errorf("expected 'not active' in result, got %q", result.ForLLM)
|
||||
}
|
||||
|
|
@ -258,6 +313,7 @@ func TestDevPreviewTool_StatusInactive(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_UnknownAction(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
|
|
@ -271,6 +327,7 @@ func TestDevPreviewTool_UnknownAction(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_MissingAction(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{})
|
||||
|
|
@ -282,15 +339,19 @@ func TestDevPreviewTool_MissingAction(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_NameAndSchema(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
if tool.Name() != "dev_preview" {
|
||||
t.Errorf("expected name dev_preview, got %q", tool.Name())
|
||||
}
|
||||
|
||||
if tool.Description() == "" {
|
||||
t.Error("expected non-empty description")
|
||||
}
|
||||
|
||||
params := tool.Parameters()
|
||||
|
||||
if params == nil {
|
||||
t.Fatal("expected non-nil parameters")
|
||||
}
|
||||
|
|
@ -300,26 +361,35 @@ func TestDevPreviewTool_NameAndSchema(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_StartMultipleTargets(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
r1 := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "start",
|
||||
|
||||
"target": "http://localhost:8080",
|
||||
|
||||
"name": "api",
|
||||
})
|
||||
|
||||
r2 := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "start",
|
||||
|
||||
"target": "http://localhost:3000",
|
||||
|
||||
"name": "frontend",
|
||||
})
|
||||
|
||||
if r1.IsError || r2.IsError {
|
||||
t.Fatalf("expected both starts to succeed, got err1=%v err2=%v", r1.IsError, r2.IsError)
|
||||
}
|
||||
|
||||
if len(mgr.targets) != 2 {
|
||||
t.Errorf("expected 2 registered targets, got %d", len(mgr.targets))
|
||||
}
|
||||
|
||||
// The second start should make the frontend active
|
||||
|
||||
if mgr.active != "http://localhost:3000" {
|
||||
t.Errorf("expected last started target to be active, got %q", mgr.active)
|
||||
}
|
||||
|
|
@ -327,11 +397,14 @@ func TestDevPreviewTool_StartMultipleTargets(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_StopPreservesRegistrations(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
tool.Execute(context.Background(), map[string]any{
|
||||
"action": "start",
|
||||
|
||||
"target": "http://localhost:3000",
|
||||
|
||||
"name": "frontend",
|
||||
})
|
||||
|
||||
|
|
@ -342,11 +415,15 @@ func TestDevPreviewTool_StopPreservesRegistrations(t *testing.T) {
|
|||
if result.IsError {
|
||||
t.Fatalf("stop failed: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Registration should still be there
|
||||
|
||||
if len(mgr.targets) != 1 {
|
||||
t.Errorf("expected 1 registered target after stop, got %d", len(mgr.targets))
|
||||
}
|
||||
|
||||
// But active should be cleared
|
||||
|
||||
if mgr.active != "" {
|
||||
t.Errorf("expected inactive after stop, got %q", mgr.active)
|
||||
}
|
||||
|
|
@ -354,9 +431,11 @@ func TestDevPreviewTool_StopPreservesRegistrations(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_StatusWithTargetsButInactive(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
mgr.RegisterDevTarget("api", "http://localhost:8080")
|
||||
|
||||
// active remains empty
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
|
|
@ -366,9 +445,11 @@ func TestDevPreviewTool_StatusWithTargetsButInactive(t *testing.T) {
|
|||
if result.IsError {
|
||||
t.Fatalf("status failed: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "not active") {
|
||||
t.Errorf("expected 'not active' in status, got %q", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "api") {
|
||||
t.Errorf("expected 'api' listed in status, got %q", result.ForLLM)
|
||||
}
|
||||
|
|
@ -376,22 +457,29 @@ func TestDevPreviewTool_StatusWithTargetsButInactive(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_ResultIsSilent(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
args map[string]any
|
||||
}{
|
||||
{"start", map[string]any{"action": "start", "target": "http://localhost:3000"}},
|
||||
|
||||
{"stop", map[string]any{"action": "stop"}},
|
||||
|
||||
{"status", map[string]any{"action": "status"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := tool.Execute(context.Background(), tc.args)
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if result.Silent != true {
|
||||
t.Errorf("expected SilentResult (IsSilent=true), got IsSilent=%v", result.Silent)
|
||||
}
|
||||
|
|
@ -401,11 +489,13 @@ func TestDevPreviewTool_ResultIsSilent(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_ActionTypeNotString(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": 123,
|
||||
})
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("expected error for non-string action")
|
||||
}
|
||||
|
|
@ -414,17 +504,26 @@ func TestDevPreviewTool_ActionTypeNotString(t *testing.T) {
|
|||
func TestDevPreviewTool_InferName(t *testing.T) {
|
||||
cases := []struct {
|
||||
target string
|
||||
|
||||
want string
|
||||
}{
|
||||
{"http://localhost:3000", "localhost:3000"},
|
||||
|
||||
{"http://localhost:8080", "localhost:8080"},
|
||||
|
||||
{"http://127.0.0.1:9000", "127.0.0.1:9000"},
|
||||
|
||||
{"http://localhost", "localhost"},
|
||||
|
||||
{"http://[::1]:5000", "::1:5000"},
|
||||
|
||||
{"not-a-url", ""}, // url.Parse succeeds but Hostname() is empty
|
||||
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := inferName(tc.target)
|
||||
|
||||
if got != tc.want {
|
||||
t.Errorf("inferName(%q) = %q, want %q", tc.target, got, tc.want)
|
||||
}
|
||||
|
|
@ -433,18 +532,23 @@ func TestDevPreviewTool_InferName(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_StartEmptyName(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
// Explicitly pass empty name — should auto-infer
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "start",
|
||||
|
||||
"target": "http://localhost:5000",
|
||||
|
||||
"name": "",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
for _, dt := range mgr.targets {
|
||||
if dt.Name != "localhost:5000" {
|
||||
t.Errorf("expected auto-name 'localhost:5000', got %q", dt.Name)
|
||||
|
|
@ -454,32 +558,41 @@ func TestDevPreviewTool_StartEmptyName(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_UnregisterActiveTarget(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
// Register and activate
|
||||
|
||||
tool.Execute(context.Background(), map[string]any{
|
||||
"action": "start",
|
||||
|
||||
"target": "http://localhost:3000",
|
||||
|
||||
"name": "frontend",
|
||||
})
|
||||
|
||||
// Find the registered ID
|
||||
|
||||
var id string
|
||||
|
||||
for k := range mgr.targets {
|
||||
id = k
|
||||
}
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "unregister",
|
||||
|
||||
"id": id,
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("unregister failed: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if len(mgr.targets) != 0 {
|
||||
t.Errorf("expected 0 targets, got %d", len(mgr.targets))
|
||||
}
|
||||
|
||||
if mgr.active != "" {
|
||||
t.Errorf("expected no active target, got %q", mgr.active)
|
||||
}
|
||||
|
|
@ -487,10 +600,12 @@ func TestDevPreviewTool_UnregisterActiveTarget(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_StartTargetEmptyString(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"action": "start",
|
||||
|
||||
"target": "",
|
||||
})
|
||||
|
||||
|
|
@ -501,8 +616,11 @@ func TestDevPreviewTool_StartTargetEmptyString(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_StatusActiveNoTargets(t *testing.T) {
|
||||
// Edge case: active proxy but no registered targets (shouldn't normally happen)
|
||||
|
||||
mgr := newMockManager()
|
||||
|
||||
mgr.active = "http://localhost:9999" // active but targets map is empty
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
|
|
@ -512,12 +630,15 @@ func TestDevPreviewTool_StatusActiveNoTargets(t *testing.T) {
|
|||
if result.IsError {
|
||||
t.Fatalf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "active") {
|
||||
t.Errorf("expected 'active' in result, got %q", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "http://localhost:9999") {
|
||||
t.Errorf("expected target URL in result, got %q", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "No registered targets") {
|
||||
t.Errorf("expected 'No registered targets' in result, got %q", result.ForLLM)
|
||||
}
|
||||
|
|
@ -525,10 +646,13 @@ func TestDevPreviewTool_StatusActiveNoTargets(t *testing.T) {
|
|||
|
||||
func TestDevPreviewTool_StatusOutputFormat(t *testing.T) {
|
||||
mgr := newMockManager()
|
||||
|
||||
tool := NewDevPreviewTool(mgr)
|
||||
|
||||
id1, _ := mgr.RegisterDevTarget("api", "http://localhost:8080")
|
||||
|
||||
mgr.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||
|
||||
mgr.ActivateDevTarget(id1)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
|
|
@ -538,15 +662,21 @@ func TestDevPreviewTool_StatusOutputFormat(t *testing.T) {
|
|||
if result.IsError {
|
||||
t.Fatalf("status failed: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Should contain IDs in bracket format
|
||||
|
||||
if !strings.Contains(result.ForLLM, "["+id1+"]") {
|
||||
t.Errorf("expected [%s] in output, got %q", id1, result.ForLLM)
|
||||
}
|
||||
|
||||
// Should contain the arrow
|
||||
|
||||
if !strings.Contains(result.ForLLM, "→") {
|
||||
t.Errorf("expected arrow in output, got %q", result.ForLLM)
|
||||
}
|
||||
|
||||
// Should contain "Registered targets:"
|
||||
|
||||
if !strings.Contains(result.ForLLM, "Registered targets:") {
|
||||
t.Errorf("expected 'Registered targets:' header, got %q", result.ForLLM)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,24 @@ import (
|
|||
)
|
||||
|
||||
// EditFileTool edits a file by replacing old_text with new_text.
|
||||
|
||||
// The old_text must exist exactly in the file.
|
||||
|
||||
type EditFileTool struct {
|
||||
fs fileSystem
|
||||
}
|
||||
|
||||
// NewEditFileTool creates a new EditFileTool with optional directory restriction.
|
||||
|
||||
func NewEditFileTool(workspace string, restrict bool) *EditFileTool {
|
||||
var fs fileSystem
|
||||
|
||||
if restrict {
|
||||
fs = &sandboxFs{workspace: workspace}
|
||||
} else {
|
||||
fs = &hostFs{}
|
||||
}
|
||||
|
||||
return &EditFileTool{fs: fs}
|
||||
}
|
||||
|
||||
|
|
@ -36,36 +41,46 @@ func (t *EditFileTool) Description() string {
|
|||
func (t *EditFileTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "The file path to edit",
|
||||
},
|
||||
|
||||
"old_text": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "The exact text to find and replace",
|
||||
},
|
||||
|
||||
"new_text": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "The text to replace with",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"path", "old_text", "new_text"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
path, ok := args["path"].(string)
|
||||
|
||||
if !ok {
|
||||
return ErrorResult("path is required")
|
||||
}
|
||||
|
||||
oldText, ok := args["old_text"].(string)
|
||||
|
||||
if !ok {
|
||||
return ErrorResult("old_text is required")
|
||||
}
|
||||
|
||||
newText, ok := args["new_text"].(string)
|
||||
|
||||
if !ok {
|
||||
return ErrorResult("new_text is required")
|
||||
}
|
||||
|
|
@ -73,6 +88,7 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
|||
if err := editFile(resolveFS(ctx, t.fs, path), path, oldText, newText); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
return SilentResult(fmt.Sprintf("File edited: %s", path))
|
||||
}
|
||||
|
||||
|
|
@ -82,11 +98,13 @@ type AppendFileTool struct {
|
|||
|
||||
func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool {
|
||||
var fs fileSystem
|
||||
|
||||
if restrict {
|
||||
fs = &sandboxFs{workspace: workspace}
|
||||
} else {
|
||||
fs = &hostFs{}
|
||||
}
|
||||
|
||||
return &AppendFileTool{fs: fs}
|
||||
}
|
||||
|
||||
|
|
@ -101,27 +119,34 @@ func (t *AppendFileTool) Description() string {
|
|||
func (t *AppendFileTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "The file path to append to",
|
||||
},
|
||||
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "The content to append",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"path", "content"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
path, ok := args["path"].(string)
|
||||
|
||||
if !ok {
|
||||
return ErrorResult("path is required")
|
||||
}
|
||||
|
||||
content, ok := args["content"].(string)
|
||||
|
||||
if !ok {
|
||||
return ErrorResult("content is required")
|
||||
}
|
||||
|
|
@ -129,11 +154,14 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool
|
|||
if err := appendFile(resolveFS(ctx, t.fs, path), path, content); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
return SilentResult(fmt.Sprintf("Appended to %s", path))
|
||||
}
|
||||
|
||||
// editFile reads the file via sysFs, performs the replacement, and writes back.
|
||||
|
||||
// It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes.
|
||||
|
||||
func editFile(sysFs fileSystem, path, oldText, newText string) error {
|
||||
content, err := sysFs.ReadFile(path)
|
||||
if err != nil {
|
||||
|
|
@ -149,17 +177,21 @@ func editFile(sysFs fileSystem, path, oldText, newText string) error {
|
|||
}
|
||||
|
||||
// appendFile reads the existing content (if any) via sysFs, appends new content, and writes back.
|
||||
|
||||
func appendFile(sysFs fileSystem, path, appendContent string) error {
|
||||
content, err := sysFs.ReadFile(path)
|
||||
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
newContent := append(content, []byte(appendContent)...)
|
||||
|
||||
return sysFs.WriteFile(path, newContent)
|
||||
}
|
||||
|
||||
// replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText.
|
||||
|
||||
func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) {
|
||||
contentStr := string(content)
|
||||
|
||||
|
|
@ -168,10 +200,12 @@ func replaceEditContent(content []byte, oldText, newText string) ([]byte, error)
|
|||
}
|
||||
|
||||
count := strings.Count(contentStr, oldText)
|
||||
|
||||
if count > 1 {
|
||||
return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count)
|
||||
}
|
||||
|
||||
newContent := strings.Replace(contentStr, oldText, newText, 1)
|
||||
|
||||
return []byte(newContent), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,261 +11,349 @@ import (
|
|||
)
|
||||
|
||||
// TestEditTool_EditFile_Success verifies successful file editing
|
||||
|
||||
func TestEditTool_EditFile_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
|
||||
os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0o644)
|
||||
|
||||
tool := NewEditFileTool(tmpDir, true)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
|
||||
"old_text": "World",
|
||||
|
||||
"new_text": "Universe",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Success should not be an error
|
||||
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Should return SilentResult
|
||||
|
||||
if !result.Silent {
|
||||
t.Errorf("Expected Silent=true for EditFile, got false")
|
||||
}
|
||||
|
||||
// ForUser should be empty (silent result)
|
||||
|
||||
if result.ForUser != "" {
|
||||
t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser)
|
||||
}
|
||||
|
||||
// Verify file was actually edited
|
||||
|
||||
content, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read edited file: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
|
||||
if !strings.Contains(contentStr, "Hello Universe") {
|
||||
t.Errorf("Expected file to contain 'Hello Universe', got: %s", contentStr)
|
||||
}
|
||||
|
||||
if strings.Contains(contentStr, "Hello World") {
|
||||
t.Errorf("Expected 'Hello World' to be replaced, got: %s", contentStr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_NotFound verifies error handling for non-existent file
|
||||
|
||||
func TestEditTool_EditFile_NotFound(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
testFile := filepath.Join(tmpDir, "nonexistent.txt")
|
||||
|
||||
tool := NewEditFileTool(tmpDir, true)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
|
||||
"old_text": "old",
|
||||
|
||||
"new_text": "new",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error for non-existent file")
|
||||
}
|
||||
|
||||
// Should mention file not found
|
||||
|
||||
if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") {
|
||||
t.Errorf("Expected 'file not found' message, got ForLLM: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_OldTextNotFound verifies error when old_text doesn't exist
|
||||
|
||||
func TestEditTool_EditFile_OldTextNotFound(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
|
||||
os.WriteFile(testFile, []byte("Hello World"), 0o644)
|
||||
|
||||
tool := NewEditFileTool(tmpDir, true)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
|
||||
"old_text": "Goodbye",
|
||||
|
||||
"new_text": "Hello",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when old_text not found")
|
||||
}
|
||||
|
||||
// Should mention old_text not found
|
||||
|
||||
if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") {
|
||||
t.Errorf("Expected 'not found' message, got ForLLM: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_MultipleMatches verifies error when old_text appears multiple times
|
||||
|
||||
func TestEditTool_EditFile_MultipleMatches(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
|
||||
os.WriteFile(testFile, []byte("test test test"), 0o644)
|
||||
|
||||
tool := NewEditFileTool(tmpDir, true)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
|
||||
"old_text": "test",
|
||||
|
||||
"new_text": "done",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when old_text appears multiple times")
|
||||
}
|
||||
|
||||
// Should mention multiple occurrences
|
||||
|
||||
if !strings.Contains(result.ForLLM, "times") && !strings.Contains(result.ForUser, "times") {
|
||||
t.Errorf("Expected 'multiple times' message, got ForLLM: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_OutsideAllowedDir verifies error when path is outside allowed directory
|
||||
|
||||
func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
otherDir := t.TempDir()
|
||||
|
||||
testFile := filepath.Join(otherDir, "test.txt")
|
||||
|
||||
os.WriteFile(testFile, []byte("content"), 0o644)
|
||||
|
||||
tool := NewEditFileTool(tmpDir, true) // Restrict to tmpDir
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
|
||||
"old_text": "content",
|
||||
|
||||
"new_text": "new",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
|
||||
assert.True(t, result.IsError, "Expected error when path is outside allowed directory")
|
||||
|
||||
// Should mention outside allowed directory
|
||||
|
||||
// Note: ErrorResult only sets ForLLM by default, so ForUser might be empty.
|
||||
|
||||
// We check ForLLM as it's the primary error channel.
|
||||
|
||||
assert.True(
|
||||
|
||||
t,
|
||||
|
||||
strings.Contains(result.ForLLM, "outside") || strings.Contains(result.ForLLM, "access denied") ||
|
||||
|
||||
strings.Contains(result.ForLLM, "escapes"),
|
||||
|
||||
"Expected 'outside allowed' or 'access denied' message, got ForLLM: %s",
|
||||
|
||||
result.ForLLM,
|
||||
)
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_MissingPath verifies error handling for missing path
|
||||
|
||||
func TestEditTool_EditFile_MissingPath(t *testing.T) {
|
||||
tool := NewEditFileTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"old_text": "old",
|
||||
|
||||
"new_text": "new",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when path is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_MissingOldText verifies error handling for missing old_text
|
||||
|
||||
func TestEditTool_EditFile_MissingOldText(t *testing.T) {
|
||||
tool := NewEditFileTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": "/tmp/test.txt",
|
||||
|
||||
"new_text": "new",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when old_text is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_MissingNewText verifies error handling for missing new_text
|
||||
|
||||
func TestEditTool_EditFile_MissingNewText(t *testing.T) {
|
||||
tool := NewEditFileTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": "/tmp/test.txt",
|
||||
|
||||
"old_text": "old",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when new_text is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_AppendFile_Success verifies successful file appending
|
||||
|
||||
func TestEditTool_AppendFile_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
|
||||
os.WriteFile(testFile, []byte("Initial content"), 0o644)
|
||||
|
||||
tool := NewAppendFileTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
|
||||
"content": "\nAppended content",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Success should not be an error
|
||||
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Should return SilentResult
|
||||
|
||||
if !result.Silent {
|
||||
t.Errorf("Expected Silent=true for AppendFile, got false")
|
||||
}
|
||||
|
||||
// ForUser should be empty (silent result)
|
||||
|
||||
if result.ForUser != "" {
|
||||
t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser)
|
||||
}
|
||||
|
||||
// Verify content was actually appended
|
||||
|
||||
content, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read file: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
|
||||
if !strings.Contains(contentStr, "Initial content") {
|
||||
t.Errorf("Expected original content to remain, got: %s", contentStr)
|
||||
}
|
||||
|
||||
if !strings.Contains(contentStr, "Appended content") {
|
||||
t.Errorf("Expected appended content, got: %s", contentStr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_AppendFile_MissingPath verifies error handling for missing path
|
||||
|
||||
func TestEditTool_AppendFile_MissingPath(t *testing.T) {
|
||||
tool := NewAppendFileTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"content": "test",
|
||||
}
|
||||
|
|
@ -273,15 +361,19 @@ func TestEditTool_AppendFile_MissingPath(t *testing.T) {
|
|||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when path is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_AppendFile_MissingContent verifies error handling for missing content
|
||||
|
||||
func TestEditTool_AppendFile_MissingContent(t *testing.T) {
|
||||
tool := NewAppendFileTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": "/tmp/test.txt",
|
||||
}
|
||||
|
|
@ -289,43 +381,67 @@ func TestEditTool_AppendFile_MissingContent(t *testing.T) {
|
|||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when content is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceEditContent verifies the helper function replaceEditContent
|
||||
|
||||
func TestReplaceEditContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
content []byte
|
||||
|
||||
oldText string
|
||||
|
||||
newText string
|
||||
|
||||
expected []byte
|
||||
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "successful replacement",
|
||||
|
||||
content: []byte("hello world"),
|
||||
|
||||
oldText: "world",
|
||||
|
||||
newText: "universe",
|
||||
|
||||
expected: []byte("hello universe"),
|
||||
|
||||
expectError: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "old text not found",
|
||||
|
||||
content: []byte("hello world"),
|
||||
|
||||
oldText: "golang",
|
||||
|
||||
newText: "rust",
|
||||
|
||||
expected: nil,
|
||||
|
||||
expectError: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple matches found",
|
||||
|
||||
content: []byte("test text test"),
|
||||
|
||||
oldText: "test",
|
||||
|
||||
newText: "done",
|
||||
|
||||
expected: nil,
|
||||
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
|
@ -333,10 +449,12 @@ func TestReplaceEditContent(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := replaceEditContent(tt.content, tt.oldText, tt.newText)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
})
|
||||
|
|
@ -344,94 +462,142 @@ func TestReplaceEditContent(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestAppendFileTool_AppendToNonExistent_Restricted verifies that AppendFileTool in restricted mode
|
||||
|
||||
// can append to a file that does not yet exist — it should silently create the file.
|
||||
|
||||
// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFile + sandboxFs.
|
||||
|
||||
func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
tool := NewAppendFileTool(workspace, true)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": "brand_new_file.txt",
|
||||
|
||||
"content": "first content",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
assert.False(
|
||||
|
||||
t,
|
||||
|
||||
result.IsError,
|
||||
|
||||
"Expected success when appending to non-existent file in restricted mode, got: %s",
|
||||
|
||||
result.ForLLM,
|
||||
)
|
||||
|
||||
// Verify the file was created with correct content
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(workspace, "brand_new_file.txt"))
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "first content", string(data))
|
||||
}
|
||||
|
||||
// TestAppendFileTool_Restricted_Success verifies that AppendFileTool in restricted mode
|
||||
|
||||
// correctly appends to an existing file within the sandbox.
|
||||
|
||||
func TestAppendFileTool_Restricted_Success(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
testFile := "existing.txt"
|
||||
|
||||
err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0o644)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
tool := NewAppendFileTool(workspace, true)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
|
||||
"content": " appended",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
|
||||
|
||||
assert.True(t, result.Silent)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(workspace, testFile))
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "initial appended", string(data))
|
||||
}
|
||||
|
||||
// TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode
|
||||
|
||||
// correctly edits a file using the sandboxFs path.
|
||||
|
||||
func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
testFile := "edit_target.txt"
|
||||
|
||||
err := os.WriteFile(filepath.Join(workspace, testFile), []byte("Hello World"), 0o644)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
tool := NewEditFileTool(workspace, true)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
|
||||
"old_text": "World",
|
||||
|
||||
"new_text": "Go",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
|
||||
|
||||
assert.True(t, result.Silent)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(workspace, testFile))
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Hello Go", string(data))
|
||||
}
|
||||
|
||||
// TestEditFileTool_Restricted_FileNotFound verifies that editFile returns a proper
|
||||
|
||||
// error message when the target file does not exist.
|
||||
|
||||
func TestEditFileTool_Restricted_FileNotFound(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
tool := NewEditFileTool(workspace, true)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": "no_such_file.txt",
|
||||
|
||||
"old_text": "old",
|
||||
|
||||
"new_text": "new",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
assert.True(t, result.IsError)
|
||||
|
||||
assert.Contains(t, result.ForLLM, "not found")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import (
|
|||
)
|
||||
|
||||
// validatePath ensures the given path is within the workspace if restrict is true.
|
||||
|
||||
// Used by shell.go for working directory validation.
|
||||
|
||||
func validatePath(path, workspace string, restrict bool) (string, error) {
|
||||
if workspace == "" {
|
||||
return path, fmt.Errorf("workspace is not defined")
|
||||
|
|
@ -25,6 +27,7 @@ func validatePath(path, workspace string, restrict bool) (string, error) {
|
|||
}
|
||||
|
||||
var absPath string
|
||||
|
||||
if filepath.IsAbs(path) {
|
||||
absPath = filepath.Clean(path)
|
||||
} else {
|
||||
|
|
@ -40,7 +43,9 @@ func validatePath(path, workspace string, restrict bool) (string, error) {
|
|||
}
|
||||
|
||||
var resolved string
|
||||
|
||||
workspaceReal := absWorkspace
|
||||
|
||||
if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil {
|
||||
workspaceReal = resolved
|
||||
}
|
||||
|
|
@ -51,6 +56,7 @@ func validatePath(path, workspace string, restrict bool) (string, error) {
|
|||
}
|
||||
} else if os.IsNotExist(err) {
|
||||
var parentResolved string
|
||||
|
||||
if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
|
||||
if !isWithinWorkspace(parentResolved, workspaceReal) {
|
||||
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
|
||||
|
|
@ -73,6 +79,7 @@ func resolveExistingAncestor(path string) (string, error) {
|
|||
} else if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if filepath.Dir(current) == current {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
|
|
@ -81,6 +88,7 @@ func resolveExistingAncestor(path string) (string, error) {
|
|||
|
||||
func isWithinWorkspace(candidate, workspace string) bool {
|
||||
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))
|
||||
|
||||
return err == nil && filepath.IsLocal(rel)
|
||||
}
|
||||
|
||||
|
|
@ -90,11 +98,13 @@ type ReadFileTool struct {
|
|||
|
||||
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool {
|
||||
var fs fileSystem
|
||||
|
||||
if restrict {
|
||||
fs = &sandboxFs{workspace: workspace}
|
||||
} else {
|
||||
fs = &hostFs{}
|
||||
}
|
||||
|
||||
return &ReadFileTool{fs: fs}
|
||||
}
|
||||
|
||||
|
|
@ -109,18 +119,22 @@ func (t *ReadFileTool) Description() string {
|
|||
func (t *ReadFileTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Path to the file to read",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"path"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
path, ok := args["path"].(string)
|
||||
|
||||
if !ok {
|
||||
return ErrorResult("path is required")
|
||||
}
|
||||
|
|
@ -129,6 +143,7 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
|||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
return NewToolResult(string(content))
|
||||
}
|
||||
|
||||
|
|
@ -138,11 +153,13 @@ type WriteFileTool struct {
|
|||
|
||||
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool {
|
||||
var fs fileSystem
|
||||
|
||||
if restrict {
|
||||
fs = &sandboxFs{workspace: workspace}
|
||||
} else {
|
||||
fs = &hostFs{}
|
||||
}
|
||||
|
||||
return &WriteFileTool{fs: fs}
|
||||
}
|
||||
|
||||
|
|
@ -157,27 +174,34 @@ func (t *WriteFileTool) Description() string {
|
|||
func (t *WriteFileTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Path to the file to write",
|
||||
},
|
||||
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Content to write to the file",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"path", "content"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
path, ok := args["path"].(string)
|
||||
|
||||
if !ok {
|
||||
return ErrorResult("path is required")
|
||||
}
|
||||
|
||||
content, ok := args["content"].(string)
|
||||
|
||||
if !ok {
|
||||
return ErrorResult("content is required")
|
||||
}
|
||||
|
|
@ -195,11 +219,13 @@ type ListDirTool struct {
|
|||
|
||||
func NewListDirTool(workspace string, restrict bool) *ListDirTool {
|
||||
var fs fileSystem
|
||||
|
||||
if restrict {
|
||||
fs = &sandboxFs{workspace: workspace}
|
||||
} else {
|
||||
fs = &hostFs{}
|
||||
}
|
||||
|
||||
return &ListDirTool{fs: fs}
|
||||
}
|
||||
|
||||
|
|
@ -214,18 +240,22 @@ func (t *ListDirTool) Description() string {
|
|||
func (t *ListDirTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Path to list",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"path"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
path, ok := args["path"].(string)
|
||||
|
||||
if !ok {
|
||||
path = "."
|
||||
}
|
||||
|
|
@ -234,32 +264,42 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
|||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
return formatDirEntries(entries)
|
||||
}
|
||||
|
||||
func formatDirEntries(entries []os.DirEntry) *ToolResult {
|
||||
var result strings.Builder
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
result.WriteString("DIR: ")
|
||||
} else {
|
||||
result.WriteString("FILE: ")
|
||||
}
|
||||
|
||||
result.WriteString(entry.Name())
|
||||
|
||||
result.WriteByte('\n')
|
||||
}
|
||||
|
||||
return NewToolResult(result.String())
|
||||
}
|
||||
|
||||
// fileSystem abstracts reading, writing, and listing files, allowing both
|
||||
|
||||
// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface.
|
||||
|
||||
type fileSystem interface {
|
||||
ReadFile(path string) ([]byte, error)
|
||||
|
||||
WriteFile(path string, data []byte) error
|
||||
|
||||
ReadDir(path string) ([]os.DirEntry, error)
|
||||
}
|
||||
|
||||
// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
|
||||
|
||||
type hostFs struct{}
|
||||
|
||||
func (h *hostFs) ReadFile(path string) ([]byte, error) {
|
||||
|
|
@ -268,11 +308,14 @@ func (h *hostFs) ReadFile(path string) ([]byte, error) {
|
|||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to read file: file not found: %w", err)
|
||||
}
|
||||
|
||||
if os.IsPermission(err) {
|
||||
return nil, fmt.Errorf("failed to read file: access denied: %w", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
|
|
@ -281,16 +324,20 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (h *hostFs) WriteFile(path string, data []byte) error {
|
||||
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||
|
||||
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||
|
||||
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||
}
|
||||
|
||||
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
|
||||
|
||||
type sandboxFs struct {
|
||||
workspace string
|
||||
}
|
||||
|
|
@ -304,6 +351,7 @@ func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string)
|
|||
if err != nil {
|
||||
return fmt.Errorf("failed to open workspace: %w", err)
|
||||
}
|
||||
|
||||
defer root.Close()
|
||||
|
||||
relPath, err := getSafeRelPath(r.workspace, path)
|
||||
|
|
@ -316,28 +364,37 @@ func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string)
|
|||
|
||||
func (r *sandboxFs) ReadFile(path string) ([]byte, error) {
|
||||
var content []byte
|
||||
|
||||
err := r.execute(path, func(root *os.Root, relPath string) error {
|
||||
fileContent, err := root.ReadFile(relPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to read file: file not found: %w", err)
|
||||
}
|
||||
|
||||
// os.Root returns "escapes from parent" for paths outside the root
|
||||
|
||||
if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") ||
|
||||
|
||||
strings.Contains(err.Error(), "permission denied") {
|
||||
return fmt.Errorf("failed to read file: access denied: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
content = fileContent
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return content, err
|
||||
}
|
||||
|
||||
func (r *sandboxFs) WriteFile(path string, data []byte) error {
|
||||
return r.execute(path, func(root *os.Root, relPath string) error {
|
||||
dir := filepath.Dir(relPath)
|
||||
|
||||
if dir != "." && dir != "/" {
|
||||
if err := root.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directories: %w", err)
|
||||
|
|
@ -345,42 +402,55 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error {
|
|||
}
|
||||
|
||||
// Use atomic write pattern with explicit sync for flash storage reliability.
|
||||
|
||||
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||
|
||||
tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())
|
||||
|
||||
tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
root.Remove(tmpRelPath)
|
||||
|
||||
return fmt.Errorf("failed to open temp file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
tmpFile.Close()
|
||||
|
||||
root.Remove(tmpRelPath)
|
||||
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
|
||||
// CRITICAL: Force sync to storage medium before rename.
|
||||
|
||||
// This ensures data is physically written to disk, not just cached.
|
||||
|
||||
if err := tmpFile.Sync(); err != nil {
|
||||
tmpFile.Close()
|
||||
|
||||
root.Remove(tmpRelPath)
|
||||
|
||||
return fmt.Errorf("failed to sync temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
root.Remove(tmpRelPath)
|
||||
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := root.Rename(tmpRelPath, relPath); err != nil {
|
||||
root.Remove(tmpRelPath)
|
||||
|
||||
return fmt.Errorf("failed to rename temp file over target: %w", err)
|
||||
}
|
||||
|
||||
// Sync directory to ensure rename is durable
|
||||
|
||||
if dirFile, err := root.Open("."); err == nil {
|
||||
_ = dirFile.Sync()
|
||||
|
||||
dirFile.Close()
|
||||
}
|
||||
|
||||
|
|
@ -390,26 +460,33 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error {
|
|||
|
||||
func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) {
|
||||
var entries []os.DirEntry
|
||||
|
||||
err := r.execute(path, func(root *os.Root, relPath string) error {
|
||||
dirEntries, err := fs.ReadDir(root.FS(), relPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries = dirEntries
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return entries, err
|
||||
}
|
||||
|
||||
// Helper to get a safe relative path for os.Root usage
|
||||
|
||||
func getSafeRelPath(workspace, path string) (string, error) {
|
||||
if workspace == "" {
|
||||
return "", fmt.Errorf("workspace is not defined")
|
||||
}
|
||||
|
||||
rel := filepath.Clean(path)
|
||||
|
||||
if filepath.IsAbs(rel) {
|
||||
var err error
|
||||
|
||||
rel, err = filepath.Rel(workspace, rel)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to calculate relative path: %w", err)
|
||||
|
|
|
|||
|
|
@ -12,13 +12,18 @@ import (
|
|||
)
|
||||
|
||||
// TestFilesystemTool_ReadFile_Success verifies successful file reading
|
||||
|
||||
func TestFilesystemTool_ReadFile_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
|
||||
os.WriteFile(testFile, []byte("test content"), 0o644)
|
||||
|
||||
tool := NewReadFileTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
}
|
||||
|
|
@ -26,26 +31,33 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
|
|||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Success should not be an error
|
||||
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// ForLLM should contain file content
|
||||
|
||||
if !strings.Contains(result.ForLLM, "test content") {
|
||||
t.Errorf("Expected ForLLM to contain 'test content', got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// ReadFile returns NewToolResult which only sets ForLLM, not ForUser
|
||||
|
||||
// This is the expected behavior - file content goes to LLM, not directly to user
|
||||
|
||||
if result.ForUser != "" {
|
||||
t.Errorf("Expected ForUser to be empty for NewToolResult, got: %s", result.ForUser)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
|
||||
|
||||
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
||||
tool := NewReadFileTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": "/nonexistent_file_12345.txt",
|
||||
}
|
||||
|
|
@ -53,107 +65,135 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
|||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Failure should be marked as error
|
||||
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error for missing file, got IsError=false")
|
||||
}
|
||||
|
||||
// Should contain error message
|
||||
|
||||
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
|
||||
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path
|
||||
|
||||
func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) {
|
||||
tool := &ReadFileTool{}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when path is missing")
|
||||
}
|
||||
|
||||
// Should mention required parameter
|
||||
|
||||
if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") {
|
||||
t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_WriteFile_Success verifies successful file writing
|
||||
|
||||
func TestFilesystemTool_WriteFile_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
testFile := filepath.Join(tmpDir, "newfile.txt")
|
||||
|
||||
tool := NewWriteFileTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
|
||||
"content": "hello world",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Success should not be an error
|
||||
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// WriteFile returns SilentResult
|
||||
|
||||
if !result.Silent {
|
||||
t.Errorf("Expected Silent=true for WriteFile, got false")
|
||||
}
|
||||
|
||||
// ForUser should be empty (silent result)
|
||||
|
||||
if result.ForUser != "" {
|
||||
t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser)
|
||||
}
|
||||
|
||||
// Verify file was actually written
|
||||
|
||||
content, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read written file: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != "hello world" {
|
||||
t.Errorf("Expected file content 'hello world', got: %s", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_WriteFile_CreateDir verifies directory creation
|
||||
|
||||
func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
testFile := filepath.Join(tmpDir, "subdir", "newfile.txt")
|
||||
|
||||
tool := NewWriteFileTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
|
||||
"content": "test",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Success should not be an error
|
||||
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success with directory creation, got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Verify directory was created and file written
|
||||
|
||||
content, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read written file: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != "test" {
|
||||
t.Errorf("Expected file content 'test', got: %s", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path
|
||||
|
||||
func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
|
||||
tool := NewWriteFileTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"content": "test",
|
||||
}
|
||||
|
|
@ -161,15 +201,19 @@ func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
|
|||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when path is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content
|
||||
|
||||
func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
|
||||
tool := NewWriteFileTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": "/tmp/test.txt",
|
||||
}
|
||||
|
|
@ -177,26 +221,35 @@ func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
|
|||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when content is missing")
|
||||
}
|
||||
|
||||
// Should mention required parameter
|
||||
|
||||
if !strings.Contains(result.ForLLM, "content is required") &&
|
||||
|
||||
!strings.Contains(result.ForUser, "content is required") {
|
||||
t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_ListDir_Success verifies successful directory listing
|
||||
|
||||
func TestFilesystemTool_ListDir_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0o644)
|
||||
|
||||
os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644)
|
||||
|
||||
os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755)
|
||||
|
||||
tool := NewListDirTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": tmpDir,
|
||||
}
|
||||
|
|
@ -204,23 +257,29 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
|
|||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Success should not be an error
|
||||
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Should list files and directories
|
||||
|
||||
if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") {
|
||||
t.Errorf("Expected files in listing, got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "subdir") {
|
||||
t.Errorf("Expected subdir in listing, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory
|
||||
|
||||
func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
|
||||
tool := NewListDirTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"path": "/nonexistent_directory_12345",
|
||||
}
|
||||
|
|
@ -228,49 +287,61 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
|
|||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Failure should be marked as error
|
||||
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error for non-existent directory, got IsError=false")
|
||||
}
|
||||
|
||||
// Should contain error message
|
||||
|
||||
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
|
||||
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory
|
||||
|
||||
func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
|
||||
tool := NewListDirTool("", false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should use "." as default path
|
||||
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// Block paths that look inside workspace but point outside via symlink.
|
||||
|
||||
func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
workspace := filepath.Join(root, "workspace")
|
||||
|
||||
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
||||
t.Fatalf("failed to create workspace: %v", err)
|
||||
}
|
||||
|
||||
secret := filepath.Join(root, "secret.txt")
|
||||
|
||||
if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil {
|
||||
t.Fatalf("failed to write secret file: %v", err)
|
||||
}
|
||||
|
||||
link := filepath.Join(workspace, "leak.txt")
|
||||
|
||||
if err := os.Symlink(secret, link); err != nil {
|
||||
t.Skipf("symlink not supported in this environment: %v", err)
|
||||
}
|
||||
|
||||
tool := NewReadFileTool(workspace, true)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"path": link,
|
||||
})
|
||||
|
|
@ -278,10 +349,15 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
|
|||
if !result.IsError {
|
||||
t.Fatalf("expected symlink escape to be blocked")
|
||||
}
|
||||
|
||||
// os.Root might return different errors depending on platform/implementation
|
||||
|
||||
// but it definitely should error.
|
||||
|
||||
// Our wrapper returns "access denied or file not found"
|
||||
|
||||
if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") &&
|
||||
|
||||
!strings.Contains(result.ForLLM, "no such file") {
|
||||
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
|
||||
}
|
||||
|
|
@ -291,8 +367,11 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
|
|||
tool := NewReadFileTool("", true) // restrict=true but workspace=""
|
||||
|
||||
// Try to read a sensitive file (simulated by a temp file outside workspace)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
secretFile := filepath.Join(tmpDir, "shadow")
|
||||
|
||||
os.WriteFile(secretFile, []byte("secret data"), 0o600)
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
|
|
@ -300,201 +379,293 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
|
|||
})
|
||||
|
||||
// We EXPECT IsError=true (access blocked due to empty workspace)
|
||||
|
||||
assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
|
||||
|
||||
// Verify it failed for the right reason
|
||||
|
||||
assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
|
||||
}
|
||||
|
||||
// TestRootMkdirAll verifies that root.MkdirAll (used by sandboxFs.WriteFile) handles all cases:
|
||||
|
||||
// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path.
|
||||
|
||||
func TestRootMkdirAll(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
root, err := os.OpenRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open root: %v", err)
|
||||
}
|
||||
|
||||
defer root.Close()
|
||||
|
||||
// Case 1: Single directory
|
||||
|
||||
err = root.MkdirAll("dir1", 0o755)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(filepath.Join(workspace, "dir1"))
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Case 2: Deeply nested directory
|
||||
|
||||
err = root.MkdirAll("a/b/c/d", 0o755)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(filepath.Join(workspace, "a/b/c/d"))
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Case 3: Already exists — must be idempotent
|
||||
|
||||
err = root.MkdirAll("a/b/c/d", 0o755)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Case 4: A regular file blocks directory creation — must error
|
||||
|
||||
err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = root.MkdirAll("file_exists", 0o755)
|
||||
|
||||
assert.Error(t, err, "expected error when a file exists at the directory path")
|
||||
}
|
||||
|
||||
func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
tool := NewWriteFileTool(workspace, true)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
testFile := "deep/nested/path/to/file.txt"
|
||||
|
||||
content := "deep content"
|
||||
|
||||
args := map[string]any{
|
||||
"path": testFile,
|
||||
|
||||
"content": content,
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
|
||||
|
||||
// Verify file content
|
||||
|
||||
actualPath := filepath.Join(workspace, testFile)
|
||||
|
||||
data, err := os.ReadFile(actualPath)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, content, string(data))
|
||||
}
|
||||
|
||||
// TestHostFs_Read_PermissionDenied verifies that hostFs.ReadFile surfaces access denied errors.
|
||||
|
||||
func TestHostFs_Read_PermissionDenied(t *testing.T) {
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("skipping permission test: running as root")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
protected := filepath.Join(tmpDir, "protected.txt")
|
||||
|
||||
err := os.WriteFile(protected, []byte("secret"), 0o000)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
defer os.Chmod(protected, 0o644) // ensure cleanup
|
||||
|
||||
_, err = (&hostFs{}).ReadFile(protected)
|
||||
|
||||
assert.Error(t, err)
|
||||
|
||||
assert.Contains(t, err.Error(), "access denied")
|
||||
}
|
||||
|
||||
// TestHostFs_Read_Directory verifies that hostFs.ReadFile returns an error when given a directory path.
|
||||
|
||||
func TestHostFs_Read_Directory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
_, err := (&hostFs{}).ReadFile(tmpDir)
|
||||
|
||||
assert.Error(t, err, "expected error when reading a directory as a file")
|
||||
}
|
||||
|
||||
// TestSandboxFs_Read_Directory verifies that sandboxFs.ReadFile returns an error when given a directory.
|
||||
|
||||
func TestSandboxFs_Read_Directory(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
root, err := os.OpenRoot(workspace)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
defer root.Close()
|
||||
|
||||
// Create a subdirectory
|
||||
|
||||
err = root.Mkdir("subdir", 0o755)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir")
|
||||
|
||||
assert.Error(t, err, "expected error when reading a directory as a file")
|
||||
}
|
||||
|
||||
// TestHostFs_Write_ParentDirMissing verifies that hostFs.WriteFile creates parent dirs automatically.
|
||||
|
||||
func TestHostFs_Write_ParentDirMissing(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
target := filepath.Join(tmpDir, "a", "b", "c", "file.txt")
|
||||
|
||||
err := (&hostFs{}).WriteFile(target, []byte("hello"))
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
data, err := os.ReadFile(target)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "hello", string(data))
|
||||
}
|
||||
|
||||
// TestSandboxFs_Write_ParentDirMissing verifies that sandboxFs.WriteFile creates
|
||||
|
||||
// nested parent directories automatically within the sandbox.
|
||||
|
||||
func TestSandboxFs_Write_ParentDirMissing(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
relPath := "x/y/z/file.txt"
|
||||
|
||||
err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested"))
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(workspace, relPath))
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "nested", string(data))
|
||||
}
|
||||
|
||||
// TestHostFs_Write verifies the hostFs.WriteFile helper function
|
||||
|
||||
func TestHostFs_Write(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
testFile := filepath.Join(tmpDir, "atomic_test.txt")
|
||||
|
||||
testData := []byte("atomic test content")
|
||||
|
||||
err := (&hostFs{}).WriteFile(testFile, testData)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(testFile)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, testData, content)
|
||||
|
||||
// Verify it overwrites correctly
|
||||
|
||||
newData := []byte("new atomic content")
|
||||
|
||||
err = (&hostFs{}).WriteFile(testFile, newData)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
content, err = os.ReadFile(testFile)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, newData, content)
|
||||
}
|
||||
|
||||
// TestSandboxFs_Write verifies the sandboxFs.WriteFile helper function
|
||||
|
||||
func TestSandboxFs_Write(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
relPath := "atomic_root_test.txt"
|
||||
|
||||
testData := []byte("atomic root test content")
|
||||
|
||||
erw := &sandboxFs{workspace: tmpDir}
|
||||
|
||||
err := erw.WriteFile(relPath, testData)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
root, err := os.OpenRoot(tmpDir)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
defer root.Close()
|
||||
|
||||
f, err := root.Open(relPath)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
defer f.Close()
|
||||
|
||||
content, err := io.ReadAll(f)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, testData, content)
|
||||
|
||||
// Verify it overwrites correctly
|
||||
|
||||
newData := []byte("new root atomic content")
|
||||
|
||||
err = erw.WriteFile(relPath, newData)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
f2, err := root.Open(relPath)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
defer f2.Close()
|
||||
|
||||
content, err = io.ReadAll(f2)
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, newData, content)
|
||||
}
|
||||
|
||||
// TestValidatePath_OutsideWorkspace_IncludesPath verifies that the access
|
||||
|
||||
// denied error includes the workspace path so the caller knows the boundary.
|
||||
|
||||
func TestValidatePath_OutsideWorkspace_IncludesPath(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
outsidePath := filepath.Join(t.TempDir(), "secret.txt")
|
||||
|
||||
_, err := validatePath(outsidePath, workspace, true)
|
||||
|
||||
assert.Error(t, err)
|
||||
|
||||
assert.Contains(t, err.Error(), "access denied")
|
||||
|
||||
assert.Contains(t, err.Error(), workspace)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,35 +12,49 @@ import (
|
|||
)
|
||||
|
||||
// worktreeInfoKey is the context key for passing WorktreeInfo to tools.
|
||||
|
||||
type worktreeInfoKey struct{}
|
||||
|
||||
// WithWorktreeInfo returns a context carrying the active WorktreeInfo.
|
||||
|
||||
func WithWorktreeInfo(ctx context.Context, wt *git.WorktreeInfo) context.Context {
|
||||
return context.WithValue(ctx, worktreeInfoKey{}, wt)
|
||||
}
|
||||
|
||||
// WorktreeInfoFromCtx extracts the WorktreeInfo from context, or nil.
|
||||
|
||||
func WorktreeInfoFromCtx(ctx context.Context) *git.WorktreeInfo {
|
||||
if v, ok := ctx.Value(worktreeInfoKey{}).(*git.WorktreeInfo); ok {
|
||||
return v
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// protectedBranches are branch names that can never be pushed to.
|
||||
|
||||
var protectedBranches = regexp.MustCompile(`^(main|master|develop|release/.*)$`)
|
||||
|
||||
// GitPushTool implements safe git push restricted to worktree branches.
|
||||
|
||||
//
|
||||
|
||||
// Safety invariants:
|
||||
|
||||
// - Only works inside a worktree (WorktreeInfo must be in context)
|
||||
|
||||
// - Pushes only the worktree's branch — no arbitrary branch targets
|
||||
|
||||
// - Protected branches (main, master, develop, release/*) are blocked
|
||||
|
||||
// - Force push is never allowed
|
||||
|
||||
// - Auto-commits uncommitted changes before pushing
|
||||
|
||||
type GitPushTool struct{}
|
||||
|
||||
// NewGitPushTool creates a GitPushTool.
|
||||
|
||||
func NewGitPushTool() *GitPushTool {
|
||||
return &GitPushTool{}
|
||||
}
|
||||
|
|
@ -49,94 +63,138 @@ func (t *GitPushTool) Name() string { return "git_push" }
|
|||
|
||||
func (t *GitPushTool) Description() string {
|
||||
return "Push the current worktree branch to origin. Only works inside a git worktree. " +
|
||||
|
||||
"Auto-commits uncommitted changes before pushing. " +
|
||||
|
||||
"Protected branches (main, master, develop) cannot be pushed to. Force push is not allowed."
|
||||
}
|
||||
|
||||
func (t *GitPushTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"commit_message": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Commit message for uncommitted changes. If omitted, uncommitted changes are auto-committed with a default message.",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *GitPushTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
wt := WorktreeInfoFromCtx(ctx)
|
||||
|
||||
if wt == nil {
|
||||
return ErrorResult(
|
||||
|
||||
"git_push requires an active worktree.\n" +
|
||||
|
||||
"This tool can only be used during worktree-based sessions " +
|
||||
|
||||
"(e.g., heartbeat tasks or plan executing phase).\n" +
|
||||
|
||||
"The worktree provides the branch name and isolation boundary — " +
|
||||
|
||||
"without it, git_push cannot determine which branch to push.")
|
||||
}
|
||||
|
||||
branch := wt.Branch
|
||||
|
||||
if branch == "" {
|
||||
return ErrorResult(
|
||||
|
||||
"worktree has no branch name.\n" +
|
||||
|
||||
"The WorktreeInfo was set but Branch is empty. " +
|
||||
|
||||
"This is an internal error — the worktree may not have been created correctly.")
|
||||
}
|
||||
|
||||
// Block protected branches
|
||||
|
||||
if protectedBranches.MatchString(branch) {
|
||||
return ErrorResult(fmt.Sprintf(
|
||||
|
||||
"cannot push to protected branch %q.\n"+
|
||||
|
||||
"Protected branches (main, master, develop, release/*) are blocked to prevent "+
|
||||
|
||||
"accidental overwrites. Work should be done on feature branches created by worktrees.",
|
||||
|
||||
branch))
|
||||
}
|
||||
|
||||
// Auto-commit uncommitted changes
|
||||
|
||||
if git.HasUncommittedChanges(wt.Path) {
|
||||
commitMsg := "auto: save before push"
|
||||
|
||||
if msg, ok := args["commit_message"].(string); ok && msg != "" {
|
||||
commitMsg = msg
|
||||
}
|
||||
|
||||
if err := git.AutoCommit(wt.Path, commitMsg); err != nil {
|
||||
return ErrorResult(fmt.Sprintf(
|
||||
|
||||
"auto-commit failed before push: %v\n"+
|
||||
|
||||
"git_push auto-commits uncommitted changes before pushing. "+
|
||||
|
||||
"The commit failed, so no push was attempted. "+
|
||||
|
||||
"Check if the worktree at %q is in a valid state (e.g., no merge conflicts).",
|
||||
|
||||
err, wt.Path))
|
||||
}
|
||||
}
|
||||
|
||||
// Check there are commits to push
|
||||
|
||||
ahead := git.CommitsAhead(wt.RepoRoot, wt.BaseBranch, branch)
|
||||
|
||||
if ahead == 0 {
|
||||
return NewToolResult(fmt.Sprintf(
|
||||
|
||||
"Nothing to push: branch %q has no commits ahead of %s.\n"+
|
||||
|
||||
"The branch is identical to the base. Make changes and commit before pushing.",
|
||||
|
||||
branch, wt.BaseBranch))
|
||||
}
|
||||
|
||||
// Push with -u (set upstream tracking)
|
||||
|
||||
pushCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(pushCtx, "git", "push", "-u", "origin", branch)
|
||||
|
||||
cmd.Dir = wt.Path
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
output := strings.TrimSpace(string(out))
|
||||
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf(
|
||||
|
||||
"git push failed for branch %q: %s\n%s\n"+
|
||||
|
||||
"Possible causes: network error, authentication failure, or remote rejected the push. "+
|
||||
|
||||
"If the remote branch has diverged, resolve the divergence in the worktree first — "+
|
||||
|
||||
"force push is not available.",
|
||||
|
||||
branch, err, output))
|
||||
}
|
||||
|
||||
return NewToolResult(fmt.Sprintf("Pushed branch %q to origin (%d commit(s) ahead of %s)\n%s",
|
||||
|
||||
branch, ahead, wt.BaseBranch, output))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,22 +9,29 @@ import (
|
|||
)
|
||||
|
||||
// TestGitPushTool_NoWorktree verifies that git_push fails without worktree context.
|
||||
|
||||
func TestGitPushTool_NoWorktree(t *testing.T) {
|
||||
tool := NewGitPushTool()
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{})
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error when no worktree in context")
|
||||
}
|
||||
|
||||
if result.ForLLM == "" {
|
||||
t.Fatal("error message should not be empty")
|
||||
}
|
||||
|
||||
// Verify helpful guidance is included
|
||||
|
||||
assertContains(t, result.ForLLM, "worktree")
|
||||
|
||||
assertContains(t, result.ForLLM, "heartbeat")
|
||||
}
|
||||
|
||||
// TestGitPushTool_ProtectedBranch verifies that protected branches are blocked.
|
||||
|
||||
func TestGitPushTool_ProtectedBranch(t *testing.T) {
|
||||
tool := NewGitPushTool()
|
||||
|
||||
|
|
@ -34,39 +41,55 @@ func TestGitPushTool_ProtectedBranch(t *testing.T) {
|
|||
t.Run(branch, func(t *testing.T) {
|
||||
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
|
||||
Branch: branch,
|
||||
|
||||
BaseBranch: "main",
|
||||
|
||||
Path: t.TempDir(),
|
||||
|
||||
RepoRoot: t.TempDir(),
|
||||
})
|
||||
|
||||
result := tool.Execute(ctx, map[string]any{})
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatalf("expected error for protected branch %q", branch)
|
||||
}
|
||||
|
||||
assertContains(t, result.ForLLM, "protected")
|
||||
|
||||
assertContains(t, result.ForLLM, branch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGitPushTool_EmptyBranch verifies that empty branch name is rejected.
|
||||
|
||||
func TestGitPushTool_EmptyBranch(t *testing.T) {
|
||||
tool := NewGitPushTool()
|
||||
|
||||
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
|
||||
Branch: "",
|
||||
|
||||
BaseBranch: "main",
|
||||
|
||||
Path: t.TempDir(),
|
||||
|
||||
RepoRoot: t.TempDir(),
|
||||
})
|
||||
|
||||
result := tool.Execute(ctx, map[string]any{})
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error for empty branch")
|
||||
}
|
||||
|
||||
assertContains(t, result.ForLLM, "no branch name")
|
||||
}
|
||||
|
||||
// TestGitPushTool_AllowedBranch verifies that non-protected branches pass the branch check.
|
||||
|
||||
// (Push itself will fail because there's no real git repo, but it should get past validation.)
|
||||
|
||||
func TestGitPushTool_AllowedBranch(t *testing.T) {
|
||||
tool := NewGitPushTool()
|
||||
|
||||
|
|
@ -76,12 +99,18 @@ func TestGitPushTool_AllowedBranch(t *testing.T) {
|
|||
t.Run(branch, func(t *testing.T) {
|
||||
ctx := WithWorktreeInfo(context.Background(), &git.WorktreeInfo{
|
||||
Branch: branch,
|
||||
|
||||
BaseBranch: "main",
|
||||
|
||||
Path: t.TempDir(),
|
||||
|
||||
RepoRoot: t.TempDir(),
|
||||
})
|
||||
|
||||
result := tool.Execute(ctx, map[string]any{})
|
||||
|
||||
// Should NOT fail with "protected branch" error
|
||||
|
||||
if result.IsError && strings.Contains(result.ForLLM, "protected") {
|
||||
t.Fatalf("branch %q should not be blocked as protected", branch)
|
||||
}
|
||||
|
|
@ -90,25 +119,36 @@ func TestGitPushTool_AllowedBranch(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestProtectedBranchesRegex tests the regex directly.
|
||||
|
||||
func TestProtectedBranchesRegex(t *testing.T) {
|
||||
tests := []struct {
|
||||
branch string
|
||||
|
||||
protected bool
|
||||
}{
|
||||
{"main", true},
|
||||
|
||||
{"master", true},
|
||||
|
||||
{"develop", true},
|
||||
|
||||
{"release/v1.0", true},
|
||||
|
||||
{"release/2026-03", true},
|
||||
|
||||
{"plan/add-feature", false},
|
||||
|
||||
{"feature/main", false}, // "main" not at start
|
||||
|
||||
{"main-backup", false}, // "main" followed by suffix
|
||||
|
||||
{"hotfix/urgent", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.branch, func(t *testing.T) {
|
||||
got := protectedBranches.MatchString(tt.branch)
|
||||
|
||||
if got != tt.protected {
|
||||
t.Errorf("branch %q: got protected=%v, want %v", tt.branch, got, tt.protected)
|
||||
}
|
||||
|
|
@ -117,48 +157,64 @@ func TestProtectedBranchesRegex(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestWorktreeInfoContext verifies context round-trip.
|
||||
|
||||
func TestWorktreeInfoContext(t *testing.T) {
|
||||
wt := &git.WorktreeInfo{
|
||||
Branch: "plan/test",
|
||||
|
||||
BaseBranch: "main",
|
||||
|
||||
Path: "/tmp/wt",
|
||||
|
||||
RepoRoot: "/tmp/repo",
|
||||
}
|
||||
|
||||
ctx := WithWorktreeInfo(context.Background(), wt)
|
||||
|
||||
got := WorktreeInfoFromCtx(ctx)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil WorktreeInfo from context")
|
||||
}
|
||||
|
||||
if got.Branch != wt.Branch {
|
||||
t.Errorf("Branch: got %q, want %q", got.Branch, wt.Branch)
|
||||
}
|
||||
|
||||
if got.BaseBranch != wt.BaseBranch {
|
||||
t.Errorf("BaseBranch: got %q, want %q", got.BaseBranch, wt.BaseBranch)
|
||||
}
|
||||
|
||||
// Nil case
|
||||
|
||||
got2 := WorktreeInfoFromCtx(context.Background())
|
||||
|
||||
if got2 != nil {
|
||||
t.Errorf("expected nil WorktreeInfo from bare context, got %+v", got2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGitPushTool_Interface verifies the tool satisfies the Tool interface.
|
||||
|
||||
func TestGitPushTool_Interface(t *testing.T) {
|
||||
var _ Tool = (*GitPushTool)(nil)
|
||||
|
||||
tool := NewGitPushTool()
|
||||
|
||||
if tool.Name() != "git_push" {
|
||||
t.Errorf("Name: got %q, want %q", tool.Name(), "git_push")
|
||||
}
|
||||
|
||||
if tool.Description() == "" {
|
||||
t.Error("Description should not be empty")
|
||||
}
|
||||
|
||||
params := tool.Parameters()
|
||||
|
||||
if params == nil {
|
||||
t.Fatal("Parameters should not be nil")
|
||||
}
|
||||
|
||||
if params["type"] != "object" {
|
||||
t.Errorf("Parameters type: got %v, want object", params["type"])
|
||||
}
|
||||
|
|
@ -166,6 +222,7 @@ func TestGitPushTool_Interface(t *testing.T) {
|
|||
|
||||
func assertContains(t *testing.T, s, substr string) {
|
||||
t.Helper()
|
||||
|
||||
if !strings.Contains(s, substr) {
|
||||
t.Errorf("expected %q to contain %q", s, substr)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
)
|
||||
|
||||
// I2CTool provides I2C bus interaction for reading sensors and controlling peripherals.
|
||||
|
||||
type I2CTool struct{}
|
||||
|
||||
func NewI2CTool() *I2CTool {
|
||||
|
|
@ -27,38 +28,55 @@ func (t *I2CTool) Description() string {
|
|||
func (t *I2CTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"enum": []string{"detect", "scan", "read", "write"},
|
||||
|
||||
"description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)",
|
||||
},
|
||||
|
||||
"bus": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.",
|
||||
},
|
||||
|
||||
"address": map[string]any{
|
||||
"type": "integer",
|
||||
|
||||
"description": "7-bit I2C device address (0x03-0x77). Required for read/write.",
|
||||
},
|
||||
|
||||
"register": map[string]any{
|
||||
"type": "integer",
|
||||
|
||||
"description": "Register address to read from or write to. If set, sends register byte before read/write.",
|
||||
},
|
||||
|
||||
"data": map[string]any{
|
||||
"type": "array",
|
||||
|
||||
"items": map[string]any{"type": "integer"},
|
||||
|
||||
"description": "Bytes to write (0-255 each). Required for write action.",
|
||||
},
|
||||
|
||||
"length": map[string]any{
|
||||
"type": "integer",
|
||||
|
||||
"description": "Number of bytes to read (1-256). Default: 1. Used with read action.",
|
||||
},
|
||||
|
||||
"confirm": map[string]any{
|
||||
"type": "boolean",
|
||||
|
||||
"description": "Must be true for write operations. Safety guard to prevent accidental writes.",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"action"},
|
||||
}
|
||||
}
|
||||
|
|
@ -69,25 +87,36 @@ func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
|||
}
|
||||
|
||||
action, ok := args["action"].(string)
|
||||
|
||||
if !ok {
|
||||
return ErrorResult("action is required")
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "detect":
|
||||
|
||||
return t.detect()
|
||||
|
||||
case "scan":
|
||||
|
||||
return t.scan(args)
|
||||
|
||||
case "read":
|
||||
|
||||
return t.readDevice(args)
|
||||
|
||||
case "write":
|
||||
|
||||
return t.writeDevice(args)
|
||||
|
||||
default:
|
||||
|
||||
return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action))
|
||||
}
|
||||
}
|
||||
|
||||
// detect lists available I2C buses by globbing /dev/i2c-*
|
||||
|
||||
func (t *I2CTool) detect() *ToolResult {
|
||||
matches, err := filepath.Glob("/dev/i2c-*")
|
||||
if err != nil {
|
||||
|
|
@ -102,11 +131,14 @@ func (t *I2CTool) detect() *ToolResult {
|
|||
|
||||
type busInfo struct {
|
||||
Path string `json:"path"`
|
||||
|
||||
Bus string `json:"bus"`
|
||||
}
|
||||
|
||||
buses := make([]busInfo, 0, len(matches))
|
||||
|
||||
re := regexp.MustCompile(`/dev/i2c-(\d+)`)
|
||||
|
||||
for _, m := range matches {
|
||||
if sub := re.FindStringSubmatch(m); sub != nil {
|
||||
buses = append(buses, busInfo{Path: m, Bus: sub[1]})
|
||||
|
|
@ -114,44 +146,62 @@ func (t *I2CTool) detect() *ToolResult {
|
|||
}
|
||||
|
||||
result, _ := json.MarshalIndent(buses, "", " ")
|
||||
|
||||
return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result)))
|
||||
}
|
||||
|
||||
// Helper functions for I2C operations (used by platform-specific implementations)
|
||||
|
||||
// isValidBusID checks that a bus identifier is a simple number (prevents path injection)
|
||||
|
||||
//
|
||||
|
||||
//nolint:unused // Used by i2c_linux.go
|
||||
|
||||
func isValidBusID(id string) bool {
|
||||
matched, _ := regexp.MatchString(`^\d+$`, id)
|
||||
|
||||
return matched
|
||||
}
|
||||
|
||||
// parseI2CAddress extracts and validates an I2C address from args
|
||||
|
||||
//
|
||||
|
||||
//nolint:unused // Used by i2c_linux.go
|
||||
|
||||
func parseI2CAddress(args map[string]any) (int, *ToolResult) {
|
||||
addrFloat, ok := args["address"].(float64)
|
||||
|
||||
if !ok {
|
||||
return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)")
|
||||
}
|
||||
|
||||
addr := int(addrFloat)
|
||||
|
||||
if addr < 0x03 || addr > 0x77 {
|
||||
return 0, ErrorResult("address must be in valid 7-bit range (0x03-0x77)")
|
||||
}
|
||||
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
// parseI2CBus extracts and validates an I2C bus from args
|
||||
|
||||
//
|
||||
|
||||
//nolint:unused // Used by i2c_linux.go
|
||||
|
||||
func parseI2CBus(args map[string]any) (string, *ToolResult) {
|
||||
bus, ok := args["bus"].(string)
|
||||
|
||||
if !ok || bus == "" {
|
||||
return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)")
|
||||
}
|
||||
|
||||
if !isValidBusID(bus) {
|
||||
return "", ErrorResult("invalid bus identifier: must be a number (e.g. \"1\")")
|
||||
}
|
||||
|
||||
return bus, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,10 +97,12 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
|
|||
|
||||
hasQuick := funcs&i2cFuncSmbusQuick != 0
|
||||
hasReadByte := funcs&i2cFuncSmbusReadByte != 0
|
||||
|
||||
if !hasQuick && !hasReadByte {
|
||||
return ErrorResult(
|
||||
fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath),
|
||||
fmt.Sprintf(
|
||||
"I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely",
|
||||
devPath,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +112,7 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
|
|||
}
|
||||
|
||||
var found []deviceEntry
|
||||
|
||||
// Scan 0x08-0x77, skipping I2C reserved addresses 0x00-0x07
|
||||
for addr := 0x08; addr <= 0x77; addr++ {
|
||||
// Set slave address — EBUSY means a kernel driver owns this address
|
||||
|
|
@ -123,7 +126,6 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
|
|||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if smbusProbe(fd, addr, hasQuick) {
|
||||
found = append(found, deviceEntry{
|
||||
Address: fmt.Sprintf("0x%02x", addr),
|
||||
|
|
@ -140,10 +142,11 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
|
|||
"devices": found,
|
||||
"count": len(found),
|
||||
}, "", " ")
|
||||
|
||||
return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result)))
|
||||
}
|
||||
|
||||
// readDevice reads bytes from an I2C device, optionally at a specific register
|
||||
// readDevice reads bytes from an I2C device, optionally at a specific register.
|
||||
func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
|
||||
bus, errResult := parseI2CBus(args)
|
||||
if errResult != nil {
|
||||
|
|
@ -210,15 +213,18 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
|
|||
"hex": hexBytes,
|
||||
"length": n,
|
||||
}, "", " ")
|
||||
|
||||
return SilentResult(string(result))
|
||||
}
|
||||
|
||||
// writeDevice writes bytes to an I2C device, optionally at a specific register
|
||||
// writeDevice writes bytes to an I2C device, optionally at a specific register.
|
||||
func (t *I2CTool) writeDevice(args map[string]any) *ToolResult {
|
||||
confirm, _ := args["confirm"].(bool)
|
||||
if !confirm {
|
||||
return ErrorResult(
|
||||
"write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.",
|
||||
"write operations require confirm: true." +
|
||||
" Please confirm with the user before writing to I2C devices," +
|
||||
" as incorrect writes can misconfigure hardware.",
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import (
|
|||
)
|
||||
|
||||
// LogsTool provides on-demand access to application logs from the in-memory ring buffer.
|
||||
|
||||
// Designed for token-efficient log analysis: defaults to WARN level to exclude noise.
|
||||
|
||||
type LogsTool struct{}
|
||||
|
||||
func NewLogsTool() *LogsTool {
|
||||
|
|
@ -20,29 +22,40 @@ func (t *LogsTool) Name() string { return "logs" }
|
|||
|
||||
func (t *LogsTool) Description() string {
|
||||
return "Retrieve recent application logs from the in-memory ring buffer. " +
|
||||
|
||||
"Use level filter to minimize token usage (default: WARN). " +
|
||||
|
||||
"Call this when the user asks about errors, issues, or system health."
|
||||
}
|
||||
|
||||
func (t *LogsTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"level": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Minimum log level: DEBUG, INFO, WARN, ERROR. Default: WARN",
|
||||
|
||||
"enum": []string{"DEBUG", "INFO", "WARN", "ERROR"},
|
||||
},
|
||||
|
||||
"component": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Filter by component name (e.g. telegram, discord, slack, agent)",
|
||||
},
|
||||
|
||||
"limit": map[string]any{
|
||||
"type": "integer",
|
||||
|
||||
"description": "Maximum number of log entries to return. Default: 50",
|
||||
},
|
||||
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Filter by substring match in log message",
|
||||
},
|
||||
},
|
||||
|
|
@ -51,38 +64,50 @@ func (t *LogsTool) Parameters() map[string]any {
|
|||
|
||||
func (t *LogsTool) Execute(_ context.Context, args map[string]any) *ToolResult {
|
||||
// Parse level (default: WARN)
|
||||
|
||||
level := logger.WARN
|
||||
|
||||
if lvlStr, ok := args["level"].(string); ok && lvlStr != "" {
|
||||
level = logger.ParseLevel(lvlStr)
|
||||
}
|
||||
|
||||
// Parse component
|
||||
|
||||
component, _ := args["component"].(string)
|
||||
|
||||
// Parse limit (default: 50, max: 300)
|
||||
|
||||
limit := 50
|
||||
|
||||
if l, ok := args["limit"].(float64); ok && l > 0 {
|
||||
limit = int(l)
|
||||
}
|
||||
|
||||
if limit > 300 {
|
||||
limit = 300
|
||||
}
|
||||
|
||||
// Parse query
|
||||
|
||||
query, _ := args["query"].(string)
|
||||
|
||||
// Fetch from ring buffer (already sanitized by RecentLogs)
|
||||
|
||||
entries := logger.RecentLogs(level, component, limit)
|
||||
|
||||
// Apply query filter if specified
|
||||
|
||||
if query != "" {
|
||||
filtered := make([]logger.LogEntry, 0, len(entries))
|
||||
|
||||
queryLower := strings.ToLower(query)
|
||||
|
||||
for _, e := range entries {
|
||||
if strings.Contains(strings.ToLower(e.Message), queryLower) {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
|
||||
entries = filtered
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,22 +11,31 @@ import (
|
|||
|
||||
func setupTestLogs(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
prev := logger.GetLevel()
|
||||
|
||||
t.Cleanup(func() { logger.SetLevel(prev) })
|
||||
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
|
||||
logger.DebugC("agent", "debug message")
|
||||
|
||||
logger.InfoC("telegram", "message received")
|
||||
|
||||
logger.WarnC("telegram", "webhook retry")
|
||||
|
||||
logger.ErrorC("discord", "connection timeout")
|
||||
|
||||
logger.WarnCF("wecom", "signature failed", map[string]any{
|
||||
"token": "secret-value",
|
||||
|
||||
"nonce": "safe-value",
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogsTool_DefaultLevel(t *testing.T) {
|
||||
setupTestLogs(t)
|
||||
|
||||
tool := NewLogsTool()
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{})
|
||||
|
|
@ -36,6 +45,7 @@ func TestLogsTool_DefaultLevel(t *testing.T) {
|
|||
}
|
||||
|
||||
var entries []logger.LogEntry
|
||||
|
||||
if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil {
|
||||
t.Fatalf("failed to parse result: %v", err)
|
||||
}
|
||||
|
|
@ -49,6 +59,7 @@ func TestLogsTool_DefaultLevel(t *testing.T) {
|
|||
|
||||
func TestLogsTool_LevelFilter(t *testing.T) {
|
||||
setupTestLogs(t)
|
||||
|
||||
tool := NewLogsTool()
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
|
|
@ -60,6 +71,7 @@ func TestLogsTool_LevelFilter(t *testing.T) {
|
|||
}
|
||||
|
||||
var entries []logger.LogEntry
|
||||
|
||||
if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil {
|
||||
t.Fatalf("failed to parse result: %v", err)
|
||||
}
|
||||
|
|
@ -73,10 +85,12 @@ func TestLogsTool_LevelFilter(t *testing.T) {
|
|||
|
||||
func TestLogsTool_ComponentFilter(t *testing.T) {
|
||||
setupTestLogs(t)
|
||||
|
||||
tool := NewLogsTool()
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"level": "DEBUG",
|
||||
|
||||
"component": "telegram",
|
||||
})
|
||||
|
||||
|
|
@ -85,6 +99,7 @@ func TestLogsTool_ComponentFilter(t *testing.T) {
|
|||
}
|
||||
|
||||
var entries []logger.LogEntry
|
||||
|
||||
if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil {
|
||||
t.Fatalf("failed to parse result: %v", err)
|
||||
}
|
||||
|
|
@ -98,10 +113,12 @@ func TestLogsTool_ComponentFilter(t *testing.T) {
|
|||
|
||||
func TestLogsTool_QueryFilter(t *testing.T) {
|
||||
setupTestLogs(t)
|
||||
|
||||
tool := NewLogsTool()
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"level": "DEBUG",
|
||||
|
||||
"query": "timeout",
|
||||
})
|
||||
|
||||
|
|
@ -110,6 +127,7 @@ func TestLogsTool_QueryFilter(t *testing.T) {
|
|||
}
|
||||
|
||||
var entries []logger.LogEntry
|
||||
|
||||
if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil {
|
||||
t.Fatalf("failed to parse result: %v", err)
|
||||
}
|
||||
|
|
@ -117,6 +135,7 @@ func TestLogsTool_QueryFilter(t *testing.T) {
|
|||
if len(entries) == 0 {
|
||||
t.Fatal("expected at least one entry matching 'timeout'")
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if !strings.Contains(strings.ToLower(e.Message), "timeout") {
|
||||
t.Errorf("entry should contain 'timeout': %s", e.Message)
|
||||
|
|
@ -126,14 +145,17 @@ func TestLogsTool_QueryFilter(t *testing.T) {
|
|||
|
||||
func TestLogsTool_QueryCaseInsensitive(t *testing.T) {
|
||||
setupTestLogs(t)
|
||||
|
||||
tool := NewLogsTool()
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"level": "DEBUG",
|
||||
|
||||
"query": "TIMEOUT",
|
||||
})
|
||||
|
||||
var entries []logger.LogEntry
|
||||
|
||||
if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil {
|
||||
t.Fatalf("failed to parse result: %v", err)
|
||||
}
|
||||
|
|
@ -145,10 +167,12 @@ func TestLogsTool_QueryCaseInsensitive(t *testing.T) {
|
|||
|
||||
func TestLogsTool_Limit(t *testing.T) {
|
||||
setupTestLogs(t)
|
||||
|
||||
tool := NewLogsTool()
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"level": "DEBUG",
|
||||
|
||||
"limit": float64(2),
|
||||
})
|
||||
|
||||
|
|
@ -157,6 +181,7 @@ func TestLogsTool_Limit(t *testing.T) {
|
|||
}
|
||||
|
||||
var entries []logger.LogEntry
|
||||
|
||||
if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil {
|
||||
t.Fatalf("failed to parse result: %v", err)
|
||||
}
|
||||
|
|
@ -170,12 +195,15 @@ func TestLogsTool_LimitMax(t *testing.T) {
|
|||
tool := NewLogsTool()
|
||||
|
||||
// limit > 300 should be capped
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"level": "DEBUG",
|
||||
|
||||
"limit": float64(999),
|
||||
})
|
||||
|
||||
// Should not error, just cap silently
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %s", result.ForLLM)
|
||||
}
|
||||
|
|
@ -183,10 +211,12 @@ func TestLogsTool_LimitMax(t *testing.T) {
|
|||
|
||||
func TestLogsTool_FieldsSanitized(t *testing.T) {
|
||||
setupTestLogs(t)
|
||||
|
||||
tool := NewLogsTool()
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"level": "WARN",
|
||||
|
||||
"component": "wecom",
|
||||
})
|
||||
|
||||
|
|
@ -195,22 +225,27 @@ func TestLogsTool_FieldsSanitized(t *testing.T) {
|
|||
}
|
||||
|
||||
var entries []logger.LogEntry
|
||||
|
||||
if err := json.Unmarshal([]byte(result.ForLLM), &entries); err != nil {
|
||||
t.Fatalf("failed to parse result: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
|
||||
for _, e := range entries {
|
||||
if e.Fields != nil && e.Fields["token"] != nil {
|
||||
found = true
|
||||
|
||||
if e.Fields["token"] != "***" {
|
||||
t.Errorf("token field should be sanitized, got %v", e.Fields["token"])
|
||||
}
|
||||
|
||||
if e.Fields["nonce"] != "safe-value" {
|
||||
t.Errorf("nonce field should be preserved, got %v", e.Fields["nonce"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("expected to find wecom entry with token field")
|
||||
}
|
||||
|
|
@ -218,19 +253,23 @@ func TestLogsTool_FieldsSanitized(t *testing.T) {
|
|||
|
||||
func TestLogsTool_NoResults(t *testing.T) {
|
||||
prev := logger.GetLevel()
|
||||
|
||||
defer logger.SetLevel(prev)
|
||||
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
|
||||
tool := NewLogsTool()
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"level": "ERROR",
|
||||
|
||||
"component": "nonexistent-component-xyz",
|
||||
})
|
||||
|
||||
if result.IsError {
|
||||
t.Fatalf("should not be an error result: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "No log entries found") {
|
||||
t.Errorf("expected 'No log entries found' message, got: %s", result.ForLLM)
|
||||
}
|
||||
|
|
@ -238,9 +277,11 @@ func TestLogsTool_NoResults(t *testing.T) {
|
|||
|
||||
func TestLogsTool_Silent(t *testing.T) {
|
||||
setupTestLogs(t)
|
||||
|
||||
tool := NewLogsTool()
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{})
|
||||
|
||||
if !result.Silent {
|
||||
t.Error("logs tool result should be Silent")
|
||||
}
|
||||
|
|
@ -252,10 +293,13 @@ func TestLogsTool_ToolInterface(t *testing.T) {
|
|||
if tool.Name() != "logs" {
|
||||
t.Errorf("expected name 'logs', got %q", tool.Name())
|
||||
}
|
||||
|
||||
if tool.Description() == "" {
|
||||
t.Error("description should not be empty")
|
||||
}
|
||||
|
||||
params := tool.Parameters()
|
||||
|
||||
if params == nil {
|
||||
t.Error("parameters should not be nil")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@ type SendCallback func(channel, chatID, content string) error
|
|||
|
||||
type MessageTool struct {
|
||||
sendCallback SendCallback
|
||||
|
||||
defaultChannel string
|
||||
|
||||
defaultChatID string
|
||||
|
||||
sentInRound bool // Tracks whether a message was sent in the current processing round
|
||||
}
|
||||
|
||||
|
|
@ -29,31 +32,41 @@ func (t *MessageTool) Description() string {
|
|||
func (t *MessageTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "The message content to send",
|
||||
},
|
||||
|
||||
"channel": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Optional: target channel (telegram, whatsapp, etc.)",
|
||||
},
|
||||
|
||||
"chat_id": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
"description": "Optional: target chat/user ID",
|
||||
},
|
||||
},
|
||||
|
||||
"required": []string{"content"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MessageTool) SetContext(channel, chatID string) {
|
||||
t.defaultChannel = channel
|
||||
|
||||
t.defaultChatID = chatID
|
||||
|
||||
t.sentInRound = false // Reset send tracking for new processing round
|
||||
}
|
||||
|
||||
// HasSentInRound returns true if the message tool sent a message during the current round.
|
||||
|
||||
func (t *MessageTool) HasSentInRound() bool {
|
||||
return t.sentInRound
|
||||
}
|
||||
|
|
@ -64,16 +77,19 @@ func (t *MessageTool) SetSendCallback(callback SendCallback) {
|
|||
|
||||
func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
content, ok := args["content"].(string)
|
||||
|
||||
if !ok {
|
||||
return &ToolResult{ForLLM: "content is required", IsError: true}
|
||||
}
|
||||
|
||||
channel, _ := args["channel"].(string)
|
||||
|
||||
chatID, _ := args["chat_id"].(string)
|
||||
|
||||
if channel == "" {
|
||||
channel = t.defaultChannel
|
||||
}
|
||||
|
||||
if chatID == "" {
|
||||
chatID = t.defaultChatID
|
||||
}
|
||||
|
|
@ -89,15 +105,20 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
|||
if err := t.sendCallback(channel, chatID, content); err != nil {
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("sending message: %v", err),
|
||||
|
||||
IsError: true,
|
||||
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
t.sentInRound = true
|
||||
|
||||
// Silent: user already received the message directly
|
||||
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
|
||||
|
||||
Silent: true,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,17 +8,23 @@ import (
|
|||
|
||||
func TestMessageTool_Execute_Success(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
tool.SetContext("test-channel", "test-chat-id")
|
||||
|
||||
var sentChannel, sentChatID, sentContent string
|
||||
|
||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
sentChannel = channel
|
||||
|
||||
sentChatID = chatID
|
||||
|
||||
sentContent = content
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"content": "Hello, world!",
|
||||
}
|
||||
|
|
@ -26,33 +32,41 @@ func TestMessageTool_Execute_Success(t *testing.T) {
|
|||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Verify message was sent with correct parameters
|
||||
|
||||
if sentChannel != "test-channel" {
|
||||
t.Errorf("Expected channel 'test-channel', got '%s'", sentChannel)
|
||||
}
|
||||
|
||||
if sentChatID != "test-chat-id" {
|
||||
t.Errorf("Expected chatID 'test-chat-id', got '%s'", sentChatID)
|
||||
}
|
||||
|
||||
if sentContent != "Hello, world!" {
|
||||
t.Errorf("Expected content 'Hello, world!', got '%s'", sentContent)
|
||||
}
|
||||
|
||||
// Verify ToolResult meets US-011 criteria:
|
||||
|
||||
// - Send success returns SilentResult (Silent=true)
|
||||
|
||||
if !result.Silent {
|
||||
t.Error("Expected Silent=true for successful send")
|
||||
}
|
||||
|
||||
// - ForLLM contains send status description
|
||||
|
||||
if result.ForLLM != "Message sent to test-channel:test-chat-id" {
|
||||
t.Errorf("Expected ForLLM 'Message sent to test-channel:test-chat-id', got '%s'", result.ForLLM)
|
||||
}
|
||||
|
||||
// - ForUser is empty (user already received message directly)
|
||||
|
||||
if result.ForUser != "" {
|
||||
t.Errorf("Expected ForUser to be empty, got '%s'", result.ForUser)
|
||||
}
|
||||
|
||||
// - IsError should be false
|
||||
|
||||
if result.IsError {
|
||||
t.Error("Expected IsError=false for successful send")
|
||||
}
|
||||
|
|
@ -60,28 +74,37 @@ func TestMessageTool_Execute_Success(t *testing.T) {
|
|||
|
||||
func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
tool.SetContext("default-channel", "default-chat-id")
|
||||
|
||||
var sentChannel, sentChatID string
|
||||
|
||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
sentChannel = channel
|
||||
|
||||
sentChatID = chatID
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"content": "Test message",
|
||||
|
||||
"channel": "custom-channel",
|
||||
|
||||
"chat_id": "custom-chat-id",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Verify custom channel/chatID were used instead of defaults
|
||||
|
||||
if sentChannel != "custom-channel" {
|
||||
t.Errorf("Expected channel 'custom-channel', got '%s'", sentChannel)
|
||||
}
|
||||
|
||||
if sentChatID != "custom-chat-id" {
|
||||
t.Errorf("Expected chatID 'custom-chat-id', got '%s'", sentChatID)
|
||||
}
|
||||
|
|
@ -89,6 +112,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
|
|||
if !result.Silent {
|
||||
t.Error("Expected Silent=true")
|
||||
}
|
||||
|
||||
if result.ForLLM != "Message sent to custom-channel:custom-chat-id" {
|
||||
t.Errorf("Expected ForLLM 'Message sent to custom-channel:custom-chat-id', got '%s'", result.ForLLM)
|
||||
}
|
||||
|
|
@ -96,14 +120,17 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
|
|||
|
||||
func TestMessageTool_Execute_SendFailure(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
tool.SetContext("test-channel", "test-chat-id")
|
||||
|
||||
sendErr := errors.New("network error")
|
||||
|
||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
return sendErr
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"content": "Test message",
|
||||
}
|
||||
|
|
@ -111,21 +138,27 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
|
|||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Verify ToolResult for send failure:
|
||||
|
||||
// - Send failure returns ErrorResult (IsError=true)
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("Expected IsError=true for failed send")
|
||||
}
|
||||
|
||||
// - ForLLM contains error description
|
||||
|
||||
expectedErrMsg := "sending message: network error"
|
||||
|
||||
if result.ForLLM != expectedErrMsg {
|
||||
t.Errorf("Expected ForLLM '%s', got '%s'", expectedErrMsg, result.ForLLM)
|
||||
}
|
||||
|
||||
// - Err field should contain original error
|
||||
|
||||
if result.Err == nil {
|
||||
t.Error("Expected Err to be set")
|
||||
}
|
||||
|
||||
if result.Err != sendErr {
|
||||
t.Errorf("Expected Err to be sendErr, got %v", result.Err)
|
||||
}
|
||||
|
|
@ -133,17 +166,21 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
|
|||
|
||||
func TestMessageTool_Execute_MissingContent(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
tool.SetContext("test-channel", "test-chat-id")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{} // content missing
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Verify error result for missing content
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("Expected IsError=true for missing content")
|
||||
}
|
||||
|
||||
if result.ForLLM != "content is required" {
|
||||
t.Errorf("Expected ForLLM 'content is required', got '%s'", result.ForLLM)
|
||||
}
|
||||
|
|
@ -151,6 +188,7 @@ func TestMessageTool_Execute_MissingContent(t *testing.T) {
|
|||
|
||||
func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
// No SetContext called, so defaultChannel and defaultChatID are empty
|
||||
|
||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
|
|
@ -158,6 +196,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
|
|||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"content": "Test message",
|
||||
}
|
||||
|
|
@ -165,9 +204,11 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
|
|||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Verify error when no target channel specified
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("Expected IsError=true when no target channel")
|
||||
}
|
||||
|
||||
if result.ForLLM != "No target channel/chat specified" {
|
||||
t.Errorf("Expected ForLLM 'No target channel/chat specified', got '%s'", result.ForLLM)
|
||||
}
|
||||
|
|
@ -175,10 +216,13 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
|
|||
|
||||
func TestMessageTool_Execute_NotConfigured(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
tool.SetContext("test-channel", "test-chat-id")
|
||||
|
||||
// No SetSendCallback called
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
args := map[string]any{
|
||||
"content": "Test message",
|
||||
}
|
||||
|
|
@ -186,9 +230,11 @@ func TestMessageTool_Execute_NotConfigured(t *testing.T) {
|
|||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Verify error when send callback not configured
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("Expected IsError=true when send callback not configured")
|
||||
}
|
||||
|
||||
if result.ForLLM != "Message sending not configured" {
|
||||
t.Errorf("Expected ForLLM 'Message sending not configured', got '%s'", result.ForLLM)
|
||||
}
|
||||
|
|
@ -196,6 +242,7 @@ func TestMessageTool_Execute_NotConfigured(t *testing.T) {
|
|||
|
||||
func TestMessageTool_Name(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
if tool.Name() != "message" {
|
||||
t.Errorf("Expected name 'message', got '%s'", tool.Name())
|
||||
}
|
||||
|
|
@ -203,7 +250,9 @@ func TestMessageTool_Name(t *testing.T) {
|
|||
|
||||
func TestMessageTool_Description(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
desc := tool.Description()
|
||||
|
||||
if desc == "" {
|
||||
t.Error("Description should not be empty")
|
||||
}
|
||||
|
|
@ -211,48 +260,63 @@ func TestMessageTool_Description(t *testing.T) {
|
|||
|
||||
func TestMessageTool_Parameters(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
params := tool.Parameters()
|
||||
|
||||
// Verify parameters structure
|
||||
|
||||
typ, ok := params["type"].(string)
|
||||
|
||||
if !ok || typ != "object" {
|
||||
t.Error("Expected type 'object'")
|
||||
}
|
||||
|
||||
props, ok := params["properties"].(map[string]any)
|
||||
|
||||
if !ok {
|
||||
t.Fatal("Expected properties to be a map")
|
||||
}
|
||||
|
||||
// Check required properties
|
||||
|
||||
required, ok := params["required"].([]string)
|
||||
|
||||
if !ok || len(required) != 1 || required[0] != "content" {
|
||||
t.Error("Expected 'content' to be required")
|
||||
}
|
||||
|
||||
// Check content property
|
||||
|
||||
contentProp, ok := props["content"].(map[string]any)
|
||||
|
||||
if !ok {
|
||||
t.Error("Expected 'content' property")
|
||||
}
|
||||
|
||||
if contentProp["type"] != "string" {
|
||||
t.Error("Expected content type to be 'string'")
|
||||
}
|
||||
|
||||
// Check channel property (optional)
|
||||
|
||||
channelProp, ok := props["channel"].(map[string]any)
|
||||
|
||||
if !ok {
|
||||
t.Error("Expected 'channel' property")
|
||||
}
|
||||
|
||||
if channelProp["type"] != "string" {
|
||||
t.Error("Expected channel type to be 'string'")
|
||||
}
|
||||
|
||||
// Check chat_id property (optional)
|
||||
|
||||
chatIDProp, ok := props["chat_id"].(map[string]any)
|
||||
|
||||
if !ok {
|
||||
t.Error("Expected 'chat_id' property")
|
||||
}
|
||||
|
||||
if chatIDProp["type"] != "string" {
|
||||
t.Error("Expected chat_id type to be 'string'")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package tools
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
|
@ -13,9 +14,12 @@ import (
|
|||
)
|
||||
|
||||
// NormalizeToolName keeps only lowercase ASCII letters.
|
||||
|
||||
// "read_file" → "readfile", "ReadFile" → "readfile", "read-file" → "readfile".
|
||||
|
||||
func NormalizeToolName(s string) string {
|
||||
var b strings.Builder
|
||||
|
||||
for _, r := range s {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
b.WriteRune(r + 32)
|
||||
|
|
@ -23,11 +27,13 @@ func NormalizeToolName(s string) string {
|
|||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type ToolRegistry struct {
|
||||
tools map[string]Tool
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
|
|
@ -39,24 +45,33 @@ func NewToolRegistry() *ToolRegistry {
|
|||
|
||||
func (r *ToolRegistry) Register(tool Tool) {
|
||||
r.mu.Lock()
|
||||
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.tools[tool.Name()] = tool
|
||||
}
|
||||
|
||||
func (r *ToolRegistry) Get(name string) (Tool, bool) {
|
||||
r.mu.RLock()
|
||||
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
// Exact match first
|
||||
|
||||
if tool, ok := r.tools[name]; ok {
|
||||
return tool, true
|
||||
}
|
||||
|
||||
// Fuzzy fallback: normalize and compare (handles "readfile" → "read_file" etc.)
|
||||
|
||||
norm := NormalizeToolName(name)
|
||||
|
||||
for _, tool := range r.tools {
|
||||
if NormalizeToolName(tool.Name()) == norm {
|
||||
return tool, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
|
|
@ -65,70 +80,99 @@ func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string
|
|||
}
|
||||
|
||||
// ExecuteWithContext executes a tool with channel/chatID context and optional async callback.
|
||||
|
||||
// If the tool implements AsyncTool and a non-nil callback is provided,
|
||||
|
||||
// the callback will be set on the tool before execution.
|
||||
|
||||
func (r *ToolRegistry) ExecuteWithContext(
|
||||
ctx context.Context,
|
||||
|
||||
name string,
|
||||
|
||||
args map[string]any,
|
||||
|
||||
channel, chatID string,
|
||||
|
||||
asyncCallback AsyncCallback,
|
||||
) *ToolResult {
|
||||
logger.InfoCF("tool", "Tool execution started",
|
||||
|
||||
map[string]any{
|
||||
"tool": name,
|
||||
|
||||
"args": args,
|
||||
})
|
||||
|
||||
tool, ok := r.Get(name)
|
||||
|
||||
if !ok {
|
||||
available := strings.Join(r.List(), ", ")
|
||||
|
||||
logger.ErrorCF("tool", "Tool not found",
|
||||
|
||||
map[string]any{
|
||||
"tool": name,
|
||||
})
|
||||
|
||||
return ErrorResult(fmt.Sprintf(
|
||||
|
||||
"tool %q not found. Available tools: %s", name, available,
|
||||
)).WithError(fmt.Errorf("tool not found"))
|
||||
}
|
||||
|
||||
// If tool implements ContextualTool, set context
|
||||
|
||||
if contextualTool, ok := tool.(ContextualTool); ok && channel != "" && chatID != "" {
|
||||
contextualTool.SetContext(channel, chatID)
|
||||
}
|
||||
|
||||
// If tool implements AsyncTool and callback is provided, set callback
|
||||
|
||||
if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil {
|
||||
asyncTool.SetCallback(asyncCallback)
|
||||
|
||||
logger.DebugCF("tool", "Async callback injected",
|
||||
|
||||
map[string]any{
|
||||
"tool": name,
|
||||
})
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
// Log based on result type
|
||||
|
||||
if result.IsError {
|
||||
logger.ErrorCF("tool", "Tool execution failed",
|
||||
|
||||
map[string]any{
|
||||
"tool": name,
|
||||
|
||||
"duration": duration.Milliseconds(),
|
||||
|
||||
"error": result.ForLLM,
|
||||
})
|
||||
} else if result.Async {
|
||||
logger.InfoCF("tool", "Tool started (async)",
|
||||
|
||||
map[string]any{
|
||||
"tool": name,
|
||||
|
||||
"duration": duration.Milliseconds(),
|
||||
})
|
||||
} else {
|
||||
logger.InfoCF("tool", "Tool execution completed",
|
||||
|
||||
map[string]any{
|
||||
"tool": name,
|
||||
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
|
||||
"result_length": len(result.ForLLM),
|
||||
})
|
||||
}
|
||||
|
|
@ -137,86 +181,128 @@ func (r *ToolRegistry) ExecuteWithContext(
|
|||
}
|
||||
|
||||
// sortedToolNames returns tool names in sorted order for deterministic iteration.
|
||||
|
||||
// This is critical for KV cache stability: non-deterministic map iteration would
|
||||
|
||||
// produce different system prompts and tool definitions on each call, invalidating
|
||||
|
||||
// the LLM's prefix cache even when no tools have changed.
|
||||
|
||||
func (r *ToolRegistry) sortedToolNames() []string {
|
||||
names := make([]string, 0, len(r.tools))
|
||||
|
||||
for name := range r.tools {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
sort.Strings(names)
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
func (r *ToolRegistry) GetDefinitions() []map[string]any {
|
||||
r.mu.RLock()
|
||||
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
sorted := r.sortedToolNames()
|
||||
|
||||
definitions := make([]map[string]any, 0, len(sorted))
|
||||
|
||||
for _, name := range sorted {
|
||||
definitions = append(definitions, ToolToSchema(r.tools[name]))
|
||||
}
|
||||
|
||||
return definitions
|
||||
}
|
||||
|
||||
// ToProviderDefs converts tool definitions to provider-compatible format.
|
||||
|
||||
// This is the format expected by LLM provider APIs.
|
||||
|
||||
func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
|
||||
r.mu.RLock()
|
||||
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
sorted := r.sortedToolNames()
|
||||
|
||||
definitions := make([]providers.ToolDefinition, 0, len(sorted))
|
||||
|
||||
for _, name := range sorted {
|
||||
tool := r.tools[name]
|
||||
|
||||
schema := ToolToSchema(tool)
|
||||
|
||||
// Safely extract nested values with type checks
|
||||
|
||||
fn, ok := schema["function"].(map[string]any)
|
||||
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
name, _ := fn["name"].(string)
|
||||
|
||||
desc, _ := fn["description"].(string)
|
||||
|
||||
params, _ := fn["parameters"].(map[string]any)
|
||||
|
||||
paramsRaw := json.RawMessage(`{}`)
|
||||
|
||||
if len(params) > 0 {
|
||||
if payload, err := json.Marshal(params); err == nil {
|
||||
paramsRaw = json.RawMessage(payload)
|
||||
}
|
||||
}
|
||||
|
||||
definitions = append(definitions, providers.ToolDefinition{
|
||||
Type: "function",
|
||||
|
||||
Function: providers.ToolFunctionDefinition{
|
||||
Name: name,
|
||||
|
||||
Description: desc,
|
||||
Parameters: params,
|
||||
|
||||
Parameters: paramsRaw,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return definitions
|
||||
}
|
||||
|
||||
// List returns a list of all registered tool names.
|
||||
|
||||
func (r *ToolRegistry) List() []string {
|
||||
r.mu.RLock()
|
||||
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
return r.sortedToolNames()
|
||||
}
|
||||
|
||||
// Count returns the number of registered tools.
|
||||
|
||||
func (r *ToolRegistry) Count() int {
|
||||
r.mu.RLock()
|
||||
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
return len(r.tools)
|
||||
}
|
||||
|
||||
// GetRuntimeStatus aggregates runtime status from all tools that implement StatusProvider.
|
||||
|
||||
// Returns empty string if no tool has status to report.
|
||||
|
||||
func (r *ToolRegistry) GetRuntimeStatus() string {
|
||||
r.mu.RLock()
|
||||
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var parts []string
|
||||
|
||||
for _, tool := range r.tools {
|
||||
if sp, ok := tool.(StatusProvider); ok {
|
||||
if s := sp.RuntimeStatus(); s != "" {
|
||||
|
|
@ -224,40 +310,53 @@ func (r *ToolRegistry) GetRuntimeStatus() string {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// buildParamHint extracts parameter names from a JSON schema and returns
|
||||
|
||||
// a hint string like "(task, label?, preset?)". Required params are bare,
|
||||
|
||||
// optional params have a trailing "?".
|
||||
|
||||
func buildParamHint(schema map[string]any) string {
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
|
||||
if len(props) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
reqSlice, _ := schema["required"].([]string)
|
||||
|
||||
reqSet := make(map[string]bool, len(reqSlice))
|
||||
|
||||
for _, r := range reqSlice {
|
||||
reqSet[r] = true
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(props))
|
||||
|
||||
for name := range props {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
sort.Strings(names)
|
||||
|
||||
parts := make([]string, 0, len(names))
|
||||
|
||||
// Required params first, then optional
|
||||
|
||||
for _, name := range names {
|
||||
if reqSet[name] {
|
||||
parts = append(parts, name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
if !reqSet[name] {
|
||||
parts = append(parts, name+"?")
|
||||
|
|
@ -268,17 +367,25 @@ func buildParamHint(schema map[string]any) string {
|
|||
}
|
||||
|
||||
// GetSummaries returns human-readable summaries of all registered tools.
|
||||
|
||||
// Returns a slice of "- `name`(params) - description" strings.
|
||||
|
||||
func (r *ToolRegistry) GetSummaries() []string {
|
||||
r.mu.RLock()
|
||||
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
sorted := r.sortedToolNames()
|
||||
|
||||
summaries := make([]string, 0, len(sorted))
|
||||
|
||||
for _, name := range sorted {
|
||||
tool := r.tools[name]
|
||||
|
||||
hint := buildParamHint(tool.Parameters())
|
||||
|
||||
summaries = append(summaries, fmt.Sprintf("- `%s`%s - %s", tool.Name(), hint, tool.Description()))
|
||||
}
|
||||
|
||||
return summaries
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,31 +13,41 @@ import (
|
|||
|
||||
type mockRegistryTool struct {
|
||||
name string
|
||||
|
||||
desc string
|
||||
|
||||
params map[string]any
|
||||
|
||||
result *ToolResult
|
||||
}
|
||||
|
||||
func (m *mockRegistryTool) Name() string { return m.name }
|
||||
|
||||
func (m *mockRegistryTool) Description() string { return m.desc }
|
||||
|
||||
func (m *mockRegistryTool) Parameters() map[string]any { return m.params }
|
||||
|
||||
func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
|
||||
return m.result
|
||||
}
|
||||
|
||||
type mockCtxTool struct {
|
||||
mockRegistryTool
|
||||
|
||||
channel string
|
||||
|
||||
chatID string
|
||||
}
|
||||
|
||||
func (m *mockCtxTool) SetContext(channel, chatID string) {
|
||||
m.channel = channel
|
||||
|
||||
m.chatID = chatID
|
||||
}
|
||||
|
||||
type mockAsyncRegistryTool struct {
|
||||
mockRegistryTool
|
||||
|
||||
cb AsyncCallback
|
||||
}
|
||||
|
||||
|
|
@ -50,8 +60,11 @@ func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) {
|
|||
func newMockTool(name, desc string) *mockRegistryTool {
|
||||
return &mockRegistryTool{
|
||||
name: name,
|
||||
|
||||
desc: desc,
|
||||
|
||||
params: map[string]any{"type": "object"},
|
||||
|
||||
result: SilentResult("ok"),
|
||||
}
|
||||
}
|
||||
|
|
@ -63,15 +76,23 @@ func TestNormalizeToolName(t *testing.T) {
|
|||
input, want string
|
||||
}{
|
||||
{"read_file", "readfile"},
|
||||
|
||||
{"readfile", "readfile"},
|
||||
|
||||
{"ReadFile", "readfile"},
|
||||
|
||||
{"read-file", "readfile"},
|
||||
|
||||
{"edit_file", "editfile"},
|
||||
|
||||
{"web_search", "websearch"},
|
||||
|
||||
{"EXEC", "exec"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := NormalizeToolName(tt.input)
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeToolName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
|
|
@ -80,9 +101,11 @@ func TestNormalizeToolName(t *testing.T) {
|
|||
|
||||
func TestNewToolRegistry(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
if r.Count() != 0 {
|
||||
t.Errorf("expected empty registry, got count %d", r.Count())
|
||||
}
|
||||
|
||||
if len(r.List()) != 0 {
|
||||
t.Errorf("expected empty list, got %v", r.List())
|
||||
}
|
||||
|
|
@ -90,13 +113,17 @@ func TestNewToolRegistry(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_RegisterAndGet(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
tool := newMockTool("echo", "echoes input")
|
||||
|
||||
r.Register(tool)
|
||||
|
||||
got, ok := r.Get("echo")
|
||||
|
||||
if !ok {
|
||||
t.Fatal("expected to find registered tool")
|
||||
}
|
||||
|
||||
if got.Name() != "echo" {
|
||||
t.Errorf("expected name 'echo', got %q", got.Name())
|
||||
}
|
||||
|
|
@ -104,7 +131,9 @@ func TestToolRegistry_RegisterAndGet(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_Get_NotFound(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
_, ok := r.Get("nonexistent")
|
||||
|
||||
if ok {
|
||||
t.Error("expected ok=false for unregistered tool")
|
||||
}
|
||||
|
|
@ -112,28 +141,42 @@ func TestToolRegistry_Get_NotFound(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_Get_FuzzyMatch(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
r.Register(newMockTool("read_file", "reads a file"))
|
||||
|
||||
r.Register(newMockTool("edit_file", "edits a file"))
|
||||
|
||||
r.Register(newMockTool("web_search", "searches the web"))
|
||||
|
||||
tests := []struct {
|
||||
query string
|
||||
|
||||
wantName string
|
||||
}{
|
||||
{"readfile", "read_file"},
|
||||
|
||||
{"ReadFile", "read_file"},
|
||||
|
||||
{"read-file", "read_file"},
|
||||
|
||||
{"editfile", "edit_file"},
|
||||
|
||||
{"EditFile", "edit_file"},
|
||||
|
||||
{"websearch", "web_search"},
|
||||
|
||||
{"WebSearch", "web_search"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tool, ok := r.Get(tt.query)
|
||||
|
||||
if !ok {
|
||||
t.Errorf("Get(%q) not found, want %q", tt.query, tt.wantName)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if tool.Name() != tt.wantName {
|
||||
t.Errorf("Get(%q).Name() = %q, want %q", tt.query, tool.Name(), tt.wantName)
|
||||
}
|
||||
|
|
@ -142,13 +185,17 @@ func TestToolRegistry_Get_FuzzyMatch(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_RegisterOverwrite(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
r.Register(newMockTool("dup", "first"))
|
||||
|
||||
r.Register(newMockTool("dup", "second"))
|
||||
|
||||
if r.Count() != 1 {
|
||||
t.Errorf("expected count 1 after overwrite, got %d", r.Count())
|
||||
}
|
||||
|
||||
tool, _ := r.Get("dup")
|
||||
|
||||
if tool.Description() != "second" {
|
||||
t.Errorf("expected overwritten description 'second', got %q", tool.Description())
|
||||
}
|
||||
|
|
@ -156,17 +203,23 @@ func TestToolRegistry_RegisterOverwrite(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_Execute_Success(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
r.Register(&mockRegistryTool{
|
||||
name: "greet",
|
||||
|
||||
desc: "says hello",
|
||||
|
||||
params: map[string]any{},
|
||||
|
||||
result: SilentResult("hello"),
|
||||
})
|
||||
|
||||
result := r.Execute(context.Background(), "greet", nil)
|
||||
|
||||
if result.IsError {
|
||||
t.Errorf("expected success, got error: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
if result.ForLLM != "hello" {
|
||||
t.Errorf("expected ForLLM 'hello', got %q", result.ForLLM)
|
||||
}
|
||||
|
|
@ -174,13 +227,17 @@ func TestToolRegistry_Execute_Success(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_Execute_NotFound(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
result := r.Execute(context.Background(), "missing", nil)
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("expected error for missing tool")
|
||||
}
|
||||
|
||||
if !strings.Contains(result.ForLLM, "not found") {
|
||||
t.Errorf("expected 'not found' in error, got %q", result.ForLLM)
|
||||
}
|
||||
|
||||
if result.Err == nil {
|
||||
t.Error("expected Err to be set via WithError")
|
||||
}
|
||||
|
|
@ -188,9 +245,11 @@ func TestToolRegistry_Execute_NotFound(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
ct := &mockCtxTool{
|
||||
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
|
||||
}
|
||||
|
||||
r.Register(ct)
|
||||
|
||||
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil)
|
||||
|
|
@ -198,6 +257,7 @@ func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
|
|||
if ct.channel != "telegram" {
|
||||
t.Errorf("expected channel 'telegram', got %q", ct.channel)
|
||||
}
|
||||
|
||||
if ct.chatID != "chat-42" {
|
||||
t.Errorf("expected chatID 'chat-42', got %q", ct.chatID)
|
||||
}
|
||||
|
|
@ -205,9 +265,11 @@ func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
ct := &mockCtxTool{
|
||||
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
|
||||
}
|
||||
|
||||
r.Register(ct)
|
||||
|
||||
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil)
|
||||
|
|
@ -219,24 +281,31 @@ func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
at := &mockAsyncRegistryTool{
|
||||
mockRegistryTool: *newMockTool("async_tool", "async work"),
|
||||
}
|
||||
|
||||
at.result = AsyncResult("started")
|
||||
|
||||
r.Register(at)
|
||||
|
||||
called := false
|
||||
|
||||
cb := func(_ context.Context, _ *ToolResult) { called = true }
|
||||
|
||||
result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb)
|
||||
|
||||
if at.cb == nil {
|
||||
t.Error("expected SetCallback to have been called")
|
||||
}
|
||||
|
||||
if !result.Async {
|
||||
t.Error("expected async result")
|
||||
}
|
||||
|
||||
at.cb(context.Background(), SilentResult("done"))
|
||||
|
||||
if !called {
|
||||
t.Error("expected callback to be invoked")
|
||||
}
|
||||
|
|
@ -244,22 +313,29 @@ func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_GetDefinitions(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
r.Register(newMockTool("alpha", "tool A"))
|
||||
|
||||
defs := r.GetDefinitions()
|
||||
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 definition, got %d", len(defs))
|
||||
}
|
||||
|
||||
if defs[0]["type"] != "function" {
|
||||
t.Errorf("expected type 'function', got %v", defs[0]["type"])
|
||||
}
|
||||
|
||||
fn, ok := defs[0]["function"].(map[string]any)
|
||||
|
||||
if !ok {
|
||||
t.Fatal("expected 'function' key to be a map")
|
||||
}
|
||||
|
||||
if fn["name"] != "alpha" {
|
||||
t.Errorf("expected name 'alpha', got %v", fn["name"])
|
||||
}
|
||||
|
||||
if fn["description"] != "tool A" {
|
||||
t.Errorf("expected description 'tool A', got %v", fn["description"])
|
||||
}
|
||||
|
|
@ -267,34 +343,47 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_ToProviderDefs(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
params := map[string]any{"type": "object", "properties": map[string]any{}}
|
||||
|
||||
r.Register(&mockRegistryTool{
|
||||
name: "beta",
|
||||
|
||||
desc: "tool B",
|
||||
|
||||
params: params,
|
||||
|
||||
result: SilentResult("ok"),
|
||||
})
|
||||
|
||||
defs := r.ToProviderDefs()
|
||||
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 provider def, got %d", len(defs))
|
||||
}
|
||||
|
||||
want := providers.ToolDefinition{
|
||||
Type: "function",
|
||||
|
||||
Function: providers.ToolFunctionDefinition{
|
||||
Name: "beta",
|
||||
|
||||
Description: "tool B",
|
||||
Parameters: params,
|
||||
|
||||
Parameters: providers.MustMarshalParameters(params),
|
||||
},
|
||||
}
|
||||
|
||||
got := defs[0]
|
||||
|
||||
if got.Type != want.Type {
|
||||
t.Errorf("Type: want %q, got %q", want.Type, got.Type)
|
||||
}
|
||||
|
||||
if got.Function.Name != want.Function.Name {
|
||||
t.Errorf("Name: want %q, got %q", want.Function.Name, got.Function.Name)
|
||||
}
|
||||
|
||||
if got.Function.Description != want.Function.Description {
|
||||
t.Errorf("Description: want %q, got %q", want.Function.Description, got.Function.Description)
|
||||
}
|
||||
|
|
@ -302,18 +391,23 @@ func TestToolRegistry_ToProviderDefs(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_List(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
r.Register(newMockTool("x", ""))
|
||||
|
||||
r.Register(newMockTool("y", ""))
|
||||
|
||||
names := r.List()
|
||||
|
||||
if len(names) != 2 {
|
||||
t.Fatalf("expected 2 names, got %d", len(names))
|
||||
}
|
||||
|
||||
nameSet := map[string]bool{}
|
||||
|
||||
for _, n := range names {
|
||||
nameSet[n] = true
|
||||
}
|
||||
|
||||
if !nameSet["x"] || !nameSet["y"] {
|
||||
t.Errorf("expected names {x, y}, got %v", names)
|
||||
}
|
||||
|
|
@ -321,17 +415,21 @@ func TestToolRegistry_List(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_Count(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
if r.Count() != 0 {
|
||||
t.Errorf("expected 0, got %d", r.Count())
|
||||
}
|
||||
|
||||
r.Register(newMockTool("a", ""))
|
||||
|
||||
r.Register(newMockTool("b", ""))
|
||||
|
||||
if r.Count() != 2 {
|
||||
t.Errorf("expected 2, got %d", r.Count())
|
||||
}
|
||||
|
||||
r.Register(newMockTool("a", "replaced"))
|
||||
|
||||
if r.Count() != 2 {
|
||||
t.Errorf("expected 2 after overwrite, got %d", r.Count())
|
||||
}
|
||||
|
|
@ -340,61 +438,90 @@ func TestToolRegistry_Count(t *testing.T) {
|
|||
func TestBuildParamHint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
schema map[string]any
|
||||
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "required and optional",
|
||||
|
||||
schema: map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"task": map[string]any{"type": "string"},
|
||||
|
||||
"label": map[string]any{"type": "string"},
|
||||
},
|
||||
|
||||
"required": []string{"task"},
|
||||
},
|
||||
|
||||
want: "(task, label?)",
|
||||
},
|
||||
|
||||
{
|
||||
name: "all required",
|
||||
|
||||
schema: map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"command": map[string]any{"type": "string"},
|
||||
},
|
||||
|
||||
"required": []string{"command"},
|
||||
},
|
||||
|
||||
want: "(command)",
|
||||
},
|
||||
|
||||
{
|
||||
name: "no properties",
|
||||
|
||||
schema: map[string]any{
|
||||
"type": "object",
|
||||
},
|
||||
|
||||
want: "",
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty schema",
|
||||
|
||||
schema: map[string]any{},
|
||||
|
||||
want: "",
|
||||
},
|
||||
|
||||
{
|
||||
name: "nil schema",
|
||||
|
||||
schema: nil,
|
||||
|
||||
want: "",
|
||||
},
|
||||
|
||||
{
|
||||
name: "multiple optional sorted",
|
||||
|
||||
schema: map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"task": map[string]any{"type": "string"},
|
||||
|
||||
"preset": map[string]any{"type": "string"},
|
||||
|
||||
"label": map[string]any{"type": "string"},
|
||||
|
||||
"agent_id": map[string]any{"type": "string"},
|
||||
},
|
||||
|
||||
"required": []string{"task"},
|
||||
},
|
||||
|
||||
want: "(task, agent_id?, label?, preset?)",
|
||||
},
|
||||
}
|
||||
|
|
@ -402,6 +529,7 @@ func TestBuildParamHint(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildParamHint(tt.schema)
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("buildParamHint() = %q, want %q", got, tt.want)
|
||||
}
|
||||
|
|
@ -411,15 +539,19 @@ func TestBuildParamHint(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_GetSummaries(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
r.Register(newMockTool("read_file", "Reads a file"))
|
||||
|
||||
summaries := r.GetSummaries()
|
||||
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("expected 1 summary, got %d", len(summaries))
|
||||
}
|
||||
|
||||
if !strings.Contains(summaries[0], "`read_file`") {
|
||||
t.Errorf("expected backtick-quoted name in summary, got %q", summaries[0])
|
||||
}
|
||||
|
||||
if !strings.Contains(summaries[0], "Reads a file") {
|
||||
t.Errorf("expected description in summary, got %q", summaries[0])
|
||||
}
|
||||
|
|
@ -427,25 +559,35 @@ func TestToolRegistry_GetSummaries(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_GetSummaries_WithParamHint(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
r.Register(&mockRegistryTool{
|
||||
name: "spawn",
|
||||
|
||||
desc: "Spawn a subagent",
|
||||
|
||||
params: map[string]any{
|
||||
"type": "object",
|
||||
|
||||
"properties": map[string]any{
|
||||
"task": map[string]any{"type": "string"},
|
||||
|
||||
"preset": map[string]any{"type": "string"},
|
||||
},
|
||||
|
||||
"required": []string{"task"},
|
||||
},
|
||||
|
||||
result: SilentResult("ok"),
|
||||
})
|
||||
|
||||
summaries := r.GetSummaries()
|
||||
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("expected 1 summary, got %d", len(summaries))
|
||||
}
|
||||
|
||||
// Should contain param hint
|
||||
|
||||
if !strings.Contains(summaries[0], "(task, preset?)") {
|
||||
t.Errorf("expected param hint in summary, got %q", summaries[0])
|
||||
}
|
||||
|
|
@ -453,21 +595,27 @@ func TestToolRegistry_GetSummaries_WithParamHint(t *testing.T) {
|
|||
|
||||
func TestToolToSchema(t *testing.T) {
|
||||
tool := newMockTool("demo", "demo tool")
|
||||
|
||||
schema := ToolToSchema(tool)
|
||||
|
||||
if schema["type"] != "function" {
|
||||
t.Errorf("expected type 'function', got %v", schema["type"])
|
||||
}
|
||||
|
||||
fn, ok := schema["function"].(map[string]any)
|
||||
|
||||
if !ok {
|
||||
t.Fatal("expected 'function' to be a map")
|
||||
}
|
||||
|
||||
if fn["name"] != "demo" {
|
||||
t.Errorf("expected name 'demo', got %v", fn["name"])
|
||||
}
|
||||
|
||||
if fn["description"] != "demo tool" {
|
||||
t.Errorf("expected description 'demo tool', got %v", fn["description"])
|
||||
}
|
||||
|
||||
if fn["parameters"] == nil {
|
||||
t.Error("expected parameters to be set")
|
||||
}
|
||||
|
|
@ -475,17 +623,25 @@ func TestToolToSchema(t *testing.T) {
|
|||
|
||||
func TestToolRegistry_ConcurrentAccess(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range 50 {
|
||||
wg.Add(1)
|
||||
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
|
||||
name := string(rune('A' + n%26))
|
||||
|
||||
r.Register(newMockTool(name, "concurrent"))
|
||||
|
||||
r.Get(name)
|
||||
|
||||
r.Count()
|
||||
|
||||
r.List()
|
||||
|
||||
r.GetDefinitions()
|
||||
}(i)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,15 @@ func TestNewToolResult(t *testing.T) {
|
|||
if result.ForLLM != "test content" {
|
||||
t.Errorf("Expected ForLLM 'test content', got '%s'", result.ForLLM)
|
||||
}
|
||||
|
||||
if result.Silent {
|
||||
t.Error("Expected Silent to be false")
|
||||
}
|
||||
|
||||
if result.IsError {
|
||||
t.Error("Expected IsError to be false")
|
||||
}
|
||||
|
||||
if result.Async {
|
||||
t.Error("Expected Async to be false")
|
||||
}
|
||||
|
|
@ -29,12 +32,15 @@ func TestSilentResult(t *testing.T) {
|
|||
if result.ForLLM != "silent operation" {
|
||||
t.Errorf("Expected ForLLM 'silent operation', got '%s'", result.ForLLM)
|
||||
}
|
||||
|
||||
if !result.Silent {
|
||||
t.Error("Expected Silent to be true")
|
||||
}
|
||||
|
||||
if result.IsError {
|
||||
t.Error("Expected IsError to be false")
|
||||
}
|
||||
|
||||
if result.Async {
|
||||
t.Error("Expected Async to be false")
|
||||
}
|
||||
|
|
@ -46,12 +52,15 @@ func TestAsyncResult(t *testing.T) {
|
|||
if result.ForLLM != "async task started" {
|
||||
t.Errorf("Expected ForLLM 'async task started', got '%s'", result.ForLLM)
|
||||
}
|
||||
|
||||
if result.Silent {
|
||||
t.Error("Expected Silent to be false")
|
||||
}
|
||||
|
||||
if result.IsError {
|
||||
t.Error("Expected IsError to be false")
|
||||
}
|
||||
|
||||
if !result.Async {
|
||||
t.Error("Expected Async to be true")
|
||||
}
|
||||
|
|
@ -63,12 +72,15 @@ func TestErrorResult(t *testing.T) {
|
|||
if result.ForLLM != "operation failed" {
|
||||
t.Errorf("Expected ForLLM 'operation failed', got '%s'", result.ForLLM)
|
||||
}
|
||||
|
||||
if result.Silent {
|
||||
t.Error("Expected Silent to be false")
|
||||
}
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("Expected IsError to be true")
|
||||
}
|
||||
|
||||
if result.Async {
|
||||
t.Error("Expected Async to be false")
|
||||
}
|
||||
|
|
@ -76,20 +88,25 @@ func TestErrorResult(t *testing.T) {
|
|||
|
||||
func TestUserResult(t *testing.T) {
|
||||
content := "user visible message"
|
||||
|
||||
result := UserResult(content)
|
||||
|
||||
if result.ForLLM != content {
|
||||
t.Errorf("Expected ForLLM '%s', got '%s'", content, result.ForLLM)
|
||||
}
|
||||
|
||||
if result.ForUser != content {
|
||||
t.Errorf("Expected ForUser '%s', got '%s'", content, result.ForUser)
|
||||
}
|
||||
|
||||
if result.Silent {
|
||||
t.Error("Expected Silent to be false")
|
||||
}
|
||||
|
||||
if result.IsError {
|
||||
t.Error("Expected IsError to be false")
|
||||
}
|
||||
|
||||
if result.Async {
|
||||
t.Error("Expected Async to be false")
|
||||
}
|
||||
|
|
@ -98,26 +115,36 @@ func TestUserResult(t *testing.T) {
|
|||
func TestToolResultJSONSerialization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
result *ToolResult
|
||||
}{
|
||||
{
|
||||
name: "basic result",
|
||||
|
||||
result: NewToolResult("basic content"),
|
||||
},
|
||||
|
||||
{
|
||||
name: "silent result",
|
||||
|
||||
result: SilentResult("silent content"),
|
||||
},
|
||||
|
||||
{
|
||||
name: "async result",
|
||||
|
||||
result: AsyncResult("async content"),
|
||||
},
|
||||
|
||||
{
|
||||
name: "error result",
|
||||
|
||||
result: ErrorResult("error content"),
|
||||
},
|
||||
|
||||
{
|
||||
name: "user result",
|
||||
|
||||
result: UserResult("user content"),
|
||||
},
|
||||
}
|
||||
|
|
@ -125,30 +152,38 @@ func TestToolResultJSONSerialization(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Marshal to JSON
|
||||
|
||||
data, err := json.Marshal(tt.result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
// Unmarshal back
|
||||
|
||||
var decoded ToolResult
|
||||
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// Verify fields match (Err should be excluded)
|
||||
|
||||
if decoded.ForLLM != tt.result.ForLLM {
|
||||
t.Errorf("ForLLM mismatch: got '%s', want '%s'", decoded.ForLLM, tt.result.ForLLM)
|
||||
}
|
||||
|
||||
if decoded.ForUser != tt.result.ForUser {
|
||||
t.Errorf("ForUser mismatch: got '%s', want '%s'", decoded.ForUser, tt.result.ForUser)
|
||||
}
|
||||
|
||||
if decoded.Silent != tt.result.Silent {
|
||||
t.Errorf("Silent mismatch: got %v, want %v", decoded.Silent, tt.result.Silent)
|
||||
}
|
||||
|
||||
if decoded.IsError != tt.result.IsError {
|
||||
t.Errorf("IsError mismatch: got %v, want %v", decoded.IsError, tt.result.IsError)
|
||||
}
|
||||
|
||||
if decoded.Async != tt.result.Async {
|
||||
t.Errorf("Async mismatch: got %v, want %v", decoded.Async, tt.result.Async)
|
||||
}
|
||||
|
|
@ -158,22 +193,27 @@ func TestToolResultJSONSerialization(t *testing.T) {
|
|||
|
||||
func TestToolResultWithErrors(t *testing.T) {
|
||||
err := errors.New("underlying error")
|
||||
|
||||
result := ErrorResult("error message").WithError(err)
|
||||
|
||||
if result.Err == nil {
|
||||
t.Error("Expected Err to be set")
|
||||
}
|
||||
|
||||
if result.Err.Error() != "underlying error" {
|
||||
t.Errorf("Expected Err message 'underlying error', got '%s'", result.Err.Error())
|
||||
}
|
||||
|
||||
// Verify Err is not serialized
|
||||
|
||||
data, marshalErr := json.Marshal(result)
|
||||
|
||||
if marshalErr != nil {
|
||||
t.Fatalf("Failed to marshal: %v", marshalErr)
|
||||
}
|
||||
|
||||
var decoded ToolResult
|
||||
|
||||
if unmarshalErr := json.Unmarshal(data, &decoded); unmarshalErr != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", unmarshalErr)
|
||||
}
|
||||
|
|
@ -192,37 +232,47 @@ func TestToolResultJSONStructure(t *testing.T) {
|
|||
}
|
||||
|
||||
// Verify JSON structure
|
||||
|
||||
var parsed map[string]any
|
||||
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("Failed to parse JSON: %v", err)
|
||||
}
|
||||
|
||||
// Check expected keys exist
|
||||
|
||||
if _, ok := parsed["for_llm"]; !ok {
|
||||
t.Error("Expected 'for_llm' key in JSON")
|
||||
}
|
||||
|
||||
if _, ok := parsed["for_user"]; !ok {
|
||||
t.Error("Expected 'for_user' key in JSON")
|
||||
}
|
||||
|
||||
if _, ok := parsed["silent"]; !ok {
|
||||
t.Error("Expected 'silent' key in JSON")
|
||||
}
|
||||
|
||||
if _, ok := parsed["is_error"]; !ok {
|
||||
t.Error("Expected 'is_error' key in JSON")
|
||||
}
|
||||
|
||||
if _, ok := parsed["async"]; !ok {
|
||||
t.Error("Expected 'async' key in JSON")
|
||||
}
|
||||
|
||||
// Check that 'err' is NOT present (it should have json:"-" tag)
|
||||
|
||||
if _, ok := parsed["err"]; ok {
|
||||
t.Error("Expected 'err' key to be excluded from JSON")
|
||||
}
|
||||
|
||||
// Verify values
|
||||
|
||||
if parsed["for_llm"] != "test content" {
|
||||
t.Errorf("Expected for_llm 'test content', got %v", parsed["for_llm"])
|
||||
}
|
||||
|
||||
if parsed["silent"] != false {
|
||||
t.Errorf("Expected silent false, got %v", parsed["silent"])
|
||||
}
|
||||
|
|
|
|||
25
pkg/tools/session_recorder.go
Normal file
25
pkg/tools/session_recorder.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package tools
|
||||
|
||||
import "github.com/sipeed/picoclaw/pkg/providers"
|
||||
|
||||
// SessionRecorder records session DAG events (fork, turns, completion, report).
|
||||
// Implemented by pkg/agent to bridge tools → session without circular imports.
|
||||
type SessionRecorder interface {
|
||||
// RecordFork creates a child session forked from the conductor session.
|
||||
RecordFork(conductorSessionKey, subagentSessionKey, taskID, label string) error
|
||||
|
||||
// RecordSubagentTurn records the initial + final messages of a subagent run.
|
||||
RecordSubagentTurn(subagentSessionKey string, messages []providers.Message) error
|
||||
|
||||
// RecordCompletion marks a subagent session as completed/failed.
|
||||
RecordCompletion(subagentSessionKey, status, result string) error
|
||||
|
||||
// RecordReport injects a TurnReport into the conductor session.
|
||||
RecordReport(conductorSessionKey, subagentSessionKey, senderID, content string) error
|
||||
|
||||
// RecordQuestion injects a TurnQuestion into the conductor session (subagent escalation).
|
||||
RecordQuestion(conductorKey, subagentKey, taskID, question string) error
|
||||
|
||||
// RecordPlanSubmit injects a TurnPlanSubmit into the conductor session (plan review request).
|
||||
RecordPlanSubmit(conductorKey, subagentKey, taskID, planText string) error
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -21,7 +21,6 @@ func terminateProcessTree(cmd *exec.Cmd) error {
|
|||
if cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
pid := cmd.Process.Pid
|
||||
if pid <= 0 {
|
||||
return nil
|
||||
|
|
@ -29,11 +28,14 @@ func terminateProcessTree(cmd *exec.Cmd) error {
|
|||
|
||||
// Kill the entire process group spawned by the shell command.
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
|
||||
// Some shells/background jobs may still leave descendants around
|
||||
// briefly; aggressively walk /proc and kill child processes too.
|
||||
killDescendants(pid)
|
||||
|
||||
// Fallback kill on the shell process itself.
|
||||
_ = cmd.Process.Kill()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +43,6 @@ func killDescendants(ppid int) {
|
|||
if ppid <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -17,11 +17,14 @@ func terminateProcessTree(cmd *exec.Cmd) error {
|
|||
}
|
||||
|
||||
pid := cmd.Process.Pid
|
||||
|
||||
if pid <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
|
||||
|
||||
_ = cmd.Process.Kill()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@ func processRunning(pid int) bool {
|
|||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// kill(0) can return success for zombie processes too, so inspect /proc
|
||||
// state and treat zombies as not-running for timeout cleanup assertions.
|
||||
err := syscall.Kill(pid, 0)
|
||||
|
|
@ -37,7 +38,6 @@ func processRunning(pid int) bool {
|
|||
if len(fields) == 0 {
|
||||
return true // best effort fallback
|
||||
}
|
||||
|
||||
state := fields[0]
|
||||
return state != "Z"
|
||||
}
|
||||
|
|
@ -47,14 +47,12 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Errorf("unable to configure exec tool: %s", err)
|
||||
}
|
||||
|
||||
tool.SetTimeout(500 * time.Millisecond)
|
||||
|
||||
args := map[string]any{
|
||||
// Spawn a child process that would outlive the shell unless process-group kill is used.
|
||||
"command": "sleep 60 & echo $! > child.pid; wait",
|
||||
}
|
||||
|
||||
result := tool.Execute(context.Background(), args)
|
||||
if !result.IsError {
|
||||
t.Fatalf("expected timeout error, got success: %s", result.ForLLM)
|
||||
|
|
@ -68,7 +66,6 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("failed to read child pid file: %v", err)
|
||||
}
|
||||
|
||||
childPID, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse child pid: %v", err)
|
||||
|
|
@ -81,6 +78,5 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
|
|||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("child process %d is still running after timeout", childPID)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue