ux(chat): clean tool-pill results (no raw JSON, Failed/exit verbs); structured build-health status instead of 'didn't reach a clean stopping point'; label active toolbar mode
This commit is contained in:
@@ -579,6 +579,83 @@ function lastToolResultsHadFailure(messages: ChatMessage[], lookback = 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pull a short, human-readable error out of the most recent failing tool
|
||||
// result so the build-health status can say WHAT broke (not just "didn't
|
||||
// reach a clean stopping point"). Secrets are already redacted upstream.
|
||||
function extractLastToolFailure(
|
||||
messages: ChatMessage[],
|
||||
lookback = 4,
|
||||
): string | null {
|
||||
const toolMsgs = messages.filter((m) => m.role === "tool").slice(-lookback);
|
||||
const clean = (s: string) => s.replace(/\s+/g, " ").trim().slice(0, 160);
|
||||
for (let i = toolMsgs.length - 1; i >= 0; i--) {
|
||||
const raw =
|
||||
typeof toolMsgs[i].content === "string"
|
||||
? (toolMsgs[i].content as string)
|
||||
: "";
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const p = JSON.parse(raw);
|
||||
if (typeof p.error === "string" && p.error.trim()) return clean(p.error);
|
||||
if (typeof p.exitCode === "number" && p.exitCode !== 0)
|
||||
return clean(
|
||||
`${p.stderr || p.stdout || "command failed"} (exit ${p.exitCode})`,
|
||||
);
|
||||
if (typeof p.code === "number" && p.code !== 0)
|
||||
return clean(
|
||||
`${p.stderr || p.stdout || "command failed"} (exit ${p.code})`,
|
||||
);
|
||||
if (p.healthCheck?.status && p.healthCheck.status >= 400)
|
||||
return clean(`health check returned ${p.healthCheck.status}`);
|
||||
if (p.ok === false && typeof p.message === "string")
|
||||
return clean(p.message);
|
||||
} catch {
|
||||
if (/(econnrefused|enoent|error|failed|exception)/i.test(raw))
|
||||
return clean(raw);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Deterministic, STRUCTURED build-health status used when the model's own
|
||||
// wrap-up comes back empty. Replaces the old vague "didn't reach a clean
|
||||
// stopping point" line with: what happened + the specific blocker + a clear
|
||||
// next action.
|
||||
function buildHealthStatus(opts: {
|
||||
loopBreakReason?: string | null;
|
||||
hitRoundCap: boolean;
|
||||
lastError: string | null;
|
||||
toolCount: number;
|
||||
}): string {
|
||||
const { loopBreakReason, hitRoundCap, lastError, toolCount } = opts;
|
||||
|
||||
if (lastError) {
|
||||
return (
|
||||
`I ran ${toolCount} step${toolCount === 1 ? "" : "s"} but hit a blocker: ` +
|
||||
`**${lastError}**. I didn't want to claim success on top of that. ` +
|
||||
`Want me to fix that specific issue and retry?`
|
||||
);
|
||||
}
|
||||
if (loopBreakReason) {
|
||||
return (
|
||||
`I kept hitting the same wall while working on this (${loopBreakReason}), ` +
|
||||
`so I stopped rather than spin. Want me to try a different approach, ` +
|
||||
`or take a look together?`
|
||||
);
|
||||
}
|
||||
if (hitRoundCap) {
|
||||
return (
|
||||
`I made progress across ${toolCount} step${toolCount === 1 ? "" : "s"} but ran out ` +
|
||||
`of room this turn before finishing. Say "continue" and I'll pick up ` +
|
||||
`exactly where I left off.`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`I worked through ${toolCount} step${toolCount === 1 ? "" : "s"} but didn't land a ` +
|
||||
`clean result. Want me to keep going, or take a different angle?`
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
await ensureChatTables();
|
||||
|
||||
@@ -1374,11 +1451,17 @@ export async function POST(request: Request) {
|
||||
assistantTextSegments.push(summary.text);
|
||||
emit({ type: "text", text: summary.text });
|
||||
} else {
|
||||
// Gemini returned empty — fall back to a deterministic
|
||||
// status so the user never sees silent ✓ pills.
|
||||
const fallback = loopBreakReason
|
||||
? `I hit a loop while working on this — ${loopBreakReason}. Want me to try a different approach, or do you want to take a look?`
|
||||
: `I ran a chain of ${assistantToolCalls.length} tool calls but didn't reach a clean stopping point. Want me to keep going, or take a different angle?`;
|
||||
// Gemini returned empty — fall back to a deterministic but
|
||||
// STRUCTURED build-health status (never a vague "didn't reach a
|
||||
// clean stopping point"). It states what happened, what broke,
|
||||
// and the next action, using the same signals as the telemetry
|
||||
// stop_reason.
|
||||
const fallback = buildHealthStatus({
|
||||
loopBreakReason,
|
||||
hitRoundCap: maxToolRounds > 0 && round >= maxToolRounds,
|
||||
lastError: extractLastToolFailure(messages),
|
||||
toolCount: assistantToolCalls.length,
|
||||
});
|
||||
assistantText += (assistantText ? "\n\n" : "") + fallback;
|
||||
assistantTextSegments.push(fallback);
|
||||
emit({ type: "text", text: fallback });
|
||||
@@ -1387,7 +1470,12 @@ export async function POST(request: Request) {
|
||||
emit({ type: "thinking", text: summary.thoughts });
|
||||
}
|
||||
} catch {
|
||||
const fallback = `I ran ${assistantToolCalls.length} tool calls but the wrap-up failed. Want me to retry, or try a different approach?`;
|
||||
const fallback = buildHealthStatus({
|
||||
loopBreakReason,
|
||||
hitRoundCap: maxToolRounds > 0 && round >= maxToolRounds,
|
||||
lastError: extractLastToolFailure(messages),
|
||||
toolCount: assistantToolCalls.length,
|
||||
});
|
||||
assistantText += (assistantText ? "\n\n" : "") + fallback;
|
||||
assistantTextSegments.push(fallback);
|
||||
emit({ type: "text", text: fallback });
|
||||
|
||||
@@ -210,12 +210,22 @@ function RailLink({
|
||||
aria-current={active ? "page" : undefined}
|
||||
style={{
|
||||
...linkBase,
|
||||
// Active item shows its label so the user always knows which mode
|
||||
// they're in; inactive items stay icon-only (with tooltip) to save
|
||||
// space. This makes the editor's modes explicit instead of a row of
|
||||
// unlabeled glyphs.
|
||||
width: active ? "auto" : 36,
|
||||
padding: active ? "0 12px" : 0,
|
||||
gap: active ? 6 : 0,
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
background: active ? "#f6f2ec" : "transparent",
|
||||
color: active ? "#1a1a1a" : "#6b665e",
|
||||
borderColor: active ? "#d9d2c5" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{active && <span>{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,6 +230,73 @@ function friendlyToolName(name: string): string {
|
||||
return map[dotted] || dotted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw tool result string (often a JSON blob like
|
||||
* `{ "code": 1, "stdout": "...", "stderr": "..." }`) into a short,
|
||||
* human-readable status. We NEVER show raw JSON in the chat — only a
|
||||
* clean verb + outcome (e.g. "Failed — exit 1: connection refused").
|
||||
*/
|
||||
function summarizeToolResult(result?: string): {
|
||||
ok: boolean;
|
||||
label: string;
|
||||
} | null {
|
||||
if (!result) return null;
|
||||
const raw = String(result).trim();
|
||||
if (!raw) return null;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const p = JSON.parse(raw);
|
||||
if (p && typeof p === "object") parsed = p as Record<string, unknown>;
|
||||
} catch {
|
||||
// not JSON — fall through to text heuristics
|
||||
}
|
||||
|
||||
const firstLine = (s: string) => s.replace(/\s+/g, " ").trim().slice(0, 80);
|
||||
|
||||
if (parsed) {
|
||||
const code = parsed.code;
|
||||
const stderr = typeof parsed.stderr === "string" ? parsed.stderr : "";
|
||||
const stdout = typeof parsed.stdout === "string" ? parsed.stdout : "";
|
||||
const errMsg = typeof parsed.error === "string" ? parsed.error : "";
|
||||
|
||||
// Explicit non-zero exit code → failure
|
||||
if (typeof code === "number" && code !== 0) {
|
||||
const detail = firstLine(stderr || errMsg || stdout);
|
||||
return {
|
||||
ok: false,
|
||||
label: detail
|
||||
? `Failed (exit ${code}) — ${detail}`
|
||||
: `Failed (exit ${code})`,
|
||||
};
|
||||
}
|
||||
if (errMsg && !/^null$/i.test(errMsg)) {
|
||||
return { ok: false, label: `Failed — ${firstLine(errMsg)}` };
|
||||
}
|
||||
if (parsed.ok === false) {
|
||||
return {
|
||||
ok: false,
|
||||
label: `Failed${stderr ? " — " + firstLine(stderr) : ""}`,
|
||||
};
|
||||
}
|
||||
// Success-ish: surface a tiny hint when available, else just "Done"
|
||||
if (typeof code === "number" && code === 0)
|
||||
return { ok: true, label: "Done" };
|
||||
return { ok: true, label: "Done" };
|
||||
}
|
||||
|
||||
// Plain-text heuristics
|
||||
const lower = raw.toLowerCase();
|
||||
if (
|
||||
/(econnrefused|enoent|error|failed|traceback|exception|not found|permission denied|cannot)/.test(
|
||||
lower,
|
||||
)
|
||||
) {
|
||||
return { ok: false, label: `Failed — ${firstLine(raw)}` };
|
||||
}
|
||||
return { ok: true, label: "Done" };
|
||||
}
|
||||
|
||||
// ── Markdown-lite renderer ────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtmlAttr(s: string): string {
|
||||
@@ -850,20 +917,26 @@ function TimelineToolGroup({
|
||||
{!e.result && e.status === "running" && (
|
||||
<span className="animate-pulse">...</span>
|
||||
)}
|
||||
{e.result && (
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: 0.7,
|
||||
maxWidth: 150,
|
||||
}}
|
||||
title={e.result}
|
||||
>
|
||||
— {e.result}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
// Render a clean, human summary of the outcome — never raw JSON.
|
||||
const summary = summarizeToolResult(e.result);
|
||||
if (!summary) return null;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 220,
|
||||
color: summary.ok ? "#6b8e6b" : "#c2554d",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
title={summary.label}
|
||||
>
|
||||
— {summary.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user