feat(KB-159): complete Step 4 — update frontend for streaming display
This commit is contained in:
5
.changeset/handle-transient-errors.md
Normal file
5
.changeset/handle-transient-errors.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Handle transient connection failures without marking tasks as failed. When the AI agent encounters network errors like "upstream connect error", "ECONNREFUSED", or "connection reset", tasks are now moved back to "todo" for automatic retry instead of being marked as failed. This prevents temporary infrastructure issues from incorrectly failing tasks.
|
||||||
@@ -649,6 +649,14 @@ export interface PlanningSession {
|
|||||||
summary: PlanningSummary | null;
|
summary: PlanningSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SSE event types for planning session streaming */
|
||||||
|
export type PlanningStreamEvent =
|
||||||
|
| { type: "thinking"; data: string }
|
||||||
|
| { type: "question"; data: PlanningQuestion }
|
||||||
|
| { type: "summary"; data: PlanningSummary }
|
||||||
|
| { type: "error"; data: string }
|
||||||
|
| { type: "complete"; data: Record<string, never> };
|
||||||
|
|
||||||
/** Start a new planning session with an initial plan */
|
/** Start a new planning session with an initial plan */
|
||||||
export function startPlanning(initialPlan: string): Promise<PlanningSession> {
|
export function startPlanning(initialPlan: string): Promise<PlanningSession> {
|
||||||
return api<PlanningSession>("/planning/start", {
|
return api<PlanningSession>("/planning/start", {
|
||||||
@@ -657,6 +665,14 @@ export function startPlanning(initialPlan: string): Promise<PlanningSession> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Start a new planning session with AI streaming support */
|
||||||
|
export function startPlanningStreaming(initialPlan: string): Promise<{ sessionId: string }> {
|
||||||
|
return api<{ sessionId: string }>("/planning/start-streaming", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ initialPlan }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Submit a response to the current planning question */
|
/** Submit a response to the current planning question */
|
||||||
export function respondToPlanning(
|
export function respondToPlanning(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -683,3 +699,107 @@ export function createTaskFromPlanning(sessionId: string): Promise<Task> {
|
|||||||
body: JSON.stringify({ sessionId }),
|
body: JSON.stringify({ sessionId }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the SSE stream URL for a planning session */
|
||||||
|
export function getPlanningStreamUrl(sessionId: string): string {
|
||||||
|
return `/api/planning/${encodeURIComponent(sessionId)}/stream`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect to planning session SSE stream and handle events
|
||||||
|
*
|
||||||
|
* Returns an object with:
|
||||||
|
* - close: function to close the connection
|
||||||
|
* - reconnect: function to reconnect after error
|
||||||
|
*/
|
||||||
|
export function connectPlanningStream(
|
||||||
|
sessionId: string,
|
||||||
|
handlers: {
|
||||||
|
onThinking?: (data: string) => void;
|
||||||
|
onQuestion?: (data: PlanningQuestion) => void;
|
||||||
|
onSummary?: (data: PlanningSummary) => void;
|
||||||
|
onError?: (data: string) => void;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
): { close: () => void; isConnected: () => boolean } {
|
||||||
|
const url = getPlanningStreamUrl(sessionId);
|
||||||
|
const eventSource = new EventSource(url);
|
||||||
|
let isClosed = false;
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
isClosed = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
// Handle comment events (heartbeats)
|
||||||
|
if (event.data.startsWith(":")) return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle specific event types
|
||||||
|
eventSource.addEventListener("thinking", (event: Event) => {
|
||||||
|
try {
|
||||||
|
const messageEvent = event as MessageEvent;
|
||||||
|
const data = JSON.parse(messageEvent.data);
|
||||||
|
handlers.onThinking?.(data);
|
||||||
|
} catch {
|
||||||
|
const messageEvent = event as MessageEvent;
|
||||||
|
handlers.onThinking?.(messageEvent.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("question", (event: Event) => {
|
||||||
|
try {
|
||||||
|
const messageEvent = event as MessageEvent;
|
||||||
|
const data = JSON.parse(messageEvent.data) as PlanningQuestion;
|
||||||
|
handlers.onQuestion?.(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[planning] Failed to parse question event:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("summary", (event: Event) => {
|
||||||
|
try {
|
||||||
|
const messageEvent = event as MessageEvent;
|
||||||
|
const data = JSON.parse(messageEvent.data) as PlanningSummary;
|
||||||
|
handlers.onSummary?.(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[planning] Failed to parse summary event:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("error", (event: Event) => {
|
||||||
|
try {
|
||||||
|
const messageEvent = event as MessageEvent;
|
||||||
|
const data = JSON.parse(messageEvent.data);
|
||||||
|
handlers.onError?.(data.message || data);
|
||||||
|
} catch {
|
||||||
|
const messageEvent = event as MessageEvent;
|
||||||
|
handlers.onError?.(messageEvent.data || "Stream error");
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("complete", () => {
|
||||||
|
handlers.onComplete?.();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle connection errors
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
if (!isClosed) {
|
||||||
|
handlers.onError?.("Connection lost");
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!isClosed) {
|
||||||
|
isClosed = true;
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
close,
|
||||||
|
isConnected: () => !isClosed && eventSource.readyState === EventSource.OPEN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { useState, useCallback, useEffect, useRef } from "react";
|
|||||||
import type { Task, PlanningQuestion, PlanningSummary } from "@kb/core";
|
import type { Task, PlanningQuestion, PlanningSummary } from "@kb/core";
|
||||||
import {
|
import {
|
||||||
startPlanning,
|
startPlanning,
|
||||||
|
startPlanningStreaming,
|
||||||
respondToPlanning,
|
respondToPlanning,
|
||||||
cancelPlanning,
|
cancelPlanning,
|
||||||
createTaskFromPlanning,
|
createTaskFromPlanning,
|
||||||
|
connectPlanningStream,
|
||||||
type PlanningSession,
|
type PlanningSession,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
|
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
|
||||||
@@ -41,7 +43,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
|||||||
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
|
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
|
||||||
const [editedSummary, setEditedSummary] = useState<PlanningSummary | null>(null);
|
const [editedSummary, setEditedSummary] = useState<PlanningSummary | null>(null);
|
||||||
const [hasAutoStarted, setHasAutoStarted] = useState(false);
|
const [hasAutoStarted, setHasAutoStarted] = useState(false);
|
||||||
|
const [streamingOutput, setStreamingOutput] = useState<string>("");
|
||||||
|
const [showThinking, setShowThinking] = useState(true);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||||
|
const currentSessionIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Focus textarea when opening
|
// Focus textarea when opening
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -70,6 +76,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Cleanup stream connection on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
streamConnectionRef.current?.close();
|
||||||
|
streamConnectionRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Handle browser unload during active session
|
// Handle browser unload during active session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
@@ -79,6 +93,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.returnValue = "";
|
e.returnValue = "";
|
||||||
}
|
}
|
||||||
|
// Close stream connection
|
||||||
|
streamConnectionRef.current?.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
@@ -109,20 +125,52 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
|||||||
if (!initialPlan.trim()) return;
|
if (!initialPlan.trim()) return;
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setStreamingOutput("");
|
||||||
setView({ type: "loading" });
|
setView({ type: "loading" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await startPlanning(initialPlan.trim());
|
// Use streaming mode for real-time AI thinking display
|
||||||
if (session.currentQuestion) {
|
const { sessionId } = await startPlanningStreaming(initialPlan.trim());
|
||||||
setView({ type: "question", session });
|
currentSessionIdRef.current = sessionId;
|
||||||
} else if (session.summary) {
|
|
||||||
setView({ type: "summary", session, summary: session.summary });
|
// Connect to SSE stream
|
||||||
setEditedSummary(session.summary);
|
const connection = connectPlanningStream(sessionId, {
|
||||||
}
|
onThinking: (data) => {
|
||||||
|
setStreamingOutput((prev) => prev + data);
|
||||||
|
},
|
||||||
|
onQuestion: (question) => {
|
||||||
|
setView({
|
||||||
|
type: "question",
|
||||||
|
session: { sessionId, currentQuestion: question, summary: null },
|
||||||
|
});
|
||||||
|
setStreamingOutput("");
|
||||||
|
},
|
||||||
|
onSummary: (summary) => {
|
||||||
|
setView({
|
||||||
|
type: "summary",
|
||||||
|
session: { sessionId, currentQuestion: null, summary },
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
setEditedSummary(summary);
|
||||||
|
setStreamingOutput("");
|
||||||
|
},
|
||||||
|
onError: (message) => {
|
||||||
|
setError(message);
|
||||||
|
setView({ type: "initial" });
|
||||||
|
setStreamingOutput("");
|
||||||
|
currentSessionIdRef.current = null;
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
currentSessionIdRef.current = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
streamConnectionRef.current = connection;
|
||||||
setResponseHistory([]);
|
setResponseHistory([]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Failed to start planning session");
|
setError(err.message || "Failed to start planning session");
|
||||||
setView({ type: "initial" });
|
setView({ type: "initial" });
|
||||||
|
currentSessionIdRef.current = null;
|
||||||
}
|
}
|
||||||
}, [initialPlan]);
|
}, [initialPlan]);
|
||||||
|
|
||||||
@@ -131,20 +179,50 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
|||||||
if (!plan.trim()) return;
|
if (!plan.trim()) return;
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setStreamingOutput("");
|
||||||
setView({ type: "loading" });
|
setView({ type: "loading" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await startPlanning(plan.trim());
|
const { sessionId } = await startPlanningStreaming(plan.trim());
|
||||||
if (session.currentQuestion) {
|
currentSessionIdRef.current = sessionId;
|
||||||
setView({ type: "question", session });
|
|
||||||
} else if (session.summary) {
|
const connection = connectPlanningStream(sessionId, {
|
||||||
setView({ type: "summary", session, summary: session.summary });
|
onThinking: (data) => {
|
||||||
setEditedSummary(session.summary);
|
setStreamingOutput((prev) => prev + data);
|
||||||
}
|
},
|
||||||
|
onQuestion: (question) => {
|
||||||
|
setView({
|
||||||
|
type: "question",
|
||||||
|
session: { sessionId, currentQuestion: question, summary: null },
|
||||||
|
});
|
||||||
|
setStreamingOutput("");
|
||||||
|
},
|
||||||
|
onSummary: (summary) => {
|
||||||
|
setView({
|
||||||
|
type: "summary",
|
||||||
|
session: { sessionId, currentQuestion: null, summary },
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
setEditedSummary(summary);
|
||||||
|
setStreamingOutput("");
|
||||||
|
},
|
||||||
|
onError: (message) => {
|
||||||
|
setError(message);
|
||||||
|
setView({ type: "initial" });
|
||||||
|
setStreamingOutput("");
|
||||||
|
currentSessionIdRef.current = null;
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
currentSessionIdRef.current = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
streamConnectionRef.current = connection;
|
||||||
setResponseHistory([]);
|
setResponseHistory([]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Failed to start planning session");
|
setError(err.message || "Failed to start planning session");
|
||||||
setView({ type: "initial" });
|
setView({ type: "initial" });
|
||||||
|
currentSessionIdRef.current = null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -153,19 +231,59 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
|||||||
if (view.type !== "question") return;
|
if (view.type !== "question") return;
|
||||||
|
|
||||||
const { session } = view;
|
const { session } = view;
|
||||||
|
const sessionId = session.sessionId;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setStreamingOutput("");
|
||||||
setView({ type: "loading" });
|
setView({ type: "loading" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedSession = await respondToPlanning(session.sessionId, responses);
|
// Close previous connection if any
|
||||||
|
streamConnectionRef.current?.close();
|
||||||
|
|
||||||
|
// Submit response - this will trigger the AI to process and stream
|
||||||
|
const updatedSession = await respondToPlanning(sessionId, responses);
|
||||||
setResponseHistory((prev) => [...prev, responses]);
|
setResponseHistory((prev) => [...prev, responses]);
|
||||||
|
|
||||||
|
// If we got an immediate response (non-streaming mode), use it
|
||||||
if (updatedSession.summary) {
|
if (updatedSession.summary) {
|
||||||
setView({ type: "summary", session: updatedSession, summary: updatedSession.summary });
|
setView({ type: "summary", session: updatedSession, summary: updatedSession.summary });
|
||||||
setEditedSummary(updatedSession.summary);
|
setEditedSummary(updatedSession.summary);
|
||||||
} else if (updatedSession.currentQuestion) {
|
return;
|
||||||
setView({ type: "question", session: updatedSession });
|
|
||||||
}
|
}
|
||||||
|
if (updatedSession.currentQuestion) {
|
||||||
|
setView({ type: "question", session: updatedSession });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, set up streaming for the next question
|
||||||
|
const connection = connectPlanningStream(sessionId, {
|
||||||
|
onThinking: (data) => {
|
||||||
|
setStreamingOutput((prev) => prev + data);
|
||||||
|
},
|
||||||
|
onQuestion: (question) => {
|
||||||
|
setView({
|
||||||
|
type: "question",
|
||||||
|
session: { sessionId, currentQuestion: question, summary: null },
|
||||||
|
});
|
||||||
|
setStreamingOutput("");
|
||||||
|
},
|
||||||
|
onSummary: (summary) => {
|
||||||
|
setView({
|
||||||
|
type: "summary",
|
||||||
|
session: { sessionId, currentQuestion: null, summary },
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
setEditedSummary(summary);
|
||||||
|
setStreamingOutput("");
|
||||||
|
},
|
||||||
|
onError: (message) => {
|
||||||
|
setError(message);
|
||||||
|
setView({ type: "question", session });
|
||||||
|
setStreamingOutput("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
streamConnectionRef.current = connection;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Failed to submit response");
|
setError(err.message || "Failed to submit response");
|
||||||
setView({ type: "question", session });
|
setView({ type: "question", session });
|
||||||
@@ -175,6 +293,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCancel = useCallback(async () => {
|
const handleCancel = useCallback(async () => {
|
||||||
|
// Always close the stream connection
|
||||||
|
streamConnectionRef.current?.close();
|
||||||
|
streamConnectionRef.current = null;
|
||||||
|
|
||||||
if (view.type === "question" || view.type === "summary") {
|
if (view.type === "question" || view.type === "summary") {
|
||||||
try {
|
try {
|
||||||
await cancelPlanning(view.session.sessionId);
|
await cancelPlanning(view.session.sessionId);
|
||||||
@@ -187,6 +309,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
|||||||
setError(null);
|
setError(null);
|
||||||
setResponseHistory([]);
|
setResponseHistory([]);
|
||||||
setEditedSummary(null);
|
setEditedSummary(null);
|
||||||
|
setStreamingOutput("");
|
||||||
|
currentSessionIdRef.current = null;
|
||||||
onClose();
|
onClose();
|
||||||
}, [view, onClose]);
|
}, [view, onClose]);
|
||||||
|
|
||||||
@@ -309,7 +433,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
|||||||
{view.type === "loading" && (
|
{view.type === "loading" && (
|
||||||
<div className="planning-loading">
|
<div className="planning-loading">
|
||||||
<Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} />
|
<Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} />
|
||||||
<p>Thinking...</p>
|
<p>{streamingOutput ? "AI is thinking..." : "Connecting..."}</p>
|
||||||
|
{streamingOutput && (
|
||||||
|
<div className="planning-thinking-container">
|
||||||
|
<button
|
||||||
|
className="planning-thinking-toggle"
|
||||||
|
onClick={() => setShowThinking(!showThinking)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{showThinking ? "Hide thinking" : "Show thinking"}
|
||||||
|
</button>
|
||||||
|
{showThinking && (
|
||||||
|
<div className="planning-thinking-output">
|
||||||
|
<pre>{streamingOutput}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -634,17 +634,32 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Clear thinking output for this turn
|
// Clear thinking output for this turn
|
||||||
const previousThinking = session.thinkingOutput;
|
|
||||||
session.thinkingOutput = "";
|
session.thinkingOutput = "";
|
||||||
|
|
||||||
// Send message to agent - it will stream thinking via onThinking callback
|
// Send message to agent using .prompt() - it will stream thinking via onThinking callback
|
||||||
const response = await session.agent.session.send(message);
|
await session.agent.session.prompt(message);
|
||||||
|
|
||||||
// Combine any thinking output with the response text
|
// Get the response text from the agent's state
|
||||||
const fullResponse = session.thinkingOutput + (response?.text || "");
|
const lastMessage = session.agent.session.state.messages
|
||||||
|
.filter(m => m.role === "assistant")
|
||||||
|
.pop();
|
||||||
|
|
||||||
|
let responseText = session.thinkingOutput;
|
||||||
|
if (lastMessage?.content) {
|
||||||
|
// Handle both string and array content types
|
||||||
|
if (typeof lastMessage.content === "string") {
|
||||||
|
responseText = lastMessage.content;
|
||||||
|
} else if (Array.isArray(lastMessage.content)) {
|
||||||
|
// Extract text from content blocks
|
||||||
|
responseText = lastMessage.content
|
||||||
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||||
|
.map(c => c.text)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Parse the JSON response
|
// Parse the JSON response
|
||||||
const parsed = parseAgentResponse(fullResponse);
|
const parsed = parseAgentResponse(responseText);
|
||||||
|
|
||||||
if (parsed.type === "question") {
|
if (parsed.type === "question") {
|
||||||
session.currentQuestion = parsed.data;
|
session.currentQuestion = parsed.data;
|
||||||
|
|||||||
@@ -2169,6 +2169,44 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/planning/start-streaming
|
||||||
|
* Start a new planning session with AI agent streaming.
|
||||||
|
* Body: { initialPlan: string }
|
||||||
|
* Returns: { sessionId: string }
|
||||||
|
*
|
||||||
|
* After receiving sessionId, connect to GET /api/planning/:sessionId/stream
|
||||||
|
* for real-time thinking output and questions.
|
||||||
|
*/
|
||||||
|
router.post("/planning/start-streaming", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { initialPlan } = req.body;
|
||||||
|
|
||||||
|
if (!initialPlan || typeof initialPlan !== "string") {
|
||||||
|
res.status(400).json({ error: "initialPlan is required and must be a string" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialPlan.length > 500) {
|
||||||
|
res.status(400).json({ error: "initialPlan must be 500 characters or less" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||||
|
const rootDir = store.getRootDir();
|
||||||
|
|
||||||
|
const { createSessionWithAgent, RateLimitError } = await import("./planning.js");
|
||||||
|
const sessionId = await createSessionWithAgent(ip, initialPlan, rootDir);
|
||||||
|
res.status(201).json({ sessionId });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === "RateLimitError") {
|
||||||
|
res.status(429).json({ error: err.message });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: err.message || "Failed to start planning session" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/planning/respond
|
* POST /api/planning/respond
|
||||||
* Submit a response to the current planning question.
|
* Submit a response to the current planning question.
|
||||||
@@ -2322,7 +2360,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
// Subscribe to session events
|
// Subscribe to session events
|
||||||
const unsubscribe = planningStreamManager.subscribe(sessionId, (event) => {
|
const unsubscribe = planningStreamManager.subscribe(sessionId, (event) => {
|
||||||
try {
|
try {
|
||||||
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event.data ?? {})}\n\n`);
|
const data = (event as { data?: unknown }).data;
|
||||||
|
res.write(`event: ${event.type}\ndata: ${JSON.stringify(data ?? {})}\n\n`);
|
||||||
|
|
||||||
// End stream on complete or error
|
// End stream on complete or error
|
||||||
if (event.type === "complete" || event.type === "error") {
|
if (event.type === "complete" || event.type === "error") {
|
||||||
|
|||||||
187
packages/engine/src/transient-error-detector.test.ts
Normal file
187
packages/engine/src/transient-error-detector.test.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
isTransientError,
|
||||||
|
classifyError,
|
||||||
|
TRANSIENT_ERROR_PATTERNS,
|
||||||
|
} from "./transient-error-detector.js";
|
||||||
|
import { isUsageLimitError } from "./usage-limit-detector.js";
|
||||||
|
|
||||||
|
describe("Transient Error Detector", () => {
|
||||||
|
describe("isTransientError", () => {
|
||||||
|
// Core error messages from the task description
|
||||||
|
it("matches the full upstream connect error message", () => {
|
||||||
|
const message =
|
||||||
|
"upstream connect error or disconnect/reset before headers. retried and the latest reset reason: remote connection failure, transport failure reason: delayed connect error: Connection refused";
|
||||||
|
expect(isTransientError(message)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches 'upstream connect error'", () => {
|
||||||
|
expect(isTransientError("upstream connect error")).toBe(true);
|
||||||
|
expect(isTransientError("Upstream Connect Error")).toBe(true);
|
||||||
|
expect(isTransientError("UPSTREAM CONNECT ERROR")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches 'disconnect/reset before headers'", () => {
|
||||||
|
expect(isTransientError("disconnect/reset before headers")).toBe(true);
|
||||||
|
expect(isTransientError("Disconnect/Reset Before Headers")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches 'retried and the latest reset reason'", () => {
|
||||||
|
expect(isTransientError("retried and the latest reset reason: timeout")).toBe(true);
|
||||||
|
expect(isTransientError("Retried And The Latest Reset Reason")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches 'remote connection failure'", () => {
|
||||||
|
expect(isTransientError("remote connection failure")).toBe(true);
|
||||||
|
expect(isTransientError("Remote Connection Failure")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches 'transport failure reason'", () => {
|
||||||
|
expect(isTransientError("transport failure reason: connection reset")).toBe(true);
|
||||||
|
expect(isTransientError("Transport Failure Reason")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches 'delayed connect error'", () => {
|
||||||
|
expect(isTransientError("delayed connect error: Connection refused")).toBe(true);
|
||||||
|
expect(isTransientError("Delayed Connect Error")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches 'Connection refused'", () => {
|
||||||
|
expect(isTransientError("Connection refused")).toBe(true);
|
||||||
|
expect(isTransientError("connection refused")).toBe(true);
|
||||||
|
expect(isTransientError("CONNECTION REFUSED")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches 'connection reset'", () => {
|
||||||
|
expect(isTransientError("connection reset by peer")).toBe(true);
|
||||||
|
expect(isTransientError("Connection Reset")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches 'ECONNREFUSED'", () => {
|
||||||
|
expect(isTransientError("ECONNREFUSED")).toBe(true);
|
||||||
|
expect(isTransientError("Error: ECONNREFUSED")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches 'ETIMEDOUT'", () => {
|
||||||
|
expect(isTransientError("ETIMEDOUT")).toBe(true);
|
||||||
|
expect(isTransientError("Error: ETIMEDOUT")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches 'socket hang up'", () => {
|
||||||
|
expect(isTransientError("socket hang up")).toBe(true);
|
||||||
|
expect(isTransientError("Socket Hang Up")).toBe(true);
|
||||||
|
expect(isTransientError("Error: socket hang up")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches connection timeout patterns", () => {
|
||||||
|
expect(isTransientError("connection timeout")).toBe(true);
|
||||||
|
expect(isTransientError("timeout connection to server")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edge cases
|
||||||
|
it("returns false for empty string", () => {
|
||||||
|
expect(isTransientError("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for null", () => {
|
||||||
|
expect(isTransientError(null as unknown as string)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for undefined", () => {
|
||||||
|
expect(isTransientError(undefined as unknown as string)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for non-string values", () => {
|
||||||
|
expect(isTransientError(123 as unknown as string)).toBe(false);
|
||||||
|
expect(isTransientError({} as unknown as string)).toBe(false);
|
||||||
|
expect(isTransientError([] as unknown as string)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should NOT match non-transient errors
|
||||||
|
it("returns false for code errors", () => {
|
||||||
|
expect(isTransientError("SyntaxError: Unexpected token")).toBe(false);
|
||||||
|
expect(isTransientError("TypeError: Cannot read property")).toBe(false);
|
||||||
|
expect(isTransientError("ReferenceError: foo is not defined")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for test failures", () => {
|
||||||
|
expect(isTransientError("Assertion failed: expected 1 to be 2")).toBe(false);
|
||||||
|
expect(isTransientError("Test timeout of 5000ms exceeded")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for usage limit errors", () => {
|
||||||
|
expect(isTransientError("rate limit exceeded")).toBe(false);
|
||||||
|
expect(isTransientError("429 Too Many Requests")).toBe(false);
|
||||||
|
expect(isTransientError("API quota exceeded")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Partial matches should not trigger false positives
|
||||||
|
it("handles partial matches correctly", () => {
|
||||||
|
// Should not match just "error" or "timeout" without connection context
|
||||||
|
expect(isTransientError("An error occurred")).toBe(false);
|
||||||
|
// "timeout" alone is not in the patterns (only connection timeouts)
|
||||||
|
expect(isTransientError("timeout")).toBe(false);
|
||||||
|
expect(isTransientError("Request timeout")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("classifyError", () => {
|
||||||
|
it("classifies usage limit errors as 'usage-limit'", () => {
|
||||||
|
expect(classifyError("rate limit exceeded")).toBe("usage-limit");
|
||||||
|
expect(classifyError("429 Too Many Requests")).toBe("usage-limit");
|
||||||
|
expect(classifyError("API overloaded")).toBe("usage-limit");
|
||||||
|
expect(classifyError("quota exceeded")).toBe("usage-limit");
|
||||||
|
expect(classifyError("billing issue")).toBe("usage-limit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies transient errors as 'transient'", () => {
|
||||||
|
expect(classifyError("upstream connect error")).toBe("transient");
|
||||||
|
expect(classifyError("ECONNREFUSED")).toBe("transient");
|
||||||
|
expect(classifyError("socket hang up")).toBe("transient");
|
||||||
|
expect(classifyError("Connection refused")).toBe("transient");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies all other errors as 'permanent'", () => {
|
||||||
|
expect(classifyError("SyntaxError: Unexpected token")).toBe("permanent");
|
||||||
|
expect(classifyError("Test failed")).toBe("permanent");
|
||||||
|
expect(classifyError("Build error")).toBe("permanent");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Priority: usage limit > transient > permanent
|
||||||
|
it("prioritizes usage limits over transient errors", () => {
|
||||||
|
// Usage limit patterns should take precedence
|
||||||
|
const usageLimitMsg = "rate limit exceeded while connecting";
|
||||||
|
expect(isUsageLimitError(usageLimitMsg)).toBe(true);
|
||||||
|
expect(classifyError(usageLimitMsg)).toBe("usage-limit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty/invalid input as 'permanent'", () => {
|
||||||
|
expect(classifyError("")).toBe("permanent");
|
||||||
|
expect(classifyError(null as unknown as string)).toBe("permanent");
|
||||||
|
expect(classifyError(undefined as unknown as string)).toBe("permanent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies the full complex error message correctly", () => {
|
||||||
|
const message =
|
||||||
|
"upstream connect error or disconnect/reset before headers. retried and the latest reset reason: remote connection failure, transport failure reason: delayed connect error: Connection refused";
|
||||||
|
expect(classifyError(message)).toBe("transient");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TRANSIENT_ERROR_PATTERNS", () => {
|
||||||
|
it("exports the patterns array", () => {
|
||||||
|
expect(Array.isArray(TRANSIENT_ERROR_PATTERNS)).toBe(true);
|
||||||
|
expect(TRANSIENT_ERROR_PATTERNS.length).toBeGreaterThan(0);
|
||||||
|
// All patterns should be RegExp
|
||||||
|
TRANSIENT_ERROR_PATTERNS.forEach((pattern) => {
|
||||||
|
expect(pattern).toBeInstanceOf(RegExp);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all patterns have case-insensitive flag", () => {
|
||||||
|
TRANSIENT_ERROR_PATTERNS.forEach((pattern) => {
|
||||||
|
expect(pattern.flags).toContain("i");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
108
packages/engine/src/transient-error-detector.ts
Normal file
108
packages/engine/src/transient-error-detector.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Transient Error Detector — classifies network/infrastructure errors as transient
|
||||||
|
* (temporary and retryable) versus permanent failures.
|
||||||
|
*
|
||||||
|
* Transient errors indicate temporary conditions like network blips, proxy hiccups,
|
||||||
|
* connection resets, or temporary service unavailability. These errors typically
|
||||||
|
* resolve on their own after a short delay and should NOT mark tasks as failed.
|
||||||
|
*
|
||||||
|
* When a transient error is detected, the task should be moved back to "todo"
|
||||||
|
* for later retry rather than being marked as "failed". This prevents tasks from
|
||||||
|
* being incorrectly marked as failed due to temporary infrastructure issues.
|
||||||
|
*
|
||||||
|
* Contrast with:
|
||||||
|
* - Usage limit errors: Systemic conditions (rate limits, quota) → trigger global pause
|
||||||
|
* - Permanent errors: Code issues, test failures, logic errors → mark task as failed
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isUsageLimitError } from "./usage-limit-detector.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patterns that indicate transient network/infrastructure errors.
|
||||||
|
* These are checked case-insensitively against error messages.
|
||||||
|
*
|
||||||
|
* These patterns cover:
|
||||||
|
* - Proxy/gateway connection errors (upstream connect, disconnect/reset)
|
||||||
|
* - Connection refusal/reset (ECONNREFUSED, connection reset)
|
||||||
|
* - Timeouts (ETIMEDOUT, timeout in connection context)
|
||||||
|
* - Socket errors (socket hang up)
|
||||||
|
* - Transport layer failures
|
||||||
|
*/
|
||||||
|
export const TRANSIENT_ERROR_PATTERNS: RegExp[] = [
|
||||||
|
// Proxy/gateway errors - indicate temporary routing issues
|
||||||
|
/upstream connect error/i,
|
||||||
|
/disconnect\/reset before headers/i,
|
||||||
|
/retried and the latest reset reason/i,
|
||||||
|
/remote connection failure/i,
|
||||||
|
/transport failure reason/i,
|
||||||
|
/delayed connect error/i,
|
||||||
|
|
||||||
|
// Connection establishment failures - usually temporary
|
||||||
|
/Connection refused/i,
|
||||||
|
/connection reset/i,
|
||||||
|
/ECONNREFUSED/i,
|
||||||
|
/ETIMEDOUT/i,
|
||||||
|
/socket hang up/i,
|
||||||
|
|
||||||
|
// Timeout patterns (only when related to connections, not general timeouts)
|
||||||
|
/timeout.*connection/i,
|
||||||
|
/connection.*timeout/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error message indicates a transient network/infrastructure error.
|
||||||
|
*
|
||||||
|
* Transient errors are temporary conditions that typically resolve after a delay:
|
||||||
|
* - Network blips and temporary routing issues
|
||||||
|
* - Proxy/gateway hiccups (upstream connect errors)
|
||||||
|
* - Connection resets during establishment
|
||||||
|
* - Temporary service unavailability (connection refused)
|
||||||
|
* - Socket timeouts during connection
|
||||||
|
*
|
||||||
|
* Returns `true` for transient errors — these should trigger a retry by moving
|
||||||
|
* the task back to "todo" rather than marking as "failed".
|
||||||
|
*
|
||||||
|
* Returns `false` for permanent failures (code errors, test failures) or
|
||||||
|
* usage limit errors (rate limits that need global pause).
|
||||||
|
*
|
||||||
|
* @param errorMessage - The error message to classify
|
||||||
|
* @returns true if the error appears transient and retryable
|
||||||
|
*/
|
||||||
|
export function isTransientError(errorMessage: string): boolean {
|
||||||
|
if (!errorMessage || typeof errorMessage !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return TRANSIENT_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comprehensive error classification that distinguishes between:
|
||||||
|
* - 'usage-limit': Rate limits, quota exceeded, billing issues → triggers global pause
|
||||||
|
* - 'transient': Network blips, connection errors → move task to "todo" for retry
|
||||||
|
* - 'permanent': Code errors, test failures, logic errors → mark task as failed
|
||||||
|
*
|
||||||
|
* This function delegates to existing usage limit detection first (to preserve
|
||||||
|
* existing behavior), then checks for transient patterns, defaulting to
|
||||||
|
* 'permanent' for all other errors.
|
||||||
|
*
|
||||||
|
* @param errorMessage - The error message to classify
|
||||||
|
* @returns The error classification category
|
||||||
|
*/
|
||||||
|
export function classifyError(errorMessage: string): "transient" | "usage-limit" | "permanent" {
|
||||||
|
if (!errorMessage || typeof errorMessage !== "string") {
|
||||||
|
return "permanent";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check usage limits first (highest priority - triggers global pause)
|
||||||
|
if (isUsageLimitError(errorMessage)) {
|
||||||
|
return "usage-limit";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check transient patterns next (move to todo for retry)
|
||||||
|
if (isTransientError(errorMessage)) {
|
||||||
|
return "transient";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to permanent (mark as failed)
|
||||||
|
return "permanent";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user