retry a turn the agent restart cut off

the cut-off notice is its own event now rather than an error: nothing is broken
and nothing is lost but the turn, so the row says what happened and offers the
one action that fixes it. the conversation is already durable — the claude
session id is written through to disk and passed back as resume: — so retry
just resends the prompt on a session the fresh agent picks up with full
context. read back out of the transcript, so a second window on the same
session can offer it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 02:00:16 +00:00
co-authored by Claude Opus 5
parent 876b39b301
commit 66fca28927
7 changed files with 83 additions and 10 deletions
+6
View File
@@ -156,6 +156,12 @@ export type ServerMessage =
| {
type: 'stopped';
}
| {
// The agent process went away mid-turn (almost always `pm2 restart officer-agent`). Its own type
// rather than an `error` because it is recoverable and the recovery is one click: the conversation
// is on disk and the next turn resumes it, so the UI offers to send the same prompt again.
type: 'cut-off';
}
| {
// Ack for a client 'disconnect': the session was torn down server-side.
type: 'disconnected';
+1 -4
View File
@@ -622,10 +622,7 @@ async function endTurnIfAgentIsGone(
const session = sessionManager.getSession(sessionId);
if (session) session.isGenerating = false;
const event: ServerMessage = {
type: 'error',
message: 'The agent restarted while this turn was running, so it was cut off. The conversation is intact.',
};
const event: ServerMessage = { type: 'cut-off' };
try {
const seq = await appendChatEvent(sessionId, event);
sendToClient(ws, event, seq);
@@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { Volume2, Loader2, Square, Clock, Check, X, CircleSlash, Eraser } from 'lucide-react';
import { Volume2, Loader2, Square, Clock, Check, X, CircleSlash, Eraser, PlugZap, RotateCcw } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import type { Tone } from '@/components/Data';
import { toneText } from '@/components/Data';
@@ -126,9 +126,10 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
type MessageBubbleProps = {
message: ChatMessage;
onAnswer?: (text: string) => void;
onRetry?: (prompt: string) => void;
};
export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
export const MessageBubble = ({ message, onAnswer, onRetry }: MessageBubbleProps) => {
switch (message.role) {
case 'user': {
const rawText = typeof message.text === 'string' ? message.text : '';
@@ -225,6 +226,32 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
</div>
);
// The agent process went away mid-turn. Styled as a seam rather than a failure bubble: nothing is
// broken and nothing is lost except the turn itself, so the row's job is to say what happened and
// offer the one action that fixes it. The prompt goes back through the normal send path, which lands
// on a session the fresh agent resumes from the transcript on disk.
case 'cutoff':
return (
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
<div className="h-px flex-1 bg-border" />
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
<PlugZap className="h-3 w-3" />
Agent restarted turn cut off
</span>
{message.prompt && onRetry && (
<button
type="button"
onClick={() => onRetry(message.prompt)}
className="flex cursor-pointer items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] font-medium tracking-wide uppercase transition-colors hover:bg-muted hover:text-foreground"
>
<RotateCcw className="h-3 w-3" />
Retry
</button>
)}
<div className="h-px flex-1 bg-border" />
</div>
);
case 'error':
return (
<div className="flex justify-start group">
@@ -84,7 +84,11 @@ export const MessageList = ({ manager }: MessageListProps) => {
}}
>
<div className="py-1.5">
<MessageBubble message={msg} onAnswer={(text) => sendPrompt(text)} />
<MessageBubble
message={msg}
onAnswer={(text) => sendPrompt(text)}
onRetry={(prompt) => sendPrompt(prompt)}
/>
</div>
</div>
);
@@ -41,6 +41,9 @@ export type ChatMessage =
// You pressed stop. Deliberately not an `error` — the turn did what you told it to, and dressing that
// in red destructive chrome (which is what it looked like) reads as "something went wrong".
| { role: 'interrupted' }
// The agent process went away mid-turn. Carries the prompt that was lost with it, so the row can offer
// to send it again — the conversation itself is on disk and the next turn resumes it.
| { role: 'cutoff'; prompt: string }
// Where a `/clear` fell inside a resumed conversation. The server splices the parts of a chain into
// one transcript (see `loadChainTranscript`), and this is the seam — deliberately visible, because
// your history runs straight through it and the agent's context does not. `sessionId` is the part
@@ -83,6 +86,8 @@ export type ServerMessage =
| { type: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: string }
| { type: 'error'; message: string; errorCode?: string }
| { type: 'stopped' }
/** The agent process went away mid-turn. Recoverable — see the `cutoff` message role. */
| { type: 'cut-off' }
| { type: 'disconnected' }
| { type: 'task:started'; taskId: string; description: string; taskType?: string }
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string };
@@ -341,6 +341,18 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
setIsGenerating(false);
break;
case 'cut-off':
// The prompt is taken from the transcript rather than remembered separately: this can arrive in a
// tab that never sent it (a second window on the same session), and the transcript is the one
// place both of them agree on.
commitStreaming();
setMessages((prev) => {
const lastUser = [...prev].reverse().find((m) => m.role === 'user');
return [...prev, { role: 'cutoff', prompt: lastUser?.role === 'user' ? lastUser.text : '' }];
});
setIsGenerating(false);
break;
case 'disconnected':
// Session torn down server-side (turn killed, in-memory session dropped). Commit any partial
// stream and settle to idle; the WS stays open so a new prompt starts a fresh session.