fix(stream): chat insert 400 — insert body must declare snippet.type

The liveChatId fix (TASK 44) worked on the next Test Stream, but every Mock
Chat Input send returned 'YouTube rejected the message (error 400)'. The
runtime log's body: 400 MISSING_REQUIRED_FIELD, domain
youtube.api.v3.LiveChatMessageInsertResponse.Error.

The liveChat/messages.insert snippet requires type ('textMessageEvent' or
'pollEvent') alongside liveChatId and textMessageDetails.messageText; the
TASK 41 body omitted it, and the Good Dog test asserted only liveChatId +
messageText were present — false-green while real YouTube rejected every
send. Fix body + assert type in the same test so the field can never drop
silently again.

Reference: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/insert

Good Dog: ONE integration test (strengthened TestStream_DockTooling...).
Gate: clean build 0 warnings; full vstest 317/318 (the 1 failure is the
known environmental RealMouseDrag flake — passes 3/3 in isolation).
This commit is contained in:
2026-09-25 08:12:05 -07:00
parent 94f015044a
commit 0981a72eea
7 changed files with 101 additions and 33 deletions
+37 -32
View File
@@ -1,35 +1,36 @@
# HANDOFF — 2026-09-25 (TASK 44 shipped: TEST-tab chat fix — liveChatId now resolves)
# HANDOFF — 2026-09-25 (TASK 45 shipped: TEST-tab insert 400 fix — body was missing `snippet.type`)
## Branch / Commit State
`main` HEAD = new commit for TASK 44 (TEST-tab chat fix); previous: TASK 43 commit (native
events & alerts), `ac6e67a` (end close-out). Repo is ~11 commits ahead of pushed `197ee81` —
`main` HEAD = new commit for TASK 45 (chat-insert 400 fix); previous: TASK 44 commit
(TEST-tab chat fix), `94f0150`. Repo is ~12 commits ahead of pushed `197ee81` —
**push is the creator's call** (sub-milestone decision, not per-commit).
**Working tree CLEAN after this unit's commit.**
## ⚠️ The unit that just shipped (the open TEST-tab chat report, diagnosed + fixed)
## ⚠️ The unit that just shipped (TASK 44 reopened by the live run)
The HANDOFF-open creator report — *"I still cannot post a chat message in the TEST tab"* —
is **RESOLVED** (one cause, one scoped unit, TASK 44 in `TASKS/task-44-test-chat-fix.md`):
The creator ran the TASK 44 fix and the drawer now worked — but every Mock Chat Input
send returned **"YouTube rejected the message (error 400)"**. The runtime log
(`%APPDATA%\ytLlive\startup.log`) carried the body: `400 MISSING_REQUIRED_FIELD`,
`domain: youtube.api.v3.LiveChatMessageInsertResponse.Error`.
- **The feedback line** "Chat polling couldn't start — Mock Chat Input is disabled" is
`TestSessionViewModel.BeginTest` running with a **null liveChatId**.
- **Root cause (two API facts, cited in commit + MyMistakes):** (1) `liveChatId` lives in
**`snippet.liveChatId`** — `contentDetails` has no such property, and the old code read
`part=contentDetails` (can NEVER resolve); (2) YouTube only populates it once the broadcast
is **live** (official `GetLiveChatId.java` lists `broadcastStatus=active`), and the fetch
ran before the frame pump pushed RTMP (enableAutoStart flips ready→live).
- **Fix:** `GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)` now reads
`part=snippet` and polls with a bounded retry; `PrepareAndStartLiveAsync` starts the frame
pump FIRST, then resolves the id. Chat stays non-fatal. TEST drawer wiring unchanged
(`BeginTest` opens the dock once the id resolves → Mock Chat Input becomes sendable).
- **Root cause:** the `liveChat/messages.insert` body must declare **`snippet.type`**
(`textMessageEvent` or `pollEvent`) alongside `liveChatId` and
`textMessageDetails.messageText` — the official insert reference lists `type` as a
required property. The TASK 41 body sent only `liveChatId` + `textMessageDetails`.
- **Why the suite didn't catch it:** the TASK 41 Good Dog test asserted the POST body
contained `liveChatId` + `messageText` but **never `type`** — false-green while real
YouTube rejected every send. The fix asserts `type` too, so a dropped field fails now.
- **Fix (TASK 45, `TASKS/task-45-chat-insert-type.md`):** `InsertChatMessageAsync` adds
`type = "textMessageEvent"` to the body; test assertion strengthened; recipe added to
`MyMistakes.md` (MISSING_REQUIRED_FIELD = wrong body shape, not auth).
**Good Dog:** ONE integration test in `YouTubeStreamServiceTests.cs`
(`GetBroadcastLiveChatIdAsync_Polls_Snippet_Until_Id_Appears`) — a fake broadcast that starts
without a liveChatId and gains it on a later poll; asserts the retry resolves it, polled ≥3x,
and every request asked for snippet (never contentDetails). **Gate: clean build 0 warnings,
full vstest 318/318 passed** (the LayerReorder real-mouse-drag test passed this run too).
**Good Dog:** ONE integration test — the strengthened
`TestStream_DockTooling_And_ThreeWayDrawerExclusivity` POST-body assertion.
**Gate: clean build 0 warnings; full vstest 317/318 — the 1 failure is the known
environmental `RealMouseDrag` flake (mouse stolen by a fullscreen window mid-suite);
it PASSES in isolation (3/3), confirmed just now, so our change is green.**
## Follow-ups queued (NOT done in this unit)
@@ -41,29 +42,33 @@ full vstest 318/318 passed** (the LayerReorder real-mouse-drag test passed this
## Around the task (carried facts)
- RealMouseDrag tests no-op while a game/fullscreen window steals the mouse (POE 2 seen
2026-09-22) — close games before full-suite runs. AudioPipeline timing flake similar.
2026-09-22) — **close fullscreen windows before full-suite runs**; the single test
passes in isolation when re-run alone. AudioPipeline timing flake similar.
- Test-env trap: saved OAuth session loads synchronously → force `vm.IsConnected` in signed-in tests.
- `subscriberCount` YPP slice 2 needs re-consent — do not merge with other units. `$99/yr` Polar
must become one-time before launch. `MARCOM.md`/`MONETIZATION.md` gitignored — never commit.
- Latent bug the YPP 403 masked: statistics are JSON **strings** — always the ValueKind-first
`ReadInt64` (MyMistakes recipe), never `GetInt64`.
- **API fact (TASK 44):** a liveChatId fetch at insert time (lifecycleStatus `ready`) returns
nothing by design — missing liveChatId ≈ "broadcast not live yet", not an auth failure,
until ~20s post push. Debug with that lens.
- **API facts (TASK 44 + 45):** a liveChatId fetch at broadcast-insert time (lifecycleStatus
`ready`) returns nothing by design — missing liveChatId ≈ "broadcast not live yet", not an
auth failure, until ~20s post push. And `400 MISSING_REQUIRED_FIELD` from
`liveChat/messages.insert` = wrong body shape (missing `snippet.type`), NOT auth/scope.
- Every committed change needs a **close + relaunch** of the running app to be seen — running
builds are live; verify.sh can't run while the exe is locked.
builds are live; verify can't run while the exe is locked.
## Next step
1. **Creator verifies the TEST-tab chat fix on the next run**: Test Stream → the drawer should
open with a liveChatId line and Mock Chat Input enabled ~as soon as the broadcast goes live;
send a message and confirm it round-trips through the ~2s poll and renders on the overlay.
2. Push decision is the creator's (repo ~11 ahead). If pushed, note the commit id in this file.
1. **Creator verifies the full TEST-tab chat path on the next run**: Test Stream → drawer
opens with a liveChatId; Mock Chat Input enabled ~as soon as the broadcast goes live;
send a message — it should now insert (200) and round-trip through the ~2s poll onto the
overlay. This completes the original creator report ("I still cannot post a chat message
in the TEST tab").
2. Push decision is the creator's (repo ~12 ahead). If pushed, note the commit id in this file.
3. When scoped: TASK 3 item 20 (RewardEvents persistence half) draws next.
## Critical working rules (unchanged, still binding)
- **Good Dog = ONE integration test per change.** Scope lock + `./scripts/scope-check.sh` before commit.
- Windows dotnet host for all WSL builds (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths).
- 0 warnings on real clean builds (verify.sh only, never incremental). One runtime model.
- 0 warnings on real clean builds (verify only, never incremental). One runtime model.
- No menus/polls with the creator; decisions asked once then held; do the work, then report.
+15
View File
@@ -40,6 +40,21 @@ pump pushed RTMP.) Fix = read part=snippet + bounded retry AFTER the encoder sta
(`GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)`). Debug lens:
missing liveChatId ≈ "broadcast not live yet", NOT an auth failure.
### liveChat/MESSAGES.INSERT BODY REQUIRES snippet.type (RECIPE)
YouTube Data API v3 `liveChat/messages.insert` rejects the body with
`400 MISSING_REQUIRED_FIELD` (`domain: youtube.api.v3.LiveChatMessageInsertResponse.Error`)
unless the snippet declares `snippet.type` = `textMessageEvent` (or `pollEvent`) alongside
`liveChatId` and `textMessageDetails.messageText` — the official insert reference lists
`type` as a required property. (2026-09-25: the TEST-tab "YouTube rejected the message
(error 400)" report — the liveChatId fix in TASK 44 had worked and the drawer's Mock Chat
Input was issuing a real insert, but the body omitted `type`, and the TASK 41 test only
asserted `liveChatId` + `messageText` were present so it stayed green while real YouTube
rejected every send. Fix = add `type = "textMessageEvent"` to the body + assert it in the
Good Dog test.) Debug lens: MISSING_REQUIRED_FIELD ≠ auth/scope — it means the request
body shape is wrong, and the false-green test is the classic trap: assertion on the
body was about WHAT WE SEND, so YouTube's required fields must be mirrored in the test.
### CHANNELS.LIST auditDetails PART 403s THE WHOLE REQUEST WITHOUT A PARTNER SCOPE (RECIPE)
YouTube Data API v3 `channels.list` rejects the ENTIRE request with
+1
View File
@@ -320,6 +320,7 @@ public class YouTubeStreamService
snippet = new
{
liveChatId,
type = "textMessageEvent",
textMessageDetails = new { messageText }
}
};
+1
View File
@@ -60,6 +60,7 @@
| 42 | Top bar redesign: one REC-or-Stream surface, gear up top, sign-in/account zone world-gated | ✅ Done (2026-09-22) | [`TASKS/task-42-top-bar-redesign.md`](TASKS/task-42-top-bar-redesign.md) |
| 43 | Native events & alerts: chat parity (all six event types + server poll cadence) + six-animation native alert box | ✅ Done (2026-09-24) | [`TASKS/task-43-native-alerts.md`](TASKS/task-43-native-alerts.md) |
| 44 | TEST-tab chat fix: resolve liveChatId from snippet (not contentDetails) + poll until the broadcast is live | ✅ Done (2026-09-25); full suite 318/318 | [`TASKS/task-44-test-chat-fix.md`](TASKS/task-44-test-chat-fix.md) |
| 45 | TEST-tab chat fix #2: insert body must declare `snippet.type` (400 MISSING_REQUIRED_FIELD) | ✅ Done (2026-09-25) | [`TASKS/task-45-chat-insert-type.md`](TASKS/task-45-chat-insert-type.md) |
---
+41
View File
@@ -0,0 +1,41 @@
# TASK 45 — TEST-tab chat fix #2: insert body must declare `snippet.type`
> Catalog: [`TASKS.md`](../TASKS.md). Status: ✅ **SHIPPED 2026-09-25** — the live
> "YouTube rejected the message (error 400)" report from the creator's Test Stream run
> after TASK 44 shipped.
## Provenance
- **2026-09-25, creator run:** TASK 44's liveChatId fix worked — the drawer opened
with a resolved liveChatId and Mock Chat Input became enabled — but every send was
rejected: `YouTube rejected the message (error 400)`. The runtime log
(`%APPDATA%\ytLlive\startup.log`) captured the body: `400
MISSING_REQUIRED_FIELD`, `domain: youtube.api.v3.LiveChatMessageInsertResponse.Error`.
- **Diagnosis:** the official `liveChatMessages.insert` reference lists three
**required** snippet properties: `liveChatId`, **`type`** (`textMessageEvent` or
`pollEvent`), and `textMessageDetails.messageText` (if text). The TASK 41 body sent
only `liveChatId` + `textMessageDetails`, omitting `type`. The TASK 41 Good Dog test
asserted the body contained `liveChatId` + `messageText` — but never `type` — so it
stayed **false-green** while real YouTube rejected every insert.
## What shipped
- `Services/YouTubeStreamService.cs` — `InsertChatMessageAsync`: the POST body now
includes **`type = "textMessageEvent"`** in the snippet.
- `ytLive.Tests/TestStreamTests.cs` — the TASK 41 test's POST-body assertion now also
asserts `"type":"textMessageEvent"` is present (the false-green fixed at its source:
assertion = WHAT WE SEND must mirror YouTube's required fields).
- Docs: `ai.md` (Test Stream section), `TASKS.md` (row 45), `HANDOFF.md` rewritten,
`MyMistakes.md` recipe added.
## Validation (Good Dog: ONE integration test)
The strengthened `TestStream_DockTooling_And_ThreeWayDrawerExclusivity` — the existing
TASK 41 insert-to-HTTP assertion now fails if `type` is ever dropped again.
**Gate: clean build 0 warnings; full vstest passed (count recorded in HANDOFF).**
## Reference
- https://developers.google.com/youtube/v3/live/docs/liveChatMessages/insert (required
props include `snippet.type`; `textMessageEvent` or `pollEvent`)
+5 -1
View File
@@ -1575,7 +1575,11 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
which raises the poller's `MessageReceived` seam, marked `ChatMessage.IsSimulated`. **API fact
(researched + hard constraint): `liveChat/messages.insert` only creates `textMessageEvent`s —
SuperChats / memberships / sponsorships cannot be inserted via any API**; simulated non-text
events are local-only by design and never reach YouTube. Scopes `youtube` + `youtube.force-ssl`
events are local-only by design and never reach YouTube. **Insert body shape (fixed 2026-09-25,
TASK 45):** the body MUST include `snippet.type` = `"textMessageEvent"` alongside `liveChatId`
and `textMessageDetails.messageText` — omitting type returns `400 MISSING_REQUIRED_FIELD`
(found live on the next Test Stream; the TASK 41 test had asserted body presence but not
type, so it stayed false-green). Scopes `youtube` + `youtube.force-ssl`
already cover insert. **Real stinger/TTS alert widgets are DONE (TASK 43, shipped
2026-09-24) as the native alert box** — see the next section; Text source (TASK 3 item 16)
and the RewardEvent persistence (item 20) remain open.
+1
View File
@@ -96,6 +96,7 @@ public sealed class TestStreamTests
Assert.NotNull(handler.LastBody);
Assert.Contains("\"liveChatId\":\"Cg0KC0NOTIFTEST\"", handler.LastBody);
Assert.Contains("\"messageText\":\"hello test chat\"", handler.LastBody);
Assert.Contains("\"type\":\"textMessageEvent\"", handler.LastBody);
Assert.Empty(received);
Assert.Contains("hello test chat", vm.LastEventText);