Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/background-task-web-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Keep frontend agent sessions attached for background task results, and prevent framework-authored task wake-ups from appearing as user messages or corrupting optimistic message reconciliation.
2 changes: 1 addition & 1 deletion docs/guides/frontend/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Most chat UIs only need `data.messages` and `status`. Drop down to `events` when

`data.messages` are eve-owned `EveMessage[]`. Common text, reasoning, file, and dynamic-tool parts follow the [AI SDK `UIMessage`](https://ai-sdk.dev/docs/reference/ai-sdk-core/ui-message) rendering convention, but the types are not interchangeable. eve also exposes authorization and HITL metadata, and a file part's URL can be absent. Adapt those parts before passing messages to an API typed as `UIMessage[]`.

When the root agent delegates, its stream emits `subagent.called` with the child's `childSessionId`, then `subagent.completed` after admission with a working task receipt. Later task notifications wake the parent with updates or the final result. Detailed child progress lives on the child session's stream instead of being flattened into the root `data.messages`. Use the lower-level [TypeScript client](../client/overview#sessions) to attach to that ID when your UI needs live subagent activity. See [What the parent sees](../../subagents#what-the-parent-sees) for the complete contract.
When the root agent delegates, its stream emits `subagent.called` with the child's `childSessionId`, then `subagent.completed` after admission with a working task receipt. Later task notifications wake the parent with updates or the final result. The frontend helpers keep following these parent turns and project their assistant responses without exposing framework-authored task state as user messages. Detailed child progress lives on the child session's stream instead of being flattened into the root `data.messages`. Use the lower-level [TypeScript client](../client/overview#sessions) to attach to that ID when your UI needs live subagent activity. See [What the parent sees](../../subagents#what-the-parent-sees) for the complete contract.

## Sending and streaming

Expand Down
90 changes: 90 additions & 0 deletions packages/eve/src/client/background-task-follower.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { ClientSession } from "#client/session.js";
import { isAbortError } from "#client/eve-agent-store-helpers.js";
import type { MessageStreamEvent } from "#protocol/message.js";

interface BackgroundTaskFollowerCallbacks {
readonly acceptEvent: (event: MessageStreamEvent) => void;
readonly getSession: () => ClientSession | undefined;
readonly onError: (error: unknown) => void;
readonly onWaiting: (session: ClientSession) => void;
}

export class BackgroundTaskFollower {
readonly #callbacks: BackgroundTaskFollowerCallbacks;
#controller: AbortController | undefined;
#enabled = false;
#promise: Promise<void> | undefined;

constructor(callbacks: BackgroundTaskFollowerCallbacks) {
this.#callbacks = callbacks;
}

observe(event: MessageStreamEvent): void {
if (isBackgroundTaskReceiptEvent(event)) this.#enabled = true;
}

seed(events: readonly MessageStreamEvent[]): void {
this.#enabled = events.some(isBackgroundTaskReceiptEvent);
}

stop(): Promise<void> | undefined {
this.#controller?.abort();
return this.#promise;
}

reset(): void {
this.#enabled = false;
this.#controller?.abort();
this.#controller = undefined;
this.#promise = undefined;
}

start(): void {
const session = this.#callbacks.getSession();
if (!this.#enabled || session === undefined || this.#controller !== undefined) return;

const controller = new AbortController();
this.#controller = controller;
let promise!: Promise<void>;
promise = this.#follow(session, controller).finally(() => {
if (this.#controller === controller) this.#controller = undefined;
if (this.#promise === promise) this.#promise = undefined;
});
this.#promise = promise;
}

async #follow(session: ClientSession, controller: AbortController): Promise<void> {
try {
while (this.#enabled && !controller.signal.aborted) {
for await (const event of session.stream({ signal: controller.signal })) {
if (this.#controller !== controller) return;
this.#callbacks.acceptEvent(event);
if (event.type === "session.waiting") {
this.#callbacks.onWaiting(session);
} else if (event.type === "session.completed" || event.type === "session.failed") {
this.#enabled = false;
}
}
}
} catch (error) {
if (!isAbortError(error)) this.#callbacks.onError(error);
}
}
}

export function isBackgroundTaskReceiptEvent(event: MessageStreamEvent): boolean {
if (event.type === "subagent.completed") {
return event.data.backgroundTask?.status === "working";
}
if (event.type !== "action.result" || event.data.result.kind !== "tool-result") return false;

const output = event.data.result.output;
return (
typeof output === "object" &&
output !== null &&
"status" in output &&
output.status === "working" &&
"taskId" in output &&
typeof output.taskId === "string"
);
}
21 changes: 20 additions & 1 deletion packages/eve/src/client/eve-agent-store-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
import type { SendTurnPayload } from "#client/types.js";
import type { MessageResponse } from "#client/message-response.js";
import type { CancelSessionResult, SendTurnPayload } from "#client/types.js";
import { isCurrentTurnBoundaryEvent, type MessageStreamEvent } from "#protocol/message.js";
import type { UserContent } from "ai";

export interface ActiveTurn {
readonly abortController: AbortController;
acceptedFollowUps: number;
readonly cancel: () => Promise<CancelSessionResult>;
readonly completion: Promise<void>;
readonly followUpDispatches: Set<Promise<void>>;
receivedFollowUps: number;
readonly resolveCompletion: () => void;
readonly response: Promise<MessageResponse | undefined>;
readonly resolveResponse: (response: MessageResponse | undefined) => void;
}

export interface PendingMessageSubmission {
readonly createdAt: number;
readonly id: string;
readonly message: string;
}

export function isSettledSessionTail(events: readonly MessageStreamEvent[]): boolean {
const tail = events.at(-1);
return (
Expand Down
120 changes: 120 additions & 0 deletions packages/eve/src/client/eve-agent-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { detachEveAgentStore, EveAgentStore } from "#client/eve-agent-store.js";
import { defaultMessageReducer } from "#client/message-reducer.js";
import { stampTestEvents } from "#internal/testing/events.js";
import {
createActionResultEvent,
createMessageAppendedEvent,
createMessageCompletedEvent,
createMessageReceivedEvent,
Expand Down Expand Up @@ -781,6 +782,125 @@ describe("EveAgentStore steering", () => {
});
});

describe("EveAgentStore background tasks", () => {
it("keeps following after a background tool receipt", async () => {
const initialEvents = stampTestEvents([
createMessageReceivedEvent({ message: "Hello", sequence: 0, turnId: "turn_0" }),
createActionResultEvent({
result: {
callId: "call_1",
kind: "tool-result",
output: { status: "working", taskId: "task_1" },
toolName: "write_later",
},
sequence: 0,
stepIndex: 0,
turnId: "turn_0",
}),
createMessageCompletedEvent({
finishReason: "stop",
message: "The background task started.",
sequence: 0,
stepIndex: 1,
turnId: "turn_0",
}),
createSessionWaitingEvent(),
] as UnstampedMessageStreamEvent[]);
const callbackStream = controlledStreamResponse();
const [callbackStarted, callbackCompleted, callbackWaiting] = stampTestEvents([
createTurnStartedEvent({ sequence: 1, turnId: "turn_1" }),
createMessageCompletedEvent({
finishReason: "stop",
message: "The background task finished.",
sequence: 1,
stepIndex: 0,
turnId: "turn_1",
}),
createSessionWaitingEvent(),
] as UnstampedMessageStreamEvent[]).map((event, index) => ({
...event,
meta: { ...event.meta, id: `callback_${index}` },
}));
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(startedResponse())
.mockResolvedValueOnce(streamResponse(initialEvents))
.mockResolvedValueOnce(callbackStream.response);
const store = new EveAgentStore({ reducer: defaultMessageReducer() });

await store.send({ message: "Hello" });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
callbackStream.emit(callbackStarted!);
callbackStream.emit(callbackCompleted!);
callbackStream.emit(callbackWaiting!);

await vi.waitFor(() =>
expect(store.snapshot.data.messages.at(-1)?.parts).toContainEqual({
state: "done",
stepIndex: 0,
text: "The background task finished.",
type: "text",
}),
);
expect(store.snapshot.status).toBe("ready");
detachEveAgentStore(store);
});

it("recognizes background subagent receipts", async () => {
const initialEvents = stampTestEvents([
createMessageReceivedEvent({ message: "Hello", sequence: 0, turnId: "turn_0" }),
{
data: {
backgroundTask: { status: "working", taskId: "task_1" },
callId: "call_1",
output: "Started.",
subagentName: "researcher",
},
type: "subagent.completed",
},
createSessionWaitingEvent(),
] as UnstampedMessageStreamEvent[]);
const callbackStream = controlledStreamResponse();
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(startedResponse())
.mockResolvedValueOnce(streamResponse(initialEvents))
.mockResolvedValueOnce(callbackStream.response);
const store = new EveAgentStore({ reducer: defaultMessageReducer() });

await store.send({ message: "Hello" });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));

detachEveAgentStore(store);
});

it("only reconciles an optimistic submission with its matching server message", async () => {
const events = stampTestEvents([
createMessageReceivedEvent({
message: "Framework-authored task state",
sequence: 0,
turnId: "turn_internal",
}),
createMessageReceivedEvent({ message: "Hello", sequence: 1, turnId: "turn_1" }),
createSessionWaitingEvent(),
] as UnstampedMessageStreamEvent[]);
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(startedResponse())
.mockResolvedValueOnce(streamResponse(events));
const store = new EveAgentStore({ reducer: defaultMessageReducer() });

await store.send({ message: "Hello" });

const userMessages = store.snapshot.data.messages.filter((message) => message.role === "user");
expect(userMessages).toHaveLength(2);
expect(userMessages.map((message) => message.parts[0])).toEqual([
{ state: "done", text: "Hello", type: "text" },
{ state: "done", text: "Framework-authored task state", type: "text" },
]);
expect(userMessages[0]?.metadata?.optimistic).toBeUndefined();
});
});

describe("EveAgentStore terminal failure", () => {
it("publishes a live terminal failure with error status", async () => {
const failed = stampTestEvents([
Expand Down
Loading
Loading