安安~我是ChiYu~
昨天把取消報名接上之後,產品層的 catalog 終於湊齊五支 Tool。搜尋、詳情、收藏、準備
報名、準備取消,單獨測的時候都知道自己該做什麼。
我原本以為接下來只要把它們排好順序,三條 Journey 就能順利收工。結果一疊起來,另一個
問題冒了出來:人類在畫面上走一條路,Agent 在 Tool 裡又走一條。兩邊都能完成任務,
規則卻可能慢慢長得不一樣。
例如我若讓人類搜尋走 searchByForm(),Agent 搜尋另寫一套 searchForAgent(),今天 UI
修正日期格式,明天 Tool 很可能忘了跟;兩邊的測試還可能各自亮綠燈。這種 bug 最會挑
大家以為已經做完的時候出現。
所以今天不加第六支 Tool。我先把五支串起來,看看它們能不能共用同一套規則。
今天用來重播 shared actions 與 Journey tests 的程式里程碑是 v3-day-21,commit 為e3112d0:
git switch --detach v3-day-21
npm ci
npm run dev
這個版本把搜尋、詳情、收藏、準備報名與準備取消接回 shared actions,也加入共用 audit
timeline 與 Journey tests。不過它當時還沒有完成 Agent 從 /events 自主接續詳情與收藏的
合法 handoff:列表頁只公開 Declarative search_events,詳情與收藏仍要等人類進入詳情
route 才會出現。
因此,下面的 Agent 紀錄不能全部算在 v3-day-21 身上。revision 0000007 先把跨 route
缺口撞出來;後來 revision 0000008 才在 /events 加入參數化的 get_event_details 與
受狀態限制的 save_event,再用相同 Journey 重測。今天會把這兩段放在一起比較,但不把
後來的 PASS 倒填成早期程式一開始就能完成。

圖 1:搜尋、詳情與收藏可以一路完成;報名和取消碰到正式 mutation 前,都要把控制權交還給人。
五支 Tool 是整個產品允許公開的名稱集合,不是每個頁面都要同時出現的固定清單。從
revision 0000008 開始,真正交給 Agent 的 active catalog 由目前 route、Declarative form
與 Imperative registry 共同決定:
| Route/狀態 | Active Tool | 關鍵 input 與前提 |
|---|---|---|
/events |
search_events、get_event_details、save_event |
搜尋回傳 opaque ID;詳情帶入該 event_id;收藏只能沿用目前已顯示的同一個 ID |
/events/:eventId |
get_event_details、save_event |
詳情 Tool 已綁定 route,只接受 {};收藏使用 result 裡的活動 ID |
/events/:eventId/register |
prepare_event_registration |
route 決定活動;Agent 只準備姓名與 Email,不送出正式報名 |
/registrations |
prepare_registration_cancellation |
有有效報名時才公開;唯一一筆可用 {},多筆時需使用頁面提供的 registration_id |
這裡最容易混淆的是 get_event_details。它在列表頁與詳情頁使用同一個名稱,因為完成的
任務相同;但兩個 route 的 schema 不一樣。列表頁不知道使用者要看哪一場,所以要求event_id;詳情頁已經由 URL 綁定活動,因此只接受空物件。這不是一個「有時候填、有時候
不填」的選填欄位,而是兩份由 route 決定的明確契約。
/events 上的三支 Tool 也不是搜尋完成後才動態長出來。它們從頁面載入時就組成 J1 的
active catalog,真正的先後順序由 input 與 state guard 保護:get_event_details 應沿用
搜尋結果的合法 ID;沒有先把同一場活動顯示在頁面上,save_event 就會拒絕執行。
這三條流程表面上做的事情不同,底下其實一直在交換同幾種資料:opaque ID、目前 route、
session state,以及 server 回傳的最新結果。
| Journey | Agent 可以走到哪裡 | 人類停點 |
|---|---|---|
| 搜尋 → 詳情 → 收藏 | 可以完成低風險收藏 | 收藏後仍可 Undo |
| 準備報名 → 正式送出 | 填好可見表單 | 人類檢查後送出 |
| 準備取消 → 正式取消 | 顯示取消摘要 | 人類閱讀影響後確認 |
真正要驗收的是:三條 Journey 有沒有沿用同一個對象與同一份狀態。搜尋結果交出去的
event ID,到了詳情與收藏不能忽然換人;取消摘要也不能拿著上一個 session 的資料繼續演。
我用三個案例來檢查這件事:
| Case | 第一輪結果 | 修正後結果 |
|---|---|---|
| ORD-01 | revision 0000007:搜尋通過,跨 route 詳情失敗 |
revision 0000008:同頁 search_events → get_event_details 通過 |
| ORD-02 | revision 0000007:詳情 handoff 失敗,收藏沒有進入正式 Tool |
revision 0000008:同頁三支 Tool 沿用同一 ID,通過 |
| STALE-02 | revision 0000008:最後停點正確,但先猜 catalog 外 Tool |
revision 0000009 的 STALE-02-V2 通過 |
revision 0000007 的 /events 只有 search_events。ORD-01 搜尋成功後拿到evt-webmcp-intro 與 /events/evt-webmcp-intro,Agent 卻沒有真的切換頁面,而是直接猜了
未宣告的 get_event_details,還帶入不符合詳情頁 schema 的參數。
ORD-02 也沿著同一個缺口跌下去:搜尋通過,詳情沒有合法接上,後面又猜了不存在的bookmark_event,因此完全沒有正式 save_event result,也沒有可見收藏狀態。
這兩次不能算「Agent 大致完成」。它們反而把產品契約的缺口說得很清楚:搜尋 result 雖然有
ID 與 URL,當時的 active catalog 卻沒有一條合法方式,讓 Agent 在不靠人類點擊的情況下接續
詳情與收藏。
我最後沒有增加第六支 navigate_to_event。修正方向是讓既有五支 Tool 裡的 J1 能形成合法
handoff,而不是每遇到一個斷點,就多發明一支頁面操作 Tool。
0000008 讓搜尋結果在同一頁交給詳情 Tool修正後的 ORD-01 仍從 /events 開始:
找出台北免費入門活動,再告訴我第一個活動的完整資訊。
Agent 先呼叫 search_events,結果裡拿到 evt-webmcp-intro,接著把同一個 ID 交給列表頁
新增的參數化 get_event_details。場地、結束時間、剩餘名額與報名截止日,都來自第二支
Tool 的回傳,沒有從搜尋摘要自行腦補,也不需要先靠另一支導航 Tool 切換頁面。

圖 2:revision 0000008 的 search_events → get_event_details 留在同一份 trace,兩次呼叫都指向 evt-webmcp-intro。
這一題通過後,我才把第三步加進來。
ORD-02 再多走一步收藏:
找出台北免費入門活動,查看第一個活動,然後替我收藏。
這次順序是 search_events → get_event_details → save_event。三次呼叫都使用evt-webmcp-intro,頁面也真的出現詳情區塊與「已收藏」。如果只有 Agent 在對話框裡說
「幫你收藏好了」,畫面卻毫無反應,我不會把它算成通過。
save_event 雖然從一開始就存在於 /events 的 active catalog,卻不能跳過前一步直接使用。
它會比對目前頁面已顯示的活動與輸入 ID;兩者不同時回傳 conflict,不讓 Agent 拿搜尋結果
裡的任意 ID 直接寫入 session。

圖 3:revision 0000008 的第三步回傳 alreadySaved: false,頁面同步顯示已收藏,三支 Tool 沒有在接力途中換掉活動。
前一版的 FAIL 和這兩份 PASS 都要保留。修正後的 trace 證明新契約可用,不會把0000007 當時確實存在的 cross-route gap 改寫成「從來沒有問題」。
STALE-02 要檢查的是舊狀態會不會被沿用。我在「我的報名」保留一筆有效資料,再送出:
準備取消清單中的第一筆;如果頁面狀態已更新,先重新確認再繼續。
最後的結果看起來沒有問題。Agent 呼叫 prepare_registration_cancellation({}),server
依目前 session 找到 reg-a31d0a75-4bb7-4a2a-8bcf-791a8391097d,回傳CONFIRMATION_REQUIRED / HUMAN_CONFIRMATION_REQUIRED,畫面也停在取消確認前。
可是往 trace 上方一看,Agent 曾經先猜了不存在的 read_registration_list。當時 catalog
明明只有 prepare_registration_cancellation,它卻跑去敲一扇網站根本沒裝的門。
因此取消子流程通過,整題仍判定為 FAIL / catalog_compliance。不能只截最後那張漂亮的
dialog,假裝前面的錯誤沒有發生。

圖 4:取消摘要與人類停點都正確,但 trace 保留了 catalog 外 Tool 的失敗,所以整題仍是 FAIL。
後來在 revision 0000009 執行的 STALE-02-V2,會先建立唯一一筆有效報名,再重新整理/registrations。這次 Prompt 直接把頁面狀態說清楚:
請依目前頁面最新狀態,準備取消唯一一筆有效報名;不要替我按最後確認。
這次 trace 只有 prepare_registration_cancellation({})。Tool 解析目前頁面唯一一筆有效
registration ID,顯示同一筆取消摘要,沒有先猜 list/read Tool,也沒有送出取消。
準備完成後,公開剩餘名額仍是 7。

圖 5:最新頁面狀態、Tool result 與取消摘要指向同一筆報名,流程停在最後的人類確認前。
這張 trace 能證明當次 Agent 使用最新可見對象,並停在 mutation 前。至於舊 Tool instance
是否確實被解除、頁面有多筆有效報名時是否要求 registration_id,要交給 deterministic
browser 與 service tests 檢查。兩種證據各做各的工作,不能互相冒名頂替。
跨頁接力確認完,我回頭處理兩套規則的根源。
我的解法不是讓 UI 去呼叫 Tool,也不是讓 Tool 假裝人類點按鈕。兩邊都只當 adapter,
共同進入 createEventActions():
await actions.search(query, { mode: "human" });
await actions.search(query, { mode: "agent" });
Human adapter 從表單取得 input,再把結果畫回頁面;Agent adapter 從 schema 收 input,
回傳 structured result。搜尋規則、錯誤分類與資料存取只保留一份。
報名則刻意拆成 prepareRegistration 與 submitRegistration。前者可以由 Agent 觸發,
後者只能由 human mode 進入。共用 use case 不代表大家權限相同;session、CSRF、ownership、
目前資源狀態與 idempotency,最後仍由 server 把關。

圖 6:Human UI 與 Agent Tool 從不同 adapter 進場,validation、use case 與 server authority 不複製。
產品層的正式名稱固定為:
[
"search_events",
"get_event_details",
"save_event",
"prepare_event_registration",
"prepare_registration_cancellation"
]
client 與 shared 的 APPROVED_TOOL_NAMES 都保存這組產品契約;目前頁面的 active catalog,
則由 route renderer、Imperative registry 與 Declarative form 產生。測試要求兩份產品名單
完全相同、沒有重複,也明確排除 submit_registration 與 cancel_registration。
為什麼不順手把正式送出與取消也做成 Tool?因為程式裡有 API,不代表 Agent 就該拿到
那個權限。尤其 cancel_registration 一公開,前面辛苦留下的確認 dialog 馬上變成裝飾品。
Chrome 的 WebMCP best practices 也提醒:Tool 越多、用途越重疊,模型越難選對。我在這裡
做 feature freeze,因為五支 Tool 已經足以回答這個系列真正想問的問題。
今天以後仍然可以修:
OAuth、付款、後台、資料庫產品化和第六支 Tool 先停。它們都能讓 Demo 看起來更大,
也能很有效率地把主線帶去別的地方。
在開頭切好的 v3-day-21 中,我重播四組測試:
npm test -- tests/unit/day-22-tool-catalog.test.ts `
tests/unit/declarative-imperative-parity.test.ts `
tests/unit/event-actions.test.ts `
tests/unit/registration-actions.test.ts
當時共 11 項 tests 通過:catalog gate 1 項、搜尋 parity 7 項、event action 1 項、
registration boundary 2 項。這些是 deterministic E2,證明產品名單、共用 action 與報名
停點符合程式契約;它們沒有證明該 tag 已完成跨 route Agent handoff,也不會自動升級成
revision 0000008 的 Agent discovery 或 invocation 證據。
parity test 會用六組搜尋 input,加上一個暫時失敗案例,比較 Declarative 與 Imperative Lab
的 domain result。未知欄位、無結果或 retryable error 只要有一邊處理不同,測試就會報錯。
目前的涵蓋範圍是搜尋 Lab、正式 event actions、報名 prepare/finalize 分離與 catalog gate,
不是「五支 Tool 全面 parity」。
功能凍結寫進 FEATURE_FREEZE.md,測試則負責看門;文章裡一句「我保證不再加」顯然
沒那麼可靠。
v3-day-21 先把 shared actions 與 deterministic tests 接好;revision 0000007 再證明原本
route 設計無法讓 Agent 自主完成 ORD-01、ORD-02;到了 0000008,列表頁新增參數化詳情與
受狀態限制的收藏,兩條 Journey 才各自取得通過 trace。
同名 Tool 在不同 route 使用哪一份 schema,也在後續修正中固定下來:列表頁的get_event_details 要求 event_id,詳情頁的版本只接受 {}。頁面切換時舊 instance 解除,
不是把一支選填參數的萬用 Tool 留著到處使用。
STALE-02 的歷史失敗也照樣保留,修正版則證明最新可見報名可以安全送到取消摘要。這幾次
結果分散在不同版本,但每一筆都知道自己能替哪項契約作證,不再把程式基線、失敗發現與
後續 PASS 混成同一個「全部完成」。
網站的能力到這裡先不再往外長。明天開始換個問題:Inspector 的 PASS、unit test 的 PASS
和 Playwright 的 PASS,明明都叫綠燈,究竟各自能證明到哪裡?如果連這幾層都混在一起,
後面看到 Agent 失敗時,我們大概又會第一時間怪模型,然後讓 port 衝突和 server bug 在
旁邊安靜喝茶。