Summary
packages/web/src/lib/types.ts hand-mirrors the server's ServerMessage frames, and the mirror has drifted: three of the queue frames omit the fields #728 added, and chat:killed_task declares two fields the server never sends. The runtime handlers work around the gap with inline as {…} casts, so TypeScript is not checking the attachment plumbing at all.
Evidence
chat:queued_flushed — web (packages/web/src/lib/types.ts:1269):
payload: { projectSlug: string; sessionId: string };
server (packages/server/src/ws-protocol.ts:555-574) additionally sends text?: string and attachments?: Array<{ id; filename; kind? }>.
chat:queued_state — web (:1279-1286) has projectSlug, sessionId, text, qid?, reason? but no attachments; server (ws-protocol.ts:588-617) sends attachments?: Array<{ id; filename; kind? }>.
chat:queued_returned — web (:1296):
payload: { projectSlug: string; sessionId: string; text: string };
server (ws-protocol.ts:633-647) also sends attachments?.
The handlers then cast straight past the declared type:
packages/web/src/lib/ws.ts:705-709 — const { sessionId, text, attachments } = msg.payload as { sessionId?; text?; attachments?: AttachmentRef[] }
packages/web/src/lib/ws.ts:734-740 — same shape for chat:queued_state
packages/web/src/lib/ws.ts:768-772 — same for chat:queued_returned
So the fields that carry a user's queued files across clients are read entirely through an assertion. If the server renamed attachments or changed the element shape, nothing in the web build would fail.
chat:killed_task — web (:1315) types the payload as Routing & { sessionId; summary; timestamp }. Routing (types.ts:1138-1148) contributes projectSlug, sessionId, jobId: string | null and seq?: number. The server's emit (packages/server/src/ws-turn.ts:848-856) sends only projectSlug, sessionId, summary, timestamp — no jobId (and it is an out-of-band hub.broadcast, so no per-turn seq either). The declared type claims a non-optional jobId that is always undefined at runtime.
Why it matters
The whole point of mirroring the frames client-side is to get a compile error when the wire contract moves. Here the mirror is stale on precisely the fields a recent feature (#728, queued attachments) added, and the casts mean the drift produced no signal. chat:killed_task fails in the other direction — the compiler will happily let code read payload.jobId as a string | null that is never present.
Suggested fix
- Bring the three queue payloads in line with
ws-protocol.ts (add text? / attachments?), then drop the inline as {…} casts in ws.ts:705-709, :734-740, :768-772 so the destructuring is type-checked.
- Type
chat:killed_task from what the server sends rather than Routing & — i.e. { projectSlug; sessionId; summary; timestamp }.
- Worth considering as the durable fix: generate or share the client union from
packages/server/src/ws-protocol.ts instead of maintaining a second hand-written copy. Related: the chat:injected gap filed separately.
Summary
packages/web/src/lib/types.tshand-mirrors the server'sServerMessageframes, and the mirror has drifted: three of the queue frames omit the fields #728 added, andchat:killed_taskdeclares two fields the server never sends. The runtime handlers work around the gap with inlineas {…}casts, so TypeScript is not checking the attachment plumbing at all.Evidence
chat:queued_flushed— web (packages/web/src/lib/types.ts:1269):server (
packages/server/src/ws-protocol.ts:555-574) additionally sendstext?: stringandattachments?: Array<{ id; filename; kind? }>.chat:queued_state— web (:1279-1286) hasprojectSlug,sessionId,text,qid?,reason?but noattachments; server (ws-protocol.ts:588-617) sendsattachments?: Array<{ id; filename; kind? }>.chat:queued_returned— web (:1296):server (
ws-protocol.ts:633-647) also sendsattachments?.The handlers then cast straight past the declared type:
packages/web/src/lib/ws.ts:705-709—const { sessionId, text, attachments } = msg.payload as { sessionId?; text?; attachments?: AttachmentRef[] }packages/web/src/lib/ws.ts:734-740— same shape forchat:queued_statepackages/web/src/lib/ws.ts:768-772— same forchat:queued_returnedSo the fields that carry a user's queued files across clients are read entirely through an assertion. If the server renamed
attachmentsor changed the element shape, nothing in the web build would fail.chat:killed_task— web (:1315) types the payload asRouting & { sessionId; summary; timestamp }.Routing(types.ts:1138-1148) contributesprojectSlug,sessionId,jobId: string | nullandseq?: number. The server's emit (packages/server/src/ws-turn.ts:848-856) sends onlyprojectSlug,sessionId,summary,timestamp— nojobId(and it is an out-of-bandhub.broadcast, so no per-turnseqeither). The declared type claims a non-optionaljobIdthat is alwaysundefinedat runtime.Why it matters
The whole point of mirroring the frames client-side is to get a compile error when the wire contract moves. Here the mirror is stale on precisely the fields a recent feature (#728, queued attachments) added, and the casts mean the drift produced no signal.
chat:killed_taskfails in the other direction — the compiler will happily let code readpayload.jobIdas astring | nullthat is never present.Suggested fix
ws-protocol.ts(addtext?/attachments?), then drop the inlineas {…}casts inws.ts:705-709,:734-740,:768-772so the destructuring is type-checked.chat:killed_taskfrom what the server sends rather thanRouting &— i.e.{ projectSlug; sessionId; summary; timestamp }.packages/server/src/ws-protocol.tsinstead of maintaining a second hand-written copy. Related: thechat:injectedgap filed separately.