feat: post-review backlog hardening (cancel/parser/stall/history/9502)
Five independent items from the post-review backlog. F1: Stop on an external agent task now aborts the running child via a per-task AbortController registry reachable from the cancel route, and finalizes the assistant message as cancelled (fixing two latent bugs — catch blocks left the message streaming, and warm success-paths wrote complete on an aborted turn); warm pools/worktrees are preserved and the native path is unchanged. F2/F3: prune the tool-call parser to its two load-bearing exports (unexport eight zero-caller symbols, add a gate test for the <invoke>-as-text fallback) and route placeholder-rejection logging through pino. F6: a 90s per-chunk stall-timeout wraps native inference's fullStream via AbortSignal.any so a hung stream finalizes the message instead of hanging — no retry (a pure classifyStreamError helper is added). F7: a read-only view_session_history MCP tool (newest-N, chronological). F9: retire the unused apps/coder/web :9502 fallback SPA, keeping every API/WS/health/MCP route. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -442,6 +442,11 @@ export type WsFrame =
|
||||
// cap-hit sentinels (and any future stamped-on-complete metadata) flow
|
||||
// to the client without a refetch.
|
||||
metadata?: MessageMetadata | null;
|
||||
// F1 (D-8): terminal status of the assistant message. Absent on the normal
|
||||
// path (reducer defaults to 'complete'); the BooCoder dispatcher stamps it
|
||||
// 'cancelled' on a user Stop / stall and 'failed' on a thrown error so the
|
||||
// reducer renders a muted "Stopped" / failed state — no new frame type.
|
||||
status?: 'complete' | 'cancelled' | 'failed';
|
||||
}
|
||||
// v1.12.2: live throughput frame, published mid-stream every ~500ms with
|
||||
// the latest token + ctx counts so ChatThroughput can render tok/s and
|
||||
|
||||
@@ -49,6 +49,9 @@ interface Props {
|
||||
// queues while busy). Omitting onStop falls back to a (disabled) Send button.
|
||||
generating?: boolean;
|
||||
onStop?: () => void | Promise<void>;
|
||||
// F1: disable the Stop button while a cancel request is already in flight, so a
|
||||
// rapid second click can't fire a duplicate Stop. Optional — BooChat omits it.
|
||||
stopDisabled?: boolean;
|
||||
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
|
||||
// ChatInput calls this with the skill name + the post-name args (possibly
|
||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||
@@ -76,7 +79,7 @@ interface Props {
|
||||
modelContextLimit?: number | null;
|
||||
}
|
||||
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop, stopDisabled, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const [value, setValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -701,10 +704,11 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
return (
|
||||
<Button
|
||||
onClick={() => void onStop()}
|
||||
disabled={stopDisabled}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
aria-label="Stop generating"
|
||||
title="Stop generating"
|
||||
title={stopDisabled ? 'Stopping…' : 'Stop generating'}
|
||||
>
|
||||
<Square className="fill-current size-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -780,6 +780,10 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
|
||||
const isStreaming = message.status === 'streaming';
|
||||
const failed = message.status === 'failed';
|
||||
// F1 (D-10): a user Stop finalizes the turn as 'cancelled' — surface a muted
|
||||
// "Stopped" label (not the red "message failed" — a deliberate Stop is not a
|
||||
// failure), keeping whatever streamed before the abort.
|
||||
const cancelled = message.status === 'cancelled';
|
||||
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
|
||||
// assistant turn doesn't render an empty bubble + dangling ActionRow.
|
||||
const hasContent = message.content.trim().length > 0;
|
||||
@@ -826,6 +830,7 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{cancelled && <div className="text-xs text-muted-foreground">Stopped</div>}
|
||||
{!isStreaming && (modelLabel || null) && (
|
||||
<span
|
||||
className="inline-flex w-fit items-center rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-[10px] font-mono text-primary/90"
|
||||
|
||||
@@ -10,7 +10,8 @@ export interface CoderMessageWire {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status?: 'streaming' | 'complete' | 'failed';
|
||||
// F1: 'cancelled' — a user Stop / stall finalized the turn (renders "Stopped").
|
||||
status?: 'streaming' | 'complete' | 'failed' | 'cancelled';
|
||||
model?: string | null;
|
||||
reasoning_text?: string;
|
||||
tool_calls?: CoderToolCallWire[];
|
||||
|
||||
@@ -29,7 +29,8 @@ interface CoderMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status?: 'streaming' | 'complete' | 'failed';
|
||||
// F1: 'cancelled' — a user Stop / stall finalized the turn (renders "Stopped").
|
||||
status?: 'streaming' | 'complete' | 'failed' | 'cancelled';
|
||||
// model-attribution: which model produced this assistant message (chip).
|
||||
model?: string | null;
|
||||
reasoning_text?: string;
|
||||
@@ -296,7 +297,10 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
|
||||
m.id === frame.message_id && m.role !== 'tool'
|
||||
? {
|
||||
...m,
|
||||
status: 'complete' as const,
|
||||
// F1 (D-8): the terminal frame carries an optional status —
|
||||
// 'cancelled' on a Stop/stall, 'failed' on error. Absent on the
|
||||
// normal path → defaults to 'complete'.
|
||||
status: ((frame as any).status ?? 'complete') as CoderMessage['status'],
|
||||
model: (frame as any).model ?? (m as any).model ?? null,
|
||||
ctx_used: (frame as any).ctx_used ?? (m as any).ctx_used ?? null,
|
||||
ctx_max: (frame as any).ctx_max ?? (m as any).ctx_max ?? null,
|
||||
@@ -669,6 +673,9 @@ export function CoderPane({
|
||||
onAgentLabelChange?.(parts.join(' · '));
|
||||
}, [agentConfig.provider, agentConfig.model, onAgentLabelChange]);
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
// F1: true while the Stop POST is in flight — disables the Stop button and makes
|
||||
// a rapid double-click a no-op (the abort is idempotent server-side regardless).
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
|
||||
const [permissionBusy, setPermissionBusy] = useState(false);
|
||||
const [providerCommands, setProviderCommands] = useState<AgentCommand[]>([]);
|
||||
@@ -986,14 +993,17 @@ export function CoderPane({
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
const taskId = activeTaskId;
|
||||
if (!taskId) return;
|
||||
if (!taskId || stopping) return; // ignore a second Stop while the POST is in flight
|
||||
setStopping(true);
|
||||
try {
|
||||
await api.coder.cancelTask(taskId);
|
||||
setActiveTaskId(null); // optimistic; WS/poll terminal-state also clears it
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'stop failed');
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
}, [activeTaskId]);
|
||||
}, [activeTaskId, stopping]);
|
||||
|
||||
// write-edit-robustness #4: reset the worktree to a message's checkpoint and
|
||||
// trim the transcript past it. The confirm lives in MessageBubble's ActionRow
|
||||
@@ -1125,6 +1135,7 @@ export function CoderPane({
|
||||
onSend={handleChatInputSend}
|
||||
generating={generating}
|
||||
onStop={handleStop}
|
||||
stopDisabled={stopping}
|
||||
onSlashCommand={handleChatInputSlash}
|
||||
slashGroups={slashGroups}
|
||||
chatId={chatId ?? undefined}
|
||||
|
||||
Reference in New Issue
Block a user