兩套測試同時全綠,可以是「這份規格從來沒被測過」的證據。

AG-UI 是一個把 agent 接進前端應用的協定,底下有兩套 client,一套 TypeScript、一套 .NET。兩邊的測試都過。可是兩邊對同一件事的處置是相反的:串流裡跑出一個 client 不認識的東西,該丟掉、該原樣傳到應用層、還是該讓整條 run 失敗,兩邊的答案不一樣。

測試沒抓到。因為測試根本不是為了抓這件事寫的。

They exist because the rules in the specification were, until now, only ever tested against the implementation that happened to hold them — which is how the two clients ended up disagreeing about what to do with something they do not recognise, with both test suites green.

這段話就寫在 spec/1.0/conformance/README.md 的開頭,解釋這整批東西為什麼會存在(2026-09-19 查 main 分支)。翻成人話:規格裡的規則,在那之前只被「剛好實作了它的那個實作」測過。

考卷跟答案是同一個人寫的。你照自己的實作寫測試,那份測試在問的是「我這次有沒有照我上次做的那樣做」,跟規格怎麼寫沒有關係。兩個人各寫各的,各自都能考一百分,直到有人把兩份答案卷疊在一起。

同一批 bytes,兩條 lane

補法很土。spec/1.0/conformance/streams/ 底下放了 68 個 JSON 檔(我用 gh api 列目錄數的,68 個 .json 加一份 MANIFEST.txt,2026-09-19 的 main 分支)。一個檔案就是一條事件串流,外加「規格要求任何 client 消費完它之後應該長什麼樣」。

同一批檔案在兩條 lane 上跑。TypeScript 那條用 aimock 把它當成真的 HTTP SSE frame 餵進 HttpAgent,.NET 那條把同樣那串 bytes 送過真的 SSE formatter 與 event converter。

關鍵在「同一批」。不是兩邊各自寫一份意思差不多的測試,是同一個位元組序列進去,比對兩邊吐出來的東西。

下面所有關於行為的敘述,來源都是這個 repo 裡的 README、fixture JSON 與 proto 檔的逐字閱讀。我沒有跑過這 68 個 fixture,沒有裝過任何一套 AG-UI SDK,也沒有送出過一次事件串流。

一個不會失敗的測試,比沒有測試更糟

每個 fixture 有一個必填欄位叫 kill

1
2
3
4
5
6
7
8
9
10
{
"name": "unknown-event-dropped", // must equal the file name
"area": "processing", // groups the test output
"description": "one line: what this proves",
"specPage": "/spec/1.0/basic/processing",
"kill": "the one-line change to the implementation that must make this fail",

"stream": [ /* raw event objects, sent verbatim */ ],
"expect": { /* … */ }
}

README 對這個欄位的說明只有三句:

Name the single change to the implementation that must make this fixture fail. It is a required field because a fixture that cannot fail is worse than no fixture — it reads as coverage while proving nothing. Before you commit one, make that change locally and watch it go red.

最後一句才是整件事的重量所在。提交之前,你要真的在本機把那一行改下去,親眼看它變紅。

我的立場是這一條比「再多寫十個測試」值錢得多,而且便宜得多。值錢的地方不在測試本身,在你寫 kill 的那五分鐘裡會發生什麼事。

unknown-event-dropped 這個 fixture 的 kill 逐字寫了什麼:

return of(event) instead of EMPTY from the !isRecognizedEvent branch in enforceEvents — the warning is still emitted and the run still completes, so outcome, warnings, messageCount and messages all stay green and only eventTypesAbsent/eventTypes catch it: FUTURE_EVENT reaches application code.

它不只寫了怎麼弄壞,還寫明了弄壞之後哪幾個斷言照樣是綠的outcome 綠,warnings 綠,messageCount 綠,messages 也綠。四個欄位全部無感。只有 eventTypeseventTypesAbsent 抓得到,因為那個叫 FUTURE_EVENT、不該送到應用層的東西真的送到了。

順帶補個座標:protobuf 那邊的 EventType enum 目前有 31 個值,編號 0 到 30,而且是 append-only 的。FUTURE_EVENT 不在裡面,它是 fixture 故意捏出來、模擬「未來版本才有的事件」的東西。

kill 的過程,就是在逼你發現「我以為在測的那幾個欄位其實測不到」。測試是綠的,沒有人會去看它為什麼綠,也就沒有別的機制會逼你走這一遭。

什麼情況會讓我收回上面那句:kill 要求你指得出「實作裡的哪一行」。如果那個測試斷言的對象根本不在你的程式碼裡,例如打第三方 API、讀環境餵進來的資料,你寫不出那一行,硬填就變成造句。那種測試該留著,但不要假裝它有 kill

fixture 紅了,不一定代表誰不合規

So a fixture failing does not always mean a client is non-conforming — it means a client changed. That is the point of a regression suite, and it is why kill names a change rather than a rule.

這句把整份語料庫的定位講死了。它是第一方 client 的回歸測試,不是一張合規認證。

它同時斷言兩種性質不同的東西。一種是規格明文的 MUST 與 MUST NOT,違反了那個 client 就是不合規。另一種是規格留白的地方「這兩套 client 選擇怎麼做」:規格說 consumer SHOULD 對自己 strip 掉的東西發警告,他們的 client 會警告,於是 fixture 把這個選擇釘住。

規格對「乾淨的串流要不要保持安靜」則是什麼都沒說,conformant-run-is-quiet 照樣要求它安靜。README 給的理由很實在:一個開始對合法流量發警告的容忍度 regression,沒有別的守門抓得到。那個 fixture 的 kill 只有一句話,make any stage warn about legal traffic — the guard no other fixture provides

一個斷言「與規格相反」的測試

unknown-enum-value-role-fatal

TypeScript 那條 lane 把封閉字串集合當成 leaf 在檢查,所以集合外的成員會讓整條 run 直接 fatal。規格要的是把它 strip 掉。兩邊對不起來,而他們選擇把現狀釘進測試裡,沒有釘規格。

這個 fixture 的 description 開頭就是大寫的 ADMITTED GAP:

ADMITTED GAP: an unrecognised member of a closed string set — a message role — is fatal today, where the specification says it should be stripped; this pins the reference implementation’s actual behaviour so the divergence cannot change unnoticed

README 解釋為什麼要這樣搞:

It asserts the opposite of the rule, on purpose, so that closing the gap is a deliberate act rather than a silent one.

補洞因此變成一個有人簽名的動作。你要修掉那個 gap,就得先讓這個測試變紅,然後主動走過去改它。

它的 kill 不是一句話,是一整段,語氣像在對未來某個要動這段程式碼的人講話:

teach stripAgainst to treat a ZodEnum as a closed set and DROP a value outside it — which is the RIGHT fix, and it makes this fixture fail. That is deliberate: this fixture pins today’s gap, not the rule. Anyone landing that fix MUST update this fixture to expect a stripped /role with a warning AND delete the <Note> on docs/spec/1.0/basic/processing.mdx that admits the gap, in the same change. A silent ‘fix’ that leaves the Note in place is what this fixture exists to catch.

一個把 Note 留在原地的「安靜修好」,正是這個測試存在要抓的東西。

同一段 kill 裡還記了一個已經踩過的坑。這個 fixture 早先的版本,串流結尾是訊息還開著,RUN_FINISHED 來的時候那則 message 沒被關掉。後果是這樣:就算有人真的把 role 改成 strip 掉,那條 run 還是會失敗,失敗在沒關的訊息上。fixture 繼續綠,綠的理由跟 enum 一點關係都沒有。

(It used to end with the message still open, so stripping the role would have left the run failing anyway, on the open message, and this fixture would have gone on passing for a reason that has nothing to do with enums.)

現在的版本把訊息開、填、關整套跑完,讓那個不認識的 role 變成唯一能弄壞這條 run 的東西。串流裡那個 role 的值是 "narrator",而同一則訊息的內容逐字長這樣:

1
2
3
4
5
{
"type": "TEXT_MESSAGE_CONTENT",
"messageId": "m-unknown-role",
"delta": "the role above is the only defect in this stream"
}

測試資料自己把「這條串流唯一的缺陷在哪」寫在內容裡。

空陣列不是「沒有警告」

插一個跟主線無關、但看了會冒冷汗的東西。

兩條 runner 都把 warnings 讀成「這幾個字串,每一個都要出現在某則警告裡」。所以 "warnings": [] 什麼都沒主張。它不代表「這條 lane 不准有警告」,它做的事情是把繼承下來的基準期望整個鬆開。

"warnings": [] does not mean “no warnings”. … an empty list asserts nothing at all — it only lifts the base expectation.

同一個陷阱適用於 "request": {},以及任何空的 subset:空物件匹配每一個物件。

搜尋框空著的時候,回來的不是零筆,是全部。斷言也一樣,條件寫空,符合的就是所有東西。真的要求安靜,欄位是 noWarnings: true

還有兩條同族的規定。outcome 單獨不算一個斷言,因為每個 fixture 都有 outcome,而且幾乎都寫 "completed",所以 corpus gate 要求每條 lane 至少要再有一個有效的 key。凡是某條 lane 的 outcome 解析成 "failed" 的,一定要配 errorContainsrunError:光寫一個 "failed",任何一種拒絕都能滿足它,包括跟這個 fixture 名字毫無關係的那種拒絕。

怎麼證明一樣東西「不見了」

subset 比對有個結構性的盲點,它表達不了缺席。messagesstate 都是 subset 比,你只能說「這幾個 key 要等於什麼」,說不了「這個 key 不准存在」。

他們的解法很漂亮:換一個錯的實作做不出來的形狀。

要證明第二個 STATE_SNAPSHOT 是整個取代而非合併,就讓那個 snapshot 是一個 root-level 的陣列,["only-this"]。任何 merge 都做不出這個形狀。state-snapshot-replaceskill 逐字是:

make STATE_SNAPSHOT merge its snapshot into the current state (state = { ...state, ...snapshot }) instead of assigning it

改成 merge,出來的會是物件不是陣列,斷言立刻紅。順帶還多證明了一件事,state 可以是任何 JSON 值,不必是物件。

要證明一件事沒發生,逐一排查永遠還差一個角落。反過來布置就簡單了:讓那件事只要發生過,就一定留下看得見的痕跡。封條是這樣用的,不必把房間翻一遍,看一眼封條還在不在。

同一招還有兩個變形。要證明 metadata 的 merge 不是遞迴的,給某個 key 一個陣列值然後改它的長度(["one","two"] 換成 ["three"]),陣列是逐長度嚴格比對的,深層 merge 會紅。要證明一個 list 元素被丟掉了,就斷言整個陣列。

數到一半,發現有東西根本沒在跑

這份語料庫存在的理由之一,README 寫得很白:規格說一方 MAY 為了舊 peer 做降版,他們出了幾個 era shim,而這件工作存在的部分原因就是不讓任何一個 shim 沒被測到。

It says a party MAY downgrade for an older peer; ours ship four era shims, and this ticket exists partly so that none of them survives untested.

四個。同一份 README 往下捲,Remaining version gates 那節寫的卻是三個,而且下一句直接把第四個劃掉:

There are three version-gated compatibility shims: 0.0.39 downgrades content for old peers, 0.0.45 translates retired THINKING_* shapes, and 0.0.57 downgrades subagent events and attribution. Only the 0.0.39 and 0.0.57 gates can be tested here.

[…] There is no 0.0.47 era shim or gate.

streams/ 底下以 era- 開頭的檔案有六個,其中四個的檔名帶舊版號(0-0-390-0-450-0-470-0-57),另外兩個是 era-current-peer-*era-pinned-peer-*,檔名沒有版號。光數檔名會數出四個「長得像 shim 的東西」,但 era-0-0-47-upgrades-binary-content 對應的那個並不是 shim:舊的 binary content 在每一次對外的 run 都會被升級,跟 peer 的協定版本無關。那個 fixture 保留歷史檔名與 peer pin,做的是另一件事,檢查 payload 有沒有被完整保住。

剩下三個裡面還有一個更妙的。README 有一節的標題叫 A shim with no fixture。0.0.45 那個 shim 負責把退役的 THINKING_* 形狀翻譯成 reasoning,可是永遠開著的相容邊界也在做同一件事,而且跑得更內層,處理的是完全相同的五種事件、輸出也相同。出貨的 pipeline 裡,那個 shim 從來沒看過一個 THINKING_* 事件。

證據是 kill 欄位自己寫的:

disable BOTH translators of the retired THINKING_* shapes … Either alone leaves this green: the boundary runs innermost and converts first, so the shim never sees these events in the shipped pipeline.

單獨關掉任何一個,測試都還是綠的。兩個都關才會紅。

所以 era-0-0-45-thinking-translated 釘住的是「翻譯這件事」,不是那個 shim。shim 是跑不到的死碼。README 處理這件事的方式我很欣賞:它沒有假裝測到了,也沒有把 fixture 刪掉,而是把結論當成「一個關於 client 的發現」寫進語料庫自己的文件裡。

一開始只是想確認四個 shim 都有被測到。數完的結果是,其中一個從來就不是 shim,另一個是永遠跑不到的死碼。

一份規格有沒有被測過,只有第二個實作看得出來

一個測試的價值不在它現在是綠的,在於你說得出哪一行改動會讓它變紅。說不出來,它就只是覆蓋率。

AG-UI 是寫到第二套 client 才發現自己踩在哪裡的。在那之前,規格的每一條規則看起來都有測試保護著,而且兩份測試確實都綠。

如果你手上也有一份只有一個實作的規格,這題你今天問不出答案——要等到有人照著它再寫一遍,才知道當初測的是規格,還是那個實作。而那一天到來的時候,兩邊的測試都會是綠的。