AIエージェントが呼んだtoolは失敗しているのに、ボタンはずっと「実行中」のまま。チャット末尾にはエラーメッセージもある。原因を探そうとして履歴を上から読み直すが、UIがどのイベントを受け損ねたのかは分からない。
この手の不具合を最後のerrorから逆算すると、見る範囲が広がりすぎる。
自分なら、ログを増やす前に再現専用routeを1つ作る。そこへ期待するイベント列を置き、実際の列が最初に外れたindexだけを出す。対象はまず次の3ケースで十分だ。
tool failure
retry after failure
approval required
CopilotKit Inspectorは、agentとfrontendの間を流れるイベントをアプリ内で観測し、failureを原因側のイベントへ結び付けて追える。保存した会話をPlaygroundへforkして別経路も試せる。ReactだけでなくVueとAngularにも対応し、接続面にはAG-UIを使う。
一方、長時間動くagentではmodel responseだけを見ても足りない。OpenAIのAgents APIも、sessionに加えてtoolやenvironmentをharness側で扱う設計になっている。実行が長くなるほど、frontendが最後のメッセージだけで状態を説明するのは無理が出てくる。
この記事ではInspectorの内部形式を再現しない。SDK固有のイベントを、アプリ内で比較するための小さなTraceEventへ寄せる。そのうえで3つのfixtureをどう切るか考える。
再現専用routeへ隔離する
本番画面へdebug用の分岐を足すと、再現条件まで既存stateに引っ張られる。削除しやすいrouteへ隔離したほうが扱いやすい。
route: /agent-ui-debug-fixture
cases:
- tool-failure
- retry-after-failure
- approval-required
fixture側で固定するのは次の項目だ。
- user input
- 疑似toolの応答
- 応答までの遅延
- approvalの有無
- 期待するイベント列
production APIへ接続したままでは、同じ失敗を何度も作れない。fixtureではtool adapterを差し替え、「必ず失敗する」「2回目だけ成功する」「承認を返すまで実行しない」を選べるようにする。
たとえばインターフェースはこのくらいでいい。
type FixtureMode =
| "tool-failure"
| "retry-after-failure"
| "approval-required"
type FixtureTool = {
execute(input: unknown, context: { attempt: number }): Promise<unknown>
}
function createFixtureTool(mode: FixtureMode): FixtureTool {
return {
async execute(_input, { attempt }) {
if (mode === "approval-required") {
throw new Error("fixture must stop before tool execution")
}
if (mode === "tool-failure" || attempt === 1) {
throw new Error("fixture tool failure")
}
return { ok: true }
},
}
}
approval-requiredでexecute()へ到達したら、fixture自体を失敗させる。これなら「承認待ちを表示した」という見た目だけで、裏ではtoolが動いていたケースを見逃しにくい。
SDKのeventをそのままfixtureへ固定しない
Inspectorが表示する内部event名や保存形式を、こちらで推測して型に写すべきではない。SDKの更新で名前が変わると、製品コードとは無関係なfixtureまで壊れる。
比較用には、アプリが必要とする情報だけを残す。
type TraceKind =
| "request_received"
| "tool_started"
| "tool_failed"
| "approval_required"
| "tool_completed"
| "ui_rendered"
type TraceEvent = {
runId: string
caseId:
| "tool-failure"
| "retry-after-failure"
| "approval-required"
attempt: number
correlationId: string
kind: TraceKind
}
これはCopilotKit、AG-UI、OpenAIの公式schemaではない。各SDKから届いた情報をadapterで正規化するためのapp-levelな型だ。
各fieldの役割も絞っておく。
-
runId: originalとforkを分ける -
attempt: retry前後を分ける -
correlationId: 同じ論理操作を追う -
kind: UIが判断に使う最小イベント
traceへpromptやtoken、tool payloadの全文を複製する必要はない。認証情報やユーザーデータを保存してしまうくらいなら、比較に使うIDとkindだけに絞るほうがいい。
最初の不一致だけを返す
イベント列を丸ごとdiff表示すると、結局また長いログを読むことになる。入口として欲しいのは最初のズレだ。
runIdとcaseIdは実行を識別するmetadataなので、列の比較からは外す。比較するのはattempt、correlationId、kindにする。
type ComparableTraceEvent = Pick<
TraceEvent,
"attempt" | "correlationId" | "kind"
>
function isSameEvent(
expected: ComparableTraceEvent | undefined,
observed: ComparableTraceEvent | undefined,
) {
return (
expected?.attempt === observed?.attempt &&
expected?.correlationId === observed?.correlationId &&
expected?.kind === observed?.kind
)
}
function firstDivergence(
expected: readonly ComparableTraceEvent[],
observed: readonly ComparableTraceEvent[],
) {
const length = Math.max(expected.length, observed.length)
for (let index = 0; index < length; index += 1) {
if (!isSameEvent(expected[index], observed[index])) {
return {
index,
expected: expected[index] ?? null,
observed: observed[index] ?? null,
}
}
}
return null
}
片方のevent数が少ない場合も、nullを含む不一致として返る。timestampは比較しない。固定したいのは時刻ではなく、単一操作内の順序と相関だ。
複数toolを並行実行する画面なら、全イベントを一列に押し込まない。先にcorrelationIdで分け、操作ごとに比較する。今回のfixtureは1ケース1操作に限定する。
Case 1: toolが失敗したあともrunningに残る
最初の期待列はこうする。
const correlationId = "profile-update"
const toolFailureExpected = [
{ attempt: 1, correlationId, kind: "request_received" },
{ attempt: 1, correlationId, kind: "tool_started" },
{ attempt: 1, correlationId, kind: "tool_failed" },
{ attempt: 1, correlationId, kind: "ui_rendered" },
] satisfies ComparableTraceEvent[]
疑似toolを必ず失敗させ、次の2項目を別々に見る。
- observed eventsは期待列と一致したか
- 最終UIはerrorとretry操作を表示し、
runningを解除したか
たとえばobservedの3件目がui_renderedなら、最初のズレはindex 2になる。画面にエラー文が出ていても、tool_failedを処理する経路が抜けた可能性が残る。
index: 2
expected.kind: tool_failed
observed.kind: ui_rendered
これは実行結果ではなく、不一致の読み方を示す例だ。実際の値はfixtureを走らせて埋める。
Inspectorでは同じrunのfailureを開き、そこへ至るagentとfrontendのイベントを確認する。Inspector上の名前をTraceKindと同一視せず、adapterの入力とapp側traceを見比べる。
Case 2: retryを同じ実行の二重完了にしない
retryで混ざりやすいのは「同じ操作」と「同じ試行」だ。
ユーザーから見れば同じprofile更新なのでcorrelationIdは維持する。ただし実行単位は別なので、最初をattempt: 1、retryをattempt: 2にする。
const retryExpected = [
{ attempt: 1, correlationId, kind: "request_received" },
{ attempt: 1, correlationId, kind: "tool_started" },
{ attempt: 1, correlationId, kind: "tool_failed" },
{ attempt: 1, correlationId, kind: "ui_rendered" },
{ attempt: 2, correlationId, kind: "request_received" },
{ attempt: 2, correlationId, kind: "tool_started" },
{ attempt: 2, correlationId, kind: "tool_completed" },
{ attempt: 2, correlationId, kind: "ui_rendered" },
] satisfies ComparableTraceEvent[]
このcaseでは、attempt 1の失敗を遅延させてからattempt 2を成功させる。確認したいのは、遅れて届いたattempt 1のeventがattempt 2の完了表示を上書きしないことだ。
UI更新側には少なくともactive attemptの判定が要る。
function canUpdateCurrentUi(event: TraceEvent, activeAttempt: number) {
return event.attempt === activeAttempt
}
もちろん実装ではcancelや並行toolも考える必要がある。ただ、このfixtureへ全部を持ち込むと失敗理由が増える。まずlate eventとretry成功だけをぶつけ、次の条件を確認する。
active attempt: 2
final UI: completed
attempt 1 late event: ignored for current UI
attempt 2 completion count: 1
attemptを持たずにretryボタンだけ追加すると、遅延eventが届いたときに「どちらの実行結果か」を判定できない。ここはチャット表示の工夫では直らない。
Case 3: approval前にtoolを起動しない
approval fixtureはもっと短い。
request_received
-> approval_required
-> stop
期待列も2件だけにする。
const approvalExpected = [
{ attempt: 1, correlationId, kind: "request_received" },
{ attempt: 1, correlationId, kind: "approval_required" },
] satisfies ComparableTraceEvent[]
UIは承認待ちを表示する。tool_started、tool_completed、成功表示が出たらfailだ。approve後やreject後の完全なstate machineは別fixtureへ分ける。今回は「承認前に止まったか」だけを見る。
この境界は、画面上のapproval panelだけ眺めても確認できない。疑似toolのexecute()へ到達していないことと、event列がapproval_requiredで止まったことの両方が必要になる。
originalとforkはrunIdを分けて比較する
再現できる失敗ができたら、保存会話をInspectorのPlaygroundへforkする。入力または疑似tool応答のどちらか1点だけを変える。
original runId: run-original
fork runId: run-fork-01
correlationId: profile-update
changed condition: attempt 1 tool response only
originalを上書きしない。forkをproduction replayやrollbackとも呼ばない。保存した会話から別経路を試すための分岐として扱う。
比較時にはrunIdを落とし、firstDivergence()へ渡す。
function toComparable(event: TraceEvent): ComparableTraceEvent {
const { attempt, correlationId, kind } = event
return { attempt, correlationId, kind }
}
const divergence = firstDivergence(
originalEvents.map(toComparable),
forkEvents.map(toComparable),
)
runIdまで比較するとindex 0で必ずズレる。それでは分岐条件の確認にならない。runの識別とevent内容の比較は分ける。
fork側で最終結果が成功へ変わっても、見るのは成功メッセージではない。最初に変わったevent、そのeventが変わった入力条件、以降のUI状態をセットで残す。
fixtureは実際のGenerative UIから1件選ぶ
空のチャット画面だけで試すと、agentとfrontendの境界が見えにくい。tool result card、approval panel、dynamic formのどれか1件をfixtureにする。
実装例やSDKを探す入口としては、生成AI UIデザインのリソース集のような分類済み一覧も使える。見つけたdemoのevent設計をそのまま正解にはせず、自分のアプリへ持ち込む時点でTraceEventへ変換する。
今回はprofile更新用のdynamic formを選んだ想定にする。それなら3ケースを同じUIで作れる。
- 保存toolが失敗し、formを再編集できる
- retry後に新しいattemptだけが完了する
- 更新内容の送信前にapprovalで止まる
fixtureごとに別の派手なUIを作るより、同じcomponentへ異なるevent列を流したほうが差を追いやすい。
結果表にnot-runとnot-observedを残す
まだ走らせていないcaseを空欄にすると、後から成功なのか未確認なのか分からなくなる。結果表には未実行と未観測を明示する。
| case | expected | observed | first divergent index | UI after failure | replay |
|---|---|---|---:|---|---|
| tool-failure | 4 events | not-observed | not-observed | not-observed | not-run |
| retry | 8 events | not-observed | not-observed | not-observed | not-run |
| approval | 2 events | not-observed | not-observed | not-observed | not-run |
使い分けはこうする。
-
not-run: fixture自体を実行していない -
not-observed: 実行したが、その観測点を取得できなかった -
none: expectedとobservedに不一致がなかった - 数値: 最初に不一致が出たindex
passは、event列とUI状態の両方を確認したときだけ付ける。Inspectorでfailureを見つけたことと、アプリ側の表示が正しいことも別判定にする。
Inspectorは答えではなく、adapterを照合する場所にする
Inspectorで見えるevent、アプリが保存するTraceEvent、画面の最終状態。この3つが一致して初めて、失敗経路を説明できる。
ただし、全部を永久保存する必要はない。fixture runの保持期間を短くし、payloadは最小化する。prompt、token、secret、tool response本文を無条件にtraceへ入れない。観測性を足した結果、機密情報の複製先を増やしたら本末転倒だ。
Automatic Learningのように会話からSKILL.md候補を作る機能もあるが、今回のdebug loopとは分ける。失敗経路を観測できたことと、その結果を次のagent動作へ採用することは別のレビューだ。
最後のerrorより、最初に外れたeventを見る
agent UIの不具合は、最後だけ見るとだいたい「失敗しました」に潰れる。その手前で起きているのは別々の問題だ。tool failureをadapterが落としたのか。古いattemptをreducerが採用したのか。approval前にtoolを起動したのか。
最初の不一致が取れると、次に読む場所も狭くなる。
- Inspectorにはeventがあるのにapp側traceにないなら、adapterを見る
-
tool_failedがtraceにあるのにUIがrunningなら、UI更新処理を見る - retry成功後にattempt 1へ戻るなら、active attemptの判定を見る
自分が先に実装するなら、全量ログの保存機能ではなく、1 routeとこの3つのfixtureだ。expectedとobservedを比べ、forkでもoriginalを残す。agentやSDKを替えたあとも、どこから挙動が変わったかを同じ方法で確認できる。