feat: add /plan command with phase-based task planning via MEMORY.md
MEMORY.md was injected into system prompts every turn but had no structured use. This turns it into a plan store with interview flow, phased execution, and selective context injection to prevent context window bloat. - /plan <task>: seeds MEMORY.md with interviewing status - /plan clear|done|add|start|next: manage plan lifecycle - Auto-advance phases when all steps marked [x] - Auto-clear plan on full completion - Interviewing: injects seed + interview guide + target format - Executing: injects current phase only, compresses completed phases - Commands section for build/test/lint CLI commands - Daily notes suppressed during active plan to save context - 22 new memory_test.go tests, 12 new loop_test.go tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
49b1f498f7
commit
f3fcb19aa2
12 changed files with 1613 additions and 10 deletions
|
|
@ -820,10 +820,15 @@ Ces commandes slash peuvent être envoyées dans n'importe quel canal de chat (T
|
|||
| `/skill <nom> [message]` | Invoquer une compétence |
|
||||
| `/show model` | Afficher le modèle actuel |
|
||||
| `/list channels` | Lister les canaux activés |
|
||||
| `/plan` | Afficher la progression du plan actuel |
|
||||
| `/plan <tâche>` | Démarrer un nouveau plan avec entretien |
|
||||
| `/plan clear` | Abandonner le plan actuel |
|
||||
|
||||
> **Note** : `/session` nécessite que la passerelle soit démarrée avec le flag `--stats`. Sans celui-ci, le suivi des statistiques est désactivé pour zéro surcharge.
|
||||
>
|
||||
> **Note** : `/skills` répond instantanément sans coût LLM. `/skill <nom>` injecte le SKILL.md dans le contexte pour le tour en cours uniquement, puis seul le nom de la compétence est conservé dans l'historique pour économiser la fenêtre de contexte.
|
||||
>
|
||||
> **Note** : `/plan` permet la planification structurée des tâches avec avancement automatique des phases. L'IA vous interview pour comprendre les exigences, organise le travail en phases et suit la progression via MEMORY.md.
|
||||
|
||||
### Tâches Planifiées / Rappels
|
||||
|
||||
|
|
|
|||
|
|
@ -768,10 +768,15 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||
| `/skill <名前> [メッセージ]` | スキルを呼び出す |
|
||||
| `/show model` | 現在のモデルを表示 |
|
||||
| `/list channels` | 有効なチャネルを一覧表示 |
|
||||
| `/plan` | 現在の計画の進捗を表示 |
|
||||
| `/plan <タスク>` | インタビュー形式で新しい計画を開始 |
|
||||
| `/plan clear` | 現在の計画を破棄 |
|
||||
|
||||
> **注意**: `/session` はゲートウェイを `--stats` フラグ付きで起動する必要があります。フラグなしではオーバーヘッドゼロで統計追跡は無効です。
|
||||
>
|
||||
> **注意**: `/skills` は LLM コストゼロで即座に応答します。`/skill <名前>` はそのターンのみ SKILL.md をコンテキストに注入し、ターン完了後はスキル名のみ履歴に残してコンテキストウィンドウを節約します。
|
||||
>
|
||||
> **注意**: `/plan` は自動フェーズ進行付きの構造化タスク計画を実現します。AIがインタビューで要件を確認し、フェーズに分割して作業を進め、MEMORY.mdで進捗を管理します。
|
||||
|
||||
## 🤝 コントリビュート&ロードマップ
|
||||
|
||||
|
|
|
|||
|
|
@ -854,10 +854,15 @@ These slash commands can be sent in any chat channel (Telegram, Discord, etc.) a
|
|||
| `/skill <name> [message]` | Invoke a skill with optional message |
|
||||
| `/show model` | Show current model |
|
||||
| `/list channels` | List enabled channels |
|
||||
| `/plan` | Show current plan progress |
|
||||
| `/plan <task>` | Start a new plan with interview flow |
|
||||
| `/plan clear` | Discard the current plan |
|
||||
|
||||
> **Note**: `/session` requires the gateway to be started with `--stats` flag. Without it, stats tracking is disabled for zero overhead.
|
||||
>
|
||||
> **Note**: `/skills` returns instantly without LLM cost. `/skill <name>` injects the skill's SKILL.md into the conversation context for the current turn, then only the skill name is kept in history to save the context window.
|
||||
>
|
||||
> **Note**: `/plan` enables structured task planning with automatic phase advancement. The AI interviews you to understand requirements, organizes work into phases, and tracks progress through MEMORY.md.
|
||||
|
||||
### Scheduled Tasks / Reminders
|
||||
|
||||
|
|
|
|||
|
|
@ -821,10 +821,15 @@ Estes comandos de barra podem ser enviados em qualquer canal de chat (Telegram,
|
|||
| `/skill <nome> [mensagem]` | Invocar uma habilidade |
|
||||
| `/show model` | Mostrar modelo atual |
|
||||
| `/list channels` | Listar canais habilitados |
|
||||
| `/plan` | Mostrar progresso do plano atual |
|
||||
| `/plan <tarefa>` | Iniciar um novo plano com fluxo de entrevista |
|
||||
| `/plan clear` | Descartar o plano atual |
|
||||
|
||||
> **Nota**: `/session` requer que o gateway seja iniciado com a flag `--stats`. Sem ela, o rastreamento de estatísticas é desabilitado para zero overhead.
|
||||
>
|
||||
> **Nota**: `/skills` responde instantaneamente sem custo LLM. `/skill <nome>` injeta o SKILL.md no contexto apenas para o turno atual, depois apenas o nome da habilidade é mantido no histórico para economizar a janela de contexto.
|
||||
>
|
||||
> **Nota**: `/plan` permite planejamento estruturado de tarefas com avanço automático de fases. A IA entrevista você para entender os requisitos, organiza o trabalho em fases e acompanha o progresso via MEMORY.md.
|
||||
|
||||
### Tarefas Agendadas / Lembretes
|
||||
|
||||
|
|
|
|||
|
|
@ -798,10 +798,15 @@ Các lệnh gạch chéo sau có thể gửi trong bất kỳ kênh chat nào (T
|
|||
| `/skill <tên> [tin nhắn]` | Gọi một kỹ năng |
|
||||
| `/show model` | Hiển thị mô hình hiện tại |
|
||||
| `/list channels` | Liệt kê các kênh đã bật |
|
||||
| `/plan` | Hiển thị tiến độ kế hoạch hiện tại |
|
||||
| `/plan <nhiệm vụ>` | Bắt đầu kế hoạch mới với phỏng vấn |
|
||||
| `/plan clear` | Hủy bỏ kế hoạch hiện tại |
|
||||
|
||||
> **Lưu ý**: `/session` yêu cầu gateway được khởi động với cờ `--stats`. Không có cờ này, theo dõi thống kê bị tắt để không có overhead.
|
||||
>
|
||||
> **Lưu ý**: `/skills` phản hồi ngay lập tức không tốn chi phí LLM. `/skill <tên>` chỉ đưa SKILL.md vào ngữ cảnh cho lượt hiện tại, sau đó chỉ giữ lại tên kỹ năng trong lịch sử để tiết kiệm cửa sổ ngữ cảnh.
|
||||
>
|
||||
> **Lưu ý**: `/plan` cho phép lập kế hoạch nhiệm vụ có cấu trúc với tự động chuyển giai đoạn. AI phỏng vấn bạn để hiểu yêu cầu, tổ chức công việc thành các giai đoạn và theo dõi tiến độ qua MEMORY.md.
|
||||
|
||||
### Tác vụ định kỳ / Nhắc nhở
|
||||
|
||||
|
|
|
|||
|
|
@ -688,10 +688,15 @@ picoclaw agent -m "你好"
|
|||
| `/skill <名称> [消息]` | 调用技能 |
|
||||
| `/show model` | 显示当前模型 |
|
||||
| `/list channels` | 列出已启用的频道 |
|
||||
| `/plan` | 显示当前计划进度 |
|
||||
| `/plan <任务>` | 以访谈方式启动新计划 |
|
||||
| `/plan clear` | 丢弃当前计划 |
|
||||
|
||||
> **注意**: `/session` 需要使用 `--stats` 标志启动网关。未启用时统计跟踪被禁用,零开销。
|
||||
>
|
||||
> **注意**: `/skills` 即时响应,不消耗 LLM Token。`/skill <名称>` 仅在当前轮次将 SKILL.md 注入上下文,轮次结束后仅保留技能名称以节省上下文窗口。
|
||||
>
|
||||
> **注意**: `/plan` 实现带有自动阶段推进的结构化任务规划。AI通过访谈了解需求,将工作组织成多个阶段,并通过 MEMORY.md 跟踪进度。
|
||||
|
||||
### 定时任务 / 提醒 (Scheduled Tasks)
|
||||
|
||||
|
|
|
|||
|
|
@ -80,8 +80,31 @@ Your workspace is at: %s
|
|||
|
||||
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
||||
|
||||
3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`,
|
||||
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
|
||||
3. **Memory & Plans**
|
||||
- Use memory/MEMORY.md for structured plans.
|
||||
- If Status is "interviewing": Ask clarifying questions.
|
||||
Update Context with answers via edit_file.
|
||||
When ready, organize into Phases and set Status to "executing".
|
||||
- If Status is "executing": Work through the current Phase's steps.
|
||||
Mark each [x] via edit_file. The system will auto-advance phases.
|
||||
- Plan format:
|
||||
# Active Plan
|
||||
> Task: <description>
|
||||
> Status: interviewing | executing
|
||||
> Phase: <current phase number>
|
||||
## Phase 1: <title>
|
||||
- [ ] Step 1
|
||||
## Phase 2: <title>
|
||||
- [ ] Step 2
|
||||
## Commands
|
||||
build: <build command>
|
||||
test: <test command>
|
||||
lint: <lint command>
|
||||
## Context
|
||||
<requirements, decisions, environment>
|
||||
- Keep each phase to 3-5 steps. Do NOT create plans without /plan.
|
||||
- Always ask about build/test/lint commands during interview.`,
|
||||
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection)
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) buildToolsSection() string {
|
||||
|
|
@ -263,6 +286,73 @@ func (cb *ContextBuilder) ListSkills() []skills.SkillInfo {
|
|||
return cb.skillsLoader.ListSkills()
|
||||
}
|
||||
|
||||
// ---------- Plan passthrough methods ----------
|
||||
|
||||
// ReadMemory reads the long-term memory (MEMORY.md).
|
||||
func (cb *ContextBuilder) ReadMemory() string {
|
||||
return cb.memory.ReadLongTerm()
|
||||
}
|
||||
|
||||
// WriteMemory writes content to the long-term memory file.
|
||||
func (cb *ContextBuilder) WriteMemory(content string) error {
|
||||
return cb.memory.WriteLongTerm(content)
|
||||
}
|
||||
|
||||
// ClearMemory removes the long-term memory file.
|
||||
func (cb *ContextBuilder) ClearMemory() error {
|
||||
return cb.memory.ClearLongTerm()
|
||||
}
|
||||
|
||||
// HasActivePlan returns true if MEMORY.md contains an active plan.
|
||||
func (cb *ContextBuilder) HasActivePlan() bool {
|
||||
return cb.memory.HasActivePlan()
|
||||
}
|
||||
|
||||
// GetPlanStatus returns the plan status: "interviewing", "executing", or "".
|
||||
func (cb *ContextBuilder) GetPlanStatus() string {
|
||||
return cb.memory.GetPlanStatus()
|
||||
}
|
||||
|
||||
// IsPlanComplete returns true if all steps in all phases are [x].
|
||||
func (cb *ContextBuilder) IsPlanComplete() bool {
|
||||
return cb.memory.IsPlanComplete()
|
||||
}
|
||||
|
||||
// IsCurrentPhaseComplete returns true if all steps in the current phase are [x].
|
||||
func (cb *ContextBuilder) IsCurrentPhaseComplete() bool {
|
||||
return cb.memory.IsCurrentPhaseComplete()
|
||||
}
|
||||
|
||||
// AdvancePhase increments the current phase number by 1.
|
||||
func (cb *ContextBuilder) AdvancePhase() error {
|
||||
return cb.memory.AdvancePhase()
|
||||
}
|
||||
|
||||
// GetCurrentPhase returns the current phase number.
|
||||
func (cb *ContextBuilder) GetCurrentPhase() int {
|
||||
return cb.memory.GetCurrentPhase()
|
||||
}
|
||||
|
||||
// FormatPlanDisplay returns a user-facing display of the full plan.
|
||||
func (cb *ContextBuilder) FormatPlanDisplay() string {
|
||||
return cb.memory.FormatPlanDisplay()
|
||||
}
|
||||
|
||||
// MarkStep marks a step as done in the specified phase.
|
||||
func (cb *ContextBuilder) MarkStep(phase, step int) error {
|
||||
return cb.memory.MarkStep(phase, step)
|
||||
}
|
||||
|
||||
// AddStep appends a new step to the given phase.
|
||||
func (cb *ContextBuilder) AddStep(phase int, desc string) error {
|
||||
return cb.memory.AddStep(phase, desc)
|
||||
}
|
||||
|
||||
// SetPlanStatus sets the plan status.
|
||||
func (cb *ContextBuilder) SetPlanStatus(status string) error {
|
||||
return cb.memory.SetStatus(status)
|
||||
}
|
||||
|
||||
// GetSkillsInfo returns information about loaded skills.
|
||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
|
||||
allSkills := cb.skillsLoader.ListSkills()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
|
@ -506,7 +507,34 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
|||
// If last tool had ForUser content and we already sent it, we might not need to send final response
|
||||
// This is controlled by the tool's Silent flag and ForUser content
|
||||
|
||||
// 5. Handle empty response
|
||||
// 5a. Auto-advance plan phases after LLM iteration
|
||||
if agent.ContextBuilder.HasActivePlan() && agent.ContextBuilder.GetPlanStatus() == "executing" {
|
||||
if agent.ContextBuilder.IsPlanComplete() {
|
||||
_ = agent.ContextBuilder.ClearMemory()
|
||||
if !constants.IsInternalChannel(opts.Channel) {
|
||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: opts.Channel,
|
||||
ChatID: opts.ChatID,
|
||||
Content: "\u2705 Plan completed!",
|
||||
SkipPlaceholder: true,
|
||||
})
|
||||
}
|
||||
} else if agent.ContextBuilder.IsCurrentPhaseComplete() {
|
||||
prev := agent.ContextBuilder.GetCurrentPhase()
|
||||
_ = agent.ContextBuilder.AdvancePhase()
|
||||
next := agent.ContextBuilder.GetCurrentPhase()
|
||||
if !constants.IsInternalChannel(opts.Channel) {
|
||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: opts.Channel,
|
||||
ChatID: opts.ChatID,
|
||||
Content: fmt.Sprintf("Phase %d complete. Moving to Phase %d.", prev, next),
|
||||
SkipPlaceholder: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5b. Handle empty response
|
||||
if finalContent == "" {
|
||||
finalContent = opts.DefaultResponse
|
||||
}
|
||||
|
|
@ -1131,6 +1159,9 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
|||
// summarizeBatch summarizes a batch of messages.
|
||||
func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) {
|
||||
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"
|
||||
if agent.ContextBuilder.HasActivePlan() {
|
||||
prompt += "Note: Active plan in MEMORY.md. Preserve plan progress references.\n"
|
||||
}
|
||||
if existingSummary != "" {
|
||||
prompt += "Existing context: " + existingSummary + "\n"
|
||||
}
|
||||
|
|
@ -1252,6 +1283,9 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
|||
|
||||
case "/skills":
|
||||
return al.handleSkillsCommand(), true
|
||||
|
||||
case "/plan":
|
||||
return al.handlePlanCommand(args), true
|
||||
}
|
||||
|
||||
return "", false
|
||||
|
|
@ -1363,6 +1397,96 @@ func (al *AgentLoop) handleSkillsCommand() string {
|
|||
return sb.String()
|
||||
}
|
||||
|
||||
// handlePlanCommand handles /plan subcommands.
|
||||
func (al *AgentLoop) handlePlanCommand(args []string) string {
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
return "No agent configured."
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
// /plan — show current plan
|
||||
return agent.ContextBuilder.FormatPlanDisplay()
|
||||
}
|
||||
|
||||
sub := args[0]
|
||||
switch sub {
|
||||
case "clear":
|
||||
if !agent.ContextBuilder.HasActivePlan() {
|
||||
return "No active plan to clear."
|
||||
}
|
||||
if err := agent.ContextBuilder.ClearMemory(); err != nil {
|
||||
return fmt.Sprintf("Error clearing plan: %v", err)
|
||||
}
|
||||
return "Plan cleared."
|
||||
|
||||
case "done":
|
||||
if !agent.ContextBuilder.HasActivePlan() {
|
||||
return "No active plan."
|
||||
}
|
||||
if len(args) < 2 {
|
||||
return "Usage: /plan done <step number>"
|
||||
}
|
||||
stepNum, err := strconv.Atoi(args[1])
|
||||
if err != nil || stepNum < 1 {
|
||||
return "Step number must be a positive integer."
|
||||
}
|
||||
phase := agent.ContextBuilder.GetCurrentPhase()
|
||||
if err := agent.ContextBuilder.MarkStep(phase, stepNum); err != nil {
|
||||
return fmt.Sprintf("Error: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("Marked step %d in phase %d as done.", stepNum, phase)
|
||||
|
||||
case "add":
|
||||
if !agent.ContextBuilder.HasActivePlan() {
|
||||
return "No active plan."
|
||||
}
|
||||
if len(args) < 2 {
|
||||
return "Usage: /plan add <step description>"
|
||||
}
|
||||
desc := strings.Join(args[1:], " ")
|
||||
phase := agent.ContextBuilder.GetCurrentPhase()
|
||||
if err := agent.ContextBuilder.AddStep(phase, desc); err != nil {
|
||||
return fmt.Sprintf("Error: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("Added step to phase %d: %s", phase, desc)
|
||||
|
||||
case "start":
|
||||
if !agent.ContextBuilder.HasActivePlan() {
|
||||
return "No active plan."
|
||||
}
|
||||
if agent.ContextBuilder.GetPlanStatus() != "interviewing" {
|
||||
return "Plan is already executing."
|
||||
}
|
||||
if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil {
|
||||
return fmt.Sprintf("Error: %v", err)
|
||||
}
|
||||
return "Plan status changed to executing."
|
||||
|
||||
case "next":
|
||||
if !agent.ContextBuilder.HasActivePlan() {
|
||||
return "No active plan."
|
||||
}
|
||||
if err := agent.ContextBuilder.AdvancePhase(); err != nil {
|
||||
return fmt.Sprintf("Error: %v", err)
|
||||
}
|
||||
phase := agent.ContextBuilder.GetCurrentPhase()
|
||||
return fmt.Sprintf("Advanced to phase %d.", phase)
|
||||
|
||||
default:
|
||||
// /plan <task description> — start new plan
|
||||
if agent.ContextBuilder.HasActivePlan() {
|
||||
return "A plan is already active. Use /plan clear first."
|
||||
}
|
||||
task := strings.Join(args, " ")
|
||||
seed := BuildInterviewSeed(task)
|
||||
if err := agent.ContextBuilder.WriteMemory(seed); err != nil {
|
||||
return fmt.Sprintf("Error creating plan: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("Plan started: %s\nStatus: interviewing\n\nI'll ask you some questions to build a detailed plan.", task)
|
||||
}
|
||||
}
|
||||
|
||||
// extractPeer extracts the routing peer from inbound message metadata.
|
||||
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||
peerKind := msg.Metadata["peer_kind"]
|
||||
|
|
|
|||
|
|
@ -875,3 +875,390 @@ func TestBuildTaskReminder_Truncation(t *testing.T) {
|
|||
t.Errorf("expected at most %d blocker chars, got %d", blockerMaxChars, xCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- /plan command tests ----------
|
||||
|
||||
func newTestAgentLoop(t *testing.T) (*AgentLoop, func()) {
|
||||
t.Helper()
|
||||
tmpDir, err := os.MkdirTemp("", "agent-plan-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
return al, func() { os.RemoveAll(tmpDir) }
|
||||
}
|
||||
|
||||
func TestPlanCommand_ShowNoPlan(t *testing.T) {
|
||||
al, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan"})
|
||||
if !handled {
|
||||
t.Fatal("expected /plan to be handled")
|
||||
}
|
||||
if !strings.Contains(response, "No active plan") {
|
||||
t.Errorf("expected 'No active plan', got %q", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCommand_StartNewPlan(t *testing.T) {
|
||||
al, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Set up monitoring"})
|
||||
if !handled {
|
||||
t.Fatal("expected /plan to be handled")
|
||||
}
|
||||
if !strings.Contains(response, "Plan started") {
|
||||
t.Errorf("expected 'Plan started', got %q", response)
|
||||
}
|
||||
if !strings.Contains(response, "Set up monitoring") {
|
||||
t.Errorf("expected task in response, got %q", response)
|
||||
}
|
||||
|
||||
// Verify plan was created
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
if !agent.ContextBuilder.HasActivePlan() {
|
||||
t.Error("expected active plan after /plan start")
|
||||
}
|
||||
if status := agent.ContextBuilder.GetPlanStatus(); status != "interviewing" {
|
||||
t.Errorf("expected 'interviewing', got %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCommand_StartBlockedByExisting(t *testing.T) {
|
||||
al, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
// Start first plan
|
||||
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan First task"})
|
||||
|
||||
// Try to start another
|
||||
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Second task"})
|
||||
if !strings.Contains(response, "already active") {
|
||||
t.Errorf("expected 'already active', got %q", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCommand_Clear(t *testing.T) {
|
||||
al, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
// Start plan then clear
|
||||
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Test task"})
|
||||
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"})
|
||||
if !strings.Contains(response, "Plan cleared") {
|
||||
t.Errorf("expected 'Plan cleared', got %q", response)
|
||||
}
|
||||
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
if agent.ContextBuilder.HasActivePlan() {
|
||||
t.Error("expected no plan after clear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCommand_ClearNoPlan(t *testing.T) {
|
||||
al, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"})
|
||||
if !strings.Contains(response, "No active plan") {
|
||||
t.Errorf("expected 'No active plan', got %q", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCommand_Start(t *testing.T) {
|
||||
al, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create interviewing plan
|
||||
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Test task"})
|
||||
|
||||
// Transition to executing
|
||||
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"})
|
||||
if !strings.Contains(response, "executing") {
|
||||
t.Errorf("expected 'executing', got %q", response)
|
||||
}
|
||||
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
if status := agent.ContextBuilder.GetPlanStatus(); status != "executing" {
|
||||
t.Errorf("expected 'executing', got %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCommand_StartAlreadyExecuting(t *testing.T) {
|
||||
al, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create interviewing plan then start
|
||||
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Test task"})
|
||||
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"})
|
||||
|
||||
// Try start again
|
||||
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"})
|
||||
if !strings.Contains(response, "already executing") {
|
||||
t.Errorf("expected 'already executing', got %q", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCommand_Done(t *testing.T) {
|
||||
al, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
// Write a plan directly with phases
|
||||
plan := `# Active Plan
|
||||
|
||||
> Task: Test task
|
||||
> Status: executing
|
||||
> Phase: 1
|
||||
|
||||
## Phase 1: Setup
|
||||
- [ ] Step one
|
||||
- [ ] Step two
|
||||
|
||||
## Context
|
||||
Test context
|
||||
`
|
||||
agent.ContextBuilder.WriteMemory(plan)
|
||||
|
||||
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done 1"})
|
||||
if !strings.Contains(response, "Marked step 1") {
|
||||
t.Errorf("expected confirmation, got %q", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCommand_DoneInvalidStep(t *testing.T) {
|
||||
al, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Test task"})
|
||||
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done abc"})
|
||||
if !strings.Contains(response, "positive integer") {
|
||||
t.Errorf("expected step validation error, got %q", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCommand_Add(t *testing.T) {
|
||||
al, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
plan := `# Active Plan
|
||||
|
||||
> Task: Test task
|
||||
> Status: executing
|
||||
> Phase: 1
|
||||
|
||||
## Phase 1: Setup
|
||||
- [ ] Step one
|
||||
|
||||
## Context
|
||||
Test context
|
||||
`
|
||||
agent.ContextBuilder.WriteMemory(plan)
|
||||
|
||||
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan add New step here"})
|
||||
if !strings.Contains(response, "Added step") {
|
||||
t.Errorf("expected 'Added step', got %q", response)
|
||||
}
|
||||
|
||||
content := agent.ContextBuilder.ReadMemory()
|
||||
if !strings.Contains(content, "New step here") {
|
||||
t.Error("expected new step in plan content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCommand_Next(t *testing.T) {
|
||||
al, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
plan := `# Active Plan
|
||||
|
||||
> Task: Test task
|
||||
> Status: executing
|
||||
> Phase: 1
|
||||
|
||||
## Phase 1: Setup
|
||||
- [x] Step one
|
||||
|
||||
## Phase 2: Deploy
|
||||
- [ ] Step two
|
||||
|
||||
## Context
|
||||
Test
|
||||
`
|
||||
agent.ContextBuilder.WriteMemory(plan)
|
||||
|
||||
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan next"})
|
||||
if !strings.Contains(response, "phase 2") {
|
||||
t.Errorf("expected 'phase 2', got %q", response)
|
||||
}
|
||||
|
||||
if phase := agent.ContextBuilder.GetCurrentPhase(); phase != 2 {
|
||||
t.Errorf("expected phase 2, got %d", phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanCommand_ShowActivePlan(t *testing.T) {
|
||||
al, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
plan := `# Active Plan
|
||||
|
||||
> Task: Deploy app
|
||||
> Status: executing
|
||||
> Phase: 1
|
||||
|
||||
## Phase 1: Build
|
||||
- [x] Compile code
|
||||
- [ ] Run tests
|
||||
|
||||
## Context
|
||||
Production server
|
||||
`
|
||||
agent.ContextBuilder.WriteMemory(plan)
|
||||
|
||||
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan"})
|
||||
if !strings.Contains(response, "Deploy app") {
|
||||
t.Errorf("expected task name in display, got %q", response)
|
||||
}
|
||||
if !strings.Contains(response, "Phase 1") {
|
||||
t.Errorf("expected phase info in display, got %q", response)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoPhaseAdvance verifies that auto-advance sends notification after LLM iteration
|
||||
// when current phase is complete.
|
||||
func TestAutoPhaseAdvance(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-auto-advance-*")
|
||||
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: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &simpleMockProvider{response: "OK"}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
t.Fatal("No default agent")
|
||||
}
|
||||
|
||||
// Write plan with phase 1 complete
|
||||
plan := `# Active Plan
|
||||
|
||||
> Task: Test auto advance
|
||||
> Status: executing
|
||||
> Phase: 1
|
||||
|
||||
## Phase 1: Setup
|
||||
- [x] Step one
|
||||
- [x] Step two
|
||||
|
||||
## Phase 2: Deploy
|
||||
- [ ] Step three
|
||||
|
||||
## Context
|
||||
Test
|
||||
`
|
||||
agent.ContextBuilder.WriteMemory(plan)
|
||||
|
||||
// Process a message which triggers runAgentLoop
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = al.ProcessDirectWithChannel(ctx, "continue", "auto-advance-test", "test", "chat1")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
|
||||
// After processing, phase should be auto-advanced
|
||||
if phase := agent.ContextBuilder.GetCurrentPhase(); phase != 2 {
|
||||
t.Errorf("expected phase auto-advanced to 2, got %d", phase)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoCompleteClears verifies that plan is cleared when all phases are complete.
|
||||
func TestAutoCompleteClears(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-auto-complete-*")
|
||||
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: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &simpleMockProvider{response: "All done"}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
t.Fatal("No default agent")
|
||||
}
|
||||
|
||||
// Write fully complete plan
|
||||
plan := `# Active Plan
|
||||
|
||||
> Task: Test auto complete
|
||||
> Status: executing
|
||||
> Phase: 1
|
||||
|
||||
## Phase 1: Setup
|
||||
- [x] Step one
|
||||
- [x] Step two
|
||||
|
||||
## Context
|
||||
Test
|
||||
`
|
||||
agent.ContextBuilder.WriteMemory(plan)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = al.ProcessDirectWithChannel(ctx, "finish up", "auto-complete-test", "test", "chat1")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
|
||||
// Plan should be cleared
|
||||
if agent.ContextBuilder.HasActivePlan() {
|
||||
t.Error("expected plan to be cleared after completion")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -60,6 +63,14 @@ func (ms *MemoryStore) WriteLongTerm(content string) error {
|
|||
return os.WriteFile(ms.memoryFile, []byte(content), 0644)
|
||||
}
|
||||
|
||||
// ClearLongTerm removes the long-term memory file.
|
||||
func (ms *MemoryStore) ClearLongTerm() error {
|
||||
if err := os.Remove(ms.memoryFile); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadToday reads today's daily note.
|
||||
// Returns empty string if the file doesn't exist.
|
||||
func (ms *MemoryStore) ReadToday() string {
|
||||
|
|
@ -128,22 +139,456 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
|
|||
return result
|
||||
}
|
||||
|
||||
// ---------- Plan state query methods ----------
|
||||
|
||||
var (
|
||||
reActivePlan = regexp.MustCompile(`(?m)^# Active Plan`)
|
||||
reStatus = regexp.MustCompile(`(?m)^> Status:\s*(.+)`)
|
||||
rePhase = regexp.MustCompile(`(?m)^> Phase:\s*(\d+)`)
|
||||
rePhaseHeader = regexp.MustCompile(`(?m)^## Phase (\d+):\s*(.*)`)
|
||||
reStepDone = regexp.MustCompile(`(?m)^- \[x\] `)
|
||||
reStepTodo = regexp.MustCompile(`(?m)^- \[ \] `)
|
||||
)
|
||||
|
||||
// HasActivePlan returns true if MEMORY.md contains an active plan.
|
||||
func (ms *MemoryStore) HasActivePlan() bool {
|
||||
content := ms.ReadLongTerm()
|
||||
return reActivePlan.MatchString(content)
|
||||
}
|
||||
|
||||
// GetPlanStatus returns the plan status: "interviewing", "executing", or "".
|
||||
func (ms *MemoryStore) GetPlanStatus() string {
|
||||
content := ms.ReadLongTerm()
|
||||
m := reStatus.FindStringSubmatch(content)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// GetCurrentPhase returns the current phase number from "> Phase: N".
|
||||
func (ms *MemoryStore) GetCurrentPhase() int {
|
||||
content := ms.ReadLongTerm()
|
||||
m := rePhase.FindStringSubmatch(content)
|
||||
if len(m) < 2 {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n
|
||||
}
|
||||
|
||||
// GetTotalPhases returns the total number of phases (max ## Phase N).
|
||||
func (ms *MemoryStore) GetTotalPhases() int {
|
||||
content := ms.ReadLongTerm()
|
||||
matches := rePhaseHeader.FindAllStringSubmatch(content, -1)
|
||||
max := 0
|
||||
for _, m := range matches {
|
||||
if len(m) >= 2 {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
if n > max {
|
||||
max = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// IsPlanComplete returns true if all steps in all phases are [x].
|
||||
func (ms *MemoryStore) IsPlanComplete() bool {
|
||||
content := ms.ReadLongTerm()
|
||||
if !reActivePlan.MatchString(content) {
|
||||
return false
|
||||
}
|
||||
// Must have at least one step
|
||||
if !reStepDone.MatchString(content) && !reStepTodo.MatchString(content) {
|
||||
return false
|
||||
}
|
||||
// No unchecked steps
|
||||
return !reStepTodo.MatchString(content)
|
||||
}
|
||||
|
||||
// IsCurrentPhaseComplete returns true if all steps in the current phase are [x].
|
||||
func (ms *MemoryStore) IsCurrentPhaseComplete() bool {
|
||||
content := ms.ReadLongTerm()
|
||||
phase := ms.GetCurrentPhase()
|
||||
if phase == 0 {
|
||||
return false
|
||||
}
|
||||
phaseContent := ms.extractPhaseContent(content, phase)
|
||||
if phaseContent == "" {
|
||||
return false
|
||||
}
|
||||
// Must have at least one step
|
||||
if !reStepDone.MatchString(phaseContent) && !reStepTodo.MatchString(phaseContent) {
|
||||
return false
|
||||
}
|
||||
return !reStepTodo.MatchString(phaseContent)
|
||||
}
|
||||
|
||||
// extractPhaseContent returns the content of a specific phase section.
|
||||
func (ms *MemoryStore) extractPhaseContent(content string, phase int) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
inPhase := false
|
||||
var result []string
|
||||
|
||||
phasePrefix := fmt.Sprintf("## Phase %d:", phase)
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, phasePrefix) {
|
||||
inPhase = true
|
||||
continue
|
||||
}
|
||||
if inPhase {
|
||||
// Stop at next phase header or Context section
|
||||
if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") {
|
||||
break
|
||||
}
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
// ---------- Plan mutation methods ----------
|
||||
|
||||
// SetStatus sets the plan status (interviewing or executing).
|
||||
func (ms *MemoryStore) SetStatus(status string) error {
|
||||
content := ms.ReadLongTerm()
|
||||
if m := reStatus.FindString(content); m != "" {
|
||||
content = strings.Replace(content, m, "> Status: "+status, 1)
|
||||
}
|
||||
return ms.WriteLongTerm(content)
|
||||
}
|
||||
|
||||
// AdvancePhase increments the current phase number by 1.
|
||||
func (ms *MemoryStore) AdvancePhase() error {
|
||||
content := ms.ReadLongTerm()
|
||||
m := rePhase.FindStringSubmatch(content)
|
||||
if len(m) < 2 {
|
||||
return fmt.Errorf("no phase marker found")
|
||||
}
|
||||
current, _ := strconv.Atoi(m[1])
|
||||
next := current + 1
|
||||
content = strings.Replace(content, m[0], fmt.Sprintf("> Phase: %d", next), 1)
|
||||
return ms.WriteLongTerm(content)
|
||||
}
|
||||
|
||||
// MarkStep marks the nth step (1-based) in the given phase as done [x].
|
||||
func (ms *MemoryStore) MarkStep(phase, step int) error {
|
||||
content := ms.ReadLongTerm()
|
||||
lines := strings.Split(content, "\n")
|
||||
phasePrefix := fmt.Sprintf("## Phase %d:", phase)
|
||||
|
||||
inPhase := false
|
||||
stepCount := 0
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, phasePrefix) {
|
||||
inPhase = true
|
||||
continue
|
||||
}
|
||||
if inPhase {
|
||||
if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(line, "- [ ] ") {
|
||||
stepCount++
|
||||
if stepCount == step {
|
||||
lines[i] = strings.Replace(line, "- [ ] ", "- [x] ", 1)
|
||||
return ms.WriteLongTerm(strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("step %d not found in phase %d", step, phase)
|
||||
}
|
||||
|
||||
// AddStep appends a new step to the given phase.
|
||||
func (ms *MemoryStore) AddStep(phase int, desc string) error {
|
||||
content := ms.ReadLongTerm()
|
||||
lines := strings.Split(content, "\n")
|
||||
phasePrefix := fmt.Sprintf("## Phase %d:", phase)
|
||||
|
||||
inPhase := false
|
||||
insertIdx := -1
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, phasePrefix) {
|
||||
inPhase = true
|
||||
continue
|
||||
}
|
||||
if inPhase {
|
||||
if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") {
|
||||
insertIdx = i
|
||||
break
|
||||
}
|
||||
// Track last step line
|
||||
if strings.HasPrefix(line, "- [") {
|
||||
insertIdx = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if insertIdx < 0 {
|
||||
// Phase not found or empty; append at end
|
||||
if inPhase {
|
||||
insertIdx = len(lines)
|
||||
} else {
|
||||
return fmt.Errorf("phase %d not found", phase)
|
||||
}
|
||||
}
|
||||
|
||||
newStep := "- [ ] " + desc
|
||||
newLines := make([]string, 0, len(lines)+1)
|
||||
newLines = append(newLines, lines[:insertIdx]...)
|
||||
newLines = append(newLines, newStep)
|
||||
newLines = append(newLines, lines[insertIdx:]...)
|
||||
|
||||
return ms.WriteLongTerm(strings.Join(newLines, "\n"))
|
||||
}
|
||||
|
||||
// ---------- Selective injection methods ----------
|
||||
|
||||
// interviewSeed is the initial content written to MEMORY.md when /plan starts.
|
||||
const interviewSeedTemplate = `# Active Plan
|
||||
|
||||
> Task: %s
|
||||
> Status: interviewing
|
||||
> Phase: 1
|
||||
`
|
||||
|
||||
// BuildInterviewSeed creates the initial plan seed for a given task description.
|
||||
func BuildInterviewSeed(task string) string {
|
||||
return fmt.Sprintf(interviewSeedTemplate, task)
|
||||
}
|
||||
|
||||
// GetInterviewContext returns context for injection during the interviewing phase.
|
||||
// Includes the full seed + interview guide + target format template.
|
||||
func (ms *MemoryStore) GetInterviewContext() string {
|
||||
content := ms.ReadLongTerm()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## Active Plan (interviewing)\n\n")
|
||||
sb.WriteString(content)
|
||||
sb.WriteString("\n\n### Interview Guide\n")
|
||||
sb.WriteString("Ask about:\n")
|
||||
sb.WriteString("- Goals and success criteria\n")
|
||||
sb.WriteString("- Constraints (time, budget, platform)\n")
|
||||
sb.WriteString("- Environment (OS, language, runtime versions)\n")
|
||||
sb.WriteString("- Tooling preferences (test framework, linter, formatter, CI)\n")
|
||||
sb.WriteString("- Key commands the user already runs (build, test, deploy)\n")
|
||||
sb.WriteString("When ready, organize into 2-5 phases with 3-5 steps each.\n")
|
||||
sb.WriteString("\n### Target Format\n")
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString("## Phase 1: <title>\n")
|
||||
sb.WriteString("- [ ] Step\n")
|
||||
sb.WriteString("## Phase 2: <title>\n")
|
||||
sb.WriteString("- [ ] Step\n")
|
||||
sb.WriteString("## Commands\n")
|
||||
sb.WriteString("build: go build ./...\n")
|
||||
sb.WriteString("test: go test ./pkg/... -count=1\n")
|
||||
sb.WriteString("lint: golangci-lint run\n")
|
||||
sb.WriteString("## Context\n")
|
||||
sb.WriteString("<collected requirements, decisions, environment>\n")
|
||||
sb.WriteString("```\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GetPlanContext returns context for injection during the executing phase.
|
||||
// Only the current phase is shown in detail; completed phases are compressed
|
||||
// to one-line summaries; future phases are omitted.
|
||||
func (ms *MemoryStore) GetPlanContext() string {
|
||||
content := ms.ReadLongTerm()
|
||||
currentPhase := ms.GetCurrentPhase()
|
||||
totalPhases := ms.GetTotalPhases()
|
||||
|
||||
// Extract task description
|
||||
taskLine := ""
|
||||
if m := regexp.MustCompile(`(?m)^> Task:\s*(.+)`).FindStringSubmatch(content); len(m) >= 2 {
|
||||
taskLine = strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## Active Plan\n")
|
||||
sb.WriteString(fmt.Sprintf("Task: %s | Phase %d/%d\n", taskLine, currentPhase, totalPhases))
|
||||
|
||||
// Completed phases: one-line summaries
|
||||
for p := 1; p < currentPhase; p++ {
|
||||
title := ms.getPhaseTitle(content, p)
|
||||
sb.WriteString(fmt.Sprintf("Done: Phase %d (%s)\n", p, title))
|
||||
}
|
||||
|
||||
// Current phase: full detail
|
||||
if currentPhase > 0 {
|
||||
title := ms.getPhaseTitle(content, currentPhase)
|
||||
sb.WriteString(fmt.Sprintf("### Current: Phase %d — %s\n", currentPhase, title))
|
||||
phaseContent := ms.extractPhaseContent(content, currentPhase)
|
||||
sb.WriteString(strings.TrimSpace(phaseContent))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Commands section: always included if present
|
||||
commandsContent := ms.extractCommandsSection(content)
|
||||
if commandsContent != "" {
|
||||
sb.WriteString("### Commands\n")
|
||||
sb.WriteString(commandsContent)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Context section: always included
|
||||
contextContent := ms.extractContextSection(content)
|
||||
if contextContent != "" {
|
||||
sb.WriteString("### Context\n")
|
||||
sb.WriteString(contextContent)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// getPhaseTitle extracts the title of a phase from "## Phase N: Title".
|
||||
func (ms *MemoryStore) getPhaseTitle(content string, phase int) string {
|
||||
matches := rePhaseHeader.FindAllStringSubmatch(content, -1)
|
||||
for _, m := range matches {
|
||||
if len(m) >= 3 {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
if n == phase {
|
||||
return strings.TrimSpace(m[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractSection extracts a named ## section from the plan content.
|
||||
// It returns everything between "## <name>" and the next "## " header.
|
||||
func (ms *MemoryStore) extractSection(content, name string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
prefix := "## " + name
|
||||
inSection := false
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
inSection = true
|
||||
continue
|
||||
}
|
||||
if inSection {
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
break
|
||||
}
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(result, "\n"))
|
||||
}
|
||||
|
||||
// extractContextSection extracts the ## Context section from the plan.
|
||||
func (ms *MemoryStore) extractContextSection(content string) string {
|
||||
return ms.extractSection(content, "Context")
|
||||
}
|
||||
|
||||
// extractCommandsSection extracts the ## Commands section from the plan.
|
||||
func (ms *MemoryStore) extractCommandsSection(content string) string {
|
||||
return ms.extractSection(content, "Commands")
|
||||
}
|
||||
|
||||
// FormatPlanDisplay returns a user-facing display of the full plan with emoji indicators.
|
||||
func (ms *MemoryStore) FormatPlanDisplay() string {
|
||||
content := ms.ReadLongTerm()
|
||||
if !ms.HasActivePlan() {
|
||||
return "No active plan."
|
||||
}
|
||||
|
||||
taskLine := ""
|
||||
if m := regexp.MustCompile(`(?m)^> Task:\s*(.+)`).FindStringSubmatch(content); len(m) >= 2 {
|
||||
taskLine = strings.TrimSpace(m[1])
|
||||
}
|
||||
status := ms.GetPlanStatus()
|
||||
currentPhase := ms.GetCurrentPhase()
|
||||
totalPhases := ms.GetTotalPhases()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Plan: %s\n", taskLine))
|
||||
sb.WriteString(fmt.Sprintf("Status: %s | Phase %d/%d\n\n", status, currentPhase, totalPhases))
|
||||
|
||||
for p := 1; p <= totalPhases; p++ {
|
||||
title := ms.getPhaseTitle(content, p)
|
||||
phaseContent := ms.extractPhaseContent(content, p)
|
||||
|
||||
// Determine phase emoji
|
||||
var emoji string
|
||||
if p < currentPhase {
|
||||
emoji = "\u2705" // checkmark
|
||||
} else if p == currentPhase {
|
||||
emoji = "\u25B6\uFE0F" // play button
|
||||
} else {
|
||||
emoji = "\u23F3" // hourglass
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s Phase %d: %s\n", emoji, p, title))
|
||||
|
||||
// Show steps for current and completed phases
|
||||
if p <= currentPhase {
|
||||
lines := strings.Split(phaseContent, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "- [x] ") {
|
||||
sb.WriteString(" \u2611 " + line[6:] + "\n")
|
||||
} else if strings.HasPrefix(line, "- [ ] ") {
|
||||
sb.WriteString(" \u2610 " + line[6:] + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commandsContent := ms.extractCommandsSection(content)
|
||||
if commandsContent != "" {
|
||||
sb.WriteString("\nCommands:\n")
|
||||
for _, line := range strings.Split(commandsContent, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
sb.WriteString(" " + line + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contextContent := ms.extractContextSection(content)
|
||||
if contextContent != "" {
|
||||
sb.WriteString("\nContext: " + contextContent + "\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ---------- GetMemoryContext (plan-aware) ----------
|
||||
|
||||
// GetMemoryContext returns formatted memory context for the agent prompt.
|
||||
// Includes long-term memory and recent daily notes.
|
||||
// When an active plan exists, it uses selective injection based on plan status.
|
||||
// During interviewing: full seed + interview guide.
|
||||
// During executing: current phase only with compressed completed phases.
|
||||
// When plan is active, daily notes injection is suppressed to save context.
|
||||
func (ms *MemoryStore) GetMemoryContext() string {
|
||||
var parts []string
|
||||
|
||||
// Long-term memory
|
||||
longTerm := ms.ReadLongTerm()
|
||||
if longTerm != "" {
|
||||
if ms.HasActivePlan() {
|
||||
status := ms.GetPlanStatus()
|
||||
if status == "interviewing" {
|
||||
parts = append(parts, ms.GetInterviewContext())
|
||||
} else {
|
||||
parts = append(parts, ms.GetPlanContext())
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, "## Long-term Memory\n\n"+longTerm)
|
||||
}
|
||||
}
|
||||
|
||||
// Recent daily notes (last 3 days)
|
||||
// Suppress daily notes when a plan is active to save context
|
||||
if !ms.HasActivePlan() {
|
||||
recentNotes := ms.GetRecentDailyNotes(3)
|
||||
if recentNotes != "" {
|
||||
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
|
|
|
|||
526
pkg/agent/memory_test.go
Normal file
526
pkg/agent/memory_test.go
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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, "go test") {
|
||||
t.Error("expected target format Commands to include test command example")
|
||||
}
|
||||
if !strings.Contains(ctx, "golangci-lint") {
|
||||
t.Error("expected target format Commands to include lint command example")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInterviewSeed(t *testing.T) {
|
||||
seed := BuildInterviewSeed("Deploy monitoring stack")
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreCreation(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "memory-test-*")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ func (c *cmd) Help(ctx context.Context, message telego.Message) error {
|
|||
/session - Show usage statistics
|
||||
/skills - List available skills
|
||||
/skill <name> [message] - Invoke a skill
|
||||
/plan - Show or manage your plan
|
||||
`
|
||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue