fix: require a successful probe result before handoff

Adapt the bounded probe-completion foundation from PR #130 by aksalatdev (source commit 535202bebe038fa1e0648f5df809d5ccfc26d071), not its mixed stream-salvage implementation.

Require a non-failed call followed by a matching probe_done result with neither isError nor explicit details.ok:false. Preserve older clean acknowledgments, saved attempts, and all existing handoff/retry/provider policies. Share the native completion seam with phase detection and exclude explicit refusals from UI phases.

Verify retained failed-probe Retry recovery with real agent/tool composition and synthetic engines: one fresh conclusion, one handoff, then teacher selection, without stored-tool replay. Focused tests 58/58; full tests 2473/2473; typecheck and full ESLint pass in isolated storage with scrubbed provider credentials.

Co-authored-by: aksalatdev <73862605+aksalatdev@users.noreply.github.com>
This commit is contained in:
amosblomqvist
2026-10-27 20:49:04 +02:00
co-authored by aksalatdev
parent 354bd34c0c
commit f01fdf3260
7 changed files with 178 additions and 32 deletions
+27 -7
View File
@@ -669,15 +669,35 @@ export function danglingQuestionOf(
return null;
}
/** The probe→teach construction seam (chat-handler): a probe_done call in
* the transcript — any state — means the probe is concluded: teach. */
export function hasProbeDone(messages: readonly LhMessage[]): boolean {
/** Successful probe_done result MESSAGE ids, in transcript order. A call
* from a failed/aborted generation grants no completion authority. Its
* later result must match both call id and tool name, must not be an
* error, and must not explicitly refuse with details.ok:false. Older
* clean acknowledgments without ok:true still count. No history changes. */
export function probeDoneResultIds(messages: readonly LhMessage[]): Set<string> {
const calls = new Set<string>();
const results = new Set<string>();
for (const m of messages) {
if ((m as { role?: string }).role !== "assistant") continue;
const assistant = m as AssistantMessage;
if (assistant.content.some((b) => b.type === "toolCall" && b.name === "probe_done")) return true;
if (m.role === "assistant") {
if (m.stopReason === "error" || m.stopReason === "aborted") continue;
for (const block of m.content) {
if (block.type === "toolCall" && block.name === "probe_done") calls.add(block.id);
}
} else if (
m.role === "toolResult" && m.toolName === "probe_done" &&
calls.has(m.toolCallId) && !m.isError &&
(m.details as { ok?: unknown } | null | undefined)?.ok !== false
) {
results.add(m.id);
}
}
return false;
return results;
}
/** The probe→teach construction seam (chat-handler and the tool guard):
* only a concluded call plus a matching successful result means teach. */
export function hasProbeDone(messages: readonly LhMessage[]): boolean {
return probeDoneResultIds(messages).size > 0;
}
/** Every toolResult id in the record — answer-event validation (the call