test: add StripThinkBlocks and DetectRepetitionLoop unit tests

Also include plan-interview-improvements design doc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 15:34:56 +09:00
parent c6b5af426c
commit 32d0147a08
2 changed files with 237 additions and 0 deletions

View file

@ -0,0 +1,112 @@
# /plan interview 改善検討
## 現象
`/plan <task>` でinterviewモードに入った後、AIが:
1. MEMORY.mdに何も書かずに会話だけ続ける
2. interviewを無視して実装を始めようとするexec, ファイル書き込み)
3. 結局フェーズ/ステップ/コマンドが書かれないまま executing に遷移する
## 原因分析
### Layer 1: 技術的障壁(修正済み)
| 問題 | 原因 | 修正 |
|---|---|---|
| XML tool callがパースされない | MiniMaxの開閉タグ不一致`<minimax:toolcall>` vs `</minimax:tool_call>` | regex + normalizeAlpha + 編集距離で fuzzy matching |
| tool名が不一致で実行失敗 | `readfile` vs `read_file` | `ToolRegistry.Get()` に normalizeAlpha フォールバック |
| interview許可リストも完全一致 | `isToolAllowedDuringInterview` が exact match | normalizeAlpha で比較 |
### Layer 2: AI行動の問題未対応
技術的障壁を除去しても、モデル(特に小規模モデル)の命令追従に起因する問題が残る:
- **「会話しながらファイル編集」が複合タスクとして難しい** — interview中にMEMORY.mdを更新する行為は、会話とファイル操作の並行処理。小さいモデルにはハードルが高い
- **AIがワークフローを無視して実装に走る** — interview指示よりも「ユーザーの要求を直接解決しよう」というバイアスが強い
- **tool callイテレーション中に目的を忘れる** — read_fileでファイルを読み始めると、そのまま実装に入ろうとする
## 検討した案と判断
### 案: ユーザー発言の自動追記(却下)
MEMORY.mdにユーザー発言を自動追記 → ちゃんとしたplanにはならない。生データの蓄積であって構造化された計画ではない。
### 案: 専用tool `save_context`(却下)
tool仕様を変えても、AIがtoolを適切に呼ばない根本問題は解決しない。
### 案: 別パスでplan生成LLMコール却下
AIが「情報が揃った」と判断するトリガーの設計が難しい。キーワード検出は不安定。
### 案: `/plan draft` コマンド(却下)
ユーザーがトリガーできても、その前にAIがワークフローを無視して実装を始める問題は残る。また状態とコマンドが増えてユーザーが混乱する。
### 案: 毎ターン固定メッセージ注入(却下)
探索的なマルチターン会話で誤爆する。AIがファイルを読んだりリサーチしている途中のターンで「edit_fileしろ」は邪魔。
## 採用方針: tool callイテレーション内リマインド
### 既知の知見
通常の開発モードexecutingで、tool callが反復される中でユーザー指示が忘れられる問題に対し、リマインド注入で自律開発がスムーズに進むようになった実績がある。同じパターンをinterview中にも適用する。
### 設計
**1. interviewフェーズのtool制限実装済み**
```
許可: read_file, list_dir, web_search, web_fetch
許可: edit_file / write_file / append_fileMEMORY.mdのみ
ブロック: exec, その他write系
```
AIが実装に走ろうとしても物理的にできない。
**2. tool callイテレーション内でリマインド注入未実装**
`runLLMIteration` 内で、tool結果をLLMに返す直前= 次のLLMコールの直前にリマインドを差し込む。
```go
// tool結果メッセージの後、次のLLMコール前
if isPlanPreExecution(agent.ContextBuilder.GetPlanStatus()) {
messages = append(messages, providers.Message{
Role: "user",
Content: "[System] You are interviewing. Ask questions and save findings " +
"to ## Context in memory/MEMORY.md. " +
"When ready, write ## Phase and ## Commands sections.",
})
}
```
- **注入タイミング**: tool callループ内のみ。ユーザーとの会話ターンには入れない
- **注入条件**: interviewing または review 状態の時
- **内容**: 固定。短く、具体的に何をすべきか指示
**3. 状態遷移は既存のまま**
```
/plan <task> → interviewingAIが質問、read系+MEMORY.md書き込み許可
→ AIがStatus:executingに変更しようとする
→ システムがreviewに横取りphases > 0 の場合)
→ ユーザーにplan表示
/plan start → executing全toolアンロック
```
新しい状態・新しいコマンドなし。
## 実装タスク
- [ ] `runLLMIteration` 内のtool callループにリマインド注入を追加
- [ ] リマインド内容をステータスごとに分岐interviewing / review / executing
- [ ] 既存のstaleness nudge2ターン無更新で警告との統合・整理
- [ ] テスト追加
## 未解決の懸念
- **リマインドの効果がモデル依存**: 大きいモデルには効くが、小さいモデルでは無視される可能性
- **リマインドの頻度**: 毎イテレーション注入でトークン消費が増えるただし1行程度なので軽微
- **interview→plan書き込みのタイミング**: AIが「もう十分」と判断する基準はモデル任せ。staleness nudgeが補助するが確実ではない

125
pkg/utils/string_test.go Normal file
View file

@ -0,0 +1,125 @@
package utils
import (
"strings"
"testing"
)
// --- StripThinkBlocks ---
func TestStripThinkBlocks_ClosedBlock(t *testing.T) {
in := "<think>\nsecret reasoning\n</think>\n\nVisible content"
got := StripThinkBlocks(in)
if got != "Visible content" {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, "Visible content")
}
}
func TestStripThinkBlocks_UnclosedBlock(t *testing.T) {
in := "<think>reasoning that never ends\nmore reasoning"
got := StripThinkBlocks(in)
if got != "" {
t.Fatalf("StripThinkBlocks() = %q, want empty", got)
}
}
func TestStripThinkBlocks_MultipleBlocks(t *testing.T) {
in := "<think>first</think>middle<think>second</think>end"
got := StripThinkBlocks(in)
if got != "middleend" {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, "middleend")
}
}
func TestStripThinkBlocks_NoBlocks(t *testing.T) {
in := "plain text without think blocks"
got := StripThinkBlocks(in)
if got != in {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, in)
}
}
func TestStripThinkBlocks_CaseInsensitive(t *testing.T) {
in := "<THINK>upper case</THINK>visible"
got := StripThinkBlocks(in)
if got != "visible" {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, "visible")
}
}
func TestStripThinkBlocks_ClosedThenUnclosed(t *testing.T) {
in := "<think>closed</think>middle<think>unclosed tail"
got := StripThinkBlocks(in)
if got != "middle" {
t.Fatalf("StripThinkBlocks() = %q, want %q", got, "middle")
}
}
// --- DetectRepetitionLoop ---
func TestDetectRepetitionLoop_HighRepetition(t *testing.T) {
// Repeat a short phrase many times → should be detected
phrase := "結構本格的なコード"
repeated := strings.Repeat(phrase, 300)
if !DetectRepetitionLoop(repeated) {
t.Fatal("DetectRepetitionLoop should return true for highly repetitive text")
}
}
func TestDetectRepetitionLoop_NormalText(t *testing.T) {
// Normal varied text should not trigger
normal := "The quick brown fox jumps over the lazy dog. " +
"Pack my box with five dozen liquor jugs. " +
"How vexingly quick daft zebras jump. " +
"Sphinx of black quartz, judge my vow. " +
"Two driven jocks help fax my big quiz. " +
"The five boxing wizards jump quickly. " +
"Jackdaws love my big sphinx of quartz. " +
"Grumpy wizards make a toxic brew for the jovial queen."
// Extend to be long enough
long := strings.Repeat(normal+" ", 10)
if DetectRepetitionLoop(long) {
t.Fatal("DetectRepetitionLoop should return false for normal text")
}
}
func TestDetectRepetitionLoop_ShortText(t *testing.T) {
// Text shorter than N-gram size should never trigger
if DetectRepetitionLoop("short") {
t.Fatal("DetectRepetitionLoop should return false for short text")
}
}
func TestDetectRepetitionLoop_EmptyString(t *testing.T) {
if DetectRepetitionLoop("") {
t.Fatal("DetectRepetitionLoop should return false for empty string")
}
}
func TestDetectRepetitionLoop_SingleCharRepeat(t *testing.T) {
// "aaaa..." repeated → only 1 unique N-gram → detected
repeated := strings.Repeat("あ", 2500)
if !DetectRepetitionLoop(repeated) {
t.Fatal("DetectRepetitionLoop should return true for single-char repetition")
}
}
func TestDetectRepetitionLoop_BelowSampleSize(t *testing.T) {
// Repetitive but under sample size still detected
phrase := "abcdefghij"
repeated := strings.Repeat(phrase, 50) // 500 chars
if !DetectRepetitionLoop(repeated) {
t.Fatal("DetectRepetitionLoop should return true for repetitive text below sample size")
}
}
// --- Truncate ---
func TestTruncate(t *testing.T) {
if got := Truncate("hello", 10); got != "hello" {
t.Errorf("Truncate short = %q", got)
}
if got := Truncate("hello world!", 8); got != "hello..." {
t.Errorf("Truncate long = %q", got)
}
}