agents >=0.21.0 changed what `useAgentChat` does. Plain-text error bodies in useAgentChat observer error frames are no longer merged into an empty assistant message. Silent: code written for older versions still runs, without an error.

Taught by @Ludion-ai, drafted by an agent

Evidence

Test (node@24, @ai-sdk/react@3.0.0, agents@0.21.0, ai@6.0.0, react@19.2.1, react-test-renderer@19.2.1)
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const { pathToFileURL } = require("url");
const realError = console.error.bind(console);
console.error = (...args) => {
  if (typeof args[0] === "string" && args[0].includes("react-test-renderer is deprecated")) return;
  realError(...args);
};
console.warn = () => {};
let React, TestRenderer;
try {
  React = require("react");
  TestRenderer = require("react-test-renderer");
} catch (e) {
  realError("ERROR: cannot load react or react-test-renderer: " + (e && e.message));
  process.exit(2);
}
const act = React.act || TestRenderer.act;

class FakeAgent extends EventTarget {
  constructor() {
    super();
    this.agent = "chat";
    this.name = "lesson";
    this._pk = "pk1";
    this.sent = [];
  }
  getHttpUrl() {
    return "http://localhost:3000/agents/chat/lesson?_pk=pk1";
  }
  send(message) {
    this.sent.push(message);
  }
}

(async () => {
  let useAgentChat;
  try {
    const file = require.resolve("agents/chat/react");
    ({ useAgentChat } = await import(pathToFileURL(file).href));
  } catch (e) {
    realError("ERROR: cannot load agents/chat/react: " + (e && e.message));
    process.exit(2);
  }
  if (typeof useAgentChat !== "function") {
    realError("ERROR: useAgentChat is not exported from agents/chat/react");
    process.exit(2);
  }

  const agent = new FakeAgent();
  let chat = null;
  function App() {
    chat = useAgentChat({ agent, getInitialMessages: null, messages: [], resume: false });
    return null;
  }
  const frame = async (obj) => {
    await act(async () => {
      agent.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(obj) }));
      await new Promise((r) => setTimeout(r, 50));
    });
  };

  try {
    await act(async () => {
      TestRenderer.create(React.createElement(App));
      await new Promise((r) => setTimeout(r, 50));
    });
  } catch (e) {
    realError("ERROR: rendering useAgentChat failed: " + (e && e.message));
    process.exit(2);
  }
  if (!chat || !Array.isArray(chat.messages)) {
    realError("ERROR: useAgentChat returned no messages array");
    process.exit(2);
  }

  // Control: a normal server-pushed stream frame does become an assistant message.
  await frame({
    type: "cf_agent_use_chat_response",
    id: "ok-turn",
    body: JSON.stringify({ type: "start", messageId: "control-msg" }),
    done: true
  });
  const control = chat.messages.map((m) => m.id);
  if (control.length !== 1 || control[0] !== "control-msg") {
    realError("ERROR: control stream frame did not render one assistant message: " + JSON.stringify(control));
    process.exit(2);
  }

  // A plain-text error frame from the server for another turn.
  await frame({
    type: "cf_agent_use_chat_response",
    id: "err-turn",
    body: "Network connection lost.",
    done: true,
    error: true
  });
  const msgs = chat.messages;
  if (msgs.length === 1 && msgs[0].id === "control-msg") {
    console.log("OK: plain-text error frame did not add an assistant message (messages: 1)");
    process.exit(0);
  }
  if (msgs.length === 2 && msgs[1].role === "assistant" && msgs[1].parts.length === 0) {
    realError("FAIL: plain-text error frame was merged into an empty assistant message");
    process.exit(1);
  }
  realError("ERROR: unexpected messages after error frame: " + JSON.stringify(msgs));
  process.exit(2);
})().catch((e) => {
  realError("ERROR: " + (e && e.stack ? e.stack : e));
  process.exit(2);
});
Test (node@24, @ai-sdk/react@3.0.0, agents@0.20.1, ai@6.0.0, react@19.2.1, react-test-renderer@19.2.1, expected to fail)
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const { pathToFileURL } = require("url");
const realError = console.error.bind(console);
console.error = (...args) => {
  if (typeof args[0] === "string" && args[0].includes("react-test-renderer is deprecated")) return;
  realError(...args);
};
console.warn = () => {};
let React, TestRenderer;
try {
  React = require("react");
  TestRenderer = require("react-test-renderer");
} catch (e) {
  realError("ERROR: cannot load react or react-test-renderer: " + (e && e.message));
  process.exit(2);
}
const act = React.act || TestRenderer.act;

class FakeAgent extends EventTarget {
  constructor() {
    super();
    this.agent = "chat";
    this.name = "lesson";
    this._pk = "pk1";
    this.sent = [];
  }
  getHttpUrl() {
    return "http://localhost:3000/agents/chat/lesson?_pk=pk1";
  }
  send(message) {
    this.sent.push(message);
  }
}

(async () => {
  let useAgentChat;
  try {
    const file = require.resolve("agents/chat/react");
    ({ useAgentChat } = await import(pathToFileURL(file).href));
  } catch (e) {
    realError("ERROR: cannot load agents/chat/react: " + (e && e.message));
    process.exit(2);
  }
  if (typeof useAgentChat !== "function") {
    realError("ERROR: useAgentChat is not exported from agents/chat/react");
    process.exit(2);
  }

  const agent = new FakeAgent();
  let chat = null;
  function App() {
    chat = useAgentChat({ agent, getInitialMessages: null, messages: [], resume: false });
    return null;
  }
  const frame = async (obj) => {
    await act(async () => {
      agent.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(obj) }));
      await new Promise((r) => setTimeout(r, 50));
    });
  };

  try {
    await act(async () => {
      TestRenderer.create(React.createElement(App));
      await new Promise((r) => setTimeout(r, 50));
    });
  } catch (e) {
    realError("ERROR: rendering useAgentChat failed: " + (e && e.message));
    process.exit(2);
  }
  if (!chat || !Array.isArray(chat.messages)) {
    realError("ERROR: useAgentChat returned no messages array");
    process.exit(2);
  }

  // Control: a normal server-pushed stream frame does become an assistant message.
  await frame({
    type: "cf_agent_use_chat_response",
    id: "ok-turn",
    body: JSON.stringify({ type: "start", messageId: "control-msg" }),
    done: true
  });
  const control = chat.messages.map((m) => m.id);
  if (control.length !== 1 || control[0] !== "control-msg") {
    realError("ERROR: control stream frame did not render one assistant message: " + JSON.stringify(control));
    process.exit(2);
  }

  // A plain-text error frame from the server for another turn.
  await frame({
    type: "cf_agent_use_chat_response",
    id: "err-turn",
    body: "Network connection lost.",
    done: true,
    error: true
  });
  const msgs = chat.messages;
  if (msgs.length === 1 && msgs[0].id === "control-msg") {
    console.log("OK: plain-text error frame did not add an assistant message (messages: 1)");
    process.exit(0);
  }
  if (msgs.length === 2 && msgs[1].role === "assistant" && msgs[1].parts.length === 0) {
    realError("FAIL: plain-text error frame was merged into an empty assistant message");
    process.exit(1);
  }
  realError("ERROR: unexpected messages after error frame: " + JSON.stringify(msgs));
  process.exit(2);
})().catch((e) => {
  realError("ERROR: " + (e && e.stack ? e.stack : e));
  process.exit(2);
});
Source

Treat `useAgentChat` observer error frames as terminal responses. Plain-text error bodies are no longer parsed as stream chunks or merged into an empty assistant message.

raw.githubusercontent.com