// Query state: QueryContext class. // // All per-query and per-turn mutable state lives here. Reentrant queries // (subagents) each get their own QueryContext instance, managed by index.ts. // Adding a new field = one property on the class. // // Extracted from index.ts so tests can import without activating the extension. import type { AssistantMessage, AssistantMessageEventStream, Model } from "@earendil-works/pi-ai"; import type { McpResult } from "./extract-tool-results.js"; import type { PromptStream } from "./prompt-stream.js"; export interface PendingToolCall { toolName: string; resolve: (result: McpResult) => void; } export class QueryContext { // Query-scoped (fully isolated per query) activeQuery: unknown | null = null; currentPiStream: AssistantMessageEventStream | null = null; latestCursor = 0; pendingToolCalls = new Map(); pendingResults = new Map(); /** tool_use ids emitted this turn. Sole purpose is routing a delivered result * to the owning query when several queries are in flight — pairing a result * to its call is done by id from Claude's tools/call _meta, not from here. */ turnToolCallIds: string[] = []; /** Streaming-input handle for the active query — how steers reach CC mid-turn. */ promptStream: PromptStream | null = null; /** Last rate-limit rejection seen on this query. Claude Code sends it just before the * failure it caused, which is the only thing tying the two together. */ rateLimitRejection: { rateLimitType?: string; resetsAt?: number } | null = null; /** Highest 5% utilization bucket we notified for, so repeat rate_limit_event spam is suppressed. */ lastRateLimitWarnStep: number | null = null; lastRateLimitWarnThreshold: number | undefined; // Per-turn (reset together) turnOutput: AssistantMessage | null = null; turnStarted = false; turnSawStreamEvent = false; turnSawToolCall = false; /** API message id from the last message_start, and whether its message_stop has * arrived. An `assistant` message under a different id while the stream is still * open is Claude Code's non-streaming fallback for a stalled stream. */ turnStreamMessageId: string | undefined; turnStreamOpen = false; /** turnBlocks length at that message_start: where an abandoned attempt's blocks begin. */ turnStreamBlockStart = 0; get turnBlocks(): Array { if (!this.turnOutput) throw new Error("turnBlocks accessed before resetTurnState"); return this.turnOutput.content; } /** Answer every parked MCP handler with `reason` and forget the turn's queued * results. Called when the query it belongs to is going away (abort, error, * normal end). Handlers must be *resolved*, not rejected: an error reply is * still a reply, and a handler left awaiting a subprocess that is gone keeps * CC's tools/call open forever, which wedges pi's turn behind it. */ releasePendingToolCalls(reason: string): void { for (const pending of this.pendingToolCalls.values()) pending.resolve({ content: [{ type: "text", text: reason }] }); this.pendingToolCalls.clear(); this.pendingResults.clear(); } resetTurnState(model: Model): void { this.turnOutput = { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp: Date.now(), }; this.turnStarted = false; this.turnSawStreamEvent = false; this.turnSawToolCall = false; this.turnStreamMessageId = undefined; this.turnStreamOpen = false; this.turnStreamBlockStart = 0; // turnToolCallIds is NOT reset — it persists across tool-result delivery // callbacks within the same assistant message so results can be routed to // this query while its handlers are still pending. } } let _ctx = new QueryContext(); export function ctx(): QueryContext { return _ctx; } // Test-only: replace the module-level context so test files start clean. // Not called from production. export function resetCtx(): void { _ctx = new QueryContext(); }