From 4b1a73d38fce8f2bc27352379d739fee84432d68 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 15 Apr 2026 05:21:43 +0900 Subject: [PATCH] =?UTF-8?q?AGENTS.md=E3=81=AE=E8=AA=AD=E3=81=BF=E5=8F=96?= =?UTF-8?q?=E3=82=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 5 + crates/pod/src/agents_md.rs | 172 ++++++++++++++++++ crates/pod/src/lib.rs | 1 + crates/pod/src/pod.rs | 7 +- .../pod/tests/system_prompt_template_test.rs | 68 +++++++ tickets/agents-md-ingestion.md | 49 ++++- tickets/agents-md-ingestion.review.md | 77 ++++++++ tickets/tui-greeting-card.md | 50 +++++ tickets/tui-notification-channel.md | 61 +++++++ tickets/tui-pod-shutdown.md | 48 +++++ tickets/tui-pod-spawn-ui.md | 52 ++++++ 11 files changed, 580 insertions(+), 10 deletions(-) create mode 100644 crates/pod/src/agents_md.rs create mode 100644 tickets/agents-md-ingestion.review.md create mode 100644 tickets/tui-greeting-card.md create mode 100644 tickets/tui-notification-channel.md create mode 100644 tickets/tui-pod-shutdown.md create mode 100644 tickets/tui-pod-spawn-ui.md diff --git a/TODO.md b/TODO.md index 2dbe8768..5654e73f 100644 --- a/TODO.md +++ b/TODO.md @@ -6,3 +6,8 @@ - [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md) - [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md) - [ ] AGENTS.md の取り込み → [tickets/agents-md-ingestion.md](tickets/agents-md-ingestion.md) +- [ ] TUI 拡充 + - [ ] 通知チャネル (Warn/Error 可視化) → [tickets/tui-notification-channel.md](tickets/tui-notification-channel.md) + - [ ] 空 Pod 起動時の Greeting カード → [tickets/tui-greeting-card.md](tickets/tui-greeting-card.md) + - [ ] Pod の明示的 shutdown → [tickets/tui-pod-shutdown.md](tickets/tui-pod-shutdown.md) + - [ ] 新しい Pod を spawn する UI の設計 → [tickets/tui-pod-spawn-ui.md](tickets/tui-pod-spawn-ui.md) diff --git a/crates/pod/src/agents_md.rs b/crates/pod/src/agents_md.rs new file mode 100644 index 00000000..182c809e --- /dev/null +++ b/crates/pod/src/agents_md.rs @@ -0,0 +1,172 @@ +//! `AGENTS.md` ingestion for system-prompt templates. +//! +//! Reads `AGENTS.md` directly under the Pod cwd and exposes its body +//! to the template engine through `SystemPromptContext.files.agents_md`. +//! Nested / parent-directory AGENTS.md files are intentionally ignored; +//! subproject context is expressed by launching a Pod with that +//! directory as cwd. + +use std::fs::File; +use std::io::{ErrorKind, Read}; +use std::path::Path; + +use tracing::warn; + +/// Hard cap on the bytes exposed to the template. Roughly 20-25k tokens, +/// well within typical provider rate limits. +pub(crate) const AGENTS_MD_LIMIT: usize = 64 * 1024; + +const TRUNCATION_NOTICE: &str = "\n\n[truncated: AGENTS.md exceeded 64KB limit]"; + +/// Read `AGENTS.md` from `cwd` if present. Returns `None` for "absent or +/// unreadable"; all non-fatal problems are logged via `tracing::warn!`. +/// +/// - Absent: `None`, no warn. +/// - Over limit: first 64KB (UTF-8 char boundary) + truncation notice, warn. +/// - Non-UTF-8 or I/O error: `None`, warn. +pub(crate) fn read_agents_md(cwd: &Path) -> Option { + let path = cwd.join("AGENTS.md"); + + let file = match File::open(&path) { + Ok(f) => f, + Err(e) if e.kind() == ErrorKind::NotFound => return None, + Err(e) => { + warn!(path = %path.display(), error = %e, "failed to open AGENTS.md"); + return None; + } + }; + + // Read one extra byte beyond the limit so we can detect oversize + // regardless of what `metadata()` claims (pipes/procfs may lie). + let mut buf = Vec::new(); + let read_limit = (AGENTS_MD_LIMIT as u64) + 1; + if let Err(e) = file.take(read_limit).read_to_end(&mut buf) { + warn!(path = %path.display(), error = %e, "failed to read AGENTS.md"); + return None; + } + + let truncated = buf.len() > AGENTS_MD_LIMIT; + if truncated { + buf.truncate(AGENTS_MD_LIMIT); + } + + // UTF-8 decoding must not depend on whether the file exceeded the + // size limit: the same "genuinely non-UTF-8" file should be rejected + // regardless of its size. The only case in which we tolerate an + // invalid tail is when truncation itself sliced through a multi-byte + // char — at most 3 bytes of the final (4-byte) code point can be + // orphaned that way. Anything worse means the file was already + // non-UTF-8 before truncation, and we reject it. + let text = match std::str::from_utf8(&buf) { + Ok(_) => { + // SAFETY path: buf is valid UTF-8 in its entirety. + String::from_utf8(buf).expect("validated above") + } + Err(e) if truncated && e.valid_up_to() >= AGENTS_MD_LIMIT - 3 => { + let valid_len = e.valid_up_to(); + buf.truncate(valid_len); + String::from_utf8(buf).expect("valid_up_to prefix is valid UTF-8") + } + Err(e) => { + warn!(path = %path.display(), error = %e, "AGENTS.md is not valid UTF-8"); + return None; + } + }; + + let mut text = text; + if truncated { + warn!( + path = %path.display(), + limit = AGENTS_MD_LIMIT, + "AGENTS.md exceeded size limit; truncating" + ); + text.push_str(TRUNCATION_NOTICE); + } + + Some(text) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn absent_file_returns_none() { + let dir = TempDir::new().unwrap(); + assert!(read_agents_md(dir.path()).is_none()); + } + + #[test] + fn reads_small_file_verbatim() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("AGENTS.md"), "# hello\nworld").unwrap(); + assert_eq!( + read_agents_md(dir.path()).as_deref(), + Some("# hello\nworld"), + ); + } + + #[test] + fn oversized_file_is_truncated_with_notice() { + let dir = TempDir::new().unwrap(); + let body = "a".repeat(AGENTS_MD_LIMIT + 1024); + fs::write(dir.path().join("AGENTS.md"), &body).unwrap(); + + let got = read_agents_md(dir.path()).expect("some"); + assert!(got.ends_with(TRUNCATION_NOTICE)); + let prefix = got.strip_suffix(TRUNCATION_NOTICE).unwrap(); + assert_eq!(prefix.len(), AGENTS_MD_LIMIT); + assert!(prefix.chars().all(|c| c == 'a')); + } + + #[test] + fn exact_limit_is_not_truncated() { + let dir = TempDir::new().unwrap(); + let body = "a".repeat(AGENTS_MD_LIMIT); + fs::write(dir.path().join("AGENTS.md"), &body).unwrap(); + + let got = read_agents_md(dir.path()).expect("some"); + assert_eq!(got.len(), AGENTS_MD_LIMIT); + assert!(!got.contains("truncated")); + } + + #[test] + fn truncation_respects_utf8_char_boundary() { + let dir = TempDir::new().unwrap(); + // Fill up to just under the limit with ASCII, then append a + // multi-byte char that straddles the boundary. + let mut body = "a".repeat(AGENTS_MD_LIMIT - 1); + body.push('あ'); // 3 bytes → pushes total past the limit + body.push_str(&"b".repeat(128)); + fs::write(dir.path().join("AGENTS.md"), &body).unwrap(); + + let got = read_agents_md(dir.path()).expect("some"); + assert!(got.ends_with(TRUNCATION_NOTICE)); + let prefix = got.strip_suffix(TRUNCATION_NOTICE).unwrap(); + // The partial 'あ' must have been dropped, leaving only the ASCII prefix. + assert_eq!(prefix.len(), AGENTS_MD_LIMIT - 1); + assert!(prefix.chars().all(|c| c == 'a')); + } + + #[test] + fn oversized_non_utf8_is_still_rejected() { + // Regression: a file that is genuinely non-UTF-8 must be rejected + // regardless of its size. Previously the truncation-recovery pop + // loop would silently accept a partial prefix of such files once + // they exceeded the limit. + let dir = TempDir::new().unwrap(); + let body = vec![0xffu8; AGENTS_MD_LIMIT + 1024]; + fs::write(dir.path().join("AGENTS.md"), body).unwrap(); + assert!(read_agents_md(dir.path()).is_none()); + } + + #[test] + fn non_utf8_returns_none() { + let dir = TempDir::new().unwrap(); + // Invalid UTF-8 start byte. + fs::write(dir.path().join("AGENTS.md"), [0xff, 0xfe, 0xfd]).unwrap(); + assert!(read_agents_md(dir.path()).is_none()); + } +} diff --git a/crates/pod/src/lib.rs b/crates/pod/src/lib.rs index 93241866..6f2c5606 100644 --- a/crates/pod/src/lib.rs +++ b/crates/pod/src/lib.rs @@ -4,6 +4,7 @@ pub mod runtime_dir; pub mod shared_state; pub mod socket_server; +mod agents_md; mod compact_interceptor; mod compact_state; mod hook_interceptor; diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 14c7870a..037d96aa 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -13,6 +13,7 @@ use tracing::{info, warn}; use manifest::{PodManifest, Scope, ScopeError, WorkerManifest}; +use crate::agents_md::read_agents_md; use crate::compact_interceptor::CompactInterceptor; use crate::compact_state::CompactState; use crate::hook::{ @@ -402,12 +403,16 @@ impl Pod { .into_iter() .map(|d| d.name) .collect(); + let mut files = std::collections::BTreeMap::new(); + if let Some(body) = read_agents_md(&self.pwd) { + files.insert("agents_md".to_string(), body); + } let ctx = SystemPromptContext { now: chrono::Utc::now(), cwd: &self.pwd, scope: &self.scope, tool_names, - files: std::collections::BTreeMap::new(), + files, }; let rendered = template .render(&ctx) diff --git a/crates/pod/tests/system_prompt_template_test.rs b/crates/pod/tests/system_prompt_template_test.rs index d11f262c..a14a99a1 100644 --- a/crates/pod/tests/system_prompt_template_test.rs +++ b/crates/pod/tests/system_prompt_template_test.rs @@ -209,6 +209,74 @@ async fn materialise_runs_only_once_across_turns() { assert_eq!(first, second); } +#[tokio::test] +async fn agents_md_is_injected_when_present() { + let client = MockClient::new(vec![single_text_events("ok")]); + let mut pod = make_pod_with_template( + Some( + "{% if files.agents_md is defined %}AGENTS:{{ files.agents_md }}\ + {% else %}NONE{% endif %}", + ), + client, + ) + .await + .unwrap(); + std::fs::write(pod.pwd().join("AGENTS.md"), "# project rules\nbe kind").unwrap(); + + pod.run("hi").await.unwrap(); + let rendered = pod.worker().get_system_prompt().unwrap().to_string(); + assert_eq!(rendered, "AGENTS:# project rules\nbe kind"); +} + +#[tokio::test] +async fn agents_md_absent_leaves_key_undefined() { + let client = MockClient::new(vec![single_text_events("ok")]); + let mut pod = make_pod_with_template( + Some("{% if files.agents_md is defined %}HAS{% else %}NONE{% endif %}"), + client, + ) + .await + .unwrap(); + // No AGENTS.md written. + pod.run("hi").await.unwrap(); + assert_eq!(pod.worker().get_system_prompt().unwrap(), "NONE"); +} + +#[tokio::test] +async fn agents_md_not_reread_after_compact() { + // Render AGENTS.md on the first turn, then mutate the file on disk + // and compact. The post-compact prompt must still reflect the + // original content (template re-rendering is forbidden). + let client = MockClient::new(vec![ + single_text_events("a"), + single_text_events("b"), + single_text_events("summary"), + single_text_events("c"), + ]); + let mut pod = make_pod_with_template( + Some("{{ files.agents_md }}"), + client, + ) + .await + .unwrap(); + let agents_path = pod.pwd().join("AGENTS.md"); + std::fs::write(&agents_path, "original").unwrap(); + + pod.run("first").await.unwrap(); + let before = pod.worker().get_system_prompt().unwrap().to_string(); + assert_eq!(before, "original"); + pod.run("second").await.unwrap(); + + // Mutate the file after the first turn — must not affect the cached + // system prompt either on a subsequent turn or across compaction. + std::fs::write(&agents_path, "mutated").unwrap(); + pod.compact(1).await.unwrap(); + assert_eq!(pod.worker().get_system_prompt().unwrap(), "original"); + + pod.run("third").await.unwrap(); + assert_eq!(pod.worker().get_system_prompt().unwrap(), "original"); +} + #[tokio::test] async fn compact_preserves_system_prompt() { // Three user turns, then compact with retained_turns=1. The new diff --git a/tickets/agents-md-ingestion.md b/tickets/agents-md-ingestion.md index 06739c43..83f42a0a 100644 --- a/tickets/agents-md-ingestion.md +++ b/tickets/agents-md-ingestion.md @@ -1,5 +1,10 @@ # AGENTS.md の取り込み +## レビュー状態 + +初回レビュー実施済み。[agents-md-ingestion.review.md](agents-md-ingestion.review.md) を参照。 +要件・完了条件すべて達成、無条件で受け入れ可。指摘1(切り詰め時の UTF-8 pop ループの非一貫)は任意修正。 + ## 背景 [agents.md](https://agents.md) は AI コーディングエージェント向けにプロジェクト固有の指示を置く標準ファイルとして普及しつつある(主要エージェント群が対応し、OpenAI の本体リポジトリだけでも 88 個の AGENTS.md が置かれている)。Insomnia の Pod も、作業ディレクトリに配置された `AGENTS.md` を自動でシステムプロンプトの文脈として取り込めるようにしたい。 @@ -8,7 +13,18 @@ agents.md 仕様では「編集対象ファイルから見て最近接の1つ」 ## 依存 -- `system-prompt-template.md`: 本チケットは読み取り結果をテンプレート変数として露出する前提に立つ。テンプレート機構が無いと組み込み先が無いため、先に着手する。 +- `system-prompt-template.md`(実装済み / `crates/pod/src/system_prompt.rs`): 本チケットは読み取り結果を `SystemPromptContext.files` マップ経由でテンプレート変数として露出する。`files` キーは既に予約済み。 + +## spec との整合 + +`https://agents.md` の仕様は薄く、以下だけを規定している: + +- ファイル名は `AGENTS.md`、リポジトリルートに配置 +- ネスト時は**最近接優先、マージしない** +- 中身は任意の Markdown、必須セクション無し +- 優先順位は chat prompt > AGENTS.md + +サイズ上限・エンコーディング・不在時挙動・テンプレート機能は spec で規定されておらず、各エージェント実装の裁量。本チケットの取り決めは下記の通り spec と矛盾しない。 ## 要件 @@ -18,21 +34,35 @@ agents.md 仕様では「編集対象ファイルから見て最近接の1つ」 - 子ディレクトリのネスト AGENTS.md も扱わない(cwd 直下の1ファイルのみ)。 - ファイルが存在しない場合は欠損ではなく「空」として扱い、エラーにしない。 +spec のネスト解決(nearest-wins)は、Pod の cwd が1点に固定される都合で「cwd 直下の1ファイルのみ」に単純化している。サブプロジェクト向けにカスタム文脈を与えたい場合はそのディレクトリを cwd にして Pod を立てる運用。 + ### テンプレートへの露出 -- `system-prompt-template.md` で定義したテンプレート変数の1つとして AGENTS.md の本文を提供する。 -- 評価タイミングはテンプレート本体と同じく first turn 開始時の1回のみ。compact 後も再評価しない。 -- ファイルが存在しないときに変数が参照された場合の値(空文字 / 未定義扱い)をテンプレート側の未定義変数ポリシーと整合させる。 +- `SystemPromptContext.files` マップに `agents_md` キーとして本文を挿入する。 +- 評価タイミングは system-prompt-template と同じく first turn 開始時の1回のみ。compact 後も再評価しない。 +- **不在・読み取り失敗時は `files` マップにキーを入れない**。テンプレ作者は `{% if files.agents_md is defined %}...{% endif %}` で存在をガードする前提(minijinja の `UndefinedBehavior::Strict` の下で安全に扱うため)。 -### 例外処理 +### サイズ上限 -- ファイルサイズの上限と、上限超過時の扱い(切り詰め / エラー / 警告)を決める。 -- 非 UTF-8 等の読み取り失敗時の扱いを決める。first turn 開始の失敗として扱うか、空として続行するかは `system-prompt-template.md` のエラー処理方針に揃える。 +- **64KB を上限**とする(約 20-25k token 相当、provider の 30k token/分レートリミットに対して余裕を持った値)。 +- 超過時は**先頭 64KB を採用し末尾に `\n\n[truncated: AGENTS.md exceeded 64KB limit]` を追記**。エラーにはしない。 +- `tracing::warn!` で切り詰めた旨をログ出力する。 + +### 読み取り失敗時 + +- 非 UTF-8、I/O エラーなど `std::fs::read_to_string` が失敗するケースは、**`tracing::warn!` を出して `files` マップにキーを入れずに続行**。Pod 起動は成功扱い。 +- ファイル自体が存在しない場合は正常系(warn は出さない)。 + +### warn の伝達 + +- 現状の Pod/Worker 層には「ユーザー可視のユーザー通知チャネル」は未整備(`tracing::warn!` のみ)。本チケットでは `tracing` に出すだけとし、TUI バナーや PodEvent への露出は別チケットで扱う。 ## 完了条件 -- Pod の cwd 直下に `AGENTS.md` を置いて Pod を起動すると、その内容が first turn のシステムプロンプトに反映される。 -- `AGENTS.md` を置かない場合でも Pod が正常に起動し、システムプロンプトが壊れない。 +- Pod の cwd 直下に `AGENTS.md` を置き、`files.agents_md` を参照するテンプレートの Pod を起動すると、その内容が first turn のシステムプロンプトに反映される。 +- `AGENTS.md` を置かない場合でも Pod が正常に起動し、テンプレート側で `{% if files.agents_md is defined %}` ガードを書いていればシステムプロンプトが壊れない。 +- 64KB 超の `AGENTS.md` は先頭 64KB + 切り詰め注記でレンダリングされ、`tracing::warn!` がログに出る。 +- 非 UTF-8 の `AGENTS.md` があっても Pod 起動は成功し、`files.agents_md` は未定義扱いになり、`tracing::warn!` がログに出る。 - compact を跨いで AGENTS.md の内容が再読込されないことを担保する。 ## 範囲外 @@ -40,3 +70,4 @@ agents.md 仕様では「編集対象ファイルから見て最近接の1つ」 - 親ディレクトリ方向への遡及、ネスト AGENTS.md のマージ。 - ユーザ単位の共通設定ファイル(将来 Insomnia 独自の user config として別チケット化)。 - AGENTS.md 以外のフォーマット(CLAUDE.md 等)への自動対応。必要なら各自シンボリックリンクで解決する前提。 +- ユーザー可視の warn 通知チャネル(TUI バナー等)。本チケットは `tracing::warn!` に出すのみ。 diff --git a/tickets/agents-md-ingestion.review.md b/tickets/agents-md-ingestion.review.md new file mode 100644 index 00000000..481ab6e3 --- /dev/null +++ b/tickets/agents-md-ingestion.review.md @@ -0,0 +1,77 @@ +# レビュー: AGENTS.md の取り込み + +対象差分: `crates/pod/src/{lib,pod,agents_md}.rs`, `crates/pod/tests/system_prompt_template_test.rs`(いずれも未コミット) + +## 要件達成状況 + +| 要件 | 検証 | +|---|---| +| cwd 直下の `AGENTS.md` のみ読む(親遡及・ネスト無し) | ✅ `agents_md.rs:28` で `cwd.join("AGENTS.md")` 単発 | +| 不在時は `files.agents_md` キー自体を省略 | ✅ `pod.rs:407-410` が `Option::Some` のときのみ挿入 | +| サイズ上限 64KB、超過時は先頭 64KB + 注記 + warn | ✅ 定数 `AGENTS_MD_LIMIT` と `TRUNCATION_NOTICE`、`oversized_file_is_truncated_with_notice` で検証 | +| 非 UTF-8 / 読み取り失敗時は warn + キー省略 | ✅ `non_utf8_returns_none`, open/read エラー経路に warn | +| 不在時は warn を出さない(正常系) | ✅ `ErrorKind::NotFound` だけ warn をスキップ | +| first turn 時に1回のみ評価、compact 後も再評価しない | ✅ 既存の `ensure_system_prompt_materialized` 機構に乗る。`agents_md_not_reread_after_compact` が file を mutate + compact 経由で明示的に担保 | +| `{% if files.agents_md is defined %}` で取得可能(B 案) | ✅ `agents_md_absent_leaves_key_undefined`, `agents_md_is_injected_when_present` 両方でテンプレ側のガードを検証 | + +完了条件4ケース(正常・不在・64KB 超過・非UTF-8)および compact 跨ぎ、合計5ケースすべてが unit / integration テストで自動化されている。**要件は完全に達成**。 + +## アーキテクチャ統合 + +- 実装を `pod.rs` に書き足さず `crates/pod/src/agents_md.rs` として別ファイルに切り出している。機能モジュール分離のプロジェクト方針と整合。 +- `read_agents_md` は `pub(crate)` に閉じており、外部 API を汚さない。 +- `pod.rs` 側の配線は 5 行の最小侵襲(既存 `files: BTreeMap` の作り方を変えただけ)。system_prompt_template の評価タイミング保証を自前で再実装せず、既存の materialize 機構に素直に乗っている。 + +アーキテクチャ的な懸念なし。 + +## 指摘事項 + +### 1. 🟡 切り詰め時の UTF-8 pop ループが「意図的な非 UTF-8 の大型ファイル」を通してしまう + +`agents_md.rs:61-65`: + +```rust +if truncated { + while !buf.is_empty() && std::str::from_utf8(&buf).is_err() { + buf.pop(); + } +} +``` + +コメントは明確: 「切り詰め時だけ partial UTF-8 を trim する。切り詰めていない本物の非 UTF-8 は拒否する」。意図は正しい。 + +しかし副作用として: + +- 小さい非 UTF-8 ファイル → `String::from_utf8` で拒否(OK) +- **64KB を超える非 UTF-8 ファイル**で、先頭が偶然 UTF-8 valid な prefix を持つ場合 → pop ループで末尾の invalid バイトを削り落として採用されてしまう + +つまり「非 UTF-8 は拒否」という要件が**ファイルサイズによって挙動が変わる非一貫**になっている。 + +**実害**: +- 普通の AGENTS.md が意図せずこの経路に落ちることは稀(エディタは UTF-8 で保存する) +- 巨大バイナリを誤配置した場合のフォールバックとしては「読める prefix を採用」は妥当とも言える + +**判断**: 実害は小さいので**必須修正ではない**。ただし挙動を意図したものとして固めるなら、`str::from_utf8(&buf).unwrap_err().valid_up_to()` で truncation 時の末尾処理をより正確に(partial UTF-8 分だけ削る)記述するとコードが仕様と一致する。将来の誰かが読んで混乱しないためにも、コメントに「pop ループは truncation 末尾の partial UTF-8 だけを狙っているが、pathological には valid prefix を持つ非 UTF-8 ファイルも通る」と明記しておくと親切。 + +### 2. 🟢 non-UTF-8 / I/O エラー時に `files` キーが省略されることの integration 検証 + +`agents_md.rs` の unit test で `read_agents_md` が `None` を返すことは確認されているため、`pod.rs:407-410` の `if let Some(...)` 経路を通れば挙動は担保される。追加の integration test は不要。 + +ただし将来 `pod.rs` 側で `read_agents_md` の戻り値の扱いを変更した場合に unit test だけでは気付けない可能性がある。気になるなら `agents_md_non_utf8_leaves_key_undefined` という integration を1つ足す程度。**任意**。 + +### 3. 🟢 pod.rs で `std::collections::BTreeMap` がインラインのまま + +`pod.rs:406` `let mut files = std::collections::BTreeMap::new();` がフルパス。既存コードスタイルへの追随のはずなので不問。必要なら別途クリーンアップ。 + +## 良い点 + +- `File::open` → `take(LIMIT + 1)` → `read_to_end` の **extra-byte トリック**でメタデータが信頼できないファイルシステム(pipe, procfs 等)でも truncation を正しく検出できる +- `oversized = metadata_len.map(|n| n as usize > AGENTS_MD_LIMIT)` と `buf.len() > AGENTS_MD_LIMIT` の**二重チェック** +- エラー経路ごとに warn のフィールド(`path`, `error`, `limit`)が構造化されている +- テストの網羅性: absent / small / oversized / exact limit / UTF-8 char boundary / non-UTF-8 / integration 3ケース = **計9テスト** +- `exact_limit_is_not_truncated` というオフバイワン検証が入っている(地味に偉い) +- 各コメントが **what ではなく why** を書いている(extra byte を読む理由、pop ループの条件分岐の意図) + +## 結論 + +**無条件で受け入れ可**。指摘1はコメント追記または valid_up_to ベースに書き換えを**任意で**検討、指摘2・3は不問。 diff --git a/tickets/tui-greeting-card.md b/tickets/tui-greeting-card.md new file mode 100644 index 00000000..5c250054 --- /dev/null +++ b/tickets/tui-greeting-card.md @@ -0,0 +1,50 @@ +# TUI: 空 Pod で起動したときの Greeting カード + +## 背景 + +新しい Pod を作って TUI で起動した直後、ユーザーは**ほぼ空の画面**を見ることになる。Pod がどういう設定で動いているか(cwd、scope、利用可能なツール、model)、何を打てばよいか(入力フォーカスはどこか、最初のメッセージを送ればよいだけなのか)が画面から読み取れない。 + +最初のターンが始まる前に、Pod の自己紹介と使い方ヒントを1枚のカードとして見せることで、**空画面の困惑と Pod 設定の不透明さを同時に解消**したい。 + +## 要件 + +### 表示タイミング + +- セッションが空(まだ turn が1つも無い)の状態で TUI を開いた直後に表示する。 +- ユーザーが最初のメッセージを送信した時点で消えるか、履歴の頭に flatten して残るかは設計時に判断。 + +### カードに載せる情報 + +最低限: + +- **Pod 名**(manifest の `pod.name`) +- **cwd**(絶対パス) +- **model / provider** +- **scope の要約**(readable / writable パス。既に `Scope::summary()` が存在する) +- **登録ツール一覧**(ツール名のみ) + +任意で: + +- システムプロンプトの冒頭数行、または AGENTS.md が取り込まれているかどうかの表示 +- 基本キーバインドのヒント(終了・通知ペインなど、別チケットで足される機能も想定) + +### 再開時の扱い + +- 既存セッション(turn がある状態)を再開したときは表示しない。履歴冒頭に残すかどうかは設計時に判断(残すなら flatten 済みカードとして)。 + +## 設計で決めること + +- **flatten するか、消すか**: 初回メッセージ送信で消える一時カード vs 履歴の一部として残るカード +- **カード内のレイアウト**: 単一 widget か、ブロック構成か +- **AGENTS.md 取り込み済みかどうかの可視化**を入れるか(AGENTS.md 取り込みチケット完了後に追加検討でも可) + +## 完了条件 + +- `test_pod.local.toml` のような空セッション Pod を TUI で開くと、Pod 名・cwd・scope・ツール一覧がカード状に表示される。 +- ユーザーが最初のメッセージを送った後、カードは規定の挙動(消える or 残る)に従う。 +- 既存セッションを再開したときは Greeting が出ない。 + +## 範囲外 + +- テーマ・配色のカスタマイズ。 +- Pod ごとにカスタムの Greeting 本文を持たせる仕組み。 diff --git a/tickets/tui-notification-channel.md b/tickets/tui-notification-channel.md new file mode 100644 index 00000000..ce786e31 --- /dev/null +++ b/tickets/tui-notification-channel.md @@ -0,0 +1,61 @@ +# TUI 通知チャネル: Warn/Error をユーザーに可視化 + +## 背景 + +Pod/Worker 層は現在、通知すべき事象(compaction 失敗、AGENTS.md 読み取り失敗、ツール出力の切り詰め、将来追加される様々な前処理エラー等)をすべて `tracing::warn!` で出している。TUI はこのログを受け取る仕組みを持たないため、**ユーザーは何も気づかないまま Pod が縮退動作している状態**になりうる。 + +tracing は開発者向けログで、ユーザー向け通知とは目的が違う。Pod 層が「これはユーザーに見せるべき」と判断した事象を、専用チャネルで TUI に届ける仕組みが必要。 + +## 関連するチケット・機能 + +- `tickets/agents-md-ingestion.md`: AGENTS.md の切り詰め / 読み取り失敗の警告を現在 `tracing::warn!` だけに出している +- `crates/pod/src/pod.rs`: compaction 失敗を `tracing::warn!` で済ませている +- `crates/llm-worker/src/worker.rs`: ツール出力の切り詰めを `tracing::warn!` で済ませている + +これらはすべて本チケットの完成後に通知チャネル経由に載せ替えることで、ユーザーに可視化される。 + +## 要件 + +### チャネルの所在と型 + +- Pod(または Controller 層)が「ユーザー向け通知」をストリームできるチャネルを持ち、TUI が subscribe する。 +- `tracing` とは**別系統**とする。開発者向けログと混ぜない。 +- 通知は構造化された型で、少なくとも以下を持つ: + - **レベル** (`Warn` / `Error` 以上。`Info` を含めるかは設計時に判断) + - **発生源** (Pod 名 / Worker / Compactor / Tool 実行境界 等の列挙) + - **メッセージ本文**(人間可読の1〜2行) + - **タイムスタンプ** + +### TUI 側の表示 + +- 新着通知はユーザーが見落としにくい位置に**一時的**に表示される(トースト / ステータスバー等、設計時に選択) +- **履歴が見られる**こと(キーバインドで通知ペインを開ける等)。履歴はセッション単位で保持。 +- 複数通知が短時間に来た場合の重ね合わせ挙動を決める。 +- Error と Warn を視覚的に区別する。 + +### 既存 warn 発生箇所の置換 + +- 本チケット完了時点で、既存の `tracing::warn!` のうち**ユーザーに伝えるべきもの**を新チャネルに移行する。`tracing::warn!` 自体は並行して残して良い(デバッグ用途)。 +- 移行対象(現時点で確認済み): + - compaction 失敗 (`pod.rs`) + - ツール出力切り詰め (`worker.rs`) + - AGENTS.md 切り詰め / 読み取り失敗 (AGENTS.md 取り込みチケット完了後) + +## 設計で決めること + +- **チャネル実装**: `tokio::sync::broadcast` / `mpsc` / 独自 `NotificationBus` のどれにするか +- **tracing subscriber でフックするか、明示的 API を生やすか**: 前者は既存 warn をほぼ無改修で流せるが、レベルや発生源の構造化が難しい。後者は呼び出し側の書き換えが必要だが型が強く出る。 +- **UI の提示方法**: トースト / ステータスバー常駐 / 通知ペイン / その組合せ +- **ペルシステンス**: 通知履歴を session-store に載せるか、TUI 起動中だけ保持するか + +## 完了条件 + +- Pod 層が型付き通知を発行する API を持つ。 +- TUI がその通知を受信して表示・履歴保存する。 +- 既存の「ユーザーに伝えるべき `tracing::warn!`」が新チャネル経由で届き、手動テストで TUI 画面上に現れることを確認できる。 + +## 範囲外 + +- tracing ログ基盤そのものの再設計。 +- 通知への対話的応答(通知から操作を起こす等)。表示・蓄積のみに集中する。 +- 通知の集計・分析・外部送信(Sentry 等)。 diff --git a/tickets/tui-pod-shutdown.md b/tickets/tui-pod-shutdown.md new file mode 100644 index 00000000..a3d57993 --- /dev/null +++ b/tickets/tui-pod-shutdown.md @@ -0,0 +1,48 @@ +# TUI: Pod を明示的に終了させる操作 + +## 背景 + +現状、TUI から Pod を終了させる手段は Ctrl-C / プロセス終了に頼っている。これだと: + +- 実行中のターンが中断された状態で終わり、session-store の永続化が中途半端になる可能性 +- 複数 Pod を並列で扱えるようになったとき(`tickets/tui-pod-spawn-ui.md`)、「今表示している Pod だけを畳む」操作と「TUI 全体を終わる」操作を区別する必要がある +- ユーザーが「Pod の作業を完了としてクローズする」という意図を表現できない + +TUI から明示的に Pod の shutdown を指示できる操作を追加する。 + +## 要件 + +### 操作 + +- TUI 内のキーバインドで Pod の終了を開始できる。 +- 破壊的な副作用(セッションは残るが実行中ターンは落ちる)を避けるため、**実行中のターンがあれば確認を挟む**。 +- 確認なしで終了する強制モードを用意するかは設計時に判断。 + +### Pod 側の shutdown 手順 + +- 実行中のターンがあれば、既存のキャンセル機構(`WorkerError::Cancelled`)で中断する。 +- キャンセル完了を待ってから session-store にフラッシュし、Pod 状態を `Stopped` 相当に遷移させる。 +- 中断時点までの turn は整合を保って永続化される(途中の partial turn は巻き戻される)。 + +### TUI 側の挙動 + +- shutdown 完了後、TUI はその Pod の表示を閉じる。 +- 他に Pod が無ければ TUI 自体も正常終了する(並列管理が無い現状ではこちらの挙動で良い)。 +- shutdown 中は進行状況が画面上で分かる(「shutting down...」等の表示)。 + +## 設計で決めること + +- **キーバインド**: `q` / `Ctrl-D` / `:quit` のどれを当てるか、強制モードのキーを別立てにするか +- **実行中ターンがあるときの確認 UI**: モーダル / フッター問い合わせ / キー再押下 +- **Pod 層 API**: 既存の cancel + persist を組合せた shutdown メソッドを Pod に生やすか、Controller 側で段取るか + +## 完了条件 + +- TUI からキー操作で Pod を shutdown できる。 +- 実行中のターンがあった場合は確認を経由し、キャンセル → 永続化 → 画面クローズの順で進む。 +- shutdown 後に `test_pod.local.toml` の session を再開すると、中断されたターンの副作用が残っていない整合状態で開ける。 + +## 範囲外 + +- 複数 Pod 並列管理下での「この Pod だけ閉じる」と「全部閉じる」の使い分け。並列管理自体が無いため、本チケットでは単一 Pod 前提とする。仕様は `tickets/tui-pod-spawn-ui.md` で改めて整理する。 +- Pod の一時停止(resume 可能な pause)。本チケットは完全な shutdown のみ扱う。 diff --git a/tickets/tui-pod-spawn-ui.md b/tickets/tui-pod-spawn-ui.md new file mode 100644 index 00000000..623df468 --- /dev/null +++ b/tickets/tui-pod-spawn-ui.md @@ -0,0 +1,52 @@ +# TUI: 新しい Pod を spawn する UI の設計 + +## 背景 + +Insomnia は複数 Pod を同時に走らせられるアーキテクチャを想定しているが、TUI は**現在単一 Pod のみを表示**する前提で作られている。Pod を増やすには TUI を起動し直すしかなく、作業文脈の並列性がユーザーに見えていない。 + +TUI から新しい Pod を生やす操作と、複数 Pod を扱う UI モデルを設計する。実装の前に**使い方の合意**が必要なため、本チケットは**設計フェーズの成果物**を完了条件に含む。 + +## 要件 + +### 成果物(設計フェーズ) + +- **ユースケース記述**: どういう場面で Pod を増やしたくなるか、複数 Pod 間で何をするか、現行 Pod をどう区別・切替するか +- **UI モデル**: タブ / 分割ペイン / リスト + 単一表示 のどれにするか、根拠つきで +- **状態遷移図**: Pod の spawn / active / background / shutdown のライフサイクル +- **spawn 時の入力**: manifest をどう指定するか(ファイル選択 / テンプレート / 新規編集) +- **既存チケットとの整合**: `tui-pod-shutdown.md` の shutdown 操作が「単一 Pod を閉じる」意味にちゃんとなるか、`tui-notification-channel.md` の通知が「どの Pod の通知か」を表現できるか +- 上記を `docs/tui-pod-spawn.md` 等にまとめる + +### 実装フェーズ(設計合意後) + +- spawn UI のエントリポイントを TUI に実装 +- 複数 Pod を扱うための内部データモデル(Pod リスト、アクティブ Pod 管理) +- Pod 間の切替操作 +- 各 Pod の shutdown 操作との統合 + +## 設計で決めること + +- **並列実行の扱い**: 全 Pod を常に走らせるか、フォーカスしている Pod のみ走らせて他は suspend か +- **リソース共有**: scope 排他制御(`tickets/scope-exclusion.md`)との関係。同一パスを同時 write できない制約を UI でどう表現するか +- **shutdown との関係**: 「この Pod を閉じる」と「TUI を閉じる」を区別するキーバインド +- **通知の帰属**: 通知チャネル(`tickets/tui-notification-channel.md`)が Pod 別に表示されるべきか、TUI 全体で集約するか + +## 完了条件 + +### 設計フェーズ + +- `docs/tui-pod-spawn.md`(または同等の設計ドキュメント)がレビュー可能な形で存在する +- 上記の「設計で決めること」すべてに結論が出ている +- 他の TUI チケット(通知・shutdown)との整合が確認できている + +### 実装フェーズ + +- TUI から新しい Pod を spawn できる +- 複数 Pod が spawn された状態で、ユーザーがそれらを切り替えられる +- 各 Pod は独立して実行・通知・shutdown できる + +## 範囲外 + +- Pod 間のメッセージパッシングや依存関係(Pod A の出力を Pod B の入力に繋ぐ等) +- Pod の保存済みテンプレートからの呼び出し UI(設計に含めるかは検討) +- 分散実行・リモート Pod(ローカル TUI の中での複数 Pod のみ扱う)