1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub Copilot SDK で AI アプリを作って気づいた「途中状態の見せ方」問題

1
Posted at

📚 Copilot Cowork 開発シリーズ

  1. なぜ作るのか — 調査と設計判断
  2. Electron + React + SDK で土台を組む
  3. C# で業務向けに拡張する設計
  4. 途中状態の見せ方と入れ子実行 ← 今ここ

AI チャットアプリを作るとき、「AI を呼ぶ部分」は意外とすぐ動きます。SDK を使えば数行でチャットが機能し始めます。

難しかったのは、その後でした。「今 AI が何をしているか」「なぜ使えないのか」 を正確に伝えること、これが想像以上に厄介でした。

📋 この記事の前提

  • TypeScript / React の基本文法がわかる
  • Electron アプリの main / renderer 構成を知っている
  • Copilot Cowork の構成を把握している(第2回で解説)
  • 「AI アプリの UX 設計」に興味がある方向け

今回は、Copilot Cowork という Electron + React 製の AI デスクトップアプリで取り組んだ 2 つの改善について書きます。

Copilot Cowork とは: GitHub Copilot SDK を使って、チャットとツール実行(ファイル操作・subagent 呼び出しなど)を組み合わせたワークフローを組めるデスクトップアプリです。開発自体も GitHub Copilot と一緒に進めています。

今回解決した 2 つの問題

問題 1: 「使えない」の原因が分からない

AI アプリを起動したとき、こんな経験はないでしょうか。

「何も動かない」「でも何が原因なのか分からない」

GitHub Copilot SDK ベースのアプリでは、「使えない状態」が少なくとも 3 種類あります。

状態 原因 対処法
未認証 GitHub Copilot にサインインしていない サインインする
CLI 不在 gh コマンド(GitHub CLI)が入っていない GitHub CLI をインストールする
SDK 接続エラー ネットワーク障害・一時的な問題 しばらく待って再試行する

これを「使えません」の 1 文だけで表示すると、ユーザーは何をすべきか分かりません。原因の分類だけで体験が大きく変わります。

問題 2: AI が入れ子で処理していても追いかけられない

GitHub Copilot SDK のような AI ランタイムでは、あるツール実行の中で別のツールが走ることがよくあります。

ユーザー: 「このリポジトリの概要を教えて」
  └─ run_subagent が起動
       ├─ list_dir を実行
       ├─ read_file を実行
       └─ subagent が結果をまとめて返答

この入れ子構造を「チャット履歴のリスト」に平らに並べると、どの処理が何のために動いているのか、なぜ止まっているのかが見えなくなります。特に途中で「ファイルを削除してよいですか?」という確認が割り込んできたとき、それがどのツールのどの段階で発生した確認なのかが分からなくなります。


この 2 つを解決するために、起動ガイダンス入れ子実行の視覚化 を実装しました。

先に結論

改善の全体像をまとめます。

変更内容 何が変わるか
未認証・CLI 不在・SDK 不達を区別して表示 ユーザーが原因を自分で特定できる
未認証時は New Chat を無効化 + Retry ボタン追加 認証後すぐにアプリを使い始められる
入れ子ツール実行を親の会話ターンに束ねるデータモデルを追加 会話の流れとツール実行の流れが対応する
権限確認・質問をチャット内に inline 表示 「どの処理に対して許可しているか」が分かる
一時的な中間イベントを履歴から除外 再読み込みしても表示が崩れない

やってみて分かったのは、難しいのは AI を呼ぶことよりも途中状態を文脈付きで見せること でした。

起動ガイダンスの実装

認証状態に「種類」を持たせる

以前は isAuthenticated: boolean だけで状態を管理していました。これを細かく分類した型に拡張します。

type AuthStatusHealth =
  | 'ready'           // 正常
  | 'auth-required'   // 未サインイン
  | 'cli-unavailable' // GitHub CLI が見つからない
  | 'sdk-unavailable' // SDK に接続できない
  | 'error';          // その他のエラー

interface AuthStatus {
  health: AuthStatusHealth;
  recommendedActions: string[]; // 「次に何をすればよいか」のリスト
}

recommendedActions を持たせることで、UI 側はそのまま「次のアクション」をユーザーに提示できます。

UI での表示切り替え

SidebarEmptyState コンポーネントで health の値に応じて表示を切り替えます。

{status.health === 'auth-required' && (
  <EmptyState
    message="GitHub Copilot にサインインしてください"
    action={<button onClick={retryStatusCheck}>サインイン後に再確認</button>}
  />
)}

{status.health === 'cli-unavailable' && (
  <EmptyState
    message="GitHub CLI が見つかりません"
    action={<a href="https://cli.github.com">インストール方法を確認する</a>}
  />
)}

未認証時は New Chat ボタンを disabled にし、Retry status check で認証後すぐ再確認できるようにしています。

これは地味に見えますが、AI アプリは「使えないときにどう見えるか」が弱いとすぐ不信感につながります。 ChatGPT や Copilot が落ちているとき、エラーの理由は必ずはっきり書いてあります。

入れ子実行(Nested Execution)の実装

UI の前にデータモデルが必要

最初に試みたのは「入れ子のツール実行を画面上にかっこよく表示すること」でした。しかし途中で気づきました。表示より先に「どれがどれの子か」を構造として持つ必要がある ということに。

中心になる型はシンプルです。

interface ToolExecution {
  toolCallId: string;
  parentToolCallId: string | null; // 親ツールの ID(なければ null)
  rootToolCallId: string | null;   // 会話ターンの起点となるツールの ID
  status: 'running' | 'complete' | 'waiting-permission' | 'waiting-input';
  childEvents: ChildEvent[];
}

parentToolCallId で「誰の子か」、rootToolCallId で「どの会話ターンの流れに属するか」が分かります。この 2 つさえあれば、任意の深さの入れ子を正しく木構造として再構成できます。

イベントをまとめる

Copilot SDK から流れてくるイベントをこの構造にまとめるイメージは次のとおりです。

function attachToExecution(event: CopilotEvent, executions: Map<string, ToolExecution>) {
  if (event.parentToolCallId) {
    // 既存の親実行に子イベントとして追加
    const parent = executions.get(event.parentToolCallId);
    parent?.childEvents.push(event);
  } else {
    // 新しいルート実行として登録
    executions.set(event.toolCallId, createNewExecution(event));
  }
}

このモデルがあると、UI 側は childEvents を再帰的に展開するだけです。SDK から流れてくるイベントの種類(tool.execution_starttool.execution_completepermission_request など)がいくつあっても、同じしくみで束ねられます。

権限確認をチャットの流れの中で応答できるようにする

以前は権限確認も質問もすべて別ウィンドウ(モーダル)で表示していました。入れ子ツールが増えると、モーダルが割り込むたびに「今どの処理に対して許可を求められているのか」が分からなくなります。

今回は、対応する toolCallId が特定できる場合に限り、チャットパネル内に inline で表示するようにしました。

ユーザー: ファイルを整理して
  └─ run_subagent が実行中...
       ├─ list_dir: 完了
       ├─ read_file: 完了
       └─ ⚠️ 確認が必要
            不要ファイルを削除してよいですか?
            [今回だけ許可]  [このセッションは許可]  [キャンセル]

ツールの流れの中で応答できるので、「何のために許可しているか」が直感的に分かります。特定できない確認(SDK 側から起点が不明なもの)は従来どおりモーダルに残します。

中間イベントを履歴に残さない

「今見せる情報」と「後から復元する情報」は違う

入れ子処理を豊かに見せようとすると、逆にノイズが増えます。ストリーミング中の中間状態など「実行中にしか意味のない」イベント(ephemeral event)をそのまま SQLite に保存すると、再読み込み時に余計な情報が増えます。

今回は次のルールに統一しました。

イベントの種類 画面表示 トレースパネル セッション履歴(SQLite)
確定した結果イベント
中間状態(ephemeral)イベント

「今見せる情報」と「後から復元する情報」の境界を明確にしただけですが、これで再読み込み後の表示が生成時と大きくずれなくなりました。

SQLite への保存

-- messages テーブルに入れ子実行の情報を追加
ALTER TABLE messages ADD COLUMN nested_executions TEXT; -- JSON として格納

-- 送信後、最終的な assistant の返答を確定して保存
UPDATE messages
SET content = ?, nested_executions = ?
WHERE id = ?;

AI アプリっぽい派手さはありませんが、壊れやすいのは AI の生成よりセッション状態の整合性 だと実感しています。セッション再読み込みで会話内容が崩れると、ツールとして使い物にならなくなります。

テスト戦略

AI 機能は「手動で確認すればよい」と思いがちですが、状態の組み合わせが多くてすぐ崩れます。

今回は Playwright 用の fake manager に 3 種類のシナリオを追加しました。

// 未認証の状態でアプリを起動
await launchApp({ mode: 'auth-required' });

// GitHub CLI が見つからない状態をシミュレート
await launchApp({ mode: 'cli-unavailable' });

// 途中で権限確認が必要なツールフローをスクリプトで再現
await launchApp({ mode: 'scripted', script: permissionRequiredFlow });

これで「未認証のとき New Chat が無効になっているか」「権限確認が正しいツールに紐づいているか」を E2E で検証できます。

Vitest 側でも ChatPanelclient-manageripc-handlerssession-historypermission-managerSidebar のテストを広げ、入れ子実行と認証ガイダンスの回帰を固定しました。

ここまで作って感じたこと

GitHub Copilot SDK を土台にする利点は、AI ランタイムを自前で実装せずに済むことです。ただその分、アプリ側で問われるのは別のスキルでした。

  • 「使えないとき」をどう説明するか — ここが弱いとすぐに不信感につながる
  • 入れ子処理をどう文脈付きで見せるか — 情報が多くても文脈がないと役に立たない
  • どこで確認を求めるか — モーダルより処理の流れの中に置いた方が自然なことがある
  • 表示と保存の整合性 — 再読み込みで崩れないことは最低ライン

一言まとめ: AI アプリの難しさは「AI を呼ぶこと」ではなく、「AI の状態を人間が扱えるかたちで見せること」にある。

次にやること

今回の改善で、起動直後の不安定さと入れ子ツール実行の追跡性はかなり良くなりました。次は予定していた Structured Settings UX(設定画面の再設計)に戻ります。

残っている大きなテーマは次の 3 つです。

  • Structured Settings UX — 設定画面の再設計
  • live custom tool registry の wiring — カスタムツールの動的登録
  • .NET worker scaffold — C# 側 worker との接続

設定面を整えたうえで、今回の入れ子実行基盤をカスタムツール側にも広げていく流れです。

まとめ

  • 未認証と CLI 不在は分けて表示するだけで体験が変わるboolean 1 つで管理していた認証状態を細かく分類するだけで、ユーザーが自力で解決できるようになる
  • 入れ子実行は UI より先にデータモデルが必要parentToolCallIdrootToolCallId を持つだけで、任意の深さの入れ子を正しく再構成できる
  • 権限確認はツールの流れの中に置く — モーダルよりチャット内 inline の方が「何のために許可しているか」が伝わる
  • 中間イベントは保存しない — 「今見せる情報」と「後から復元する情報」を分ける原則が再読み込み時の整合性を守る

次は設定 UI を進めつつ、今回整えた入れ子実行基盤をカスタムツールプラットフォーム側に広げていく予定です。


よくある質問

Q: 入れ子実行の中間イベントは全て SQLite に保存していますか?

A: いいえ。ストリーミング中にしか意味のない一時的なイベント(ephemeral event)は SQLite に保存せず、確定した結果イベントのみを保存しています。これにより、セッション再読み込み時の表示崩れを防いでいます。

Q: Playwright テストで AI の応答はどうシミュレートしていますか?

A: Copilot SDK の代わりに fake manager を使い、認証状態やツール実行フローをスクリプトで再現しています。launchApp({ mode: 'auth-required' }) のようにシナリオを指定することで、E2E テストで各状態を検証できます。

Q: 認証状態は何種類に分類していますか?

A: 5 種類です。ready(正常)、auth-required(未サインイン)、cli-unavailable(GitHub CLI 不在)、sdk-unavailable(SDK 接続エラー)、error(その他)。それぞれに recommendedActions を持たせることで、UI がユーザーに適切な対処法を提示できます。


この記事が参考になったら「いいね」で応援お願いします!


📝 この記事は Zenn で最初に公開されました。
最新版はZennをご覧ください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?