fix(stop+stability): stop button interrupts live generation; classifier, prompt + preview pane improvements

Stop button fix:
- Plumb AbortSignal end-to-end: callVibnChat → Gemini SDK (config.abortSignal) / OpenAI fetch → executeMcpTool (/api/mcp fetch)
- Treat abort as clean user stop (not fatal error); partial reply persisted with '(stopped by user)'

Classifier fix:
- Add timeout/gateway/5xx/connection-error vocabulary to diagnose intent
- Prevents 'I get a gateway timeout' from falling through to feature_build (40 rounds) and looping

Prompt / agent behaviour:
- Render verification is now scope-aware: small edits stop at green healthCheck; no browser_console/curl audit on healthy server
- Sanitize stale '### Phase Checkpoint' walls from loaded history so old threads stop biasing new turns
- Next.js dev command updated to --no-turbopack for container stability (per-route lazy compile caused cold-start 503s)
- New public page prompt: agent checks middleware allowlist in the same turn
- Scope discipline and QA-tool gating carried forward from prior session

Code cleanup:
- Remove duplicate AgentPhase declaration (TS2440)
- Remove dead checkpoint emit branch and orphan 'checkpoint' phase value
- Remove unused MAX_TOOL_ROUNDS constant

Preview pane (build status):
- 4-state machine: initial-load / building (with elapsed timer) / build-failed / not-running
- pollMs 0 → 5 000ms so dev-server recovery and build completion auto-update without refresh
- anatomy route + use-anatomy type: inFlightBuild gains createdAt for elapsed timer
This commit is contained in:
2026-06-10 21:40:48 -07:00
parent 39cb9194a5
commit 82a41f7e95
10 changed files with 638 additions and 337 deletions

View File

@@ -109,6 +109,8 @@ export async function callGeminiChat(opts: {
tools?: ToolDefinition[];
temperature?: number;
includeThoughts?: boolean;
/** Cancels the in-flight generation when the user clicks Stop. */
signal?: AbortSignal;
}): Promise<{
text: string;
thoughts: string;
@@ -129,6 +131,10 @@ export async function callGeminiChat(opts: {
const fns = toGeminiFunctions(opts.tools ?? []);
if (fns) config.tools = fns;
// Forward the request abort signal so a Stop click cancels the actual
// Gemini generation instead of only short-circuiting the next round.
if (opts.signal) config.abortSignal = opts.signal;
console.log("\n========================================================");
console.log("➡️ [GEMINI API REQUEST]");
console.log("========================================================");
@@ -227,11 +233,16 @@ export async function callGeminiChat(opts: {
finishReason: response.candidates?.[0]?.finishReason,
};
} catch (error) {
const aborted =
opts.signal?.aborted ||
(error instanceof Error && error.name === "AbortError");
return {
text: "",
thoughts: "",
toolCalls: [],
error: `GoogleGenAI error: ${error instanceof Error ? error.message : String(error)}`,
error: aborted
? "aborted"
: `GoogleGenAI error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}

View File

@@ -257,6 +257,8 @@ export async function callOpenAiCompatibleChat(opts: {
temperature?: number;
/** Unused for OpenAI-compat; kept for call-site symmetry */
includeThoughts?: boolean;
/** Cancels the in-flight request when the user clicks Stop. */
signal?: AbortSignal;
}): Promise<{
text: string;
thoughts: string;
@@ -330,13 +332,18 @@ export async function callOpenAiCompatibleChat(opts: {
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal: opts.signal,
});
} catch (e) {
const aborted =
opts.signal?.aborted || (e instanceof Error && e.name === "AbortError");
return {
text: "",
thoughts: "",
toolCalls: [],
error: `Network error: ${e instanceof Error ? e.message : String(e)}`,
error: aborted
? "aborted"
: `Network error: ${e instanceof Error ? e.message : String(e)}`,
};
}

View File

@@ -12,9 +12,9 @@
* Optional: VIBN_OPENAI_COMPATIBLE_MODEL (default deepseek-chat)
*/
import type { ChatMessage, ToolDefinition } from './gemini-chat';
import { callGeminiChat } from './gemini-chat';
import { callOpenAiCompatibleChat } from './openai-compatible-chat';
import type { ChatMessage, ToolDefinition } from "./gemini-chat";
import { callGeminiChat } from "./gemini-chat";
import { callOpenAiCompatibleChat } from "./openai-compatible-chat";
export type VibnChatCallOpts = {
systemPrompt: string;
@@ -22,11 +22,13 @@ export type VibnChatCallOpts = {
tools?: ToolDefinition[];
temperature?: number;
includeThoughts?: boolean;
/** Forwarded to the provider so a Stop click cancels the live request. */
signal?: AbortSignal;
};
export async function callVibnChat(opts: VibnChatCallOpts) {
const p = (process.env.VIBN_CHAT_PROVIDER || 'gemini').toLowerCase().trim();
if (p === 'deepseek' || p === 'openai_compatible') {
const p = (process.env.VIBN_CHAT_PROVIDER || "gemini").toLowerCase().trim();
if (p === "deepseek" || p === "openai_compatible") {
return callOpenAiCompatibleChat(opts);
}
return callGeminiChat(opts);

View File

@@ -12,13 +12,7 @@ import type { ToolDefinition } from "./gemini-chat";
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
export type AgentPhase =
| "plan"
| "recon"
| "checkpoint"
| "execute"
| "verify"
| "final";
export type AgentPhase = "plan" | "recon" | "execute" | "verify" | "final";
export type TurnIntent =
| "conversational"
@@ -77,7 +71,12 @@ export function filterToolsForPhase(
phase: AgentPhase,
intent: TurnIntent,
): ToolDefinition[] {
if (phase === "recon" || phase === "verify") {
// `verify` stays read-only: when the agent is checking its work it should
// inspect, not start new edits. `recon` and `execute` both expose the full
// toolset so the agent can read AND edit directly in one fluid turn — the
// old read-only `recon` paired with the (now-removed) forced checkpoint to
// gate editing, which made the agent plan and stall instead of acting.
if (phase === "verify") {
return tools.filter(
(t) =>
READ_ONLY_TOOLS.has(t.name) ||
@@ -85,10 +84,7 @@ export function filterToolsForPhase(
t.name === "browser_navigate",
);
}
if (phase === "execute") {
return tools; // All tools allowed
}
return tools; // Default fallback
return tools; // recon + execute: all tools allowed
}
export const VIBN_TOOL_DEFINITIONS: ToolDefinition[] = [
@@ -1887,6 +1883,7 @@ export async function executeMcpTool(
mcpToken: string,
baseUrl: string,
projectId?: string,
signal?: AbortSignal,
): Promise<string> {
if (toolName === "github_search") return executeGithubSearch(args);
if (toolName === "github_file") return executeGithubFile(args);
@@ -1923,6 +1920,7 @@ export async function executeMcpTool(
method: "POST",
headers,
body: JSON.stringify({ action, params }),
signal,
});
const data = await res.json();
return JSON.stringify(data.result ?? data.error ?? data, null, 2).slice(
@@ -1930,8 +1928,14 @@ export async function executeMcpTool(
8000,
);
} catch (e) {
const aborted =
signal?.aborted || (e instanceof Error && e.name === "AbortError");
return JSON.stringify({
error: e instanceof Error ? e.message : String(e),
error: aborted
? "aborted by user"
: e instanceof Error
? e.message
: String(e),
});
}
}