Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/CONSUMING.md
Original file line number Diff line number Diff line change
Expand Up @@ -822,7 +822,14 @@ learns the one it was given from `RUN_STARTED`, which is always the first event.
read; the rest of `messages` is ignored and the checkpointer's transcript is the
truth. That diverges from AG-UI's client-is-authoritative convention on purpose —
the values session state exists to keep out of the model's context would
otherwise have to live in the browser and be posted back every turn.
otherwise have to live in the browser and be posted back every turn. The stream
says so rather than leaving a client to discover it: a `MESSAGES_SNAPSHOT`
closes every run with the thread as the server holds it. It closes rather than
opens the run because a snapshot drops every local message it does not name, and
one sent before the turn was checkpointed would take the user's own question off
their screen. Ids line up — the question keeps the client's `id`, the answer
carries the id the thread will store — so a client reconciles in place rather
than rebuilding its list.

**The read routes are what the stream deliberately leaves out.** A
`STATE_SNAPSHOT` carries `{kind, tool, bytes}` per key and never the payload, so
Expand Down Expand Up @@ -902,7 +909,9 @@ nothing else.

`STATE_SNAPSHOT` carries metadata only — `kind`, `tool`, `bytes`, and `seq` once
known — never the stored value, which a frontend fetches when it actually wants
to draw it. `seq` is assigned when a write is merged, so mid-turn snapshots omit
to draw it. It sits under `toolState` inside AG-UI's state object, so read
`snapshot.toolState`; the rest of that object is the client's, to hold whatever
state of its own it wants to keep there. `seq` is assigned when a write is merged, so mid-turn snapshots omit
it and the snapshot closing the turn carries it. Snapshots are also **partial
mid-turn**: one names what its node wrote, so a client merges them into what it
holds rather than replacing.
Expand Down
47 changes: 42 additions & 5 deletions examples/agui-events/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,15 @@ reader can only demonstrate that a format is self-consistent.
| `RUN_STARTED` / `RUN_FINISHED` / `RUN_ERROR` | run lifecycle |
| `TEXT_MESSAGE_START` / `CONTENT` / `END` | the answer, streamed |
| `TOOL_CALL_START` / `ARGS` / `END` / `RESULT` | the tool-call lifecycle |
| `MESSAGES_SNAPSHOT` | the thread as the server holds it, closing each run |
| `STATE_SNAPSHOT` | the state channel |
| `ACTIVITY_SNAPSHOT` | a first-class message role in AG-UI, which is what receipts ride |

Those are the only event types this server emits, out of the 33 AG-UI defines.
No `STATE_DELTA`, no `MESSAGES_SNAPSHOT`, no `STEP_*`, `REASONING_*`, `CUSTOM`
or `RAW`. Snapshots only, each complete in itself, because a delta for an
unknown `messageId` is dropped silently by the client and would make the wire
depend on a patch having applied.
No `STATE_DELTA`, no `STEP_*`, `REASONING_*`, `CUSTOM` or `RAW`. Snapshots only,
each complete in itself, because a delta for an unknown `messageId` is dropped
silently by the client and would make the wire depend on a patch having
applied.

### What a consumer has to know that the protocol does not tell it

Expand All @@ -107,14 +108,50 @@ what session state exists to keep out of the conversation. Fetching one is
`GET /threads/{id}/state/{key}`, which is outside the protocol entirely. A stock
client showing "state" will show sizes and kinds and think it has everything.

The metadata sits under a **`toolState`** key rather than at the root of the
state object, so the rest of that object stays the client's own — read
`snapshot.toolState`, not `snapshot`.

**History is the server's.** AG-UI's convention is client-authoritative:
`RunAgentInput.messages` is the conversation, and the client owns it.
`HttpAgent` duly posts its whole array every turn — and this server reads the
trailing user message and discards the rest, because the values kept out of the
model's context would otherwise have to live in the browser and be posted back.
The consequence for a consumer is concrete: **mutating `agent.messages` does not
edit the thread.** Editing a message, branching, or dropping a turn are
client-side illusions here. `GET /threads/{id}` is the truth.
client-side illusions here.

The server now says so on the wire: a **`MESSAGES_SNAPSHOT`** closes every
run, carrying the thread as the server holds it, so a client that had diverged
is corrected rather than drifting.

It closes the run rather than opening it, and that matters. A snapshot is
applied by dropping every local message it does not name — sent up front, before
the turn is checkpointed, it would take the question the user just typed off the
screen and leave the answer under nothing. At the end everything the turn
produced is in the thread.

**Ids line up on purpose.** The question keeps the `id` the client gave it, and
the answer is labelled with the id the thread will store, taken off the
provider's own stream. So the snapshot reconciles a client's list in place
instead of dropping every message and re-appending the server's — which would
leave activities stranded at the top. One caveat the server enforces for you: an
id the thread already holds is not reused, because the message reducer matches on
id and would replace that message rather than add one.

`GET /threads/{id}` remains the way to read a thread without running one — and
this client now uses it. The thread id is in the URL as `?thread=`, so
**reloading the page brings the conversation back** rather than starting a fresh
one. Two routes rebuild it: `/threads/{id}` for the transcript and
`/threads/{id}/turns` for what state held at the end of each turn, joined on the
question, since the stream carries no turn boundary a reloaded client could have
seen.

What does *not* come back is the annotation. Receipts, views and citations are
activity messages, and the server does not rebuild past turns' activities — so a
restored thread shows what was said and what is in session state, but not where
a tool's arguments came from, and the cross-highlighting is empty until the next
turn.

**Views are a second protocol.** `mcp.view` names a `ui://` URI and carries the
tool's structured content; the HTML comes from `GET /views/{toolset}/{view}`,
Expand Down
26 changes: 23 additions & 3 deletions examples/agui-events/web/src/agui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,31 @@ export async function readState(
return (await response.json()) as StateValue;
}

/** A thread's messages, for a client that reloaded.
*
* The conversation, and nothing around it: receipts, views and the rest are
* activities, and the server does not rebuild past turns' activities. So a
* restored thread shows what was said but not where each tool's arguments came
* from — see the README.
*/
export async function readThread(threadId: string) {
const response = await fetch(`/api/threads/${threadId}`);
// 404 is the ordinary answer for a thread id that has never run, which is
// what a hand-edited URL produces. The caller starts fresh instead.
if (response.status === 404) return null;
if (!response.ok) throw new Error(`${response.status}`);
return (await response.json()) as {
threadId: string;
messages: { id: string; role: string; content?: string | null }[];
state: Record<string, { kind: string | null; tool?: string; bytes?: number }>;
};
}

/** A thread's turns, and what session state held at the end of each.
*
* Not used by the live client — it builds turns from the events as they
* arrive — but this is how a client that reloaded would get them back, and it
* is the route the panel's per-turn view is really made of.
* The live client builds turns from the events as they arrive; this is how one
* that reloaded gets them back, and it is the route the panel's per-turn view
* is really made of.
*/
export async function readTurns(threadId: string) {
const response = await fetch(`/api/threads/${threadId}/turns`);
Expand Down
86 changes: 80 additions & 6 deletions examples/agui-events/web/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HttpAgent, type Message } from "@ag-ui/client";
import { useEffect, useMemo, useRef, useState } from "react";
import Markdown from "react-markdown";

import { readState } from "./agui";
import { readState, readThread, readTurns } from "./agui";

/** Session state as the stream describes it: no payloads, one line per key. */
type StateEntry = {
Expand All @@ -15,6 +15,13 @@ type StateEntry = {
/** Every key the thread holds, which is what `STATE_SNAPSHOT` carries. */
type Snapshot = Record<string, StateEntry>;

/**
* Key inside AG-UI's `state` object holding session-state metadata. The rest
* of that object belongs to the client, so this reads the one key rather than
* treating the whole snapshot as the agent's.
*/
const TOOL_STATE = "toolState";

/** Where a key came from, read off the `state.published` that announced it. */
type Origin = { toolCallId: string; tool: string; activityId: string };

Expand Down Expand Up @@ -171,9 +178,16 @@ export function Chat() {
// `RunAgentInput`, runs the SSE through `verifyEvents`, and applies each
// event to `messages` and `state`. If this server emitted anything the
// protocol disallows, the run would fail here rather than render wrongly.
// `?thread=` if the URL names one, so a reload comes back to the same
// conversation rather than a fresh one — the thread lives in the
// checkpointer, and the id is the only thing a client needs to keep.
const [threadId] = useState(
() =>
new URLSearchParams(location.search).get("thread") || crypto.randomUUID(),
);
const agent = useMemo(
() => new HttpAgent({ url: "/api/runs", threadId: crypto.randomUUID() }),
[],
() => new HttpAgent({ url: "/api/runs", threadId }),
[threadId],
);
const log = useRef<HTMLDivElement>(null);
const [messages, setMessages] = useState<Message[]>([]);
Expand Down Expand Up @@ -209,6 +223,59 @@ export function Chat() {
log.current?.scrollTo({ top: log.current.scrollHeight });
}, [messages]);

// Put the thread in the URL, so reloading the page restores it. Replace
// rather than push: this is not a navigation, and a back button that stepped
// through thread ids would be nonsense.
useEffect(() => {
const url = new URL(location.href);
if (url.searchParams.get("thread") === threadId) return;
url.searchParams.set("thread", threadId);
history.replaceState(null, "", url);
}, [threadId]);

/** Rebuild the conversation from the thread id alone.
*
* Two routes, because the stream has no turn boundary a reloaded client
* could have seen: `/threads/{id}` is the transcript, `/threads/{id}/turns`
* is what state held at the end of each turn. They are joined on the
* question — turn *n* starts at the *n*th user message.
*
* **Activities do not come back.** Receipts, views and citations are
* activity messages, and the server does not rebuild past turns' — so a
* restored thread shows what was said and what is in state, but not where a
* tool's arguments came from. `published` is empty for the same reason: it
* is read off `state.published`, which is an activity.
*/
useEffect(() => {
let cancelled = false;
(async () => {
const thread = await readThread(threadId).catch(() => null);
if (cancelled || !thread || thread.messages.length === 0) return;
const past = await readTurns(threadId).catch(() => null);
if (cancelled) return;

const questions = thread.messages
.map((message, index) => ({ message, index }))
.filter(({ message }) => message.role === "user");
const restored: Turn[] = questions.map(({ message, index }, n) => ({
n: n + 1,
question: message.content || "",
questionId: message.id,
from: index,
state: (past?.history[n]?.state ?? {}) as Snapshot,
published: {},
}));

agent.setMessages(thread.messages as unknown as Message[]);
setMessages([...agent.messages]);
setTurns(restored);
setShowing(Math.max(restored.length - 1, 0));
})();
return () => {
cancelled = true;
};
}, [agent, threadId]);

/** Put the question that started a turn at the top of the log.
*
* `scrollTo` on the log rather than `scrollIntoView` on the message, which
Expand Down Expand Up @@ -293,8 +360,10 @@ export function Chat() {
setMessages([...messages]);
patch((turn) => ({ ...turn, published: origins(messages, turn.from) }));
},
// Session state is ours, not AG-UI's `state` — see the README. It
// arrives on the standard channel carrying `{kind, tool, bytes, seq}`.
// Session state arrives on AG-UI's standard `state` channel, under
// `toolState` — the rest of that object is the client's, so read the
// one key rather than the whole snapshot. Each entry carries
// `{kind, tool, bytes, seq}`; see the README.
//
// Merged rather than assigned: a mid-turn snapshot is built from what
// that node wrote, so it names only those keys, and assigning it would
Expand All @@ -306,7 +375,12 @@ export function Chat() {
onStateSnapshotEvent: ({ event }) => {
patch((turn) => ({
...turn,
state: { ...turn.state, ...((event.snapshot ?? {}) as Snapshot) },
state: {
...turn.state,
...(((event.snapshot as Record<string, unknown> | undefined)?.[
TOOL_STATE
] ?? {}) as Snapshot),
},
}));
},
});
Expand Down
Loading