diff --git a/CHANGELOG.md b/CHANGELOG.md index 1531f32..ae3e38a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ follow semantic versioning. ## Unreleased +### Added + +- Research pieces (`research` / `sns-research` / `sns-deep-sweep`) now write + date-stamped outputs (e.g. `output/report-2026-07-09.md`, + `output/deepdive/2026-07-09/`) so repeated runs in a persistent workspace + accumulate instead of overwriting the previous report. The agent system + prompt now always includes the current date/time (2026-07-09). +- Draft autosave for the new-task dialog, chat composer, feedback comments, + and continue instructions — text survives an accidental close or reload. + Files can be attached by pasting (Ctrl+V) into the new-task dialog + (2026-07-09). +- Settings: a "Skill quotas" section plus editors for several previously + config-file-only options (max stream time, subtask debounce, summary + headroom, .msg size limit, TLS minimum version, worker health-check + interval) (2026-07-09). + +### Fixed + +- Delegate run cards showed "0 tool calls" (and empty tool/file breakdowns) + regardless of actual activity; tool events now carry their own run + attribution (2026-07-09). + ## v0.2.0 (2026-07-09) A month of work since the initial release, centered on shared workspaces, diff --git a/pieces/research.yaml b/pieces/research.yaml index 11cda14..b7b0f87 100644 --- a/pieces/research.yaml +++ b/pieces/research.yaml @@ -16,6 +16,12 @@ movements: dig で立てた並列調査計画に従い、各テーマを delegate で1件ずつ直列に深掘りさせ、 最後に統合レポートにまとめる。重い調査は各サブに任せ、自分の文脈は軽く保つ。 + 成果物の上書き防止: システムプロンプトの「現在日時」の日付 (YYYY-MM-DD) を {DATE} とし、 + 成果物は {DATE} 入りのパスに書く。**過去の実行が残したファイルは編集も削除もしない**。 + {DATE} 入りの成果物(output/report-{DATE}.md や output/research/{DATE}/ 等、途中で + 中断した実行の残骸も含む)が既にあり、それが別タスクのものなら、{DATE}-2, {DATE}-3 ... + を全ファイル共通で使う。 + 手順: 1. dig で立てた調査テーマを思い出す(2〜5 個程度)。各テーマに上から 1 始まりの 連番を振る(1, 2, 3 ...)。 @@ -25,21 +31,21 @@ movements: - 調べるテーマと目的・背景 - 「WebSearch / WebFetch / 一次情報で裏を取る。モデルの内部知識だけで書かない」指示 - 「概要・主要な特徴・数値や事例・まとめと考察 の構成で - output/research/theme-{連番}.md に Write せよ。関連する画像・グラフは - output/images/ に保存し Markdown で埋め込め」指示 + output/research/{DATE}/theme-{連番}.md に Write せよ({DATE} は展開済みの実日付で渡す)。 + 関連する画像・グラフは output/images/{DATE}/ に保存し Markdown で埋め込め」指示 - 「完了したら 3〜5 行の要約だけを返せ。深掘り本文は返さずファイルに書け」 - 「あなたは末端の調査担当。delegate / SpawnSubTask は呼ぶな」 各サブの戻り値(短い要約)だけが手元に残る。本文はファイルにある。 - 3. Glob で output/research/*.md の件数を確認する。 - 4. 各サブの要約とファイルを基に統合レポートを output/report.md に Write する: + 3. Glob で output/research/{DATE}/*.md の件数を確認する。 + 4. 各サブの要約とファイルを基に統合レポートを output/report-{DATE}.md に Write する: - 各テーマの主要な知見を統合(矛盾・重複は整理) - 比較・対照が必要なら表形式で整理 - 全体のまとめと考察を付ける - - 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ - 5. output/report.md を書き終えたら verify へ遷移する + - 各テーマの詳細へ相対リンク [theme-{連番}](./research/{DATE}/theme-{連番}.md) で繋ぐ + 5. output/report-{DATE}.md を書き終えたら verify へ遷移する default_next: verify rules: - - condition: output/report.md に統合レポートを作成した + - condition: output/report-{DATE}.md に統合レポートを作成した next: verify - name: dig @@ -71,6 +77,15 @@ movements: WebSearch、WebFetch、ファイル読み込み等で情報を集め、必ず Write で output/ にファイルとして書き出すこと。 テキストで回答するだけでは不十分。 + 上書き防止(実行キーの決定・以後の全 movement で共有): + システムプロンプトの「現在日時」の日付 (YYYY-MM-DD) を実行キーとする。ただし + 同じ日付入りの成果物(output/report-{日付}.md や作業ファイル・画像等)が既にあり + 別タスクのものなら、空いている {日付}-2, {日付}-3 ... を実行キーにする。 + 作業ファイルはファイル名に実行キーを含め、画像は output/images/{実行キー}/ に保存する。 + キーを決めたら最初の作業ファイルをすぐ書き出してキーを確保する(並行する別タスクとの + 衝突の余地を減らす)。analyze・verify もこの実行キーを引き継いで使う。 + **過去の実行が残したファイルは編集も削除もしない**(読むのは可。内容は鵜呑みにしない)。 + ## 検索の原則(必須) - モデルの内部知識だけで情報を書かないこと。主張・事実・数値は必ず WebSearch/WebFetch で裏付けを取る @@ -88,7 +103,7 @@ movements: ## 画像・ビジュアル素材の収集(必須) - 調査中は画像・グラフ・図表を積極的に収集し、output/images/ に保存すること。 + 調査中は画像・グラフ・図表を積極的に収集し、output/images/{実行キー}/ に保存すること。 テキストだけの調査で終わらせない。ビジュアル素材がレポートの品質を大きく左右する。 収集すべきもの: @@ -97,7 +112,7 @@ movements: - データの可視化(統計グラフ、トレンド図等) - 関連する図解・インフォグラフィック - 収集した画像はレポートの Markdown から相対パスで参照する: `![説明](./images/ファイル名.png)` + 収集した画像はレポートの Markdown から相対パスで参照する: `![説明](./images/{実行キー}/ファイル名.png)` ## 終了 / 遷移方法 - **次の analyze へ**: `transition({next_step: "analyze"})` @@ -122,6 +137,13 @@ movements: 必ず Write ツールで output/ にレポートファイルを書き出すこと。 前のステップから指摘事項がある場合は、それに対応すること。 + 成果物の上書き防止: レポートのファイル名には dig で決めた実行キーを入れる + (例: output/report-2026-07-08.md、同日再実行なら output/report-2026-07-08-2.md)。 + dig を経ていない等でキーが不明な場合のみ、同じルール(「現在日時」の日付、 + 別タスクの同日成果物があれば空いている -2, -3 ...)で決め直す。 + **過去の実行が残したレポートは編集も削除もしない**。 + このタスク内での verify 差し戻しでは同じファイルを修正してよい。 + ## 検索の原則(必須) - レポートに記載する事実・数値・主張は、dig で収集した検索結果に基づくこと @@ -131,8 +153,9 @@ movements: ## 画像の活用(必須) - output/images/ に画像が保存されている場合は、必ずレポートの該当箇所に埋め込む: - `![説明](./images/ファイル名.png)` + output/images/{実行キー}/ にこのタスクで収集した画像がある場合は、 + 必ずレポートの該当箇所に実際の保存パスで埋め込む: + `![説明](./images/{実行キー}/ファイル名.png)`(例: `./images/2026-07-08/graph.png`) 画像があるのにテキストだけのレポートにしないこと。 レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。 @@ -147,6 +170,13 @@ movements: persona: reviewer instruction: | output/ のレポートを確認する。 + 審査対象は**このタスクの実行で作られたレポート**。ここまでの会話で書き出したファイル + パスをそのまま使う。パスが会話から分からない場合のみ Glob output/report-*.md で列挙し、 + GetFileProvenance で created_by_task_id がこのタスクのものを選ぶ。 + それでも特定できなければ日付と連番を数値として解釈して最新を選ぶ + (-2 は無印より新しく -10 は -9 より新しい。拡張子込みの辞書順ソートは + 無印が -2 より後に並ぶため使わない)。 + 過去の実行が残したレポートは審査対象外で、編集・削除もしない。 確認手順: 1. まず Glob で output/ 内のファイル一覧を確認する @@ -169,8 +199,9 @@ movements: - ユーザーの追加質問(前回タスクへの補足・深掘り)への回答が含まれる場合、その内容に WebSearch/WebFetch による検索の裏付けがあるか確認する。内部知識だけで回答している形跡がある場合は「追加質問への回答に検索根拠が不足」として analyze に差し戻す 追加チェック(画像): - - output/images/ に画像があるのにレポートに `![` が一つもない場合、 - 画像埋め込み漏れとして analyze に差し戻す + - このタスクの実行で収集した画像(output/images/{実行キー}/ 配下)があるのに + レポートに `![` が一つもない場合、画像埋め込み漏れとして analyze に差し戻す + (過去の実行が残した画像は対象外) ## 合格時のユーザーへの返答(complete ツール) diff --git a/pieces/sns-deep-sweep.yaml b/pieces/sns-deep-sweep.yaml index 8a45505..e9d5d59 100644 --- a/pieces/sns-deep-sweep.yaml +++ b/pieces/sns-deep-sweep.yaml @@ -21,12 +21,22 @@ movements: 1件ずつ独立したサブエージェント(delegate)にクリーンな文脈で深掘りさせ、最後に 統合レポートにまとめる。重い調査は各サブに任せ、あなた自身の文脈は軽く保つ。 + ### 0. 実行日 {DATE} を決める(成果物の上書き防止) + システムプロンプトの「現在日時」から今日の日付を YYYY-MM-DD 形式で取り、以下 {DATE} と呼ぶ。 + 成果物は必ず {DATE} 入りのパスに書き、**過去の実行が残したファイルは編集も削除もしない**。 + 同じ日に2回目以降を実行する場合は {DATE} の代わりに {DATE}-2, {DATE}-3 ... を + 全ファイル共通で使う。判定は report だけでなく {DATE} 入りの成果物のいずれか + (output/triage-{DATE}.md / output/deepdive/{DATE}/ / output/report-{DATE}.md、 + 途中で中断した実行の残骸も含む)が既に存在するかで行う。 + {DATE} を決めたら、取得を待たずまず output/triage-{DATE}.md を作業メモとして + 書き出してキーを確保する(並行する別タスクとの衝突の余地を減らす)。 + ### 1. 取得(手短に) タスク本文の指定(対象アカウント・キーワード・期間・件数)に従って投稿を集める。 - ホームタイムライン → XTimeline - キーワード → XSearch - 特定アカウント → XUserPosts - 取得が空・失敗ならその事実を output/triage.md に記録する(内部知識で投稿を捏造しない)。 + 取得が空・失敗ならその事実を output/triage-{DATE}.md に記録する(内部知識で投稿を捏造しない)。 続行できないほど取得できない場合は complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."}) を呼ぶ。 @@ -34,7 +44,7 @@ movements: 挨拶・広告・重複・無内容を除き、議論する価値のある投稿を選ぶ。 - タスクに件数指定(「上位20件」等)があればそれに従う。 - 指定がなければ 15 件を超えない。 - 選んだ投稿の一覧と選定理由を output/triage.md に短く書く。ここで長考しない。 + 選んだ投稿の一覧と選定理由を output/triage-{DATE}.md に短く書く。ここで長考しない。 ### 3. 深掘り委譲(選んだ各投稿に delegate を1回ずつ・直列) 選んだ投稿それぞれについて、delegate を1回呼ぶ。1件ずつ順番に処理する。 @@ -44,17 +54,18 @@ movements: - 対象投稿の本文・著者・URL(または id) - 「スレッド展開(XPostDetail)・リンク先記事(WebFetch)・著者の関連投稿(XUserPosts)・ 関連 Web 検索(WebSearch)で裏を取る」指示 - - その連番と「事実 / 背景 / 論点 / 評価 / 出典 を output/deepdive/tweet-{連番}.md に Write せよ」指示 + - その連番と「事実 / 背景 / 論点 / 評価 / 出典 を output/deepdive/{DATE}/tweet-{連番}.md に Write せよ」指示 + ({DATE} は展開済みの実日付で渡す。例: output/deepdive/2026-07-08/tweet-3.md) - 「完了したら 3〜5 行の要約だけを返せ。深掘り本文は返さずファイルに書け」 - 「あなたは末端の調査担当。delegate は呼ぶな」 各サブの戻り値(短い要約)だけが手元に残る。深掘り本文はファイルにある。 ### 4. 統合 → 終了 - - Glob で output/deepdive/*.md の件数を確認する。 - - 各サブの要約を束ね、全体傾向・横断テーマ・注目点を output/report.md に Write する。 - 各投稿の詳細へは相対リンク [tweet-{連番}](./deepdive/tweet-{連番}.md) で繋ぐ。 + - Glob で output/deepdive/{DATE}/*.md の件数を確認する。 + - 各サブの要約を束ね、全体傾向・横断テーマ・注目点を output/report-{DATE}.md に Write する。 + 各投稿の詳細へは相対リンク [tweet-{連番}](./deepdive/{DATE}/tweet-{連番}.md) で繋ぐ。 - 仕上がったら complete({status: "success", result: ...}) を呼ぶ。 - result は output/report.md の内容をベースに、ユーザー向けの最終回答として整形する。 + result は output/report-{DATE}.md の内容をベースに、ユーザー向けの最終回答として整形する。 「✅ 完了」等のメタ説明は書かず、1行目から本題を書く。 ### 原則 diff --git a/pieces/sns-research.yaml b/pieces/sns-research.yaml index 07d95e7..f79173c 100644 --- a/pieces/sns-research.yaml +++ b/pieces/sns-research.yaml @@ -12,6 +12,16 @@ movements: - name: gather persona: researcher instruction: | + ## 実行日 {DATE}(過去の成果物の上書き防止) + システムプロンプトの「現在日時」の日付 (YYYY-MM-DD) を {DATE} とする。 + 成果物は必ず {DATE} 入りのパスに書き、**過去の実行が残したファイルは編集も削除もしない**。 + {DATE} 入りの成果物が1つでも既に存在し(output/raw/{DATE}/ だけでなく、他の調査 piece が + 作った output/report-{DATE}.md 等も含めて確認する)、それが別タスクのものなら + {DATE}-2, {DATE}-3 ... を全ファイル共通で使う + (このタスク内で gather に戻ってきた場合は同じディレクトリに追記してよい)。 + {DATE} を決めたら、最初の収集結果をすぐ output/raw/{DATE}/ に書き出してキーを確保する + (並行する別タスクとの衝突の余地を減らす)。 + ## 調査計画 着手前に調査計画を立てる: @@ -19,7 +29,7 @@ movements: 2. 検索クエリ案を複数考える(日本語・英語の両方を検討) 3. verify からの差し戻しがある場合は、不足点を優先的に解消する - 計画に従って SNS から情報を収集し、Write で output/raw/ にテキストファイルとして書き出す。 + 計画に従って SNS から情報を収集し、Write で output/raw/{DATE}/ にテキストファイルとして書き出す。 ## SNS 別の収集方針 @@ -40,8 +50,8 @@ movements: - 記事詳細: `https://hn.algolia.com/api/v1/items/{id}` ## ファイル命名規則 - `output/raw/{platform}-{query-slug}.txt` - 例: reddit-ollama-vs-vllm.txt, x-ollama-review.txt, hn-local-llm.txt + `output/raw/{DATE}/{platform}-{query-slug}.txt` + 例: output/raw/2026-07-08/reddit-ollama-vs-vllm.txt, output/raw/2026-07-08/x-ollama-review.txt ## SNS 調査の原則 モデルの内部知識だけで情報を書かないこと。必ず実際の SNS データを収集する。 @@ -49,7 +59,7 @@ movements: ## 画像・スクリーンショットの収集 SNS 投稿には画像・グラフが含まれることが多い。重要なビジュアルは DownloadFile で - `output/images/{platform}-{slug}.png` に保存する。 + `output/images/{DATE}/{platform}-{slug}.png` に保存する。 ## 終了 / 遷移方法 - **次の analyze へ**: `transition({next_step: "analyze"})` @@ -68,12 +78,20 @@ movements: instruction: | output/raw/ の収集データを読み込み、分析してレポートを作成する。 + このタスクの gather が書き出した日付ディレクトリを {DATE} とする。 + 会話から分からない場合のみ、Glob output/raw/*/* でファイルを列挙し + (ディレクトリ末尾の output/raw/*/ はファイルにマッチせず常に空になるので使わない)、 + GetFileProvenance で created_by_task_id がこのタスクのものを選ぶ。 + それでも特定できなければ親ディレクトリ名の日付と連番を数値として解釈して + 最新のキーを選ぶ(-2 は無印より新しく -10 は -9 より新しい)。 + 過去の実行のファイルは編集・削除しない。 + 手順: - 1. Glob で output/raw/ 内のファイル一覧を確認 + 1. Glob で output/raw/{DATE}/ 内のファイル一覧を確認 2. 各ファイルを Read で読み込む 3. 重要な意見・トレンド・共通見解を抽出 4. ポジティブ/ネガティブな意見を分類 - 5. output/report.md にレポートを書き出す + 5. output/report-{DATE}.md にレポートを書き出す ## レポートの構成 - トピック概要 @@ -82,15 +100,15 @@ movements: - まとめ ## 画像の活用 - output/images/ に画像がある場合は必ずレポートに埋め込む: - `![説明](./images/ファイル名.png)` + output/images/{DATE}/ に画像がある場合は必ずレポートに埋め込む: + `![説明](./images/{DATE}/ファイル名.png)` 情報が不足している場合は gather に戻る(追加の検索クエリを明示すること)。 verify からの差し戻しがある場合は、指摘された不足点・期待する修正を優先的に解消すること。 default_next: verify rules: - - condition: output/report.md にレポートを書き出した + - condition: output/report-{DATE}.md にレポートを書き出した next: verify - condition: 情報が不十分で追加収集が必要 next: gather @@ -99,10 +117,17 @@ movements: persona: supervisor instruction: | output/ のレポートを確認する。 + 審査対象はこのタスクの analyze が書いたレポート(output/report-{DATE}.md)。ここまでの + 会話で書き出したパスをそのまま使う。会話から分からない場合のみ Glob output/report-*.md で + 列挙し、GetFileProvenance で created_by_task_id がこのタスクのものを選ぶ。 + それでも特定できなければ日付と連番を数値として解釈して最新を選ぶ + (-2 は無印より新しく -10 は -9 より新しい。拡張子込みの辞書順ソートは + 無印が -2 より後に並ぶため使わない)。 + 過去の実行のレポートは対象外で、編集・削除もしない。 確認手順: 1. Glob で output/ 内のファイル一覧を確認する - 2. output/report.md がなければ「不足がある」と判断し analyze に差し戻す + 2. output/report-{DATE}.md がなければ「不足がある」と判断し analyze に差し戻す 3. ファイルがあれば Read で内容を確認し、網羅性・正確性・分かりやすさをチェックする 4. 不足があれば、`transition({next_step: "analyze", summary: ...})` で差し戻す。summary は次の形式で書く: [判定] needs_fix @@ -117,7 +142,7 @@ movements: 5. summary は抽象論で終えず、具体的な不足点・期待する修正内容を必ず含める 追加チェック(画像): - - output/images/ に画像があるのにレポートに `![` が一つもない場合、 + - output/images/{DATE}/ に画像があるのにレポートに `![` が一つもない場合、 画像埋め込み漏れとして analyze に差し戻す ## チェックシート確認 @@ -126,7 +151,7 @@ movements: ## 合格時のユーザーへの返答(complete ツール) output/ の内容で合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。 - result はそのままユーザーに表示される最終回答。output/report.md を Read で読み、その内容をベースに整形する。 + result はそのままユーザーに表示される最終回答。output/report-{DATE}.md を Read で読み、その内容をベースに整形する。 - 「output/xxx.md を確認してください」のようなファイル参照ではなく、内容そのものを回答として返すこと - 【厳守】「✅ 完了」「レポートを作成しました」「確認しました」等のステータス表示・メタ説明は一切書かない。1行目からいきなり本題の内容を書き始めること - 調査結果・発見・結論を会話調で分かりやすく伝える diff --git a/src/bridge/delegate-runs-api.test.ts b/src/bridge/delegate-runs-api.test.ts index c0512e8..180e27d 100644 --- a/src/bridge/delegate-runs-api.test.ts +++ b/src/bridge/delegate-runs-api.test.ts @@ -257,6 +257,26 @@ describe('GET /api/local/tasks/:id/delegate-runs/:delegateRunId/timeline', () => expect(res.body.events.some((e: { correlationId?: string }) => e.correlationId === 'R1')).toBe(true); }); + it('回帰: tool_call が独自 correlationId でも delegateRunId でタイムラインに含まれる', async () => { + // 本番形状: tool_call は per-tool UUID で correlationId を上書きし、実行帰属は + // delegateRunId タグで持つ。旧実装は correlationId フィルタだったため、展開時の + // タイムラインからツールイベントが丸ごと落ちていた(サマリの「ツール N 回」と矛盾)。 + const { app, taskId, workspacePath } = await seedTaskWithWorkspace(); + tmpDirs.push(workspacePath); + const REAL_TOOL = { + v: 1, ts: '2026-06-25T00:00:02.000Z', seq: 2, eventId: 't-real', runId: 'r', + kind: 'tool_call', correlationId: 'per-tool-uuid-xyz', delegateRunId: 'R1', + movement: 'delegate:d', payload: { tool: 'Read' }, + }; + writeEventsJsonl(workspacePath, [START('R1'), REAL_TOOL, DONE('R1')]); + + const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`); + + expect(res.status).toBe(200); + // ツールイベントが(correlation 上書きされていても)返る + expect(res.body.events.some((e: { kind: string }) => e.kind === 'tool_call')).toBe(true); + }); + it('timeline: events.jsonl 不在なら空配列', async () => { const { app, taskId, workspacePath } = await seedTaskWithWorkspace(); tmpDirs.push(workspacePath); diff --git a/src/bridge/delegate-runs-api.ts b/src/bridge/delegate-runs-api.ts index 6d7ec00..21190d9 100644 --- a/src/bridge/delegate-runs-api.ts +++ b/src/bridge/delegate-runs-api.ts @@ -81,6 +81,10 @@ export function createDelegateRunsRouter(repo: Repository): Router { const taskId = Number(req.params.id); const runId = req.params.delegateRunId; const ownerJobId = typeof req.query.jobId === 'string' ? req.query.jobId : null; + // reconstructDelegateRuns と同じ帰属判定。tool_call/tool_result は + // ペアリング用に correlationId を per-tool UUID で上書きするため、実行への + // 帰属は delegateRunId タグで見る(旧ログは correlationId フォールバック)。 + const belongsToRun = (e: EventBase): boolean => (e.delegateRunId ?? e.correlationId) === runId; const viewer = req.user as Express.User | undefined; const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return; @@ -96,16 +100,16 @@ export function createDelegateRunsRouter(repo: Repository): Router { } events = readJobEvents(job.worktreePath); } else { - // jobId 未指定: 親 events を読み、correlationId 一致がなければサブジョブを線形探索 + // jobId 未指定: 親 events を読み、run 帰属イベントが無ければサブジョブを線形探索 events = readEvents(task!); - if (!events.some((e) => e.correlationId === runId)) { + if (!events.some(belongsToRun)) { const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId); if (latestJob) { for (const { job } of (await collectAllSubJobs(repo, latestJob.id)).slice(0, MAX_SUBJOBS)) { if (!job.worktreePath) continue; if (!isJobWithinWorkspace(task!.workspacePath, job.worktreePath)) continue; const ev = readJobEvents(job.worktreePath); - if (ev.some((e) => e.correlationId === runId)) { + if (ev.some(belongsToRun)) { events = ev; break; } @@ -114,7 +118,7 @@ export function createDelegateRunsRouter(repo: Repository): Router { } } - res.json({ events: events.filter((e) => e.correlationId === runId) }); + res.json({ events: events.filter(belongsToRun) }); } catch (err) { logger.error(`[delegate-runs] timeline error: ${err}`); res.status(500).json({ error: 'Failed to fetch delegate run timeline' }); diff --git a/src/engine/agent-loop.test.ts b/src/engine/agent-loop.test.ts index 7e6640b..51fae4d 100644 --- a/src/engine/agent-loop.test.ts +++ b/src/engine/agent-loop.test.ts @@ -2179,8 +2179,13 @@ describe('buildGlobalPreamble / buildMovementGuidance split (Phase A)', () => { it('preamble is byte-identical across two different movements (job-stable)', () => { const tools = [toolDef('Read'), toolDef('Bash')]; - const p1 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-1' }) }); - const p2 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-2' }) }); + // The clock is an explicit input (現在日時 section); pin it so the assertion + // isolates the actual contract — the preamble must not depend on the movement. + // At runtime the preamble is built once per conversation seed, so within a + // job the clock is naturally fixed. + const now = new Date(2026, 6, 8, 12, 0); + const p1 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-1' }), now }); + const p2 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-2' }), now }); expect(p1).toBe(p2); }); diff --git a/src/engine/agent-loop/delegate-runner.ts b/src/engine/agent-loop/delegate-runner.ts index 5d6a255..2a05b7b 100644 --- a/src/engine/agent-loop/delegate-runner.ts +++ b/src/engine/agent-loop/delegate-runner.ts @@ -83,7 +83,11 @@ export async function runDelegateSubAgent( const subCache = new ToolResultCache(); const subEvents = parentCtx.eventLogger?.child({ movement: `delegate:${params.description.slice(0, 40)}`, + // delegateRunId: 実行帰属タグ。tool_call が correlationId を上書きしても + // 保持されるので、reconstructDelegateRuns がツールイベントを確実に束ねられる。 + // correlationId: 後方互換(correlationId 上書きしないイベントの既定値)。 correlationId: delegateRunId, + delegateRunId, }); const subCtx: ToolContext = { diff --git a/src/engine/agent-loop/prompt.test.ts b/src/engine/agent-loop/prompt.test.ts index 574b97a..d018684 100644 --- a/src/engine/agent-loop/prompt.test.ts +++ b/src/engine/agent-loop/prompt.test.ts @@ -152,3 +152,28 @@ describe('buildGlobalPreamble — SSH console screen scoped to allowed connectio expect(out).toContain('connOnly-line-3'); }); }); + +describe('buildGlobalPreamble — current datetime injection', () => { + it('renders the given clock as a 現在日時 section with weekday, so agents can name dated files', () => { + // Local-time constructor: 2026-07-08 is a Wednesday regardless of server TZ. + const out = buildGlobalPreamble([], { now: new Date(2026, 6, 8, 9, 30) }); + expect(out).toContain('## 現在日時'); + // The rendered clock is server-local, so the timezone offset must be explicit + // (e.g. "2026-07-08 (水) 09:30 (UTC+9)") to disambiguate for users elsewhere. + expect(out).toMatch(/2026-07-08 \(水\) 09:30 \(UTC[+-]\d{1,2}(?::\d{2})?\)/); + // Guidance so pieces can rely on it for dated output filenames. + expect(out).toContain('report-2026-07-08.md'); + }); + + it('falls back to the real clock when now is not passed', () => { + const fmt = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + // Sample the clock on both sides of the call so a midnight rollover + // between the internal new Date() and ours cannot flake the test. + const before = new Date(); + const out = buildGlobalPreamble([]); + const after = new Date(); + expect(out).toContain('## 現在日時'); + expect(out.includes(fmt(before)) || out.includes(fmt(after))).toBe(true); + }); +}); diff --git a/src/engine/agent-loop/prompt.ts b/src/engine/agent-loop/prompt.ts index 69a76fa..a6bf9c3 100644 --- a/src/engine/agent-loop/prompt.ts +++ b/src/engine/agent-loop/prompt.ts @@ -176,6 +176,25 @@ export interface GlobalPreambleOpts { skillIndex?: string; folderContext?: { rootDir: string; leafId: string }; movementForConsole?: ConsoleMovement; + /** テスト用の時刻注入。未指定なら実時刻 */ + now?: Date; +} + +const WEEKDAYS_JA = ['日', '月', '火', '水', '木', '金', '土']; + +function formatPromptDatetime(d: Date): { full: string; date: string } { + const pad = (n: number) => String(n).padStart(2, '0'); + const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + // Server-local clock: make the zone explicit so readers in other timezones + // (and the LLM) know which zone the date belongs to. + const offsetMin = -d.getTimezoneOffset(); + const sign = offsetMin >= 0 ? '+' : '-'; + const absMin = Math.abs(offsetMin); + const tz = `UTC${sign}${Math.floor(absMin / 60)}${absMin % 60 ? `:${pad(absMin % 60)}` : ''}`; + return { + date, + full: `${date} (${WEEKDAYS_JA[d.getDay()]}) ${pad(d.getHours())}:${pad(d.getMinutes())} (${tz})`, + }; } export function buildGlobalPreamble(tools: ToolDef[] = [], opts: GlobalPreambleOpts = {}): string { @@ -191,6 +210,11 @@ export function buildGlobalPreamble(tools: ToolDef[] = [], opts: GlobalPreambleO movementForConsole, } = opts; + const clock = formatPromptDatetime(opts.now ?? new Date()); + const datetimeSection = `## 現在日時 +${clock.full} +- 「今日」「最新」等の相対表現や、レポート・ファイル名に入れる日付はこの日時を基準にすること (例: \`output/report-${clock.date}.md\`)`; + const missionBlock = renderMissionBrief(missionBrief); const hasBash = tools.some((t) => t.function.name === 'Bash'); const scriptGuidanceSection = hasBash @@ -290,7 +314,9 @@ workspace の input/ output/ logs/ には前 piece の成果物が残ってい 新規作業を始める前に Glob / Read で既存ファイルを確認してください。`; } - const preamble = `${missionSection}${workingDirectorySection}${existingFilesSection}${handoffStaticSection}${handoffDynamicSection} + const preamble = `${datetimeSection} + +${missionSection}${workingDirectorySection}${existingFilesSection}${handoffStaticSection}${handoffDynamicSection} ## アプローチの考え方 (全タスク共通) - 依頼に着手する前に、想定アプローチを **2-3 個** 浮かべて比較してから動く。最初に思いついた手段で即着手しないこと diff --git a/src/engine/piece-runner.delegate.test.ts b/src/engine/piece-runner.delegate.test.ts index 7ab18f8..c40a3e4 100644 --- a/src/engine/piece-runner.delegate.test.ts +++ b/src/engine/piece-runner.delegate.test.ts @@ -38,6 +38,7 @@ vi.mock('./tools/index.js', () => ({ // Import after mocking import { runPiece } from './piece-runner.js'; import { Conversation } from './context/conversation.js'; +import { reconstructDelegateRuns } from '../progress/delegate-runs.js'; // --------------------------------------------------------------------------- // Unique sentinel strings — must survive the full test without false positives @@ -454,15 +455,100 @@ describe('runPiece — delegate E2E (Phase B Task 4)', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((complete as any).payload.status).toBe('success'); - // Sub-internal events (e.g. tool_call from Write) must share correlationId === delegateRunId + // Sub-internal events must carry the delegateRunId attribution tag. + // NOTE: correlationId は tool_call が per-tool UUID で上書きするため + // 帰属キーには使えない。delegateRunId フィールドで束ねるのが正。 const subEvent = events.find( // eslint-disable-next-line @typescript-eslint/no-explicit-any (e: any) => - e.correlationId === runId && + e.delegateRunId === runId && e.kind !== 'delegate_start' && e.kind !== 'delegate_complete', ); - expect(subEvent, 'at least one sub-internal event must carry correlationId === delegateRunId').toBeTruthy(); + expect(subEvent, 'at least one sub-internal event must carry delegateRunId === runId').toBeTruthy(); + }, + ); + + it( + '回帰: sub-agent の通常ツール呼び出しが reconstructDelegateRuns で toolCalls に数えられる', + async () => { + // 「ツール 0 回」バグの真のエンドツーエンド再発防止。 + // sub-agent が通常ツール(ReadThing)を呼ぶと tool-execution が + // per-tool correlationId を発行して上書きする。それでも scope の + // delegateRunId で束ねられ、toolCalls が加算されねばならない。 + const tmp = makeTmpWorkspace(); + workspacePath = tmp.workspacePath; + const { logsRoot } = tmp; + + getToolDefsMock.mockResolvedValue(makeToolDefs(['delegate', 'ReadThing'])); + + executeToolMock.mockImplementation( + async (name: string, input: Record, ctx: ToolContext) => { + if (name === 'delegate') { + if (!ctx.runDelegate) { + return { output: 'runDelegate not injected', isError: true }; + } + const { result, status } = await ctx.runDelegate({ + description: String(input['description'] ?? 'sub-task'), + prompt: String(input['prompt'] ?? ''), + }); + if (status === 'aborted') { + return { output: `[delegate aborted] ${result}`, isError: true }; + } + return { output: result, isError: false }; + } + return { output: 'ok', isError: false }; + }, + ); + + const client = new SpyFakeClient([ + // parent turn 1 — delegate + [ + { type: 'tool_use', id: 'del-tc-1', name: 'delegate', input: { description: 'read a thing', prompt: SUB_PROMPT_MARKER } }, + { type: 'done', usage: { prompt_tokens: 100, completion_tokens: 10 } }, + ], + // sub turn 1 — regular tool call (ReadThing) + [ + { type: 'tool_use', id: 'sub-read-1', name: 'ReadThing', input: { file_path: 'x.txt' } }, + { type: 'done', usage: { prompt_tokens: 80, completion_tokens: 5 } }, + ], + // sub turn 2 — complete + [ + { type: 'tool_use', id: 'sub-complete-1', name: 'complete', input: { status: 'success', result: SUB_RESULT_MARKER } }, + { type: 'done', usage: { prompt_tokens: 60, completion_tokens: 5 } }, + ], + // parent turn 2 — complete + [ + { type: 'tool_use', id: 'par-complete-1', name: 'complete', input: { status: 'success', result: `parent: ${SUB_RESULT_MARKER}` } }, + { type: 'done', usage: { prompt_tokens: 120, completion_tokens: 10 } }, + ], + ]); + + const piece = delegatePiece(); + await runPiece( + piece, + PARENT_TASK_INSTRUCTION, + client as never, + workspacePath, + undefined, + undefined, + { runtimeDir: logsRoot, workspaceTools: { allowedTools: ['delegate', 'ReadThing'], editAllowed: true } }, + ); + + const { parseEventLine: pel } = await import('../progress/event-log.js'); + const raw = readFileSync(join(logsRoot, 'events.jsonl'), 'utf-8'); + const events = raw + .trim() + .split('\n') + .map((l) => pel(l)) + .filter((r) => r.kind === 'ok') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((r: any) => r.event); + + const runs = reconstructDelegateRuns(events); + expect(runs, 'exactly one delegate run reconstructed').toHaveLength(1); + expect(runs[0].toolCalls, 'ReadThing tool_call must be counted (not 0)').toBe(1); + expect(runs[0].toolSummary).toEqual([{ tool: 'ReadThing', count: 1 }]); }, ); }); diff --git a/src/engine/sns-deep-sweep.piece.test.ts b/src/engine/sns-deep-sweep.piece.test.ts index 488b349..918a048 100644 --- a/src/engine/sns-deep-sweep.piece.test.ts +++ b/src/engine/sns-deep-sweep.piece.test.ts @@ -18,12 +18,16 @@ describe('sns-deep-sweep piece', () => { expect(mv.default_next).toBe('COMPLETE'); }); - it('instruction guides one-delegate-per-tweet and the deepdive/report file layout', () => { + it('instruction guides one-delegate-per-tweet and the dated deepdive/report file layout', () => { const piece = loadPiece('sns-deep-sweep'); const instr = piece.movements[0]!.instruction; - expect(instr).toContain('output/deepdive/'); - expect(instr).toContain('output/report.md'); - expect(instr).toContain('output/triage.md'); + // Outputs are date-scoped so repeated runs in a persistent space accumulate + // instead of overwriting the previous run's files. + expect(instr).toContain('output/deepdive/{DATE}/'); + expect(instr).toContain('output/report-{DATE}.md'); + expect(instr).toContain('output/triage-{DATE}.md'); + expect(instr).toContain('現在日時'); + expect(instr).not.toContain('output/report.md '); // The sub must be told not to delegate further (leaf guard via prompt). expect(instr).toContain('delegate は呼ぶな'); }); diff --git a/src/progress/delegate-runs.test.ts b/src/progress/delegate-runs.test.ts index d0c0fc6..aa92c72 100644 --- a/src/progress/delegate-runs.test.ts +++ b/src/progress/delegate-runs.test.ts @@ -8,6 +8,7 @@ function ev(partial: Partial & { kind: string }): EventBase { eventId: partial.eventId ?? `e${partial.seq ?? 0}`, runId: 'job-run', kind: partial.kind, payload: partial.payload ?? {}, correlationId: partial.correlationId, movement: partial.movement, + delegateRunId: partial.delegateRunId, } as EventBase; } @@ -28,6 +29,30 @@ describe('reconstructDelegateRuns', () => { }); }); + it('本番のイベント形状: tool_call は独自 correlationId を持つが delegateRunId で集計される', () => { + // 実機では tool-execution.ts が tool_call/tool_result に per-tool の + // correlationId を付与し、子スコープの delegateRunId フィールドで実行に紐づく。 + // 旧実装は correlationId 突き合わせだったため toolCalls が常に 0 になっていた。 + const events = [ + ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R1', parentRunId: null, description: 'x', depth: 1 } }), + ev({ seq: 2, kind: 'tool_call', correlationId: 'tool-uuid-a', delegateRunId: 'R1', movement: 'delegate:x', payload: { tool: 'WebFetch' } }), + ev({ seq: 3, kind: 'tool_result', correlationId: 'tool-uuid-a', delegateRunId: 'R1', movement: 'delegate:x', payload: { tool: 'WebFetch' } }), + ev({ seq: 4, kind: 'tool_call', correlationId: 'tool-uuid-b', delegateRunId: 'R1', movement: 'delegate:x', payload: { tool: 'Write', args: { file_path: 'out.txt' } } }), + ev({ seq: 5, kind: 'llm_call_end', delegateRunId: 'R1', movement: 'delegate:x', payload: { promptTokens: 10, completionTokens: 5 } }), + ev({ seq: 6, kind: 'delegate_complete', payload: { delegateRunId: 'R1', parentRunId: null, description: 'x', depth: 1, next: 'COMPLETE', status: 'success' } }), + ]; + const runs = reconstructDelegateRuns(events); + expect(runs[0].toolCalls).toBe(2); + expect(runs[0].toolSummary).toEqual([ + { tool: 'WebFetch', count: 1 }, + { tool: 'Write', count: 1 }, + ]); + expect(runs[0].filesChanged).toEqual(['out.txt']); + expect(runs[0].totalTokens).toBe(15); + // eventCount は correlationId ではなく delegateRunId 帰属で数える + expect(runs[0].eventCount).toBe(4); + }); + it('complete 欠落 → status running', () => { const events = [ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R2', parentRunId: null, description: 'x', depth: 1 } })]; const runs = reconstructDelegateRuns(events); diff --git a/src/progress/delegate-runs.ts b/src/progress/delegate-runs.ts index 0fa89a9..c5a599f 100644 --- a/src/progress/delegate-runs.ts +++ b/src/progress/delegate-runs.ts @@ -68,9 +68,14 @@ export function reconstructDelegateRuns(events: EventBase[]): DelegateRun[] { continue; } if (e.kind === 'delegate_start') continue; - // 内部イベント: correlationId が run の ID に一致するものを束ねる - if (e.correlationId && byId.has(e.correlationId)) { - const run = byId.get(e.correlationId)!; + // 内部イベント: delegateRunId 帰属タグが run の ID に一致するものを束ねる。 + // correlationId は tool_call↔tool_result のペアリングで per-tool UUID に + // 上書きされるため集計キーには使えない(delegateRunId は子スコープが刻印し + // 上書きされない)。旧イベント(delegateRunId 欠落)は correlationId に + // フォールバックして後方互換を保つ。 + const attributionId = e.delegateRunId ?? e.correlationId; + if (attributionId && byId.has(attributionId)) { + const run = byId.get(attributionId)!; run.eventCount++; if (e.kind === 'tool_call') { run.toolCalls++; diff --git a/src/progress/event-log.test.ts b/src/progress/event-log.test.ts index 77a322a..a9d5497 100644 --- a/src/progress/event-log.test.ts +++ b/src/progress/event-log.test.ts @@ -148,6 +148,18 @@ describe('FileEventLogger', () => { expect(parsed.iteration).toBe(5); }); + it('child() の delegateRunId は per-event correlationId 上書きでも保持される', () => { + // delegate 実行の帰属タグ回帰: tool_call は自前の correlationId を付けるが、 + // scope の delegateRunId は残らねばならない(これが 0 回バグの再発防止)。 + const log = createFileEventLogger({ workspacePath: workspace, runId: 'run-1' }); + const child = log.child({ movement: 'delegate:x', delegateRunId: 'RUN-9' }); + const perToolCorr = child.startCorrelation(); + child.emit('tool_call', { tool: 'Read' }, { correlationId: perToolCorr, llmToolCallId: 'r-1' }); + const parsed = JSON.parse(readFileSync(join(workspace, EVENT_LOG_FILE), 'utf-8').trim()) as EventBase; + expect(parsed.delegateRunId).toBe('RUN-9'); + expect(parsed.correlationId).toBe(perToolCorr); // ペアリング用は上書きされたまま + }); + // root ignores chmod-based write denial, so this only holds as non-root (e.g. CI running as root). it.skipIf(process.getuid?.() === 0)('does NOT throw when the workspace directory is not writable (failure isolation)', () => { // Make the workspace read-only so appendFileSync fails. diff --git a/src/progress/event-log.ts b/src/progress/event-log.ts index e3ec79e..756297b 100644 --- a/src/progress/event-log.ts +++ b/src/progress/event-log.ts @@ -56,6 +56,13 @@ export interface EventBase { parentEventId?: string; correlationId?: string; llmToolCallId?: string; + /** + * delegate サブ実行への帰属。子スコープが刻印し、個々の tool_call が + * `correlationId` を上書きしても失われない。reconstructDelegateRuns が + * 内部イベントを実行へ束ねるのに使う(correlationId は tool_call↔tool_result + * のペアリング専用に残す)。 + */ + delegateRunId?: string; movement?: string; iteration?: number; kind: string; @@ -66,6 +73,7 @@ export interface EmitOptions { parentEventId?: string; correlationId?: string; llmToolCallId?: string; + delegateRunId?: string; movement?: string; iteration?: number; } @@ -162,7 +170,7 @@ function fitToEnvelope(eventLine: string, event: EventBase): string { export interface EventLogger { emit(kind: string, payload: unknown, opts?: EmitOptions): string; startCorrelation(): string; - child(scope: { movement?: string; iteration?: number; correlationId?: string }): EventLogger; + child(scope: { movement?: string; iteration?: number; correlationId?: string; delegateRunId?: string }): EventLogger; /** Ids and counters for diagnostics. */ describe(): { runId: string; seq: number; degraded: boolean }; } @@ -176,7 +184,7 @@ export class NoopEventLogger implements EventLogger { startCorrelation(): string { return 'noop'; } - child(_scope: { movement?: string; iteration?: number; correlationId?: string }): EventLogger { + child(_scope: { movement?: string; iteration?: number; correlationId?: string; delegateRunId?: string }): EventLogger { return this; } describe(): { runId: string; seq: number; degraded: boolean } { @@ -198,7 +206,7 @@ interface FileLoggerCore { class ScopedEventLogger implements EventLogger { constructor( private readonly core: FileLoggerCore, - private readonly scope: { movement?: string; iteration?: number; correlationId?: string }, + private readonly scope: { movement?: string; iteration?: number; correlationId?: string; delegateRunId?: string }, ) {} emit(kind: string, payload: unknown, opts?: EmitOptions): string { @@ -213,6 +221,7 @@ class ScopedEventLogger implements EventLogger { parentEventId: opts?.parentEventId, correlationId: opts?.correlationId ?? this.scope.correlationId, llmToolCallId: opts?.llmToolCallId, + delegateRunId: opts?.delegateRunId ?? this.scope.delegateRunId, movement: opts?.movement ?? this.scope.movement, iteration: opts?.iteration ?? this.scope.iteration, kind, @@ -239,11 +248,12 @@ class ScopedEventLogger implements EventLogger { return randomUUID(); } - child(scope: { movement?: string; iteration?: number; correlationId?: string }): EventLogger { + child(scope: { movement?: string; iteration?: number; correlationId?: string; delegateRunId?: string }): EventLogger { return new ScopedEventLogger(this.core, { movement: scope.movement ?? this.scope.movement, iteration: scope.iteration ?? this.scope.iteration, correlationId: scope.correlationId ?? this.scope.correlationId, + delegateRunId: scope.delegateRunId ?? this.scope.delegateRunId, }); } diff --git a/ui/src/components/chat/ChatPane.drafts.test.tsx b/ui/src/components/chat/ChatPane.drafts.test.tsx new file mode 100644 index 0000000..a087eea --- /dev/null +++ b/ui/src/components/chat/ChatPane.drafts.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import '../../test/dom-setup'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../../test/render-helpers'; +import { ChatPane } from './ChatPane'; +import { __resetDraftPruneForTest } from '../../hooks/useDraft'; +import type { LocalTask } from '../../api'; + +const DRAFT_KEY = 'maestro:draft:v1:chat:42'; + +// useJobStream が EventSource を張るため jsdom にスタブを入れる +class FakeEventSource { + onmessage: ((e: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + addEventListener() {} + removeEventListener() {} + close() {} +} + +const task = { + id: 42, + title: 'テストタスク', + pieceName: 'chat', + latestJob: undefined, +} as unknown as LocalTask; + +beforeEach(() => { + localStorage.clear(); + __resetDraftPruneForTest(); + vi.stubGlobal('EventSource', FakeEventSource); + vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 }))); + // ChatPetOverlay (rendered unconditionally by ChatPane) reads matchMedia for + // prefers-reduced-motion; jsdom doesn't implement it. Minimal stub only — + // not part of the brief, needed to get past mount. + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })); +}); + +describe('ChatPane drafts', () => { + it('入力途中の内容がアンマウント後も復元される', async () => { + const user = userEvent.setup(); + const first = renderWithProviders( + {})} />, + ); + await user.type(screen.getByRole('textbox'), '書きかけの返信'); + first.unmount(); + expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toBe('書きかけの返信'); + + renderWithProviders( + {})} />, + ); + expect(screen.getByRole('textbox')).toHaveValue('書きかけの返信'); + }); + + it('送信成功で下書きが消える', async () => { + localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '送る内容', updatedAt: Date.now() })); + const user = userEvent.setup(); + const onSubmit = vi.fn(async () => {}); + renderWithProviders(); + const textbox = screen.getByRole('textbox'); + expect(textbox).toHaveValue('送る内容'); + // Ctrl+Enter は textarea にフォーカスがないと発火しないため、まずフォーカスする。 + await user.click(textbox); + await user.keyboard('{Control>}{Enter}{/Control}'); + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(localStorage.getItem(DRAFT_KEY)).toBeNull(); + }); +}); diff --git a/ui/src/components/chat/ChatPane.tsx b/ui/src/components/chat/ChatPane.tsx index fc31421..f8d824c 100644 --- a/ui/src/components/chat/ChatPane.tsx +++ b/ui/src/components/chat/ChatPane.tsx @@ -12,21 +12,10 @@ import { ToolRequestApproval } from './ToolRequestApproval'; import { PackageRequestApproval } from './PackageRequestApproval'; import { DelegateLiveConsole } from './DelegateLiveConsole'; import { useJobStream } from '../../hooks/useJobStream'; +import { useDraft } from '../../hooks/useDraft'; import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract'; import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize'; - - -async function toBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const result = String(reader.result ?? ''); - resolve(result.includes(',') ? result.split(',')[1]! : result); - }; - reader.onerror = () => reject(reader.error ?? new Error('file read error')); - reader.readAsDataURL(file); - }); -} +import { toBase64 } from '../../lib/fileAttachments'; interface ChatPaneProps { task: LocalTask; @@ -43,7 +32,9 @@ interface ChatPaneProps { export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activeDetailTab, onSelectDetailTab }: ChatPaneProps) { const { t } = useTranslation('chat'); const { t: dt } = useTranslation('detail'); - const [draft, setDraft] = useState(''); + // 下書き保存: ChatPane はチャット切替で remount されるため、初期値復元で足りる + const { draft: restoredDraft, saveDraft, clearDraft } = useDraft(`chat:${task.id}`); + const [draft, setDraft] = useState(restoredDraft ?? ''); const [attachments, setAttachments] = useState>([]); const [submitting, setSubmitting] = useState(false); const [cancelling, setCancelling] = useState(false); @@ -147,6 +138,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ await onSubmit(draft, attachments.length > 0 ? attachments : undefined); setDraft(''); setAttachments([]); + clearDraft(); // Hold the lock until the agent is visibly responding (see effect below). // Safety net: if the worker never picks the job up (queue stuck, server // crash, etc.), release the lock after 10s so the user isn't trapped. @@ -507,7 +499,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ