Skip to content
Closed
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
4 changes: 4 additions & 0 deletions ui/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ export interface AgentInstancesApi {
): Promise<AgentInstance[]>;
get(id: string, options?: ReadOptions): Promise<AgentInstance>;

/** Restores a crashed runtime to its last retained snapshot. */
revert(id: string): Promise<AgentInstance>;

create(input: {
agent: ResourceRefInput;
/**
Expand Down Expand Up @@ -355,6 +358,7 @@ export function createApiClient(): KagentApiClient {
invoke("agentInstances.rename", { id, name }),
get: (id, options) =>
invoke("agentInstances.get", { id }, options),
revert: (id) => invoke("agentInstances.revert", { id }),
create: (input) => invoke("agentInstances.create", input),
remove: (id) => invoke("agentInstances.delete", { id }),
fork: (id, name) =>
Expand Down
12 changes: 12 additions & 0 deletions ui/src/api/domain/agentInstances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ export type AgentInstanceOperation =
| "delete"
| "unknown";

export interface SessionMessageReference {
taskId: string;
messageId: string;
}

export interface AgentInstanceRecovery {
/** Absent when the controller could not observe the runtime. */
needed?: boolean;
failedMessages: SessionMessageReference[];
}

/**
* Why an instance failed, as the controller recorded it.
*
Expand Down Expand Up @@ -106,6 +117,7 @@ export interface AgentInstance {
operation: AgentInstanceOperation;
/** Set only when the controller recorded a failure. */
failure?: AgentInstanceFailure;
recovery?: AgentInstanceRecovery;
/** RFC3339, or empty when the record carried no timestamp. */
createdAt: string;
/** RFC3339, or empty when the record carried no timestamp. */
Expand Down
20 changes: 20 additions & 0 deletions ui/src/api/grpc/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,15 @@ function toAgentInstance(instance: PbSession): AgentInstance {
message: orUndefined(instance.failure.message),
}
: undefined,
recovery: instance.recovery
? {
needed: instance.recovery.needed,
failedMessages: instance.recovery.failedMessages.map((message) => ({
taskId: message.taskId,
messageId: message.messageId,
})),
}
: undefined,
createdAt: isoFrom(instance.createdAt),
updatedAt: isoFrom(instance.updatedAt),
};
Expand Down Expand Up @@ -666,6 +675,7 @@ const agentInstances: Pick<
| "agentInstances.rename"
| "agentInstances.fork"
| "agentInstances.delete"
| "agentInstances.revert"
> = {
"agentInstances.list": async (input, options) => {
const name = "SessionService/ListSessions";
Expand Down Expand Up @@ -941,6 +951,16 @@ const agentInstances: Pick<
return toAgentInstance(required(response.session, name, "agent instance"));
},

"agentInstances.revert": async (input, options) => {
const name = "SessionService/RevertSession";
const response = await rpc(name, options.signal, () =>
serviceClient(SessionService).revertSession(
{ sessionId: input.id },
call("agentInstances.revert", options),
),
);
return toAgentInstance(required(response.session, name, "agent instance"));
},
};

// endregion
Expand Down
14 changes: 14 additions & 0 deletions ui/src/api/hooks/useLiveTranscript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,17 @@ export function useLiveTranscript(
};
}, [enabled, isBusy]);
}

/**
* Re-reads a conversation's transcript and its Session record together.
*
* Settled rather than awaited as one: a failed Session read must not drop the
* transcript refresh, and the reverse. Each keeps its previous reading on failure
* and the next tick tries again, so neither rejection escapes as unhandled.
*/
export async function refreshConversation(
transcript: () => Promise<void>,
session: () => Promise<void>,
): Promise<void> {
await Promise.allSettled([transcript(), session()]);
}
34 changes: 34 additions & 0 deletions ui/src/api/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,40 @@ describe("agent instances", () => {
expect(isNotFound(missing)).toBe(true);
});

it("reverts through the existing Session RPC and retains exact failed message references", async () => {
let received: string | undefined;
serve(({ service }) => {
service(SessionService, {
revertSession: (request) => {
received = request.sessionId;
return { session: instanceMessage({
state: RuntimeState.SUSPENDED,
recovery: { needed: false, failedMessages: [
{ taskId: "completed-task", messageId: "discarded-message" },
] },
}) };
},
});
});
const reverted = await apiClient.agentInstances.revert(INSTANCE_ID);
expect(received).toBe(INSTANCE_ID);
expect(reverted.state).toBe("suspended");
expect(reverted.recovery).toEqual({ needed: false, failedMessages: [
{ taskId: "completed-task", messageId: "discarded-message" },
] });
});

it("keeps an unknown recovery observation distinct from not needing recovery", async () => {
serve(({ service }) => {
service(SessionService, {
getSession: () => ({ session: instanceMessage({ recovery: {} }) }),
});
});
expect((await apiClient.agentInstances.get(INSTANCE_ID)).recovery).toEqual({
needed: undefined, failedMessages: [],
});
});

});

describe("session shares", () => {
Expand Down
1 change: 1 addition & 0 deletions ui/src/api/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ export interface OperationMap {
output: void;
};

"agentInstances.revert": { input: AgentInstanceRef; output: AgentInstance };

"harnesses.list": { input: { namespace?: string }; output: Harness[] };
/**
Expand Down
25 changes: 25 additions & 0 deletions ui/src/components/chat/ChatMessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ToolApprovalRecord } from "./ToolApprovalRecord";
import { AskUserRecord } from "./AskUserRecord";
import { AttachmentChip } from "./AttachmentChip";
import { isAwaitingContent, messageText } from "./messageText";
import type { DiscardedMark } from "./discardedTurns";

const { Text } = Typography;

Expand All @@ -29,13 +30,16 @@ export function ChatMessageItem({
message,
sessionId,
isCheckpointed = false,
discarded,
onManagePush,
}: {
message: ChatMessage;
/** The conversation this message belongs to, for the per-message extension point. */
sessionId?: string;
/** Whether this message is above the nearest saved boundary, for the browser suite. */
isCheckpointed?: boolean;
/** Whether a runtime recovery discarded this message, and how; see `discardedMessages`. */
discarded?: DiscardedMark;
/** Opens the callback settings for this message's task. */
onManagePush?: () => void;
}) {
Expand All @@ -54,6 +58,7 @@ export function ChatMessageItem({
data-message-id={message.id}
data-role={message.role}
data-checkpointed={isCheckpointed || undefined}
data-discarded={discarded}
css={{
display: "grid",
gap: theme.space(2),
Expand All @@ -72,6 +77,13 @@ export function ChatMessageItem({
<Text css={{ color: "inherit", fontSize: "inherit" }}>
{isUser ? "You" : "Agent"}
</Text>
{/* Labelled in the header rather than only dimmed: the reader message above it
carries the explanation, and a dimmed reply on its own reads as styling. */}
{discarded === "reply" ? (
<Text css={{ color: "inherit", fontSize: "inherit" }}>
· Not remembered after recovery
</Text>
) : null}
{onManagePush ? (
<Tooltip title="Task notifications">
<Button
Expand Down Expand Up @@ -107,6 +119,9 @@ export function ChatMessageItem({
display: "grid",
gap: theme.space(2),
width: isUser && !isQuestionRecord ? "auto" : "100%",
// Still readable, because it is still what was said; only no longer in the
// runtime's memory.
opacity: discarded ? 0.6 : undefined,
}}
>
{message.parts.map((part, index) => {
Expand All @@ -129,6 +144,16 @@ export function ChatMessageItem({
);
})}

{/* Not "failed to send": the message reached the agent, which may have answered
part of it before the crash. Not a live region either: this is persisted
history, and a status role would announce every note on each load. */}
{discarded === "message" ? (
<Text type="warning">
The runtime crashed during this turn. After recovery, the agent does not
remember this message or the replies after it.
</Text>
) : null}

{/* A reply that has been announced but has no text yet: without this the
message would be an invisible gap between the tool result and the
answer, and the stream would look stalled. */}
Expand Down
98 changes: 98 additions & 0 deletions ui/src/components/chat/ChatTranscript.recovery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { ThemeProvider } from "@emotion/react";
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { themeFor } from "@/theme/theme";
import type { AgentInstanceRecovery, ChatController } from "@/api";
import { ChatTranscript } from "./ChatTranscript";

const NOTE = /does not remember this message/;

const chat: ChatController = {
messages: [
{
id: "captured-answer", taskId: "task", role: "user", createdAt: "2026-10-22T00:00:00Z",
parts: [{ kind: "ask_user", interaction: {
questions: [{ question: "Captured question?", choices: ["Retained"], multiple: false }],
answers: [["Retained"]],
} }],
},
{ id: "discarded", taskId: "task", role: "user", createdAt: "2026-10-22T00:00:01Z", parts: [{ kind: "text", text: "Lost reply" }] },
{ id: "partial", taskId: "task", role: "agent", createdAt: "2026-10-22T00:00:02Z", parts: [{ kind: "text", text: "Half a story" }] },
{ id: "next-turn", taskId: "next", role: "agent", createdAt: "2026-10-22T00:00:03Z", parts: [{ kind: "text", text: "Later turn" }] },
],
isLoadingHistory: false,
turnState: "idle", turnPhase: "idle", phase: "idle",
refreshTranscript: vi.fn(), send: vi.fn(), cancel: vi.fn(), dismissQuestion: vi.fn(),
answerQuestion: vi.fn(), answerToolApproval: vi.fn(), retry: vi.fn(),
};

function transcript(recovery: AgentInstanceRecovery) {
return <ThemeProvider theme={themeFor("dark")}><ChatTranscript chat={chat} recovery={recovery} /></ThemeProvider>;
}

function message(id: string) {
return screen.getAllByTestId("chat-message").find((node) => node.dataset.messageId === id)!;
}

describe("persisted recovery message failures", () => {
it.each([
{ taskId: "other-task", messageId: "discarded" },
{ taskId: "task", messageId: "other-message" },
])("requires both task and message identity: %j", (reference) => {
render(transcript({ failedMessages: [reference] }));
expect(screen.queryByText(NOTE)).not.toBeInTheDocument();
});

it("marks the rest of the discarded turn, but not earlier history or another task", () => {
render(transcript({ failedMessages: [{ taskId: "task", messageId: "discarded" }] }));
expect(message("discarded")).toHaveAttribute("data-discarded", "message");
expect(message("partial")).toHaveAttribute("data-discarded", "reply");
expect(within(message("partial")).getByText(/Not remembered after recovery/)).toBeInTheDocument();
expect(message("captured-answer")).not.toHaveAttribute("data-discarded");
expect(message("next-turn")).not.toHaveAttribute("data-discarded");
});

it("does not announce persisted notes as a live region", () => {
render(transcript({ failedMessages: [{ taskId: "task", messageId: "discarded" }] }));
expect(within(message("discarded")).getByText(NOTE).closest("[role=status],[aria-live]")).toBeNull();
});

it("retains the failure indicator in a read-only shared transcript", () => {
render(
<ThemeProvider theme={themeFor("dark")}>
<ChatTranscript chat={chat} recovery={{ failedMessages: [{ taskId: "task", messageId: "discarded" }] }} readOnly />
</ThemeProvider>,
);
expect(within(message("discarded")).getByText(NOTE)).toBeInTheDocument();
});

it("hides a discarded task's stale question while retaining a captured question", () => {
const pending: ChatController = { ...chat, turnState: "input_required", turnPhase: "awaiting_input", pendingQuestion: { kind: "unknown", taskId: "task" } };
const view = render(
<ThemeProvider theme={themeFor("dark")}>
<ChatTranscript chat={pending} recovery={{ needed: false, failedMessages: [{ taskId: "task", messageId: "discarded" }] }} />
</ThemeProvider>,
);
expect(screen.queryByTestId("chat-awaiting-reply")).not.toBeInTheDocument();
expect(screen.queryByText("Waiting for you")).not.toBeInTheDocument();
view.rerender(
<ThemeProvider theme={themeFor("dark")}>
<ChatTranscript chat={pending} recovery={{ needed: false, failedMessages: [] }} />
</ThemeProvider>,
);
expect(screen.getByTestId("chat-awaiting-reply")).toBeInTheDocument();
});

it("marks the exact discarded message after refresh and reload, preserving captured HITL history", () => {
const recovery = { failedMessages: [{ taskId: "task", messageId: "discarded" }] };
const view = render(transcript({ failedMessages: [] }));
view.rerender(transcript(recovery));
expect(within(message("discarded")).getByText(NOTE)).toHaveTextContent("the agent does not remember this message or the replies after it");
expect(within(message("captured-answer")).queryByText(NOTE)).not.toBeInTheDocument();
expect(screen.getAllByTestId("chat-message")).toHaveLength(4);
view.unmount();
render(transcript(recovery));
expect(within(message("discarded")).getByText(NOTE)).toBeInTheDocument();
expect(screen.getAllByTestId("chat-message")).toHaveLength(4);
});
});
Loading