diff --git a/CHANGELOG.md b/CHANGELOG.md index cc08bd68..5735d69f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -203,6 +203,24 @@ points at the token field instead. A deployment that reaches its MCP servers by ordinary vendor hostnames sees no difference. +### One unreadable turn no longer takes a whole conversation down + +Restoring a thread cast whatever the history store held straight to messages and handed it to the +transcript. A turn stored in a different shape — a tool call written `{id, name, args}` rather than +AG-UI's `{id, type: "function", function: …}`, which interrupted runs have produced — reached a +renderer that read `toolCall.function.arguments` and threw, so a single bad turn made the whole +conversation unopenable rather than that one message unreadable. + +Each stored turn is now parsed against the schema AG-UI ships, and one that does not parse is left +out instead of being drawn. Checked where history enters the app rather than in one renderer, so +every surface that reads a transcript is covered by the same check. + +**A turn that is left out is said out loud.** The conversation shows a line above it naming how many +earlier messages could not be read, because a record people read back must not have a hole in it that +nothing accounts for — a turn that silently disappears reads as one that was never sent. Multimodal +content and every well-formed tool call are unaffected, and a history that cannot be read at all +still opens the composer rather than blocking it. + ## 0.0.4 ### A click citing a ref this deployment cannot resolve is refused diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 0f4a4e25..38d013db 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -115,6 +115,14 @@ export function ChannelChat({ * already has the message that started it. */ const [restoring, setRestoring] = useState(seed === null); + /** + * How many stored turns this app could not read. + * + * Held rather than derived, because the transcript is the running agent's once history is handed + * over: `agent.messages` is what was restored, and what was dropped on the way in is not + * recoverable from it. + */ + const [unreadable, setUnreadable] = useState(0); useEffect(() => { if (isReady) openReadyGate.current(); }, [isReady]); @@ -146,9 +154,20 @@ export function ChannelChat({ runtimeAgentId, ); // Never overwrite local messages that arrived while history was loading. - if (current && stored.length > 0 && agent.messages.length === 0) { - agent.setMessages(stored); + if ( + current && + stored.messages.length > 0 && + agent.messages.length === 0 + ) { + agent.setMessages(stored.messages); } + /* + * Said on screen rather than only counted. A turn the history store holds and this app cannot + * parse is left out of the transcript, and a record people read back must not have a hole in + * it that nothing accounts for. Set even when nothing was restored: a thread whose every turn + * is unreadable is exactly the case where silence would read as "this conversation is empty". + */ + if (current) setUnreadable(stored.unreadable); } finally { // Cleared on failure too: placeholders over an empty transcript promise messages that are // never coming. @@ -376,12 +395,26 @@ export function ChannelChat({ disabled={!channel.active} messages={transcriptMessages(agent.messages, seed)} notice={ - channel.active ? null : ( -
- This coworker has been deleted. The conversation stays readable, - but it can no longer reply. -
- ) + /* + * Two things can be worth saying at once — a deleted coworker and a history with holes in + * it — and they are independent, so neither is an `else` for the other. + */ + <> + {unreadable > 0 ? ( ++ {unreadable === 1 + ? "One earlier message could not be read and is not shown." + : `${unreadable} earlier messages could not be read and are not shown.`}{" "} + The rest of this conversation is complete. +
+ ) : null} + {channel.active ? null : ( ++ This coworker has been deleted. The conversation stays readable, + but it can no longer reply. +
+ )} + > } onSubmit={async (draft) => { // `draft.agentId` carries the @mentioned coworker, but nothing routes on it yet: this diff --git a/app/src/lib/copilot/thread-messages.ts b/app/src/lib/copilot/thread-messages.ts index fcc2b8aa..887ecec3 100644 --- a/app/src/lib/copilot/thread-messages.ts +++ b/app/src/lib/copilot/thread-messages.ts @@ -1,4 +1,4 @@ -import type { Message } from "@ag-ui/core"; +import { type Message, MessageSchema } from "@ag-ui/core"; import { tryClient } from "@/lib/client"; /** @@ -8,19 +8,72 @@ import { tryClient } from "@/lib/client"; * then owned by the running agent, so a cached copy would be a second version of the same * conversation — and an unreadable history is not a reason to keep somebody from typing. Every * failure returns nothing and lets the composer open. + * + * WHAT ARRIVES HERE IS NOT TRUSTED. This used to end `stored as Message[]`, which is a cast rather + * than a check: whatever the history store held was handed to `setMessages` and then to every + * projection that reads a transcript. A turn shaped differently — a tool call persisted as + * `{id, name, args}` instead of AG-UI's `{id, type: "function", function: {…}}`, which interrupted + * runs have produced — reached a renderer that dereferenced `toolCall.function.arguments` and took + * the whole conversation down with it. One bad turn made a thread unreadable. + * + * So each turn is parsed against the schema AG-UI ships, and one that does not parse is left out. + * Checked here rather than in a projection because there are several projections and one history: + * fixing it in the reader that is closest to the wire is what makes every consumer safe at once. */ + +/** + * What a read gives back: the turns that parsed, and how many did not. + * + * The count is returned rather than logged. A turn quietly missing from a record people read back is + * worse than a visible failure — it is a conversation that reads as though it never had that message, + * with nothing to say otherwise. The caller is expected to say so on screen. + */ +export type StoredThread = { + messages: Message[]; + /** Zero on every ordinary read. Above zero means the history store holds something unreadable. */ + unreadable: number; +}; + +const NOTHING: StoredThread = { messages: [], unreadable: 0 }; + +/** + * The turns that parse, kept in order, and a count of the ones that did not. + * + * Exported so it can be tested against real stored shapes without a server. Takes `unknown[]` + * because that is honestly what the wire gives. + * + * THE ORIGINAL OBJECT IS KEPT, not `parsed.data`. Zod strips keys a schema does not name, so + * returning the parsed copy would quietly drop anything the runtime carries and this file has not + * heard of — turning a validation step into a silent rewrite of every message that passed. The parse + * is asked whether the turn is well formed; it is not asked to decide what the turn contains. + */ +export function readableTurns(stored: readonly unknown[]): StoredThread { + const messages: Message[] = []; + let unreadable = 0; + + for (const turn of stored) { + if (MessageSchema.safeParse(turn).success) { + messages.push(turn as Message); + } else { + unreadable += 1; + } + } + + return { messages, unreadable }; +} + export async function readThreadMessages( threadId: string, agentId: string, -): Promise