fix(tools): the abort cascade — kill running tool calls, honest activity labels

Root cause of the live-test bugs (2026-09-19): pi passes every run's
AbortSignal to tool execute, but toPiTool dropped it — agent.abort()
only landed at the next step boundary, so a long local grep held its
turn hostage until the dev server's memory restart killed the run
mid-tool, freezing the record at its write-ahead state (the 'went
back in time' rollback).

- lh-tool: execute ctx gains signal (parent + timeout combined); every
  call races a 30s system-level timeout with a 250ms cooperative grace —
  abort kills a stuck tool but lets a poll-the-signal tool deliver its
  partial results. Pre-aborted signals never fire listeners: checked
  upfront. toolTimeoutMs is the test seam (pdfModelOverride idiom).
- fs-shared: walkFiles returns {timedOut, aborted} instead of throwing —
  abort yields partial results.
- grep: the scan phase was unbounded (the walk's 10s deadline only
  bounded collection) — same budget now checked per file; find/grep
  check the signal and return partials with a notice.
- pending/chat: streamingToolActivity names the ACTUAL running tool —
  a part sits input-streaming through its whole execution, so the old
  shimmer called minutes-long greps 'Writing a question…'.

Tests: toPiTool guard (forward, abort, timeout, grace, question
bypass, ToolInputError passthrough), walk mid-abort, find/grep
pre-abort + mid-run abort, activity labels. Gates: 617/617.
This commit is contained in:
amosblomqvist
2026-09-19 17:26:57 +03:00
parent 1b9122c88b
commit 36c5d19437
9 changed files with 430 additions and 21 deletions
+9 -9
View File
@@ -2,7 +2,7 @@
import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { createChatTransport } from "@/components/chat-transport";
import { useStickToBottomContext } from "use-stick-to-bottom";
import {
@@ -34,7 +34,7 @@ import { QuestionDock } from "@/components/question-dock";
import { SidebarToggle } from "@/components/sidebar";
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage, TurnUsage } from "@/src/core";
import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider";
import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
import { findPendingToolCall, phaseAt3, phaseTimeline, streamingToolActivity } from "@/src/core";
/** One width for everything: transcript, cards, and the bottom blob share this column. */
/**
@@ -262,12 +262,12 @@ export function Chat({
// session's current phase.
const phases = useMemo(() => phaseTimeline(messages), [messages]);
// A tool call still streaming its input: the teacher is writing a question.
// Shown as a transcript shimmer, exactly like the thinking indicator.
const questionStreaming =
!pending &&
messages.at(-1)?.role === "assistant" &&
(messages.at(-1)?.parts.some((p) => isToolUIPart(p) && p.state === "input-streaming") ?? false);
// A tool call still streaming its input: the teacher is mid-activity. The
// label names the ACTUAL tool (a long grep used to shimmer as “Writing a
// question…” for minutes — a part stays input-streaming through its whole
// execution, see streamingToolActivity); shown as the transcript shimmer,
// exactly like the thinking indicator.
const streamingActivity = !pending ? streamingToolActivity(messages) : null;
const [text, setText] = useState("");
// Voice dictation (STT wave, 2026-09-17): finals append at the end of
@@ -941,7 +941,7 @@ export function Chat({
))}
{(status === "submitted" || turnInFlight) && <Shimmer className="text-sm">Thinking…</Shimmer>}
{questionStreaming && <Shimmer className="text-sm">Writing a question…</Shimmer>}
{streamingActivity && <Shimmer className="text-sm">{streamingActivity}</Shimmer>}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
Something went wrong: {error.message}