diff --git a/bun.lock b/bun.lock index 7281e5d2..a3e8c026 100644 --- a/bun.lock +++ b/bun.lock @@ -482,6 +482,7 @@ "@corbits/api-query": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/context-menu": "workspace:*", + "@corbits/error-sink": "workspace:*", "@corbits/icons": "workspace:*", "@corbits/inference-settings": "workspace:*", "@corbits/preferences": "workspace:*", @@ -3445,6 +3446,16 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@corbits/artifact-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/bench-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3467,6 +3478,10 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], + "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + + "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/packages/chat-ui/package.json b/packages/chat-ui/package.json index 43a209e7..753f8ae7 100644 --- a/packages/chat-ui/package.json +++ b/packages/chat-ui/package.json @@ -18,6 +18,7 @@ "@corbits/api-query": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/context-menu": "workspace:*", + "@corbits/error-sink": "workspace:*", "@corbits/inference-settings": "workspace:*", "@corbits/preferences": "workspace:*", "@corbits/presence": "workspace:*", diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 5ac17d37..d8077da6 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -250,17 +250,26 @@ export function composerPlaceholderFor( * `POST`/`GET` routes with a different shape). */ const STREAMING_REPLY_ITEM_ID = "streaming_reply"; +/** The workbench's first agent participant — the best available + * attribution for a synthetic timeline item that has no real sender of + * its own (`chat.agent` events carry none, and a client-side timeout + * notice never had a server-issued sender to begin with). Workbenches + * with more than one invited agent are a known approximation here, not a + * regression — today's non-streaming refetch has the same "which agent + * replied" gap until the persisted message's real sender lands. */ +function firstAgentParticipant( + participants: readonly ParticipantRecord[], +): ParticipantRecord | undefined { + return participants.find((participant) => + isAgentAddress(participant.address), + ); +} + /** * Folds the active turn's in-progress reply onto the end of the timeline, * exactly the way `mergePendingSends` folds this reader's own optimistic * sends — except this synthetic item is the *other* side's message, so it - * needs a sender to attribute it to. `chat.agent` events carry no sender - * (the raw `InferenceEvent` union has no such field, see - * `streaming-reply.ts`), so this picks the workbench's first agent - * participant as the best available attribution; workbenches with more than - * one invited agent are a known approximation here, not a regression — - * today's non-streaming refetch has the same "which agent replied" gap - * until the persisted message's real sender lands. + * needs a sender to attribute it to (see `firstAgentParticipant`). */ export function mergeStreamingReply( items: readonly TimelineMessageItem[], @@ -279,9 +288,7 @@ export function mergeStreamingReply( ) { return items; } - const agent = participants.find((participant) => - isAgentAddress(participant.address), - ); + const agent = firstAgentParticipant(participants); if (agent === undefined) return items; return [ ...items, @@ -303,22 +310,38 @@ const REPLY_TIMED_OUT_ITEM_ID = "reply_timed_out_notice"; * Appends an honest inline notice once `useStreamingReply`'s own backstop * (`PENDING_REPLY_CLEAR_MS`) has fired — a turn that opened but never got a * single token and never closed out either, so the reader was left staring - * at a typing indicator that just vanished with no explanation. Renders - * through the same event-line path `mergeStreamingReply`'s bubble and every - * other system line already use — no new CSS, no new item shape. + * at a typing indicator that just vanished with no explanation. This is + * the same class of failure `postUndeliveredNotice` (`@corbits/chat`) + * already gives an honest, actionable backstop to when the dispatch fails + * loud enough for the server to see it — a cold-waking agent that never + * streams a token back fails silently instead, so before CL-6677 this + * synthetic notice rendered as a bare quiet event line with no ref id and + * no Retry. It now carries a `turnFailed` text part exactly like the + * server's own notice (`replyTimedOutRefId` is minted by `reportError` at + * the moment `useStreamingReply`'s timer fires), so it renders through + * `FailedTurnStrip` — same ref-quotable copy, same Retry action — instead + * of a second, weaker backstop living beside the real one. */ export function appendReplyTimedOutNotice( items: readonly TimelineMessageItem[], - replyTimedOut: boolean, + replyTimedOutRefId: string | null, + participants: readonly ParticipantRecord[], ): readonly TimelineMessageItem[] { - if (!replyTimedOut) return items; + if (replyTimedOutRefId === null) return items; + const agent = firstAgentParticipant(participants); return [ ...items, { id: REPLY_TIMED_OUT_ITEM_ID, createdAt: new Date().toISOString(), - parts: [{ kind: "event", event: "chat.reply-timed-out", data: {} }], - sender: { name: null, address: "" }, + parts: [ + { + kind: "text", + text: `${CHAT_STRINGS.replyTimedOutNotice} (ref ${replyTimedOutRefId})`, + turnFailed: true, + }, + ], + sender: { name: null, address: agent?.address ?? "" }, }, ]; } @@ -667,7 +690,7 @@ function ChatWorkspaceInner({ useTypingIndicator(currentUser?.principalId, activeWorkbenchId); const { streamingReply, - replyTimedOut, + replyTimedOutRefId, handleStreamEvent: handleStreamingReplyEvent, noteAwaitingReply, resumeFromTurn, @@ -1282,7 +1305,8 @@ function ChatWorkspaceInner({ streamingReply, activeWorkbench?.participants ?? [], ), - replyTimedOut, + replyTimedOutRefId, + activeWorkbench?.participants ?? [], )} participants={activeWorkbench?.participants ?? []} {...(currentUser !== undefined ? { currentUser } : {})} diff --git a/packages/chat-ui/src/streaming-reply.ts b/packages/chat-ui/src/streaming-reply.ts index 99b8bf89..8a3ec628 100644 --- a/packages/chat-ui/src/streaming-reply.ts +++ b/packages/chat-ui/src/streaming-reply.ts @@ -14,6 +14,7 @@ import { useEffect, useRef, useState } from "react"; import { isAgentAddress } from "@corbits/chat/mentions"; +import { reportError } from "@corbits/error-sink"; import type { ParticipantRecord } from "./api"; import { displayNameFromHandle } from "./timeline"; @@ -262,12 +263,17 @@ export function useStreamingReply( minVisibleMs: number = TYPING_INDICATOR_MIN_VISIBLE_MS, ): { readonly streamingReply: StreamingReplyState; - /** True once the backstop above has fired for the turn just cleared — - * the host's cue to render `CHAT_STRINGS.replyTimedOutNotice` rather - * than silently dropping back to no indicator at all. Reset on the - * next workbench switch, stream event, or awaited reply, same lifecycle - * as `streamingReply` itself. */ - readonly replyTimedOut: boolean; + /** Set once the backstop above has fired for the turn just cleared — a + * `reportError` refId (CL-6677) the host's honest notice quotes, the + * same "ref id + Retry" treatment `postUndeliveredNotice` + * (`@corbits/chat`) gives a dispatch failure that surfaces server-side. + * This is the same class of failure with no server signal at all (a + * cold-waking agent that never streams back a token), so it deserves + * the same backstop rather than the ref-less, action-less notice this + * used to render. `null` when idle. Reset on the next workbench switch, + * stream event, or awaited reply, same lifecycle as `streamingReply` + * itself. */ + readonly replyTimedOutRefId: string | null; readonly handleStreamEvent: (eventType: string, data: unknown) => void; readonly noteAwaitingReply: () => void; /** See `resumeFromTurn`'s own doc comment below. */ @@ -277,13 +283,15 @@ export function useStreamingReply( } { const [streamingReply, setStreamingReply] = useState(null); - const [replyTimedOut, setReplyTimedOut] = useState(false); + const [replyTimedOutRefId, setReplyTimedOutRefId] = useState( + null, + ); const pendingSinceRef = useRef(null); const holdTimerRef = useRef | null>(null); useEffect(() => { setStreamingReply(null); - setReplyTimedOut(false); + setReplyTimedOutRefId(null); pendingSinceRef.current = null; if (holdTimerRef.current !== null) { clearTimeout(holdTimerRef.current); @@ -307,12 +315,22 @@ export function useStreamingReply( // timer) the instant `streamingReply` changes, so this callback only // ever runs while it's still the same pending reply it was armed // for — no need to re-check identity here. + const refId = reportError( + new Error( + `Reply timed out: no token received within ${clearMs}ms` + + (workbenchId !== null ? ` for workbench ${workbenchId}` : ""), + ), + { + operation: "chat.replyTimedOut", + ...(workbenchId !== null ? { roomId: workbenchId } : {}), + }, + ); setStreamingReply(null); - setReplyTimedOut(true); + setReplyTimedOutRefId(refId); pendingSinceRef.current = null; }, clearMs); return () => clearTimeout(timer); - }, [streamingReply, clearMs]); + }, [streamingReply, clearMs, workbenchId]); function commitReply( next: StreamingReplyState, @@ -348,7 +366,7 @@ export function useStreamingReply( } function handleStreamEvent(eventType: string, data: unknown) { - setReplyTimedOut(false); + setReplyTimedOutRefId(null); setStreamingReply((current) => commitReply( nextStreamingReplyState(current, { eventType, data }), @@ -358,7 +376,7 @@ export function useStreamingReply( } function noteAwaitingReply() { - setReplyTimedOut(false); + setReplyTimedOutRefId(null); setStreamingReply((current) => commitReply(openPendingReply(current), current), ); @@ -378,7 +396,7 @@ export function useStreamingReply( runningTurn: { readonly textSnapshot?: string | null } | null, ) { if (runningTurn === null) return; - setReplyTimedOut(false); + setReplyTimedOutRefId(null); setStreamingReply( (current) => current ?? hydrateStreamingReplyFromTurn(runningTurn), ); @@ -386,7 +404,7 @@ export function useStreamingReply( return { streamingReply, - replyTimedOut, + replyTimedOutRefId, handleStreamEvent, noteAwaitingReply, resumeFromTurn, diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index 3d09cbf3..26160143 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -634,8 +634,6 @@ export function friendlyEventText( } return CHAT_STRINGS.eventSettingsChanged; } - case "chat.reply-timed-out": - return CHAT_STRINGS.replyTimedOutNotice; case "block.response": { const kind = data !== undefined ? data.kind : undefined; return kind === "poll" diff --git a/packages/chat-ui/test/message-grouping-and-failed-polish.test.tsx b/packages/chat-ui/test/message-grouping-and-failed-polish.test.tsx index 75a27740..cc9f4e94 100644 --- a/packages/chat-ui/test/message-grouping-and-failed-polish.test.tsx +++ b/packages/chat-ui/test/message-grouping-and-failed-polish.test.tsx @@ -184,29 +184,59 @@ describe("failed pending message's inline recovery affordance", () => { }); }); -// CL-6252 #6: the reply backstop's synthetic event item renders through -// the same event-line path every other system line uses, with an honest -// message rather than the strip just silently vanishing. -describe("the reply-timed-out notice renders as an event line", () => { - test("shows the honest 'no reply arrived' copy", async () => { +// CL-6677: the client-side reply-timeout backstop (a cold-waking room — +// PR #327's defer-to-wake path — that never streams a single token back) +// used to render as a bare quiet event line: no ref id, no Retry. It now +// carries a `turnFailed` text part exactly like the server's own +// undelivered-turn notice (`postUndeliveredNotice`, CL-6308/CL-6644), so +// it renders through the same `FailedTurnStrip` — ref id quotable, Retry +// wired — instead of a second, weaker backstop with no actions. +describe("the reply-timed-out notice gets the same ref+Retry treatment as the server-side backstop", () => { + test("shows the honest 'no reply arrived' copy with a quotable ref id, through FailedTurnStrip", async () => { const items: MessageItem[] = [ { id: "notice_1", createdAt: "2026-01-01T00:00:00.000Z", - parts: [{ kind: "event", event: "chat.reply-timed-out", data: {} }], - sender: { name: null, address: "" }, + parts: [ + { + kind: "text", + text: "No reply arrived — the agent may be unavailable. (ref mt4ewrje-zvbmti)", + turnFailed: true, + }, + ], + sender: { name: null, address: "myra@agents.example" }, }, ]; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); await act(async () => { - root?.render(); + root?.render( + , + ); }); - expect(container.querySelector(".chat-event-line")?.textContent).toContain( - "No reply arrived — the agent may be unavailable.", - ); + const strip = container.querySelector(".chat-turn-failed"); + expect(strip).not.toBeNull(); + expect(strip?.textContent).toContain("didn't reply"); + + const retryButton = container.querySelector(".chat-turn-failed-retry"); + expect(retryButton).not.toBeNull(); + + act(() => { + container + ?.querySelector(".chat-turn-failed-disclosure") + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect( + container.querySelector(".chat-turn-failed-detail")?.textContent, + ).toContain("(ref mt4ewrje-zvbmti)"); + + // Never the old, action-less plain event line for this failure. + expect(container.querySelector(".chat-event-line")).toBeNull(); }); }); diff --git a/packages/chat-ui/test/refresh-and-send.test.ts b/packages/chat-ui/test/refresh-and-send.test.ts index 74d8e9c3..3be1aa25 100644 --- a/packages/chat-ui/test/refresh-and-send.test.ts +++ b/packages/chat-ui/test/refresh-and-send.test.ts @@ -271,7 +271,13 @@ describe("composerPlaceholderFor (CL-6070: a chat's composer reads as a DM, not }); }); -describe("appendReplyTimedOutNotice (CL-6252 #6: an honest note when the reply backstop fires)", () => { +// CL-6677: this notice used to render as a bare `event`-kind item with no +// ref id and no Retry — unlike `postUndeliveredNotice`'s server-side +// backstop for the same class of failure (a turn that ends with no +// reply). It now carries a `turnFailed` text part instead, so it renders +// through the same `FailedTurnStrip` treatment: ref id quotable, Retry +// wired. +describe("appendReplyTimedOutNotice (CL-6677: same ref+Retry backstop as the server-side notice)", () => { const serverItems = [ { id: "m1", @@ -280,17 +286,36 @@ describe("appendReplyTimedOutNotice (CL-6252 #6: an honest note when the reply b sender: { name: null, address: "prn_alice@acme.example" }, }, ]; + const participants = [{ address: "myra@agents.example", handle: "myra" }]; - test("not timed out leaves the timeline untouched", () => { - expect(appendReplyTimedOutNotice(serverItems, false)).toBe(serverItems); + test("no timeout leaves the timeline untouched", () => { + expect(appendReplyTimedOutNotice(serverItems, null, participants)).toBe( + serverItems, + ); }); - test("timed out appends an event-kind notice item", () => { - const withNotice = appendReplyTimedOutNotice(serverItems, true); + test("a timed-out turn appends a turnFailed text part carrying the ref id, attributed to the agent", () => { + const withNotice = appendReplyTimedOutNotice( + serverItems, + "mt4ewrje-zvbmti", + participants, + ); expect(withNotice).toHaveLength(2); expect(withNotice[1]?.parts).toEqual([ - { kind: "event", event: "chat.reply-timed-out", data: {} }, + { + kind: "text", + text: "No reply arrived — the agent may be unavailable. (ref mt4ewrje-zvbmti)", + turnFailed: true, + }, ]); + expect(withNotice[1]?.sender.address).toBe("myra@agents.example"); + }); + + test("with no agent participant to attribute to, still appends the notice", () => { + const withNotice = appendReplyTimedOutNotice(serverItems, "abc-123", []); + expect(withNotice).toHaveLength(2); + expect(withNotice[1]?.parts[0]).toMatchObject({ turnFailed: true }); + expect(withNotice[1]?.sender.address).toBe(""); }); }); diff --git a/packages/chat-ui/test/use-streaming-reply.test.tsx b/packages/chat-ui/test/use-streaming-reply.test.tsx index 7bc9efac..37278b5c 100644 --- a/packages/chat-ui/test/use-streaming-reply.test.tsx +++ b/packages/chat-ui/test/use-streaming-reply.test.tsx @@ -22,7 +22,7 @@ function mount( document.body.appendChild(container); const root = createRoot(container); let latestState: StreamingReplyState = null; - let latestTimedOut = false; + let latestTimedOutRefId: string | null = null; let send: (eventType: string, data: unknown) => void = () => {}; let setWorkbenchId: (id: string | null) => void = () => {}; let awaitReply: () => void = () => {}; @@ -35,13 +35,13 @@ function mount( setWorkbenchId = updateWorkbenchId; const { streamingReply, - replyTimedOut, + replyTimedOutRefId, handleStreamEvent, noteAwaitingReply, resumeFromTurn, } = useStreamingReply(workbenchId, clearMs, minVisibleMs); latestState = streamingReply; - latestTimedOut = replyTimedOut; + latestTimedOutRefId = replyTimedOutRefId; send = handleStreamEvent; awaitReply = noteAwaitingReply; resume = resumeFromTurn; @@ -73,7 +73,7 @@ function mount( }), settle: (ms: number) => act(() => sleep(ms)), get: () => latestState, - timedOut: () => latestTimedOut, + timedOutRefId: () => latestTimedOutRefId, unmount: () => act(() => root.unmount()), }; } @@ -151,15 +151,50 @@ describe("useStreamingReply (CL-6115: live wiring)", () => { }); describe("useStreamingReply's reply-timeout backstop (CL-6252 #6)", () => { - test("a pending reply with no tokens for the whole clearMs marks replyTimedOut", async () => { + test("a pending reply with no tokens for the whole clearMs mints a timed-out ref id", async () => { const harness = mount("chan_a", 30); harness.awaitReply(); expect(harness.get()).toEqual({ phase: "awaiting", text: "" }); - expect(harness.timedOut()).toBe(false); + expect(harness.timedOutRefId()).toBeNull(); await harness.settle(60); expect(harness.get()).toBeNull(); - expect(harness.timedOut()).toBe(true); + expect(harness.timedOutRefId()).not.toBeNull(); + harness.unmount(); + }); + + // CL-6677: a cold-waking agent (a parked room re-deploying and + // re-deriving its inference source, PR #327's defer-to-wake path) never + // emits a single `chat.agent` event until it either replies or the + // sidecar gives up — from this hook's perspective that is + // indistinguishable from any other silent turn, so it must hit the + // exact same backstop, ref id included, rather than a special ref-less + // path of its own. + test("a cold-waking room that never streams a single token still gets a quotable ref id", async () => { + const harness = mount("chan_cold_wake", 30); + harness.awaitReply(); + expect(harness.get()).toEqual({ phase: "awaiting", text: "" }); + + await harness.settle(60); + expect(harness.get()).toBeNull(); + const refId = harness.timedOutRefId(); + expect(refId).not.toBeNull(); + expect(refId).toMatch(/^[0-9a-z]+-[0-9a-z]+$/); + harness.unmount(); + }); + + test("two separate timed-out turns mint two distinct ref ids", async () => { + const harness = mount("chan_a", 30); + harness.awaitReply(); + await harness.settle(60); + const first = harness.timedOutRefId(); + expect(first).not.toBeNull(); + + harness.awaitReply(); + await harness.settle(60); + const second = harness.timedOutRefId(); + expect(second).not.toBeNull(); + expect(second).not.toBe(first); harness.unmount(); }); @@ -173,7 +208,7 @@ describe("useStreamingReply's reply-timeout backstop (CL-6252 #6)", () => { harness.send("chat.agent", delta("Hi")); await harness.settle(20); expect(harness.get()).toEqual({ phase: "awaiting", text: "Hi" }); - expect(harness.timedOut()).toBe(false); + expect(harness.timedOutRefId()).toBeNull(); harness.unmount(); }); @@ -188,7 +223,7 @@ describe("useStreamingReply's reply-timeout backstop (CL-6252 #6)", () => { // forever just because *some* text streamed. await harness.settle(60); expect(harness.get()).toBeNull(); - expect(harness.timedOut()).toBe(true); + expect(harness.timedOutRefId()).not.toBeNull(); harness.unmount(); }); @@ -196,10 +231,10 @@ describe("useStreamingReply's reply-timeout backstop (CL-6252 #6)", () => { const harness = mount("chan_a", 30); harness.awaitReply(); await harness.settle(60); - expect(harness.timedOut()).toBe(true); + expect(harness.timedOutRefId()).not.toBeNull(); harness.switchWorkbench("chan_b"); - expect(harness.timedOut()).toBe(false); + expect(harness.timedOutRefId()).toBeNull(); harness.unmount(); }); @@ -207,10 +242,10 @@ describe("useStreamingReply's reply-timeout backstop (CL-6252 #6)", () => { const harness = mount("chan_a", 30); harness.awaitReply(); await harness.settle(60); - expect(harness.timedOut()).toBe(true); + expect(harness.timedOutRefId()).not.toBeNull(); harness.awaitReply(); - expect(harness.timedOut()).toBe(false); + expect(harness.timedOutRefId()).toBeNull(); harness.unmount(); }); }); @@ -236,7 +271,7 @@ describe("useStreamingReply (CL-false-no-reply: the notice must never fire once // parks instead of ending. The backstop must not have armed for a // "replied" phase, so it must never fire. await harness.settle(60); - expect(harness.timedOut()).toBe(false); + expect(harness.timedOutRefId()).toBeNull(); harness.unmount(); }); @@ -264,7 +299,7 @@ describe("useStreamingReply (CL-false-no-reply: the notice must never fire once await harness.settle(60); expect(harness.get()).toEqual({ phase: "replied" }); - expect(harness.timedOut()).toBe(false); + expect(harness.timedOutRefId()).toBeNull(); harness.unmount(); }); @@ -275,7 +310,7 @@ describe("useStreamingReply (CL-false-no-reply: the notice must never fire once await harness.settle(60); expect(harness.get()).toBeNull(); - expect(harness.timedOut()).toBe(true); + expect(harness.timedOutRefId()).not.toBeNull(); harness.unmount(); }); }); diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index eed9a1d8..b1d81ce0 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -957,6 +957,21 @@ export function createHubChatPlatform( // fresh run at a fresh address. Re-resolve afterwards so the // send that follows targets the run that is actually alive, not // the one that just died. + // + // CL-6677: this wake — and the deploy/source-re-derivation it can + // trigger for a deferred-to-wake single-step deployment (CL-6648, + // `apps/sidecar/src/workflow-host-wiring/index.ts`'s + // `restoreDeploymentFromRecord`) — emits no progress event of its + // own. The reader sees nothing but the generic empty typing pulse + // for however long this call takes, indistinguishable from an + // agent that is simply thinking; a real cold wake measured ~105s + // of that silence before the client's own reply-timeout backstop + // fired (`streaming-reply.ts`'s `PENDING_REPLY_CLEAR_MS`). Surfacing + // a "waking up…" state needs a wake-in-progress signal threaded + // through this call, out through `subscribeToWorkbench`'s + // `chat.agent` stream, and into `useStreamingReply` — real plumbing + // across this package and the sidecar, not a client-side fix, so + // it is left as a follow-up rather than done here. if (lifecycle !== undefined) { await lifecycle.ensureAwake(liveAddress); } else if (!isRoutable(liveAddress)) {