安安~我是ChiYu~
昨天把唯讀失敗拆成「該停止」與「可以稍後重試」,也確認 Agent 不能一遇到錯誤就擅自換
動作。今天終於往前跨一步,讓它真的改變網站狀態。
不過第一次寫入,我沒有直接挑報名。報名會占用名額,一個不小心就不是 Demo,而是替其他
參加者製造困擾。先從可以復原的收藏開始,比較適合拿來確認寫入流程有沒有站穩。
今天仍沿用 v3-day-15。這個版本已具備活動詳情頁、get_event_details 與 save_event,所以
不用為了一題收藏測試再切出沒有 source 差異的里程碑。單篇閱讀時,先切到固定版本:
git switch --detach v3-day-15
npm ci
npm run dev
今天新增的是寫入與重複呼叫的實測證據,不是另一份 source。版本先說清楚,後面看到alreadySaved 從 false 變成 true 時,才知道它是同一版網站的冪等行為。
接著進入活動詳情頁,我在 Inspector 輸入:
把這個活動收藏,然後再做一次同樣的事。
Agent 真的連續呼叫兩次 save_event。如果後端每收到一次 request 就 append 一筆資料,畫面
應該會長出兩筆相同收藏。實際結果是第一次回傳 alreadySaved: false,第二次變成alreadySaved: true,畫面始終只有一筆,旁邊還留著讓人類反悔的 Undo。
這就是今天要驗收的冪等性:相同動作重複執行,最後狀態仍然相同。第一次建立收藏;第二次
發現目標狀態已成立,不再新增資料,只把這件事明確回報給呼叫端。
收藏不會占用活動名額,也可以取消,確實比正式報名安全。但「風險較低」不代表在前端切換
一個布林值就能交差。
save_event 至少要處理四件事:收藏屬於哪個 session、event ID 是否指向公開活動、重複呼叫
會不會多出第二筆,以及使用者能不能在畫面上看見結果並復原。

圖 1:收藏後的狀態與操作紀錄同步更新,旁邊也保留 Undo。
寫入結果若只存在 Tool result 裡,人類不知道網站被改了什麼;反過來,若只有畫面改字、
server 沒留下狀態,也只是演得很像成功。兩邊必須對得起來。
這支 Tool 的輸入很小,Agent 只需要傳目前活動的不透明 ID:
{ "event_id": "evt-webmcp-intro" }
我沒有讓它傳 user_id。使用者身分來自 server session,不是由 Tool body 自我申報。否則
Agent 只要送出下面這種資料,就可能把「我要收藏」變成「替別人收藏」:
{ "event_id": "...", "user_id": "admin" }
在 Tool schema 這一層,additionalProperties: false 會擋掉 user_id 之類的額外欄位。到了
HTTP route,server 只讀取 eventId 與 interactionMode,實際收藏仍寫進 cookie 對應的
session;request 沒有有效 CSRF token 時,直接回傳 403。
readOnlyHint: false 只是告訴 Agent「這支 Tool 會改變狀態」,不是通行證,也不會自動完成
身分驗證。真正的存取邊界仍在 HTTP 與 server-side state。
第一次收藏時,server 回傳:
{
"ok": true,
"code": "SUCCESS",
"data": {
"eventId": "evt-webmcp-intro",
"alreadySaved": false
},
"uiUpdated": true
}
同一個 session 再收藏同一場活動,結果變成:
{
"ok": true,
"code": "SUCCESS",
"data": {
"eventId": "evt-webmcp-intro",
"alreadySaved": true
},
"uiUpdated": true
}
第二次依然是 SUCCESS,因為使用者想要的狀態已經成立;alreadySaved: true 則告訴呼叫端
「這筆原本就在」。後端使用 Set 保存 event ID,再次加入相同值不會產生第二筆資料。
網路 timeout、模型重試,甚至使用者自己多說一次,都可能讓相同 request 再來一遍。前面把
冪等性守住,第二次敲門時才不會順便多搬一張沙發進來。
在題庫中,這個案例的編號是 REPEAT-01。程式層的驗收條件是:
同一個 session
同一個 event_id
連續呼叫 save_event 兩次
→ 收藏集合仍只有一筆
→ 第二次回傳 SUCCESS,且 data.alreadySaved = true
service test 與 browser test 先確認 server、session 和 UI 符合契約。接著才在 Inspector 執行
REPEAT-01,確認模型真的會從自然語言走進同一條路徑。
Agent 先用 get_event_details({}) 取得目前活動 ID,再連續執行:
save_event({ "event_id": "evt-webmcp-intro" })
save_event({ "event_id": "evt-webmcp-intro" })
| 呼叫 | code | alreadySaved | 畫面狀態 |
|---|---|---|---|
| 第一次 | SUCCESS |
false |
顯示已收藏 |
| 第二次 | SUCCESS |
true |
維持同一筆收藏 |

圖 2:Agent 的確呼叫兩次收藏,UI 仍維持同一筆,並讓使用者可以 Undo。
這次不是我在下方手動按兩次 Execute Tool。原始 Copy trace 包含中文 Prompt、Agent 自行
取得活動 ID、兩次 save_event、兩份 Tool result,以及最後的自然語言回答。畫面上方雖然
還留著 Inspector 預設英文示例句,Copy trace 裡的 userPrompt.message 只有本題中文,判讀
時以原始 JSON 為準。
自動測試回答「程式被重複呼叫時,系統是否安全處理」;Agent trace 補上「模型在這次對話中
是否真的選擇並呼叫兩次」。前者屬於 deterministic E2,後者是這次 invocation 的 E4。
這份 trace 沒有公開網址、commit 與 revision,無法單獨綁定不可變部署版本,也不能宣稱
乾淨環境重播的 E5。證據限制在這裡說清楚一次即可。
unsave_event?畫面可以 Undo,五支正式 Tool 裡卻找不到 unsave_event。這是刻意留下的產品邊界:收藏後,
人類可以立刻從可見介面復原;catalog 不需要把每個反向操作都公開給 Agent。
每多一支 Tool,就會多一組選擇、授權與測試情境。前幾天已經固定五支 Tool,我不打算為了
讓清單看起來更豐富,再偷偷塞進第六支。
目前設計已足以驗證低風險寫入的三件事:狀態屬於 session、重複呼叫不會複製資料,以及
人類看得到結果也能反悔。這比 Tool 數量更重要。
重播時可以照三步確認:
data.alreadySaved 變成 true,收藏清單仍只有一筆。user_id 不會成為授權依據,缺少 CSRF做完後,save_event 已經不只是「Agent 可以按收藏」:它有 session 歸屬、可辨識的重複
結果、可見 UI,也留了一條人類 Undo 的退路。
明天要碰的報名沒這麼客氣。它會真的占用一個名額,不能把今天的低風險寫入原封不動搬過去。
我會讓 Agent 把姓名與 Email 帶進可見表單,接著停在 CONFIRMATION_REQUIRED;除了畫面要
停下來,Network 裡也不能偷跑 registration POST。那顆煞車若只存在文案裡,可不算煞車。