sync: update from private repo (061c701c)
All checks were successful
CI / build-and-test (push) Successful in 6m22s

This commit is contained in:
oss-sync 2026-07-09 03:01:06 +00:00
parent 96bffa9a15
commit 63d34d7cf6
36 changed files with 1180 additions and 92 deletions

View File

@ -6,6 +6,28 @@ follow semantic versioning.
## Unreleased ## 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) ## v0.2.0 (2026-07-09)
A month of work since the initial release, centered on shared workspaces, A month of work since the initial release, centered on shared workspaces,

View File

@ -16,6 +16,12 @@ movements:
dig で立てた並列調査計画に従い、各テーマを delegate で1件ずつ直列に深掘りさせ、 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. dig で立てた調査テーマを思い出す2〜5 個程度)。各テーマに上から 1 始まりの
連番を振る1, 2, 3 ...)。 連番を振る1, 2, 3 ...)。
@ -25,21 +31,21 @@ movements:
- 調べるテーマと目的・背景 - 調べるテーマと目的・背景
- 「WebSearch / WebFetch / 一次情報で裏を取る。モデルの内部知識だけで書かない」指示 - 「WebSearch / WebFetch / 一次情報で裏を取る。モデルの内部知識だけで書かない」指示
- 「概要・主要な特徴・数値や事例・まとめと考察 の構成で - 「概要・主要な特徴・数値や事例・まとめと考察 の構成で
output/research/theme-{連番}.md に Write せよ。関連する画像・グラフは output/research/{DATE}/theme-{連番}.md に Write せよ{DATE} は展開済みの実日付で渡す)
output/images/ に保存し Markdown で埋め込め」指示 関連する画像・グラフは output/images/{DATE}/ に保存し Markdown で埋め込め」指示
- 「完了したら 3〜5 行の要約だけを返せ。深掘り本文は返さずファイルに書け」 - 「完了したら 3〜5 行の要約だけを返せ。深掘り本文は返さずファイルに書け」
- 「あなたは末端の調査担当。delegate / SpawnSubTask は呼ぶな」 - 「あなたは末端の調査担当。delegate / SpawnSubTask は呼ぶな」
各サブの戻り値(短い要約)だけが手元に残る。本文はファイルにある。 各サブの戻り値(短い要約)だけが手元に残る。本文はファイルにある。
3. Glob で output/research/*.md の件数を確認する。 3. Glob で output/research/{DATE}/*.md の件数を確認する。
4. 各サブの要約とファイルを基に統合レポートを output/report.md に Write する: 4. 各サブの要約とファイルを基に統合レポートを output/report-{DATE}.md に Write する:
- 各テーマの主要な知見を統合(矛盾・重複は整理) - 各テーマの主要な知見を統合(矛盾・重複は整理)
- 比較・対照が必要なら表形式で整理 - 比較・対照が必要なら表形式で整理
- 全体のまとめと考察を付ける - 全体のまとめと考察を付ける
- 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ - 各テーマの詳細へ相対リンク [theme-{連番}](./research/{DATE}/theme-{連番}.md) で繋ぐ
5. output/report.md を書き終えたら verify へ遷移する 5. output/report-{DATE}.md を書き終えたら verify へ遷移する
default_next: verify default_next: verify
rules: rules:
- condition: output/report.md に統合レポートを作成した - condition: output/report-{DATE}.md に統合レポートを作成した
next: verify next: verify
- name: dig - name: dig
@ -71,6 +77,15 @@ movements:
WebSearch、WebFetch、ファイル読み込み等で情報を集め、必ず Write で output/ にファイルとして書き出すこと。 WebSearch、WebFetch、ファイル読み込み等で情報を集め、必ず Write で output/ にファイルとして書き出すこと。
テキストで回答するだけでは不十分。 テキストで回答するだけでは不十分。
上書き防止(実行キーの決定・以後の全 movement で共有):
システムプロンプトの「現在日時」の日付 (YYYY-MM-DD) を実行キーとする。ただし
同じ日付入りの成果物output/report-{日付}.md や作業ファイル・画像等)が既にあり
別タスクのものなら、空いている {日付}-2, {日付}-3 ... を実行キーにする。
作業ファイルはファイル名に実行キーを含め、画像は output/images/{実行キー}/ に保存する。
キーを決めたら最初の作業ファイルをすぐ書き出してキーを確保する(並行する別タスクとの
衝突の余地を減らす。analyze・verify もこの実行キーを引き継いで使う。
**過去の実行が残したファイルは編集も削除もしない**(読むのは可。内容は鵜呑みにしない)。
## 検索の原則(必須) ## 検索の原則(必須)
- モデルの内部知識だけで情報を書かないこと。主張・事実・数値は必ず WebSearch/WebFetch で裏付けを取る - モデルの内部知識だけで情報を書かないこと。主張・事実・数値は必ず 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"})` - **次の analyze へ**: `transition({next_step: "analyze"})`
@ -122,6 +137,13 @@ movements:
必ず Write ツールで output/ にレポートファイルを書き出すこと。 必ず Write ツールで output/ にレポートファイルを書き出すこと。
前のステップから指摘事項がある場合は、それに対応すること。 前のステップから指摘事項がある場合は、それに対応すること。
成果物の上書き防止: レポートのファイル名には dig で決めた実行キーを入れる
(例: output/report-2026-07-08.md、同日再実行なら output/report-2026-07-08-2.md
dig を経ていない等でキーが不明な場合のみ、同じルール(「現在日時」の日付、
別タスクの同日成果物があれば空いている -2, -3 ...)で決め直す。
**過去の実行が残したレポートは編集も削除もしない**。
このタスク内での verify 差し戻しでは同じファイルを修正してよい。
## 検索の原則(必須) ## 検索の原則(必須)
- レポートに記載する事実・数値・主張は、dig で収集した検索結果に基づくこと - レポートに記載する事実・数値・主張は、dig で収集した検索結果に基づくこと
@ -131,8 +153,9 @@ movements:
## 画像の活用(必須) ## 画像の活用(必須)
output/images/ に画像が保存されている場合は、必ずレポートの該当箇所に埋め込む: output/images/{実行キー}/ にこのタスクで収集した画像がある場合は、
`![説明](./images/ファイル名.png)` 必ずレポートの該当箇所に実際の保存パスで埋め込む:
`![説明](./images/{実行キー}/ファイル名.png)`(例: `./images/2026-07-08/graph.png`
画像があるのにテキストだけのレポートにしないこと。 画像があるのにテキストだけのレポートにしないこと。
レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。 レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。
@ -147,6 +170,13 @@ movements:
persona: reviewer persona: reviewer
instruction: | instruction: |
output/ のレポートを確認する。 output/ のレポートを確認する。
審査対象は**このタスクの実行で作られたレポート**。ここまでの会話で書き出したファイル
パスをそのまま使う。パスが会話から分からない場合のみ Glob output/report-*.md で列挙し、
GetFileProvenance で created_by_task_id がこのタスクのものを選ぶ。
それでも特定できなければ日付と連番を数値として解釈して最新を選ぶ
-2 は無印より新しく -10 は -9 より新しい。拡張子込みの辞書順ソートは
無印が -2 より後に並ぶため使わない)。
過去の実行が残したレポートは審査対象外で、編集・削除もしない。
確認手順: 確認手順:
1. まず Glob で output/ 内のファイル一覧を確認する 1. まず Glob で output/ 内のファイル一覧を確認する
@ -169,8 +199,9 @@ movements:
- ユーザーの追加質問(前回タスクへの補足・深掘り)への回答が含まれる場合、その内容に WebSearch/WebFetch による検索の裏付けがあるか確認する。内部知識だけで回答している形跡がある場合は「追加質問への回答に検索根拠が不足」として analyze に差し戻す - ユーザーの追加質問(前回タスクへの補足・深掘り)への回答が含まれる場合、その内容に WebSearch/WebFetch による検索の裏付けがあるか確認する。内部知識だけで回答している形跡がある場合は「追加質問への回答に検索根拠が不足」として analyze に差し戻す
追加チェック(画像): 追加チェック(画像):
- output/images/ に画像があるのにレポートに `![` が一つもない場合、 - このタスクの実行で収集した画像output/images/{実行キー}/ 配下)があるのに
画像埋め込み漏れとして analyze に差し戻す レポートに `![` が一つもない場合、画像埋め込み漏れとして analyze に差し戻す
(過去の実行が残した画像は対象外)
## 合格時のユーザーへの返答complete ツール) ## 合格時のユーザーへの返答complete ツール)

View File

@ -21,12 +21,22 @@ movements:
1件ずつ独立したサブエージェントdelegateにクリーンな文脈で深掘りさせ、最後に 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. 取得(手短に) ### 1. 取得(手短に)
タスク本文の指定(対象アカウント・キーワード・期間・件数)に従って投稿を集める。 タスク本文の指定(対象アカウント・キーワード・期間・件数)に従って投稿を集める。
- ホームタイムライン → XTimeline - ホームタイムライン → XTimeline
- キーワード → XSearch - キーワード → XSearch
- 特定アカウント → XUserPosts - 特定アカウント → XUserPosts
取得が空・失敗ならその事実を output/triage.md に記録する(内部知識で投稿を捏造しない)。 取得が空・失敗ならその事実を output/triage-{DATE}.md に記録する(内部知識で投稿を捏造しない)。
続行できないほど取得できない場合は 続行できないほど取得できない場合は
complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."}) を呼ぶ。 complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."}) を呼ぶ。
@ -34,7 +44,7 @@ movements:
挨拶・広告・重複・無内容を除き、議論する価値のある投稿を選ぶ。 挨拶・広告・重複・無内容を除き、議論する価値のある投稿を選ぶ。
- タスクに件数指定「上位20件」等があればそれに従う。 - タスクに件数指定「上位20件」等があればそれに従う。
- 指定がなければ 15 件を超えない。 - 指定がなければ 15 件を超えない。
選んだ投稿の一覧と選定理由を output/triage.md に短く書く。ここで長考しない。 選んだ投稿の一覧と選定理由を output/triage-{DATE}.md に短く書く。ここで長考しない。
### 3. 深掘り委譲(選んだ各投稿に delegate を1回ずつ・直列 ### 3. 深掘り委譲(選んだ各投稿に delegate を1回ずつ・直列
選んだ投稿それぞれについて、delegate を1回呼ぶ。1件ずつ順番に処理する。 選んだ投稿それぞれについて、delegate を1回呼ぶ。1件ずつ順番に処理する。
@ -44,17 +54,18 @@ movements:
- 対象投稿の本文・著者・URLまたは id - 対象投稿の本文・著者・URLまたは id
- 「スレッド展開XPostDetail・リンク先記事WebFetch・著者の関連投稿XUserPosts - 「スレッド展開XPostDetail・リンク先記事WebFetch・著者の関連投稿XUserPosts
関連 Web 検索WebSearchで裏を取る」指示 関連 Web 検索WebSearchで裏を取る」指示
- その連番と「事実 / 背景 / 論点 / 評価 / 出典 を output/deepdive/tweet-{連番}.md に Write せよ」指示 - その連番と「事実 / 背景 / 論点 / 評価 / 出典 を output/deepdive/{DATE}/tweet-{連番}.md に Write せよ」指示
{DATE} は展開済みの実日付で渡す。例: output/deepdive/2026-07-08/tweet-3.md
- 「完了したら 3〜5 行の要約だけを返せ。深掘り本文は返さずファイルに書け」 - 「完了したら 3〜5 行の要約だけを返せ。深掘り本文は返さずファイルに書け」
- 「あなたは末端の調査担当。delegate は呼ぶな」 - 「あなたは末端の調査担当。delegate は呼ぶな」
各サブの戻り値(短い要約)だけが手元に残る。深掘り本文はファイルにある。 各サブの戻り値(短い要約)だけが手元に残る。深掘り本文はファイルにある。
### 4. 統合 → 終了 ### 4. 統合 → 終了
- Glob で output/deepdive/*.md の件数を確認する。 - Glob で output/deepdive/{DATE}/*.md の件数を確認する。
- 各サブの要約を束ね、全体傾向・横断テーマ・注目点を output/report.md に Write する。 - 各サブの要約を束ね、全体傾向・横断テーマ・注目点を output/report-{DATE}.md に Write する。
各投稿の詳細へは相対リンク [tweet-{連番}](./deepdive/tweet-{連番}.md) で繋ぐ。 各投稿の詳細へは相対リンク [tweet-{連番}](./deepdive/{DATE}/tweet-{連番}.md) で繋ぐ。
- 仕上がったら complete({status: "success", result: ...}) を呼ぶ。 - 仕上がったら complete({status: "success", result: ...}) を呼ぶ。
result は output/report.md の内容をベースに、ユーザー向けの最終回答として整形する。 result は output/report-{DATE}.md の内容をベースに、ユーザー向けの最終回答として整形する。
「✅ 完了」等のメタ説明は書かず、1行目から本題を書く。 「✅ 完了」等のメタ説明は書かず、1行目から本題を書く。
### 原則 ### 原則

View File

@ -12,6 +12,16 @@ movements:
- name: gather - name: gather
persona: researcher persona: researcher
instruction: | 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. 検索クエリ案を複数考える(日本語・英語の両方を検討) 2. 検索クエリ案を複数考える(日本語・英語の両方を検討)
3. verify からの差し戻しがある場合は、不足点を優先的に解消する 3. verify からの差し戻しがある場合は、不足点を優先的に解消する
計画に従って SNS から情報を収集し、Write で output/raw/ にテキストファイルとして書き出す。 計画に従って SNS から情報を収集し、Write で output/raw/{DATE}/ にテキストファイルとして書き出す。
## SNS 別の収集方針 ## SNS 別の収集方針
@ -40,8 +50,8 @@ movements:
- 記事詳細: `https://hn.algolia.com/api/v1/items/{id}` - 記事詳細: `https://hn.algolia.com/api/v1/items/{id}`
## ファイル命名規則 ## ファイル命名規則
`output/raw/{platform}-{query-slug}.txt` `output/raw/{DATE}/{platform}-{query-slug}.txt`
: reddit-ollama-vs-vllm.txt, x-ollama-review.txt, hn-local-llm.txt : output/raw/2026-07-08/reddit-ollama-vs-vllm.txt, output/raw/2026-07-08/x-ollama-review.txt
## SNS 調査の原則 ## SNS 調査の原則
モデルの内部知識だけで情報を書かないこと。必ず実際の SNS データを収集する。 モデルの内部知識だけで情報を書かないこと。必ず実際の SNS データを収集する。
@ -49,7 +59,7 @@ movements:
## 画像・スクリーンショットの収集 ## 画像・スクリーンショットの収集
SNS 投稿には画像・グラフが含まれることが多い。重要なビジュアルは DownloadFile で SNS 投稿には画像・グラフが含まれることが多い。重要なビジュアルは DownloadFile で
`output/images/{platform}-{slug}.png` に保存する。 `output/images/{DATE}/{platform}-{slug}.png` に保存する。
## 終了 / 遷移方法 ## 終了 / 遷移方法
- **次の analyze へ**: `transition({next_step: "analyze"})` - **次の analyze へ**: `transition({next_step: "analyze"})`
@ -68,12 +78,20 @@ movements:
instruction: | instruction: |
output/raw/ の収集データを読み込み、分析してレポートを作成する。 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 で読み込む 2. 各ファイルを Read で読み込む
3. 重要な意見・トレンド・共通見解を抽出 3. 重要な意見・トレンド・共通見解を抽出
4. ポジティブ/ネガティブな意見を分類 4. ポジティブ/ネガティブな意見を分類
5. output/report.md にレポートを書き出す 5. output/report-{DATE}.md にレポートを書き出す
## レポートの構成 ## レポートの構成
- トピック概要 - トピック概要
@ -82,15 +100,15 @@ movements:
- まとめ - まとめ
## 画像の活用 ## 画像の活用
output/images/ に画像がある場合は必ずレポートに埋め込む: output/images/{DATE}/ に画像がある場合は必ずレポートに埋め込む:
`![説明](./images/ファイル名.png)` `![説明](./images/{DATE}/ファイル名.png)`
情報が不足している場合は gather に戻る(追加の検索クエリを明示すること)。 情報が不足している場合は gather に戻る(追加の検索クエリを明示すること)。
verify からの差し戻しがある場合は、指摘された不足点・期待する修正を優先的に解消すること。 verify からの差し戻しがある場合は、指摘された不足点・期待する修正を優先的に解消すること。
default_next: verify default_next: verify
rules: rules:
- condition: output/report.md にレポートを書き出した - condition: output/report-{DATE}.md にレポートを書き出した
next: verify next: verify
- condition: 情報が不十分で追加収集が必要 - condition: 情報が不十分で追加収集が必要
next: gather next: gather
@ -99,10 +117,17 @@ movements:
persona: supervisor persona: supervisor
instruction: | instruction: |
output/ のレポートを確認する。 output/ のレポートを確認する。
審査対象はこのタスクの analyze が書いたレポートoutput/report-{DATE}.md。ここまでの
会話で書き出したパスをそのまま使う。会話から分からない場合のみ Glob output/report-*.md で
列挙し、GetFileProvenance で created_by_task_id がこのタスクのものを選ぶ。
それでも特定できなければ日付と連番を数値として解釈して最新を選ぶ
-2 は無印より新しく -10 は -9 より新しい。拡張子込みの辞書順ソートは
無印が -2 より後に並ぶため使わない)。
過去の実行のレポートは対象外で、編集・削除もしない。
確認手順: 確認手順:
1. Glob で output/ 内のファイル一覧を確認する 1. Glob で output/ 内のファイル一覧を確認する
2. output/report.md がなければ「不足がある」と判断し analyze に差し戻す 2. output/report-{DATE}.md がなければ「不足がある」と判断し analyze に差し戻す
3. ファイルがあれば Read で内容を確認し、網羅性・正確性・分かりやすさをチェックする 3. ファイルがあれば Read で内容を確認し、網羅性・正確性・分かりやすさをチェックする
4. 不足があれば、`transition({next_step: "analyze", summary: ...})` で差し戻す。summary は次の形式で書く: 4. 不足があれば、`transition({next_step: "analyze", summary: ...})` で差し戻す。summary は次の形式で書く:
[判定] needs_fix [判定] needs_fix
@ -117,7 +142,7 @@ movements:
5. summary は抽象論で終えず、具体的な不足点・期待する修正内容を必ず含める 5. summary は抽象論で終えず、具体的な不足点・期待する修正内容を必ず含める
追加チェック(画像): 追加チェック(画像):
- output/images/ に画像があるのにレポートに `![` が一つもない場合、 - output/images/{DATE}/ に画像があるのにレポートに `![` が一つもない場合、
画像埋め込み漏れとして analyze に差し戻す 画像埋め込み漏れとして analyze に差し戻す
## チェックシート確認 ## チェックシート確認
@ -126,7 +151,7 @@ movements:
## 合格時のユーザーへの返答complete ツール) ## 合格時のユーザーへの返答complete ツール)
output/ の内容で合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。 output/ の内容で合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。
result はそのままユーザーに表示される最終回答。output/report.md を Read で読み、その内容をベースに整形する。 result はそのままユーザーに表示される最終回答。output/report-{DATE}.md を Read で読み、その内容をベースに整形する。
- 「output/xxx.md を確認してください」のようなファイル参照ではなく、内容そのものを回答として返すこと - 「output/xxx.md を確認してください」のようなファイル参照ではなく、内容そのものを回答として返すこと
- 【厳守】「✅ 完了」「レポートを作成しました」「確認しました」等のステータス表示・メタ説明は一切書かない。1行目からいきなり本題の内容を書き始めること - 【厳守】「✅ 完了」「レポートを作成しました」「確認しました」等のステータス表示・メタ説明は一切書かない。1行目からいきなり本題の内容を書き始めること
- 調査結果・発見・結論を会話調で分かりやすく伝える - 調査結果・発見・結論を会話調で分かりやすく伝える

View File

@ -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); 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 () => { it('timeline: events.jsonl 不在なら空配列', async () => {
const { app, taskId, workspacePath } = await seedTaskWithWorkspace(); const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
tmpDirs.push(workspacePath); tmpDirs.push(workspacePath);

View File

@ -81,6 +81,10 @@ export function createDelegateRunsRouter(repo: Repository): Router {
const taskId = Number(req.params.id); const taskId = Number(req.params.id);
const runId = req.params.delegateRunId; const runId = req.params.delegateRunId;
const ownerJobId = typeof req.query.jobId === 'string' ? req.query.jobId : null; 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 viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return; if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
@ -96,16 +100,16 @@ export function createDelegateRunsRouter(repo: Repository): Router {
} }
events = readJobEvents(job.worktreePath); events = readJobEvents(job.worktreePath);
} else { } else {
// jobId 未指定: 親 events を読み、correlationId 一致がなければサブジョブを線形探索 // jobId 未指定: 親 events を読み、run 帰属イベントが無ければサブジョブを線形探索
events = readEvents(task!); events = readEvents(task!);
if (!events.some((e) => e.correlationId === runId)) { if (!events.some(belongsToRun)) {
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId); const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (latestJob) { if (latestJob) {
for (const { job } of (await collectAllSubJobs(repo, latestJob.id)).slice(0, MAX_SUBJOBS)) { for (const { job } of (await collectAllSubJobs(repo, latestJob.id)).slice(0, MAX_SUBJOBS)) {
if (!job.worktreePath) continue; if (!job.worktreePath) continue;
if (!isJobWithinWorkspace(task!.workspacePath, job.worktreePath)) continue; if (!isJobWithinWorkspace(task!.workspacePath, job.worktreePath)) continue;
const ev = readJobEvents(job.worktreePath); const ev = readJobEvents(job.worktreePath);
if (ev.some((e) => e.correlationId === runId)) { if (ev.some(belongsToRun)) {
events = ev; events = ev;
break; 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) { } catch (err) {
logger.error(`[delegate-runs] timeline error: ${err}`); logger.error(`[delegate-runs] timeline error: ${err}`);
res.status(500).json({ error: 'Failed to fetch delegate run timeline' }); res.status(500).json({ error: 'Failed to fetch delegate run timeline' });

View File

@ -2179,8 +2179,13 @@ describe('buildGlobalPreamble / buildMovementGuidance split (Phase A)', () => {
it('preamble is byte-identical across two different movements (job-stable)', () => { it('preamble is byte-identical across two different movements (job-stable)', () => {
const tools = [toolDef('Read'), toolDef('Bash')]; const tools = [toolDef('Read'), toolDef('Bash')];
const p1 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-1' }) }); // The clock is an explicit input (現在日時 section); pin it so the assertion
const p2 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-2' }) }); // 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); expect(p1).toBe(p2);
}); });

View File

@ -83,7 +83,11 @@ export async function runDelegateSubAgent(
const subCache = new ToolResultCache(); const subCache = new ToolResultCache();
const subEvents = parentCtx.eventLogger?.child({ const subEvents = parentCtx.eventLogger?.child({
movement: `delegate:${params.description.slice(0, 40)}`, movement: `delegate:${params.description.slice(0, 40)}`,
// delegateRunId: 実行帰属タグ。tool_call が correlationId を上書きしても
// 保持されるので、reconstructDelegateRuns がツールイベントを確実に束ねられる。
// correlationId: 後方互換correlationId 上書きしないイベントの既定値)。
correlationId: delegateRunId, correlationId: delegateRunId,
delegateRunId,
}); });
const subCtx: ToolContext = { const subCtx: ToolContext = {

View File

@ -152,3 +152,28 @@ describe('buildGlobalPreamble — SSH console screen scoped to allowed connectio
expect(out).toContain('connOnly-line-3'); 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);
});
});

View File

@ -176,6 +176,25 @@ export interface GlobalPreambleOpts {
skillIndex?: string; skillIndex?: string;
folderContext?: { rootDir: string; leafId: string }; folderContext?: { rootDir: string; leafId: string };
movementForConsole?: ConsoleMovement; 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 { export function buildGlobalPreamble(tools: ToolDef[] = [], opts: GlobalPreambleOpts = {}): string {
@ -191,6 +210,11 @@ export function buildGlobalPreamble(tools: ToolDef[] = [], opts: GlobalPreambleO
movementForConsole, movementForConsole,
} = opts; } = opts;
const clock = formatPromptDatetime(opts.now ?? new Date());
const datetimeSection = `## 現在日時
${clock.full}
- (: \`output/report-${clock.date}.md\`)`;
const missionBlock = renderMissionBrief(missionBrief); const missionBlock = renderMissionBrief(missionBrief);
const hasBash = tools.some((t) => t.function.name === 'Bash'); const hasBash = tools.some((t) => t.function.name === 'Bash');
const scriptGuidanceSection = hasBash const scriptGuidanceSection = hasBash
@ -290,7 +314,9 @@ workspace の input/ output/ logs/ には前 piece の成果物が残ってい
Glob / Read `; Glob / Read `;
} }
const preamble = `${missionSection}${workingDirectorySection}${existingFilesSection}${handoffStaticSection}${handoffDynamicSection} const preamble = `${datetimeSection}
${missionSection}${workingDirectorySection}${existingFilesSection}${handoffStaticSection}${handoffDynamicSection}
## () ## ()
- **2-3 ** - **2-3 **

View File

@ -38,6 +38,7 @@ vi.mock('./tools/index.js', () => ({
// Import after mocking // Import after mocking
import { runPiece } from './piece-runner.js'; import { runPiece } from './piece-runner.js';
import { Conversation } from './context/conversation.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 // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((complete as any).payload.status).toBe('success'); 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( const subEvent = events.find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(e: any) => (e: any) =>
e.correlationId === runId && e.delegateRunId === runId &&
e.kind !== 'delegate_start' && e.kind !== 'delegate_start' &&
e.kind !== 'delegate_complete', 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<string, unknown>, 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 }]);
}, },
); );
}); });

View File

@ -18,12 +18,16 @@ describe('sns-deep-sweep piece', () => {
expect(mv.default_next).toBe('COMPLETE'); 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 piece = loadPiece('sns-deep-sweep');
const instr = piece.movements[0]!.instruction; const instr = piece.movements[0]!.instruction;
expect(instr).toContain('output/deepdive/'); // Outputs are date-scoped so repeated runs in a persistent space accumulate
expect(instr).toContain('output/report.md'); // instead of overwriting the previous run's files.
expect(instr).toContain('output/triage.md'); 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). // The sub must be told not to delegate further (leaf guard via prompt).
expect(instr).toContain('delegate は呼ぶな'); expect(instr).toContain('delegate は呼ぶな');
}); });

View File

@ -8,6 +8,7 @@ function ev(partial: Partial<EventBase> & { kind: string }): EventBase {
eventId: partial.eventId ?? `e${partial.seq ?? 0}`, runId: 'job-run', eventId: partial.eventId ?? `e${partial.seq ?? 0}`, runId: 'job-run',
kind: partial.kind, payload: partial.payload ?? {}, kind: partial.kind, payload: partial.payload ?? {},
correlationId: partial.correlationId, movement: partial.movement, correlationId: partial.correlationId, movement: partial.movement,
delegateRunId: partial.delegateRunId,
} as EventBase; } 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', () => { it('complete 欠落 → status running', () => {
const events = [ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R2', parentRunId: null, description: 'x', depth: 1 } })]; const events = [ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R2', parentRunId: null, description: 'x', depth: 1 } })];
const runs = reconstructDelegateRuns(events); const runs = reconstructDelegateRuns(events);

View File

@ -68,9 +68,14 @@ export function reconstructDelegateRuns(events: EventBase[]): DelegateRun[] {
continue; continue;
} }
if (e.kind === 'delegate_start') continue; if (e.kind === 'delegate_start') continue;
// 内部イベント: correlationId が run の ID に一致するものを束ねる // 内部イベント: delegateRunId 帰属タグが run の ID に一致するものを束ねる。
if (e.correlationId && byId.has(e.correlationId)) { // correlationId は tool_call↔tool_result のペアリングで per-tool UUID に
const run = byId.get(e.correlationId)!; // 上書きされるため集計キーには使えないdelegateRunId は子スコープが刻印し
// 上書きされない。旧イベントdelegateRunId 欠落)は correlationId に
// フォールバックして後方互換を保つ。
const attributionId = e.delegateRunId ?? e.correlationId;
if (attributionId && byId.has(attributionId)) {
const run = byId.get(attributionId)!;
run.eventCount++; run.eventCount++;
if (e.kind === 'tool_call') { if (e.kind === 'tool_call') {
run.toolCalls++; run.toolCalls++;

View File

@ -148,6 +148,18 @@ describe('FileEventLogger', () => {
expect(parsed.iteration).toBe(5); 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). // 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)', () => { 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. // Make the workspace read-only so appendFileSync fails.

View File

@ -56,6 +56,13 @@ export interface EventBase {
parentEventId?: string; parentEventId?: string;
correlationId?: string; correlationId?: string;
llmToolCallId?: string; llmToolCallId?: string;
/**
* delegate tool_call
* `correlationId` reconstructDelegateRuns
* 使correlationId tool_calltool_result
*
*/
delegateRunId?: string;
movement?: string; movement?: string;
iteration?: number; iteration?: number;
kind: string; kind: string;
@ -66,6 +73,7 @@ export interface EmitOptions {
parentEventId?: string; parentEventId?: string;
correlationId?: string; correlationId?: string;
llmToolCallId?: string; llmToolCallId?: string;
delegateRunId?: string;
movement?: string; movement?: string;
iteration?: number; iteration?: number;
} }
@ -162,7 +170,7 @@ function fitToEnvelope(eventLine: string, event: EventBase): string {
export interface EventLogger { export interface EventLogger {
emit(kind: string, payload: unknown, opts?: EmitOptions): string; emit(kind: string, payload: unknown, opts?: EmitOptions): string;
startCorrelation(): 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. */ /** Ids and counters for diagnostics. */
describe(): { runId: string; seq: number; degraded: boolean }; describe(): { runId: string; seq: number; degraded: boolean };
} }
@ -176,7 +184,7 @@ export class NoopEventLogger implements EventLogger {
startCorrelation(): string { startCorrelation(): string {
return 'noop'; return 'noop';
} }
child(_scope: { movement?: string; iteration?: number; correlationId?: string }): EventLogger { child(_scope: { movement?: string; iteration?: number; correlationId?: string; delegateRunId?: string }): EventLogger {
return this; return this;
} }
describe(): { runId: string; seq: number; degraded: boolean } { describe(): { runId: string; seq: number; degraded: boolean } {
@ -198,7 +206,7 @@ interface FileLoggerCore {
class ScopedEventLogger implements EventLogger { class ScopedEventLogger implements EventLogger {
constructor( constructor(
private readonly core: FileLoggerCore, 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 { emit(kind: string, payload: unknown, opts?: EmitOptions): string {
@ -213,6 +221,7 @@ class ScopedEventLogger implements EventLogger {
parentEventId: opts?.parentEventId, parentEventId: opts?.parentEventId,
correlationId: opts?.correlationId ?? this.scope.correlationId, correlationId: opts?.correlationId ?? this.scope.correlationId,
llmToolCallId: opts?.llmToolCallId, llmToolCallId: opts?.llmToolCallId,
delegateRunId: opts?.delegateRunId ?? this.scope.delegateRunId,
movement: opts?.movement ?? this.scope.movement, movement: opts?.movement ?? this.scope.movement,
iteration: opts?.iteration ?? this.scope.iteration, iteration: opts?.iteration ?? this.scope.iteration,
kind, kind,
@ -239,11 +248,12 @@ class ScopedEventLogger implements EventLogger {
return randomUUID(); 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, { return new ScopedEventLogger(this.core, {
movement: scope.movement ?? this.scope.movement, movement: scope.movement ?? this.scope.movement,
iteration: scope.iteration ?? this.scope.iteration, iteration: scope.iteration ?? this.scope.iteration,
correlationId: scope.correlationId ?? this.scope.correlationId, correlationId: scope.correlationId ?? this.scope.correlationId,
delegateRunId: scope.delegateRunId ?? this.scope.delegateRunId,
}); });
} }

View File

@ -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(
<ChatPane task={task} comments={[]} onSubmit={vi.fn(async () => {})} />,
);
await user.type(screen.getByRole('textbox'), '書きかけの返信');
first.unmount();
expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toBe('書きかけの返信');
renderWithProviders(
<ChatPane task={task} comments={[]} onSubmit={vi.fn(async () => {})} />,
);
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(<ChatPane task={task} comments={[]} onSubmit={onSubmit} />);
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();
});
});

View File

@ -12,21 +12,10 @@ import { ToolRequestApproval } from './ToolRequestApproval';
import { PackageRequestApproval } from './PackageRequestApproval'; import { PackageRequestApproval } from './PackageRequestApproval';
import { DelegateLiveConsole } from './DelegateLiveConsole'; import { DelegateLiveConsole } from './DelegateLiveConsole';
import { useJobStream } from '../../hooks/useJobStream'; import { useJobStream } from '../../hooks/useJobStream';
import { useDraft } from '../../hooks/useDraft';
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract'; import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize'; import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize';
import { toBase64 } from '../../lib/fileAttachments';
async function toBase64(file: File): Promise<string> {
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);
});
}
interface ChatPaneProps { interface ChatPaneProps {
task: LocalTask; task: LocalTask;
@ -43,7 +32,9 @@ interface ChatPaneProps {
export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activeDetailTab, onSelectDetailTab }: ChatPaneProps) { export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activeDetailTab, onSelectDetailTab }: ChatPaneProps) {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const { t: dt } = useTranslation('detail'); 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<Array<{ name: string; contentBase64: string }>>([]); const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [cancelling, setCancelling] = 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); await onSubmit(draft, attachments.length > 0 ? attachments : undefined);
setDraft(''); setDraft('');
setAttachments([]); setAttachments([]);
clearDraft();
// Hold the lock until the agent is visibly responding (see effect below). // 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 // 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. // 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
<textarea <textarea
ref={composerRef} ref={composerRef}
value={draft} value={draft}
onChange={e => setDraft(e.target.value)} onChange={e => { setDraft(e.target.value); saveDraft(e.target.value); }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={e => void handlePaste(e)} onPaste={e => void handlePaste(e)}
rows={1} rows={1}

View File

@ -1,23 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toBase64 } from '../../lib/fileAttachments';
interface AttachmentDropzoneProps { interface AttachmentDropzoneProps {
attachments: Array<{ name: string; contentBase64: string }>; attachments: Array<{ name: string; contentBase64: string }>;
onFilesChange: (files: Array<{ name: string; contentBase64: string }>) => void; onFilesChange: (files: Array<{ name: string; contentBase64: string }>) => void;
} }
async function toBase64(file: File): Promise<string> {
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);
});
}
export function AttachmentDropzone({ attachments, onFilesChange }: AttachmentDropzoneProps) { export function AttachmentDropzone({ attachments, onFilesChange }: AttachmentDropzoneProps) {
const { t } = useTranslation('create'); const { t } = useTranslation('create');
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);

View File

@ -0,0 +1,61 @@
// @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 { CreateTaskDialog } from './CreateTaskDialog';
import { __resetDraftPruneForTest } from '../../hooks/useDraft';
beforeEach(() => {
localStorage.clear();
__resetDraftPruneForTest();
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 })));
});
// Radix の外側クリック検知は overlay への pointerdown で発火する。
// jsdom には pointer capture API が無いので userEvent.pointer ではなく
// click内部で pointerdown/up を発火)を overlay に対して使う。
async function clickOverlay(user: ReturnType<typeof userEvent.setup>) {
// Dialog.Overlay は fixed inset-0 の要素。Portal 直下から取得する。
const overlay = document.querySelector('.fixed.inset-0.bg-slate-900\\/50');
expect(overlay).not.toBeNull();
await user.click(overlay as Element);
}
describe('CreateTaskDialog outside-click safety', () => {
it('本文が空なら外側クリックで閉じる', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithProviders(<CreateTaskDialog onClose={onClose} onSubmit={vi.fn(async () => {})} />);
await clickOverlay(user);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('本文に入力があると外側クリックでは閉じない', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithProviders(<CreateTaskDialog onClose={onClose} onSubmit={vi.fn(async () => {})} />);
await user.type(screen.getByTestId('create-task-body'), '書きかけ');
await clickOverlay(user);
expect(onClose).not.toHaveBeenCalled();
});
it('入力があっても Esc では閉じる(下書きが残るため逃げ道は塞がない)', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithProviders(<CreateTaskDialog onClose={onClose} onSubmit={vi.fn(async () => {})} />);
await user.type(screen.getByTestId('create-task-body'), '書きかけ');
await user.keyboard('{Escape}');
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('入力があっても ×ボタンでは閉じる', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithProviders(<CreateTaskDialog onClose={onClose} onSubmit={vi.fn(async () => {})} />);
await user.type(screen.getByTestId('create-task-body'), '書きかけ');
await user.click(screen.getByLabelText(/閉じる|close/i));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
});

View File

@ -0,0 +1,88 @@
// @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 { CreateTaskDialog } from './CreateTaskDialog';
import { __resetDraftPruneForTest } from '../../hooks/useDraft';
const DRAFT_KEY = 'maestro:draft:v1:create-task:default:global';
// CreateTaskDialog は auth/me・pieces・spaces 等を fetch する。全部 404 で
// 落としても各 hook は空データで動くretry:false
beforeEach(() => {
localStorage.clear();
__resetDraftPruneForTest();
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 })));
});
function renderDialog(props: Partial<Parameters<typeof CreateTaskDialog>[0]> = {}) {
const onClose = vi.fn();
const onSubmit = vi.fn(async () => {});
const utils = renderWithProviders(
<CreateTaskDialog onClose={onClose} onSubmit={onSubmit} {...props} />,
);
return { onClose, onSubmit, ...utils };
}
describe('CreateTaskDialog drafts', () => {
it('入力すると下書きが保存され、開き直すと復元される', async () => {
const user = userEvent.setup();
const first = renderDialog();
await user.type(screen.getByTestId('create-task-body'), '調査タスクの下書き');
first.unmount(); // アンマウントフラッシュで保存される
expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toBe('調査タスクの下書き');
renderDialog();
expect(screen.getByTestId('create-task-body')).toHaveValue('調査タスクの下書き');
});
it('initialBody があるときは下書きより優先し、下書きも書かない', async () => {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '古い下書き', updatedAt: Date.now() }));
const user = userEvent.setup();
renderDialog({ initialBody: '明示指定の本文' });
const box = screen.getByTestId('create-task-body');
expect(box).toHaveValue('明示指定の本文');
await user.type(box, '追記');
// initialBody フローでは既存下書きを上書きしない
expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toBe('古い下書き');
});
it('送信成功で下書きが消える', async () => {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '送信する本文', updatedAt: Date.now() }));
const user = userEvent.setup();
const { onSubmit } = renderDialog();
await user.click(screen.getByTestId('create-task-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
expect(localStorage.getItem(DRAFT_KEY)).toBeNull();
});
it('スケジュール送信成功でも下書きが消える', async () => {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '定期実行する本文', updatedAt: Date.now() }));
// /api/scheduled-tasks だけ 200 を返し、他は 404 のままscheduled 分岐に入る)
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes('/api/scheduled-tasks')) return new Response('{}', { status: 200 });
return new Response('{}', { status: 404 });
});
vi.stubGlobal('fetch', fetchMock);
const user = userEvent.setup();
const { onClose, onSubmit } = renderDialog();
// 詳細設定を開いてスケジュール実行を ON にする(ラベルは i18n 言語依存なので両対応)
await user.click(screen.getByRole('button', { name: /詳細設定を開く|show advanced settings/i }));
const scheduleToggle = document.getElementById('schedule-toggle');
expect(scheduleToggle).not.toBeNull();
await user.click(scheduleToggle!);
await user.click(screen.getByTestId('create-task-submit'));
// scheduled 分岐POST /api/scheduled-tasks → clearDraft → onCloseを通ったこと
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(
fetchMock.mock.calls.some(([input]) => String(input).includes('/api/scheduled-tasks')),
).toBe(true);
expect(onSubmit).not.toHaveBeenCalled();
expect(localStorage.getItem(DRAFT_KEY)).toBeNull();
});
});

View File

@ -0,0 +1,67 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/render-helpers';
import { CreateTaskDialog } from './CreateTaskDialog';
import { __resetDraftPruneForTest } from '../../hooks/useDraft';
import * as fileAttachments from '../../lib/fileAttachments';
beforeEach(() => {
localStorage.clear();
__resetDraftPruneForTest();
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 })));
});
function pasteFiles(target: Element, files: File[]) {
fireEvent.paste(target, {
clipboardData: {
files,
items: files.map(f => ({ kind: 'file', getAsFile: () => f })),
getData: () => '',
},
});
}
describe('CreateTaskDialog paste-to-attach', () => {
it('ファイル貼り付けで添付チップが追加されるimage.png は日時名に変換)', async () => {
renderWithProviders(<CreateTaskDialog onClose={vi.fn()} onSubmit={vi.fn(async () => {})} />);
const body = screen.getByTestId('create-task-body');
pasteFiles(body, [new File(['png-bytes'], 'image.png', { type: 'image/png' })]);
await waitFor(() => {
expect(screen.getByText(/^pasted-\d{8}-\d{6}\.png$/)).toBeInTheDocument();
});
});
it('固有名ファイルは元の名前のまま添付される', async () => {
renderWithProviders(<CreateTaskDialog onClose={vi.fn()} onSubmit={vi.fn(async () => {})} />);
pasteFiles(screen.getByTestId('create-task-body'), [
new File(['csv'], 'data.csv', { type: 'text/csv' }),
]);
await waitFor(() => expect(screen.getByText('data.csv')).toBeInTheDocument());
});
it('テキストのみの貼り付けでは添付されない', () => {
renderWithProviders(<CreateTaskDialog onClose={vi.fn()} onSubmit={vi.fn(async () => {})} />);
const body = screen.getByTestId('create-task-body');
fireEvent.paste(body, { clipboardData: { files: [], items: [], getData: () => 'ただの文字' } });
expect(screen.queryByText(/pasted-/)).not.toBeInTheDocument();
});
it('一部のファイル変換が失敗しても成功分は添付されエラーも表示される', async () => {
const original = fileAttachments.toBase64;
const spy = vi.spyOn(fileAttachments, 'toBase64').mockImplementation(async (f: File) => {
if (f.name === 'broken.bin') throw new Error('read failed');
return original(f);
});
renderWithProviders(<CreateTaskDialog onClose={vi.fn()} onSubmit={vi.fn(async () => {})} />);
pasteFiles(screen.getByTestId('create-task-body'), [
new File(['bad'], 'broken.bin', { type: 'application/octet-stream' }),
new File(['ok'], 'good.csv', { type: 'text/csv' }),
]);
await waitFor(() => expect(screen.getByText('good.csv')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Could not read the pasted file')).toBeInTheDocument());
expect(screen.queryByText('broken.bin')).not.toBeInTheDocument();
spy.mockRestore();
});
});

View File

@ -11,6 +11,8 @@ import { useSpaces } from '../../hooks/useSpaces';
import { sortSpacesForRail } from '../../lib/spaceSort'; import { sortSpacesForRail } from '../../lib/spaceSort';
import { resolvePieceOptions } from '../../lib/splitPieces'; import { resolvePieceOptions } from '../../lib/splitPieces';
import { useAuthState } from '../../App'; import { useAuthState } from '../../App';
import { useDraft } from '../../hooks/useDraft';
import { toBase64, uniqueAttachmentName } from '../../lib/fileAttachments';
interface CreateTaskDialogProps { interface CreateTaskDialogProps {
onClose: () => void; onClose: () => void;
@ -69,8 +71,14 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
if (orgs.length === 0) return; if (orgs.length === 0) return;
setVisibilityScopeOrgId(orgs[0].orgId); setVisibilityScopeOrgId(orgs[0].orgId);
}, [orgs, visibilityScopeOrgId]); }, [orgs, visibilityScopeOrgId]);
// 下書き保存: ヘルプ/継続フロー等で initialBody が来たときは明示指定を優先し、
// 下書きの復元も上書きもしないkey=null で opt-out
const draftKey = initialBody
? null
: `create-task:${initialPiece ?? 'default'}:${initialSpaceId ?? 'global'}`;
const { draft, saveDraft, clearDraft } = useDraft(draftKey);
const [form, setForm] = useState<CreateLocalTaskInput>({ const [form, setForm] = useState<CreateLocalTaskInput>({
body: initialBody ?? '', body: initialBody ?? draft ?? '',
piece: initialPiece ?? 'auto', piece: initialPiece ?? 'auto',
profile: 'auto', profile: 'auto',
outputFormat: 'markdown', outputFormat: 'markdown',
@ -108,6 +116,32 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
) )
: []; : [];
// ダイアログ全体でファイル貼り付けを受けるtextarea 外でも効く)。
// テキストだけの貼り付けには一切介入しない。
const handlePaste = async (e: React.ClipboardEvent) => {
const files = Array.from(e.clipboardData?.files ?? []);
if (files.length === 0) return;
e.preventDefault();
const taken = new Set(attachments.map(a => a.name));
const converted: Array<{ name: string; contentBase64: string }> = [];
let failed = 0;
for (const f of files) {
const name = uniqueAttachmentName(f.name, taken);
taken.add(name);
try {
converted.push({ name, contentBase64: await toBase64(f) });
} catch {
failed += 1;
}
}
if (converted.length > 0) {
setAttachments(prev => [...prev, ...converted]);
}
if (failed > 0) {
setError(t('errors.pasteAttachFailed'));
}
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!form.body.trim()) { if (!form.body.trim()) {
setError(t('errors.bodyRequired')); setError(t('errors.bodyRequired'));
@ -131,6 +165,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
}), }),
}); });
if (!res.ok) throw new Error(t('errors.scheduleFailed')); if (!res.ok) throw new Error(t('errors.scheduleFailed'));
clearDraft();
onClose(); onClose();
return; return;
} }
@ -152,6 +187,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
...(Object.keys(options).length > 0 ? { options } : {}), ...(Object.keys(options).length > 0 ? { options } : {}),
}; };
await onSubmit(submitForm, attachments); await onSubmit(submitForm, attachments);
clearDraft();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
} finally { } finally {
@ -159,6 +195,9 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
} }
}; };
// 入力があるときだけ外側クリックのクローズを止めるEsc/×/キャンセルは常に有効)
const dirty = form.body.trim().length > 0 || attachments.length > 0;
return ( return (
<Dialog.Root open onOpenChange={(open) => { if (!open) onClose(); }}> <Dialog.Root open onOpenChange={(open) => { if (!open) onClose(); }}>
<Dialog.Portal> <Dialog.Portal>
@ -169,6 +208,9 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
onOpenAutoFocus={e => { onOpenAutoFocus={e => {
e.preventDefault(); e.preventDefault();
}} }}
onPointerDownOutside={e => { if (dirty) e.preventDefault(); }}
onInteractOutside={e => { if (dirty) e.preventDefault(); }}
onPaste={e => void handlePaste(e)}
> >
<div className="p-5"> <div className="p-5">
<div className="flex items-start justify-between gap-3 mb-5"> <div className="flex items-start justify-between gap-3 mb-5">
@ -200,7 +242,11 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
autoFocus autoFocus
data-testid="create-task-body" data-testid="create-task-body"
value={form.body} value={form.body}
onChange={e => setForm(prev => ({ ...prev, body: e.target.value }))} onChange={e => {
const body = e.target.value;
setForm(prev => ({ ...prev, body }));
saveDraft(body);
}}
onKeyDown={e => { onKeyDown={e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
@ -275,7 +321,10 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
<PromptCoachPanel <PromptCoachPanel
body={form.body} body={form.body}
piece={initialPiece ?? form.piece} piece={initialPiece ?? form.piece}
onApplyRewrite={(text) => setForm(prev => ({ ...prev, body: text }))} onApplyRewrite={(text) => {
setForm(prev => ({ ...prev, body: text }));
saveDraft(text);
}}
/> />
{/* MCP warnings (always visible when applicable) */} {/* MCP warnings (always visible when applicable) */}

View File

@ -0,0 +1,49 @@
// @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 { ContinueWithPieceDialog } from './ContinueWithPieceDialog';
import { __resetDraftPruneForTest } from '../../hooks/useDraft';
const DRAFT_KEY = 'maestro:draft:v1:continue:9';
const prevJob = { id: 'j1', pieceName: 'chat', status: 'succeeded' };
beforeEach(() => {
localStorage.clear();
__resetDraftPruneForTest();
// pieces / comments / continue API をまとめてスタブ。continue だけ 200 を返す
vi.stubGlobal('fetch', vi.fn(async (url: RequestInfo | URL) => {
const u = String(url);
if (u.includes('/continue')) return new Response(JSON.stringify({ ok: true }), { status: 200 });
return new Response('[]', { status: 200 });
}));
});
describe('ContinueWithPieceDialog draft', () => {
it('指示の書きかけが再マウントで復元される', async () => {
const user = userEvent.setup();
const first = renderWithProviders(
<ContinueWithPieceDialog taskId={9} prevJob={prevJob} onClose={vi.fn()} />,
);
await user.type(screen.getByLabelText(/新しい指示|instruction/i), '続きをやって');
first.unmount();
expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toBe('続きをやって');
renderWithProviders(
<ContinueWithPieceDialog taskId={9} prevJob={prevJob} onClose={vi.fn()} />,
);
expect(screen.getByLabelText(/新しい指示|instruction/i)).toHaveValue('続きをやって');
});
it('送信成功で下書きが消える', async () => {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '続きをやって', updatedAt: Date.now() }));
const user = userEvent.setup();
const onClose = vi.fn();
renderWithProviders(<ContinueWithPieceDialog taskId={9} prevJob={prevJob} onClose={onClose} />);
await user.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(localStorage.getItem(DRAFT_KEY)).toBeNull();
});
});

View File

@ -6,6 +6,7 @@ import { usePieceList } from '../../hooks/usePieces';
import { resolvePieceOptions } from '../../lib/splitPieces'; import { resolvePieceOptions } from '../../lib/splitPieces';
import { MarkdownText } from '../../lib/markdown-text'; import { MarkdownText } from '../../lib/markdown-text';
import { useBackdropClose } from '../../lib/useBackdropClose'; import { useBackdropClose } from '../../lib/useBackdropClose';
import { useDraft } from '../../hooks/useDraft';
interface PrevJobInfo { interface PrevJobInfo {
id: string; id: string;
@ -26,7 +27,8 @@ export function ContinueWithPieceDialog({
}: ContinueWithPieceDialogProps) { }: ContinueWithPieceDialogProps) {
const { t } = useTranslation('detail'); const { t } = useTranslation('detail');
const [piece, setPiece] = useState<string>(prevJob.pieceName); const [piece, setPiece] = useState<string>(prevJob.pieceName);
const [instruction, setInstruction] = useState<string>(''); const { draft: instructionDraft, saveDraft, clearDraft } = useDraft(`continue:${taskId}`);
const [instruction, setInstruction] = useState<string>(instructionDraft ?? '');
const [resultExpanded, setResultExpanded] = useState<boolean>(false); const [resultExpanded, setResultExpanded] = useState<boolean>(false);
const qc = useQueryClient(); const qc = useQueryClient();
const backdrop = useBackdropClose(onClose); const backdrop = useBackdropClose(onClose);
@ -54,6 +56,7 @@ export function ContinueWithPieceDialog({
const continueMutation = useMutation({ const continueMutation = useMutation({
mutationFn: () => continueTaskWithPiece(taskId, { piece, instruction: instruction.trim() }), mutationFn: () => continueTaskWithPiece(taskId, { piece, instruction: instruction.trim() }),
onSuccess: () => { onSuccess: () => {
clearDraft();
qc.invalidateQueries({ queryKey: ['localTaskDetail', taskId] }); qc.invalidateQueries({ queryKey: ['localTaskDetail', taskId] });
qc.invalidateQueries({ queryKey: ['localTasks'] }); qc.invalidateQueries({ queryKey: ['localTasks'] });
onClose(); onClose();
@ -145,7 +148,7 @@ export function ContinueWithPieceDialog({
<textarea <textarea
id="continue-instruction" id="continue-instruction"
value={instruction} value={instruction}
onChange={e => setInstruction(e.target.value)} onChange={e => { setInstruction(e.target.value); saveDraft(e.target.value); }}
autoFocus autoFocus
rows={5} rows={5}
placeholder={t('continue.instructionPlaceholder')} placeholder={t('continue.instructionPlaceholder')}

View File

@ -0,0 +1,91 @@
// @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 { OverviewTab } from './OverviewTab';
import { __resetDraftPruneForTest } from '../../../hooks/useDraft';
import type { LocalTask } from '../../../api';
const DRAFT_KEY = 'maestro:draft:v1:feedback-comment:7';
const baseTask = {
id: 7,
title: 'タスク',
body: '本文',
pieceName: 'chat',
priority: 'medium',
latestJob: { status: 'succeeded' },
feedbackRating: null,
feedbackTags: [],
feedbackComment: null,
} as unknown as LocalTask;
beforeEach(() => {
localStorage.clear();
__resetDraftPruneForTest();
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 })));
});
describe('FeedbackPanel comment draft', () => {
it('👍を押してコメントを書くと下書きが保存され、再マウントで復元される', async () => {
const user = userEvent.setup();
const first = renderWithProviders(<OverviewTab task={baseTask} />);
await user.click(screen.getByRole('button', { name: /👍/ }));
await user.type(screen.getByRole('textbox'), '精度が良かった');
first.unmount();
expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toBe('精度が良かった');
renderWithProviders(<OverviewTab task={baseTask} />);
await user.click(screen.getByRole('button', { name: /👍/ }));
expect(screen.getByRole('textbox')).toHaveValue('精度が良かった');
});
it('保存済みコメントがあるタスクではそちらを優先する', async () => {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '古い下書き', updatedAt: Date.now() }));
const saved = {
...baseTask,
feedbackRating: 'good',
feedbackComment: '確定済みコメント',
} as unknown as LocalTask;
const user = userEvent.setup();
renderWithProviders(<OverviewTab task={saved} />);
// 保存済み表示 → change で編集に入る
await user.click(screen.getByRole('button', { name: /change|変更/i }));
expect(screen.getByRole('textbox')).toHaveValue('確定済みコメント');
});
it('フィードバック送信が成功すると下書きが削除される', async () => {
// 送信前にデバウンス書き込みのタイミングに依存しないよう、下書きは
// localStorage へ直接シードする(既存テストと同じ堅牢なパターン)。
localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '送信予定のコメント', updatedAt: Date.now() }));
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (typeof url === 'string' && url.includes('/feedback') && init?.method === 'PUT') {
return new Response(JSON.stringify({ task: { ...baseTask, feedbackRating: 'good' } }), { status: 200 });
}
return new Response('{}', { status: 404 });
}));
const user = userEvent.setup();
renderWithProviders(<OverviewTab task={baseTask} />);
await user.click(screen.getByRole('button', { name: /👍/ }));
await user.click(screen.getByRole('button', { name: /submit|送信/i }));
await waitFor(() => expect(localStorage.getItem(DRAFT_KEY)).toBeNull());
});
it('キャンセルすると下書きが削除される', async () => {
const saved = {
...baseTask,
feedbackRating: 'good',
feedbackComment: '確定済みコメント',
} as unknown as LocalTask;
const user = userEvent.setup();
renderWithProviders(<OverviewTab task={saved} />);
await user.click(screen.getByRole('button', { name: /change|変更/i }));
await user.type(screen.getByRole('textbox'), '書きかけの変更');
// saveDraft はデバウンス書き込みのため、反映を待ってからキャンセルする
await waitFor(() => expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toContain('書きかけの変更'));
await user.click(screen.getByRole('button', { name: /cancel|キャンセル/i }));
expect(localStorage.getItem(DRAFT_KEY)).toBeNull();
});
});

View File

@ -8,6 +8,7 @@ import { DelegateRunsSection } from './DelegateRunsSection';
import { ContextUsageGauge } from '../ContextUsageGauge'; import { ContextUsageGauge } from '../ContextUsageGauge';
import { ReflectionBadge } from '../ReflectionBadge'; import { ReflectionBadge } from '../ReflectionBadge';
import { showTitleEdit, showFeedback, showMissionEdit } from '../detail-readonly'; import { showTitleEdit, showFeedback, showMissionEdit } from '../detail-readonly';
import { useDraft } from '../../../hooks/useDraft';
// Feedback tags. `value` is the canonical string that gets persisted (and fed // Feedback tags. `value` is the canonical string that gets persisted (and fed
// to the reflection LLM prompt) — it stays in Japanese so existing stored rows // to the reflection LLM prompt) — it stays in Japanese so existing stored rows
@ -44,7 +45,10 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
const [rating, setRating] = useState<'good' | 'bad' | null>(task.feedbackRating ?? null); const [rating, setRating] = useState<'good' | 'bad' | null>(task.feedbackRating ?? null);
const [selectedTags, setSelectedTags] = useState<string[]>(task.feedbackTags ?? []); const [selectedTags, setSelectedTags] = useState<string[]>(task.feedbackTags ?? []);
const [comment, setComment] = useState(task.feedbackComment ?? ''); // 下書き: 確定済みコメントがあればそちらを優先。無ければ書きかけを復元
// 既に評価済みのタスクでは下書きより確定値が常に勝つ(意図的なトレードオフ)。
const { draft: commentDraft, saveDraft, clearDraft } = useDraft(`feedback-comment:${task.id}`);
const [comment, setComment] = useState(task.feedbackComment ?? commentDraft ?? '');
const [editing, setEditing] = useState(!hasFeedback); const [editing, setEditing] = useState(!hasFeedback);
const mutation = useMutation({ const mutation = useMutation({
@ -54,6 +58,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
qc.invalidateQueries({ queryKey: ['localTasks'] }); qc.invalidateQueries({ queryKey: ['localTasks'] });
qc.invalidateQueries({ queryKey: ['localTaskDetail', task.id] }); qc.invalidateQueries({ queryKey: ['localTaskDetail', task.id] });
setEditing(false); setEditing(false);
clearDraft();
}, },
}); });
@ -168,7 +173,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
</div> </div>
<textarea <textarea
value={comment} value={comment}
onChange={e => setComment(e.target.value)} onChange={e => { setComment(e.target.value); saveDraft(e.target.value); }}
placeholder={t('feedback.commentPlaceholder')} placeholder={t('feedback.commentPlaceholder')}
maxLength={1000} maxLength={1000}
rows={2} rows={2}
@ -177,7 +182,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{hasFeedback && ( {hasFeedback && (
<button <button
onClick={() => { setEditing(false); setRating(task.feedbackRating ?? null); setSelectedTags(task.feedbackTags ?? []); setComment(task.feedbackComment ?? ''); }} onClick={() => { setEditing(false); setRating(task.feedbackRating ?? null); setSelectedTags(task.feedbackTags ?? []); setComment(task.feedbackComment ?? ''); clearDraft(); }}
className="px-3 py-1 text-xs text-slate-400 hover:text-slate-600" className="px-3 py-1 text-xs text-slate-400 hover:text-slate-600"
> >
{t('feedback.cancel')} {t('feedback.cancel')}

View File

@ -12,9 +12,20 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に
> 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。 > 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。
## 2026-07-09 — 入力の下書き自動保存・貼り付けでファイル添付・誤クローズ防止
書きかけのテキストが消えてしまう事故への対策をまとめて入れました。新規タスクの本文、チャット入力欄、フィードバックのコメント、継続指示は入力中の内容がブラウザに自動保存され、誤ってダイアログを閉じたりリロードしたりしても復元されます(送信すると消えます)。また、新規タスクのダイアログでスクリーンショットやコピーしたファイルを Ctrl+V で貼り付けて添付できるようになりました。あわせて、本文や添付を入力した状態ではダイアログの外側をクリックしても閉じないようにしています×・キャンセル・Esc では閉じられます)。
## 2026-07-09 — 委譲delegate実行の「ツール N 回」が常に 0 と表示される問題を修正
概要タブの委譲実行カードで、サブエージェントが何回ツールを呼んでも「ツール 0 回」と表示され、ツール内訳や変更ファイルも空になっていました。トークン数や経過時間は正しく出るのにツールだけ数えられない、という状態です。原因は、ツール呼び出しの記録が「呼び出しと結果を紐づける識別子」を使い回していて、実行への帰属が失われていたことでした。実行ごとの帰属を別のタグで持たせるようにして、ツール回数・内訳・変更ファイル、さらに実行を展開したときのイベント一覧や「実行中のツール」表示も正しく出るようにしました。過去に実行済みのタスクはログに帰属タグが残っていないため 0 のままですが、今後の実行では正しく表示されます。
## 2026-07-09 — 設定画面に「スキルのクォータ」を追加し、これまで隠れていた設定を編集可能に ## 2026-07-09 — 設定画面に「スキルのクォータ」を追加し、これまで隠れていた設定を編集可能に
これまで `config.yaml` を直接いじらないと変えられなかった項目を、設定画面から編集できるようにしました。Agent Runtime に新しく「スキルのクォータ」セクションが増え、1 ユーザーあたりのスキル数やサイズ上限を調整できます。あわせて、LLM の暴走を止める総時間の上限Max Stream、サブタスク発火の間引き遅延、履歴要約の headroom 上限、Outlook メール(.msgの読み込みサイズ上限、TLS の最小バージョン、Gateway の内部チーム、ワーカーのヘルスチェック間隔も、それぞれの設定セクションから変更できます。さらに Execution 画面に「Python サンドボックスのパッケージ」案内を追加し、どのパッケージが使えるか・どう増やすかを確認できるようにしました(→[設定](./17-settings.md))。 これまで `config.yaml` を直接いじらないと変えられなかった項目を、設定画面から編集できるようにしました。Agent Runtime に新しく「スキルのクォータ」セクションが増え、1 ユーザーあたりのスキル数やサイズ上限を調整できます。あわせて、LLM の暴走を止める総時間の上限Max Stream、サブタスク発火の間引き遅延、履歴要約の headroom 上限、Outlook メール(.msgの読み込みサイズ上限、TLS の最小バージョン、Gateway の内部チーム、ワーカーのヘルスチェック間隔も、それぞれの設定セクションから変更できます。さらに Execution 画面に「Python サンドボックスのパッケージ」案内を追加し、どのパッケージが使えるか・どう増やすかを確認できるようにしました(→[設定](./17-settings.md))。
## 2026-07-08 — 調査系エージェントのレポートが日付別に積み上がるように
`research` / `sns-research` / `sns-deep-sweep` の成果物ファイル名に実行日が入るようになりました(例: `output/report-2026-07-08.md`、深掘りメモは `output/deepdive/2026-07-08/` 配下)。これまでは毎回 `output/report.md` などの固定名で書いていたため、同じワークスペースで繰り返し実行すると前回のレポートが上書きされて消えていました。今後は過去の実行分がそのまま残り、日付をまたいで情報が蓄積されます。あわせて、エージェントのシステムプロンプトに現在日時が常に入るようになり、「今日」「最新」などの相対表現やファイル名の日付がずれにくくなりました。
## 2026-07-08 — 名前が食い違ったスキルを削除・編集できない問題を修正 ## 2026-07-08 — 名前が食い違ったスキルを削除・編集できない問題を修正

View File

@ -37,6 +37,16 @@ keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask po
`input/` に同じ名前のファイルが既にある場合は、上書きせず `名前 (2).ext` のように自動でリネームして保存します(元のファイルは残ります)。 `input/` に同じ名前のファイルが既にある場合は、上書きせず `名前 (2).ext` のように自動でリネームして保存します(元のファイルは残ります)。
### 入力内容の自動下書き保存
新規タスクの本文・チャット入力欄・フィードバックのコメント欄・継続指示は、入力するそばから下書きとしてブラウザに自動保存されます。誤ってダイアログを閉じたり、ページをリロードしたりしても、次に開いたときに書きかけの内容が復元されます。下書きは送信すると消え、14日間使われなかったものは自動的に削除されます。保存先はお使いのブラウザ内なので、別の端末には引き継がれません。
### 貼り付けでファイルを添付する
新規タスクのダイアログでは、スクリーンショットやコピーしたファイルを Ctrl+VMac は Cmd+Vで貼り付けるだけで添付できます。ドラッグ&ドロップ・ファイル選択も従来どおり使えます。クリップボードから貼り付けた画像には日時ベースのファイル名が自動で付きます。
なお、本文か添付ファイルを入力した状態では、ダイアログの外側をクリックしても閉じなくなります(書きかけの誤消去防止)。閉じたいときは ×ボタン・キャンセル・Esc キーを使ってください。入力が空のときは従来どおり外側クリックで閉じられます。
## 詳細設定 ## 詳細設定
「詳細設定を開く」を押すと、次の項目を調整できます。 「詳細設定を開く」を押すと、次の項目を調整できます。

View File

@ -52,6 +52,8 @@ Piece は「タスクの種類ごとの実行手順」を定義したもので
`ssh-ops` / `ssh-console` は admin が `config.yaml` で SSH を有効化し、接続登録・grant が済んでいる場合のみ使えます。詳細は [SSH 連携](14-ssh.md) を参照。 `ssh-ops` / `ssh-console` は admin が `config.yaml` で SSH を有効化し、接続登録・grant が済んでいる場合のみ使えます。詳細は [SSH 連携](14-ssh.md) を参照。
調査系(`research` / `sns-research` / `sns-deep-sweep`)のレポートは実行日入りのファイル名(例: `output/report-2026-07-08.md`)で保存されます。同じワークスペースで繰り返し実行しても前回の成果物は上書きされず、日付別に積み上がります。
> このほか組織が独自に追加した Piece もここに加わります。利用可能なツールの一覧は [ツール一覧](16-tools.md) を参照。 > このほか組織が独自に追加した Piece もここに加わります。利用可能なツールの一覧は [ツール一覧](16-tools.md) を参照。
## Default Pieces と Custom Pieces ## Default Pieces と Custom Pieces

View File

@ -0,0 +1,93 @@
// @vitest-environment jsdom
import '../test/dom-setup';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDraft, __resetDraftPruneForTest } from './useDraft';
const PREFIX = 'maestro:draft:v1:';
describe('useDraft', () => {
beforeEach(() => {
localStorage.clear();
__resetDraftPruneForTest();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('保存済み下書きを初回マウント時に復元する', () => {
localStorage.setItem(PREFIX + 'k1', JSON.stringify({ text: 'こんにちは', updatedAt: Date.now() }));
const { result } = renderHook(() => useDraft('k1'));
expect(result.current.draft).toBe('こんにちは');
});
it('下書きが無ければ draft は null', () => {
const { result } = renderHook(() => useDraft('k1'));
expect(result.current.draft).toBeNull();
});
it('saveDraft は約400msデバウンスして書き込む', () => {
const { result } = renderHook(() => useDraft('k1'));
act(() => result.current.saveDraft('a'));
act(() => result.current.saveDraft('ab'));
expect(localStorage.getItem(PREFIX + 'k1')).toBeNull();
act(() => vi.advanceTimersByTime(400));
const stored = JSON.parse(localStorage.getItem(PREFIX + 'k1')!);
expect(stored.text).toBe('ab');
expect(typeof stored.updatedAt).toBe('number');
});
it('空白のみのテキストはキー削除として扱う', () => {
localStorage.setItem(PREFIX + 'k1', JSON.stringify({ text: 'old', updatedAt: Date.now() }));
const { result } = renderHook(() => useDraft('k1'));
act(() => result.current.saveDraft(' '));
act(() => vi.advanceTimersByTime(400));
expect(localStorage.getItem(PREFIX + 'k1')).toBeNull();
});
it('clearDraft はキーを消し、保留中の書き込みも取り消す', () => {
const { result } = renderHook(() => useDraft('k1'));
act(() => result.current.saveDraft('pending'));
act(() => result.current.clearDraft());
act(() => vi.advanceTimersByTime(400));
expect(localStorage.getItem(PREFIX + 'k1')).toBeNull();
});
it('アンマウント時に保留中の書き込みをフラッシュする', () => {
const { result, unmount } = renderHook(() => useDraft('k1'));
act(() => result.current.saveDraft('flush me'));
unmount();
expect(JSON.parse(localStorage.getItem(PREFIX + 'k1')!).text).toBe('flush me');
});
it('clearDraft 後のアンマウントで下書きが復活しない', () => {
const { result, unmount } = renderHook(() => useDraft('k1'));
act(() => result.current.saveDraft('typed'));
act(() => result.current.clearDraft());
unmount();
expect(localStorage.getItem(PREFIX + 'k1')).toBeNull();
});
it('key=null では何もしないdraft は null、保存も no-op', () => {
const { result, unmount } = renderHook(() => useDraft(null));
expect(result.current.draft).toBeNull();
act(() => result.current.saveDraft('x'));
act(() => vi.advanceTimersByTime(400));
unmount();
expect(localStorage.length).toBe(0);
});
it('14日より古い下書きを初回利用時に掃除する壊れた値も対象', () => {
const old = Date.now() - 15 * 24 * 60 * 60 * 1000;
localStorage.setItem(PREFIX + 'stale', JSON.stringify({ text: 'old', updatedAt: old }));
localStorage.setItem(PREFIX + 'broken', 'not-json');
localStorage.setItem(PREFIX + 'fresh', JSON.stringify({ text: 'new', updatedAt: Date.now() }));
localStorage.setItem('unrelated', 'keep');
renderHook(() => useDraft('k1'));
expect(localStorage.getItem(PREFIX + 'stale')).toBeNull();
expect(localStorage.getItem(PREFIX + 'broken')).toBeNull();
expect(localStorage.getItem(PREFIX + 'fresh')).not.toBeNull();
expect(localStorage.getItem('unrelated')).toBe('keep');
});
});

119
ui/src/hooks/useDraft.ts Normal file
View File

@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* useDraft localStorage best-effort
* - draftsaveDraft
* clearDraft
* - key=null opt-out
* -
* - localStorage quota
* - key draft
* remount
*/
const PREFIX = 'maestro:draft:v1:';
const TTL_MS = 14 * 24 * 60 * 60 * 1000;
const DEBOUNCE_MS = 400;
let pruned = false;
export function __resetDraftPruneForTest(): void {
pruned = false;
}
function pruneOldDrafts(): void {
if (pruned) return;
pruned = true;
try {
const now = Date.now();
const stale: string[] = [];
for (let i = 0; i < window.localStorage.length; i++) {
const k = window.localStorage.key(i);
if (!k || !k.startsWith(PREFIX)) continue;
try {
const v = JSON.parse(window.localStorage.getItem(k) ?? '');
if (typeof v?.updatedAt !== 'number' || now - v.updatedAt > TTL_MS) stale.push(k);
} catch {
stale.push(k);
}
}
for (const k of stale) window.localStorage.removeItem(k);
} catch {
// localStorage 不可 — 下書き機能ごと best-effort
}
}
function writeDraft(key: string, text: string): void {
try {
if (text.trim().length === 0) {
window.localStorage.removeItem(PREFIX + key);
} else {
window.localStorage.setItem(PREFIX + key, JSON.stringify({ text, updatedAt: Date.now() }));
}
} catch {
// quota / SecurityError — ignore
}
}
export function useDraft(key: string | null): {
draft: string | null;
saveDraft: (text: string) => void;
clearDraft: () => void;
} {
const [draft] = useState<string | null>(() => {
if (key === null || typeof window === 'undefined') return null;
pruneOldDrafts();
try {
const raw = window.localStorage.getItem(PREFIX + key);
if (raw === null) return null;
const parsed = JSON.parse(raw) as { text?: unknown };
return typeof parsed.text === 'string' && parsed.text.length > 0 ? parsed.text : null;
} catch {
return null;
}
});
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<string | null>(null);
const saveDraft = useCallback(
(text: string) => {
if (key === null || typeof window === 'undefined') return;
pendingRef.current = text;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
timerRef.current = null;
if (pendingRef.current !== null) {
writeDraft(key, pendingRef.current);
pendingRef.current = null;
}
}, DEBOUNCE_MS);
},
[key],
);
const clearDraft = useCallback(() => {
if (key === null || typeof window === 'undefined') return;
pendingRef.current = null;
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
try {
window.localStorage.removeItem(PREFIX + key);
} catch {
// ignore
}
}, [key]);
// アンマウント時に保留分をフラッシュclearDraft 済みなら pendingRef=null で no-op
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
if (key !== null && typeof window !== 'undefined' && pendingRef.current !== null) {
writeDraft(key, pendingRef.current);
pendingRef.current = null;
}
};
}, [key]);
return { draft, saveDraft, clearDraft };
}

View File

@ -73,8 +73,8 @@
"maestroTips": "MAESTRO features you could use", "maestroTips": "MAESTRO features you could use",
"personalized": "Notes for you" "personalized": "Notes for you"
}, },
"attachments": { "title": "Attachments", "hint": "Drag & drop or choose files" }, "attachments": { "title": "Attachments", "hint": "Drag & drop, choose files, or paste (Ctrl+V)" },
"errors": { "bodyRequired": "Request is required", "scheduleFailed": "Failed to create schedule" }, "errors": { "bodyRequired": "Request is required", "scheduleFailed": "Failed to create schedule", "pasteAttachFailed": "Could not read the pasted file" },
"cancel": "Cancel", "cancel": "Cancel",
"submit": "Create Task", "submit": "Create Task",
"submitSchedule": "Create schedule", "submitSchedule": "Create schedule",

View File

@ -73,8 +73,8 @@
"maestroTips": "活用できる MAESTRO 機能", "maestroTips": "活用できる MAESTRO 機能",
"personalized": "あなた向けの指摘" "personalized": "あなた向けの指摘"
}, },
"attachments": { "title": "添付ファイル", "hint": "ドラッグ&ドロップまたはファイル選択" }, "attachments": { "title": "添付ファイル", "hint": "ドラッグ&ドロップ・ファイル選択・貼り付けCtrl+Vで追加" },
"errors": { "bodyRequired": "依頼内容は必須です", "scheduleFailed": "スケジュール作成に失敗しました" }, "errors": { "bodyRequired": "依頼内容は必須です", "scheduleFailed": "スケジュール作成に失敗しました", "pasteAttachFailed": "貼り付けたファイルを読み取れませんでした" },
"cancel": "キャンセル", "cancel": "キャンセル",
"submit": "Task 作成", "submit": "Task 作成",
"submitSchedule": "スケジュール作成", "submitSchedule": "スケジュール作成",

View File

@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { uniqueAttachmentName } from './fileAttachments';
describe('uniqueAttachmentName', () => {
const at = new Date('2026-07-08T15:30:12');
it('固有名のファイルはそのまま使う', () => {
expect(uniqueAttachmentName('report.pdf', new Set(), at)).toBe('report.pdf');
});
it('クリップボード画像 (image.png) は日時ベースの名前を付ける', () => {
expect(uniqueAttachmentName('image.png', new Set(), at)).toBe('pasted-20260708-153012.png');
});
it('既存名と衝突したら連番サフィックスで回避する', () => {
const taken = new Set(['pasted-20260708-153012.png', 'pasted-20260708-153012-2.png']);
expect(uniqueAttachmentName('image.png', taken, at)).toBe('pasted-20260708-153012-3.png');
});
it('固有名でも既存と衝突したら連番を付ける(拡張子は保持)', () => {
expect(uniqueAttachmentName('report.pdf', new Set(['report.pdf']), at)).toBe('report-2.pdf');
});
it('拡張子なしのファイル名も扱える', () => {
expect(uniqueAttachmentName('Makefile', new Set(['Makefile']), at)).toBe('Makefile-2');
});
});

View File

@ -0,0 +1,43 @@
/**
*
* - toBase64: File base64data: URL
* - uniqueAttachmentName: クリップボード画像の "image.png"
* name React key / 使
*/
export async function toBase64(file: File): Promise<string> {
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);
});
}
function splitExt(name: string): { stem: string; ext: string } {
const dot = name.lastIndexOf('.');
if (dot <= 0) return { stem: name, ext: '' };
return { stem: name.slice(0, dot), ext: name.slice(dot) };
}
export function uniqueAttachmentName(
original: string,
taken: Set<string>,
now: Date = new Date(),
): string {
let base = original;
// ブラウザのクリップボード画像は一律 "image.png" で来るため日時名に差し替える
if (original === 'image.png') {
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
base = `pasted-${stamp}.png`;
}
if (!taken.has(base)) return base;
const { stem, ext } = splitExt(base);
for (let i = 2; ; i++) {
const candidate = `${stem}-${i}${ext}`;
if (!taken.has(candidate)) return candidate;
}
}