diff --git a/bun.lock b/bun.lock index 185d6908c..d0634b4b1 100644 --- a/bun.lock +++ b/bun.lock @@ -217,6 +217,7 @@ "@types/bun": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "linkedom": "catalog:", "postcss": "catalog:", }, }, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7de11cc4d..0af06bb6f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed advisor context maintenance undercounting the provider context: the compaction decision now anchors on the advisor's provider-reported context usage (cached input + generated output) floored by a full local estimate that includes the advisor system prompt and tool schemas, rejects stale provider usage retained across advisor compaction, and recovers a provider overflow by clearing only the advisor's own context at the current primary cursor — retrying the bounded failing batch once against a fresh context without replaying old primary history and keeping later updates eligible ([#5282](https://github.com/can1357/oh-my-pi/issues/5282)) - Improved search reliability for Perplexity provider by forcing retrieval for all queries - Fixed JS eval cells losing top-level `function` and `var` declarations across cells when the defining cell contained top-level `await` — the async wrapper scoped them to the cell's IIFE instead of publishing them to the worker global diff --git a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts index 179756d80..82a9085c4 100644 --- a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts +++ b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "bun:test"; import type { AgentMessage, AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import type { TUI } from "@oh-my-pi/pi-tui"; import { type } from "arktype"; import type { ModelRegistry } from "../../config/model-registry"; @@ -978,7 +980,7 @@ describe("advisor", () => { expect(promptInputs[1]).toContain("summary-bbb"); }); - it("triggers a re-prime and full replay when maintainContext returns true", async () => { + it("clears advisor context without replaying primary history when maintenance requests recovery", async () => { const promptInputs: string[] = []; let resetCount = 0; const agent: AdvisorAgent = { @@ -992,37 +994,326 @@ describe("advisor", () => { state: { messages: [] }, }; const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage]; - let shouldRePrime = false; + let shouldResetContext = false; const host: AdvisorRuntimeHost = { snapshotMessages: () => messages, enqueueAdvice: () => {}, maintainContext: async tokens => { expect(tokens).toBeGreaterThan(0); - return shouldRePrime; + return shouldResetContext; }, }; const runtime = new AdvisorRuntime(agent, host); - // First turn: normal incremental prompt runtime.onTurnEnd(messages); await Promise.resolve(); expect(promptInputs).toHaveLength(1); expect(promptInputs[0]).toContain("aaa"); expect(resetCount).toBe(0); - // Second turn: maintainContext resolves true, triggering a re-prime - shouldRePrime = true; + shouldResetContext = true; messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage); runtime.onTurnEnd(messages); await Promise.resolve(); await Promise.resolve(); - // The reset cleared history and prompted a full replay (so the batch contains both aaa and bbb) expect(promptInputs).toHaveLength(2); - expect(promptInputs[1]).toContain("aaa"); expect(promptInputs[1]).toContain("bbb"); + expect(promptInputs[1]).not.toContain("aaa"); expect(resetCount).toBe(1); }); + + it("preserves updates queued while async maintenance resets the advisor context", async () => { + const promptInputs: string[] = []; + let resetCount = 0; + const agent: AdvisorAgent = { + prompt: async input => { + promptInputs.push(input); + }, + abort: () => {}, + reset: () => { + resetCount++; + }, + state: { messages: [] }, + }; + const maintenanceStarted = Promise.withResolvers(); + const maintenanceFinished = Promise.withResolvers(); + let maintenanceCalls = 0; + const messages: AgentMessage[] = [{ role: "user", content: "bbb", timestamp: 1 } as AgentMessage]; + const host: AdvisorRuntimeHost = { + snapshotMessages: () => messages, + enqueueAdvice: () => {}, + maintainContext: async () => { + maintenanceCalls++; + if (maintenanceCalls !== 1) return false; + maintenanceStarted.resolve(); + return await maintenanceFinished.promise; + }, + }; + const runtime = new AdvisorRuntime(agent, host); + + runtime.onTurnEnd(messages); + await maintenanceStarted.promise; + messages.push({ role: "user", content: "ccc", timestamp: 2 } as AgentMessage); + runtime.onTurnEnd(messages); + maintenanceFinished.resolve(true); + await runtime.waitForCatchup(1000, 1); + + expect(promptInputs).toHaveLength(2); + expect(promptInputs[0]).toContain("bbb"); + expect(promptInputs[0]).not.toContain("ccc"); + expect(promptInputs[1]).toContain("ccc"); + expect(promptInputs[1]).not.toContain("bbb"); + expect(resetCount).toBe(1); + }); + + it("re-expands active primary context when maintenance clears advisor history", async () => { + const promptInputs: string[] = []; + const agent = makeAgent(promptInputs); + const planRule = + "Plan mode is active. You MUST remain read-only except for the approved plan file at local://PLAN.md."; + const messages: AgentMessage[] = [ + { role: "user", content: "aaa", timestamp: 1 } as AgentMessage, + { + role: "custom", + customType: "plan-mode-context", + content: planRule, + display: false, + timestamp: 2, + } as AgentMessage, + ]; + let shouldResetContext = false; + const host: AdvisorRuntimeHost = { + snapshotMessages: () => messages, + enqueueAdvice: () => {}, + maintainContext: async () => shouldResetContext, + }; + const runtime = new AdvisorRuntime(agent, host); + + runtime.onTurnEnd(messages); + await runtime.waitForCatchup(1000, 1); + expect(promptInputs[0]).toContain(planRule); + + shouldResetContext = true; + messages.push({ role: "user", content: "bbb", timestamp: 3 } as AgentMessage); + messages.push({ + role: "custom", + customType: "plan-mode-context", + content: planRule, + display: false, + timestamp: 4, + } as AgentMessage); + runtime.onTurnEnd(messages); + await runtime.waitForCatchup(1000, 1); + + expect(promptInputs).toHaveLength(2); + expect(promptInputs[1]).toContain("bbb"); + expect(promptInputs[1]).not.toContain("aaa"); + expect(promptInputs[1]).toContain(planRule); + expect(promptInputs[1]).not.toContain("unchanged — still in effect"); + }); + + it("recovers a provider overflow at the current cursor without replaying primary history", async () => { + const overflowMessage = "context_length_exceeded: Your input exceeds the context window of this model."; + const promptInputs: string[] = []; + const state: { messages: AgentMessage[]; error?: string } = { + messages: [{ role: "user", content: "existing advisor context", timestamp: 1 } as AgentMessage], + }; + let promptCalls = 0; + let resetCount = 0; + const agent: AdvisorAgent = { + prompt: async input => { + promptInputs.push(input); + promptCalls++; + state.error = promptCalls === 1 ? overflowMessage : undefined; + }, + abort: () => {}, + reset: () => { + resetCount++; + state.messages.length = 0; + state.error = undefined; + }, + state, + }; + const messages: AgentMessage[] = [ + { role: "user", content: "ancient-primary-one", timestamp: 1 } as AgentMessage, + { + role: "assistant", + content: [{ type: "text", text: "ancient-primary-two" }], + timestamp: 2, + } as AgentMessage, + ]; + const host: AdvisorRuntimeHost = { + snapshotMessages: () => messages, + enqueueAdvice: () => {}, + }; + const runtime = new AdvisorRuntime(agent, host, 0); + runtime.seedTo(messages.length); + + messages.push({ role: "user", content: "overflowing-current-update", timestamp: 3 } as AgentMessage); + runtime.onTurnEnd(messages); + await runtime.waitForCatchup(1000, 1); + + expect(promptInputs).toHaveLength(2); + for (const input of promptInputs) { + expect(input).toContain("overflowing-current-update"); + expect(input).not.toContain("ancient-primary-one"); + expect(input).not.toContain("ancient-primary-two"); + } + expect(resetCount).toBe(1); + + messages.push({ role: "user", content: "post-recovery-update", timestamp: 4 } as AgentMessage); + runtime.onTurnEnd(messages); + await runtime.waitForCatchup(1000, 1); + + expect(promptInputs).toHaveLength(3); + expect(promptInputs[2]).toContain("post-recovery-update"); + expect(promptInputs[2]).not.toContain("overflowing-current-update"); + expect(promptInputs[2]).not.toContain("ancient-primary-one"); + expect(promptInputs[2]).not.toContain("ancient-primary-two"); + expect(resetCount).toBe(1); + }); + + it("classifies structured overflow metadata before rolling back the failed turn", async () => { + const promptInputs: string[] = []; + const state: { messages: AgentMessage[]; error?: string } = { + messages: [{ role: "user", content: "existing advisor context", timestamp: 1 } as AgentMessage], + }; + let promptCalls = 0; + let resetCount = 0; + const agent: AdvisorAgent = { + prompt: async input => { + promptInputs.push(input); + promptCalls++; + if (promptCalls !== 1) { + state.error = undefined; + return; + } + state.messages.push({ role: "user", content: input, timestamp: 2 } as AgentMessage); + const failure: AssistantMessage = { + role: "assistant", + content: [], + api: "openai-responses", + provider: "openai", + model: "structured-overflow-model", + usage: { + input: 1, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 1, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "error", + errorMessage: "opaque provider rejection", + errorStatus: 400, + errorId: AIError.create(AIError.Flag.ContextOverflow), + timestamp: 3, + }; + state.messages.push(failure); + state.error = "opaque provider rejection"; + }, + abort: () => {}, + reset: () => { + resetCount++; + state.messages.length = 0; + state.error = undefined; + }, + rollbackTo: count => { + state.messages.length = Math.min(count, state.messages.length); + state.error = undefined; + }, + state, + }; + const messages: AgentMessage[] = [{ role: "user", content: "ancient-primary", timestamp: 1 } as AgentMessage]; + const host: AdvisorRuntimeHost = { + snapshotMessages: () => messages, + enqueueAdvice: () => {}, + }; + const runtime = new AdvisorRuntime(agent, host, 0); + runtime.seedTo(messages.length); + + messages.push({ role: "user", content: "structured-current-update", timestamp: 2 } as AgentMessage); + runtime.onTurnEnd(messages); + await runtime.waitForCatchup(1000, 1); + + expect(promptInputs).toHaveLength(2); + for (const input of promptInputs) { + expect(input).toContain("structured-current-update"); + expect(input).not.toContain("ancient-primary"); + } + expect(resetCount).toBe(1); + }); + + it("drops only a double-overflowing batch and continues queued and later updates", async () => { + const overflowMessage = "context_length_exceeded: Your input exceeds the context window of this model."; + const promptInputs: string[] = []; + const failures: unknown[] = []; + const secondAttemptStarted = Promise.withResolvers(); + const finishSecondAttempt = Promise.withResolvers(); + const state: { messages: AgentMessage[]; error?: string } = { + messages: [{ role: "user", content: "existing advisor context", timestamp: 1 } as AgentMessage], + }; + let failingAttempts = 0; + const agent: AdvisorAgent = { + prompt: async input => { + promptInputs.push(input); + if (!input.includes("first-overflow")) { + state.error = undefined; + return; + } + failingAttempts++; + if (failingAttempts === 2) { + secondAttemptStarted.resolve(); + await finishSecondAttempt.promise; + } + state.error = overflowMessage; + }, + abort: () => {}, + reset: () => { + state.messages.length = 0; + state.error = undefined; + }, + state, + }; + const messages: AgentMessage[] = [{ role: "user", content: "ancient-history", timestamp: 1 } as AgentMessage]; + const host: AdvisorRuntimeHost = { + snapshotMessages: () => messages, + enqueueAdvice: () => {}, + notifyFailure: error => failures.push(error), + }; + const runtime = new AdvisorRuntime(agent, host, 0); + runtime.seedTo(messages.length); + + messages.push({ role: "user", content: "first-overflow", timestamp: 2 } as AgentMessage); + runtime.onTurnEnd(messages); + await secondAttemptStarted.promise; + + messages.push({ role: "user", content: "queued-small-update", timestamp: 3 } as AgentMessage); + runtime.onTurnEnd(messages); + finishSecondAttempt.resolve(); + await runtime.waitForCatchup(1000, 1); + + expect(failingAttempts).toBe(2); + expect(promptInputs).toHaveLength(3); + for (const input of promptInputs.slice(0, 2)) { + expect(input).toContain("first-overflow"); + expect(input).not.toContain("ancient-history"); + } + expect(promptInputs[2]).toContain("queued-small-update"); + expect(promptInputs[2]).not.toContain("first-overflow"); + expect(promptInputs[2]).not.toContain("ancient-history"); + expect(failures).toHaveLength(1); + expect(runtime.backlog).toBe(0); + + messages.push({ role: "user", content: "later-small-update", timestamp: 4 } as AgentMessage); + runtime.onTurnEnd(messages); + await runtime.waitForCatchup(1000, 1); + + expect(promptInputs).toHaveLength(4); + expect(promptInputs[3]).toContain("later-small-update"); + expect(promptInputs[3]).not.toContain("first-overflow"); + }); it("tracks backlog and blocks until caught up", async () => { const promptInputs: string[] = []; const { promise: promptStarted, resolve: startPrompt } = Promise.withResolvers(); diff --git a/packages/coding-agent/src/advisor/runtime.ts b/packages/coding-agent/src/advisor/runtime.ts index 8c88f86ae..3937a7e6a 100644 --- a/packages/coding-agent/src/advisor/runtime.ts +++ b/packages/coding-agent/src/advisor/runtime.ts @@ -1,6 +1,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction"; import type { AssistantMessage, ImageContent, TextContent } from "@oh-my-pi/pi-ai"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { logger } from "@oh-my-pi/pi-utils"; import { obfuscateToolArguments, type SecretObfuscator } from "../secrets/obfuscator"; import { formatSessionHistoryMarkdown, PRIMARY_CONTEXT_CUSTOM_TYPES } from "../session/session-history-format"; @@ -35,10 +36,10 @@ export interface AdvisorRuntimeHost { * Pre-prompt context maintenance for the advisor's own append-only context. * Promotes the advisor model to a larger sibling when its context nears the * window (mirroring the primary's promote-first policy) and resolves `true` - * when the advisor should re-prime — reset and replay the current - * primary-bounded transcript — because promotion did not free enough room. - * Optional: hosts that omit it get no maintenance (context only shrinks when - * the primary's next compaction triggers {@link AdvisorRuntime.reset}). + * when the advisor must clear its own context before sending the current + * incremental update. The cursor stays at the current primary position: this + * recovery path must never replay the full primary transcript. + * Optional: hosts that omit it get no proactive maintenance. */ maintainContext?(incomingTokens: number): Promise; /** @@ -65,7 +66,10 @@ export interface AdvisorRuntimeHost { interface PendingDelta { text: string; + rawMessages: AgentMessage[]; + renderRevision: number; turns: number; + overflowRecovery?: boolean; } interface CatchupWaiter { @@ -83,6 +87,8 @@ export class AdvisorRuntime { * marker so the advisor isn't re-fed the full ~1k-token rules each turn. * Cleared on every re-prime/seed and when a failed batch is dropped. */ #seenContext = new Map(); + /** Incremented whenever the advisor loses context so queued raw deltas are re-rendered against fresh dedupe state. */ + #renderRevision = 0; #pending: PendingDelta[] = []; #busy = false; #backlog = 0; @@ -111,9 +117,9 @@ export class AdvisorRuntime { if (this.disposed) return; const all = messages ?? this.host.snapshotMessages(); this.#latestMessages = all; - const render = this.#renderDelta(all); - if (render) { - this.#pending.push({ text: render, turns: 1 }); + const rendered = this.#renderDelta(all); + if (rendered) { + this.#pending.push({ ...rendered, turns: 1 }); this.#backlog++; this.#notifyWaiters(); void this.#drain(); @@ -153,18 +159,15 @@ export class AdvisorRuntime { } catch {} } - #resetAdvisorContext(clearBacklog: boolean, wakeWaiters: boolean): void { - this.#lastCount = 0; - this.#pending = []; + #clearSeenContext(): void { + this.#seenContext.clear(); + this.#renderRevision++; + } + + #clearAdvisorContextAtCurrentCursor(): void { this.#consecutiveFailures = 0; this.#failureNotified = false; - this.#seenContext.clear(); - if (clearBacklog) { - this.#backlog = 0; - } - if (wakeWaiters) { - this.#wakeAllWaiters(); - } + this.#clearSeenContext(); try { this.agent.reset(); } catch {} @@ -173,6 +176,18 @@ export class AdvisorRuntime { } catch {} } + #resetAdvisorContext(clearBacklog: boolean, wakeWaiters: boolean): void { + this.#lastCount = 0; + this.#pending = []; + this.#clearAdvisorContextAtCurrentCursor(); + if (clearBacklog) { + this.#backlog = 0; + } + if (wakeWaiters) { + this.#wakeAllWaiters(); + } + } + /** * Re-prime the advisor after a history rewrite (compaction, session * switch/resume, branch). Clears the advisor's own (non-persisted) context @@ -196,22 +211,14 @@ export class AdvisorRuntime { this.#backlog = 0; this.#consecutiveFailures = 0; this.#failureNotified = false; - this.#seenContext.clear(); + this.#clearSeenContext(); this.#wakeAllWaiters(); } - #renderDelta(messages?: AgentMessage[]): string | null { - const all = messages ?? this.#latestMessages ?? this.host.snapshotMessages(); - if (all.length < this.#lastCount) { - this.#lastCount = all.length; - this.#seenContext.clear(); - return null; - } - const delta = all - .slice(this.#lastCount) - .filter(m => !(m.role === "custom" && (m as { customType?: string }).customType === "advisor")) - .map(m => this.#dedupContextMessage(m)); - this.#lastCount = all.length; + #formatRawDelta(rawMessages: AgentMessage[]): string | null { + const delta = rawMessages + .filter(message => !(message.role === "custom" && message.customType === "advisor")) + .map(message => this.#dedupContextMessage(message)); if (delta.length === 0) return null; const obfuscator = this.host.obfuscator; const formattedDelta = obfuscator?.hasSecrets() ? obfuscateAdvisorDelta(obfuscator, delta) : delta; @@ -226,6 +233,19 @@ export class AdvisorRuntime { return `### Session update\n\n${md}`; } + #renderDelta(messages?: AgentMessage[]): Omit | null { + const all = messages ?? this.#latestMessages ?? this.host.snapshotMessages(); + if (all.length < this.#lastCount) { + this.#lastCount = all.length; + this.#clearSeenContext(); + return null; + } + const rawMessages = all.slice(this.#lastCount); + this.#lastCount = all.length; + const text = this.#formatRawDelta(rawMessages); + return text ? { text, rawMessages, renderRevision: this.#renderRevision } : null; + } + /** * Collapse a re-injected primary-context prompt (plan/goal mode rules, the * approved plan) to a short marker when its body is byte-identical to the @@ -236,12 +256,12 @@ export class AdvisorRuntime { */ #dedupContextMessage(msg: AgentMessage): AgentMessage { if (msg.role !== "custom") return msg; - const type = (msg as { customType?: string }).customType; - if (!type || !PRIMARY_CONTEXT_CUSTOM_TYPES.has(type)) return msg; - const content = (msg as { content?: unknown }).content; + const type = msg.customType; + if (!PRIMARY_CONTEXT_CUSTOM_TYPES.has(type)) return msg; + const content = msg.content; if (typeof content !== "string") return msg; if (this.#seenContext.get(type) === content) { - return { ...(msg as object), content: "(unchanged — still in effect)" } as AgentMessage; + return { ...msg, content: "(unchanged — still in effect)" }; } this.#seenContext.set(type, content); return msg; @@ -284,27 +304,61 @@ export class AdvisorRuntime { } } + #terminalAssistantFailure(snapshot: number): AssistantMessage | undefined { + const messages = this.agent.state.messages; + for (let i = messages.length - 1; i >= snapshot; i--) { + const message = messages[i]; + if (message.role === "assistant" && message.stopReason === "error") return message; + } + return undefined; + } + + #notifyFailureOnce(error: unknown): void { + if (this.#failureNotified) return; + this.#failureNotified = true; + try { + this.host.notifyFailure?.(error); + } catch (notifyErr) { + logger.warn("advisor failure notification failed", { err: String(notifyErr) }); + } + } + async #drain(): Promise { if (this.#busy) return; this.#busy = true; try { while (!this.disposed && this.#pending.length) { - const popped = this.#pending.splice(0); + let popped: PendingDelta[]; + if (this.#pending[0]?.overflowRecovery) { + const recovery = this.#pending.shift(); + if (!recovery) continue; + popped = [recovery]; + } else { + popped = this.#pending.splice(0); + } const epoch = this.#epoch; + for (const delta of popped) { + if (delta.renderRevision === this.#renderRevision) continue; + const refreshed = this.#formatRawDelta(delta.rawMessages); + if (refreshed) delta.text = refreshed; + delta.renderRevision = this.#renderRevision; + } + const rawMessages = popped.flatMap(delta => delta.rawMessages); // Each delta already opens with a `### Session update` heading, so // join with a blank line rather than a `---` rule. - const candidateBatch = popped.map(b => b.text).join("\n\n"); - const turnsCovered = popped.reduce((sum, b) => sum + b.turns, 0); + let batch = popped.map(delta => delta.text).join("\n\n"); + const finalTurns = popped.reduce((sum, delta) => sum + delta.turns, 0); + const recoveringOverflow = popped.some(delta => delta.overflowRecovery === true); const incomingTokens = estimateTokens({ role: "user", - content: candidateBatch, + content: batch, timestamp: Date.now(), }); - let shouldReprime = false; + let shouldResetContext = false; if (this.host.maintainContext) { try { - shouldReprime = await this.host.maintainContext(incomingTokens); + shouldResetContext = await this.host.maintainContext(incomingTokens); } catch (err) { logger.debug("advisor context maintenance failed", { err: String(err) }); } @@ -312,20 +366,16 @@ export class AdvisorRuntime { // A reset/dispose during context maintenance invalidates this batch. if (this.#epoch !== epoch) continue; - let batch: string | null; - let finalTurns: number; - if (shouldReprime) { - // Promotion could not fit the advisor's context — re-prime. - const newTurns = this.#pending.reduce((sum, b) => sum + b.turns, 0); - this.#resetAdvisorContext(false, false); - batch = this.#renderDelta(this.#latestMessages); - finalTurns = turnsCovered + newTurns; - } else { - batch = candidateBatch; - finalTurns = turnsCovered; + if (shouldResetContext) { + // Reset only the advisor Agent/log. The primary cursor, queued deltas, + // backlog, waiters, latest snapshot, and epoch stay untouched. Re-render + // only this already-popped raw batch so active plan/reference bodies are + // restored without replaying any older primary transcript. + this.#clearAdvisorContextAtCurrentCursor(); + batch = this.#formatRawDelta(rawMessages) ?? batch; } - if (this.disposed || batch === null) { + if (this.disposed) { this.#backlog = Math.max(0, this.#backlog - finalTurns); this.#notifyWaiters(); continue; @@ -338,6 +388,7 @@ export class AdvisorRuntime { // failed batch on top of the stale turns and the dropped-after-3 path // would leak orphan failures into the next successful run's context. const messageSnapshot = this.agent.state.messages.length; + const contextWasFresh = shouldResetContext || recoveringOverflow || messageSnapshot === 0; try { // Reset the host's per-update advisor state (one-advise-per-update // gate) before each model cycle, so the new batch starts with a @@ -356,11 +407,14 @@ export class AdvisorRuntime { this.#consecutiveFailures = 0; this.#failureNotified = false; } catch (err) { - // reset()/dispose() aborts the in-flight prompt; the rejection is the - // reset itself, not a transient advisor failure. Drop the stale batch - // (reset already cleared #pending and rewound the cursor) instead of - // requeuing it into the post-reset conversation. + // An external reset/dispose invalidates the in-flight bounded batch; + // never requeue it into the post-reset conversation. if (this.#epoch !== epoch) continue; + const terminalFailure = this.#terminalAssistantFailure(messageSnapshot); + const contextOverflow = + (terminalFailure !== undefined && + AIError.is(AIError.classifyMessage(terminalFailure), AIError.Flag.ContextOverflow)) || + AIError.is(AIError.classify(err), AIError.Flag.ContextOverflow); this.#rollbackFailedTurn(messageSnapshot); logger.debug("advisor turn failed", { err: String(err) }); try { @@ -371,26 +425,48 @@ export class AdvisorRuntime { // The hook awaits; a reset during it invalidates this batch like the // prompt await above — drop it instead of requeueing stale content. if (this.#epoch !== epoch) continue; - this.#consecutiveFailures++; - if (this.#consecutiveFailures >= 3) { - logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall"); - if (!this.#failureNotified) { - this.#failureNotified = true; - try { - this.host.notifyFailure?.(err); - } catch (notifyErr) { - logger.warn("advisor failure notification failed", { err: String(notifyErr) }); - } + if (contextOverflow) { + this.#clearAdvisorContextAtCurrentCursor(); + if (contextWasFresh) { + // The bounded update cannot fit even with no advisor history. Drop + // only this batch after its one fresh-context retry; pending and later + // deltas remain eligible so one oversized update cannot disable the advisor. + logger.warn("advisor update overflowed a fresh context; dropping bounded batch"); + this.#notifyFailureOnce(err); + success = true; + } else { + // Retry once against the fresh advisor context, using only the same + // bounded raw batch. Pending updates remain queued behind it. + const recoveryBatch = this.#formatRawDelta(rawMessages) ?? batch; + this.#pending.unshift({ + text: recoveryBatch, + rawMessages, + renderRevision: this.#renderRevision, + turns: finalTurns, + overflowRecovery: true, + }); + logger.debug("advisor context overflow recovered at current primary cursor"); } - this.#consecutiveFailures = 0; - // The dropped batch may carry primary-context we never delivered; drop - // the seen-state too so the next turn re-expands it instead of marking - // it "unchanged" against content the advisor never received. - this.#seenContext.clear(); - success = true; } else { - this.#pending.unshift({ text: batch, turns: finalTurns }); - await Bun.sleep(this.retryDelayMs); + this.#consecutiveFailures++; + if (this.#consecutiveFailures >= 3) { + logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall"); + this.#notifyFailureOnce(err); + this.#consecutiveFailures = 0; + // The dropped batch may carry primary-context we never delivered; drop + // the seen-state too so queued raw deltas re-expand before delivery. + this.#clearSeenContext(); + success = true; + } else { + this.#pending.unshift({ + text: batch, + rawMessages, + renderRevision: this.#renderRevision, + turns: finalTurns, + overflowRecovery: recoveringOverflow || undefined, + }); + await Bun.sleep(this.retryDelayMs); + } } } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 31d6b1383..5e035c8a2 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -248,7 +248,11 @@ import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate"; import { theme } from "../modes/theme/theme"; import { parseTurnBudget } from "../modes/turn-budget"; import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink"; -import { computeNonMessageBreakdown, computeNonMessageTokens } from "../modes/utils/context-usage"; +import { + computeNonMessageBreakdown, + computeNonMessageTokens, + estimateToolSchemaTokens, +} from "../modes/utils/context-usage"; import { containsWorkflow, renderWorkflowNotice } from "../modes/workflow"; import { createPlanReadMatcher } from "../plan-mode/plan-protection"; import type { PlanModeState } from "../plan-mode/state"; @@ -1037,6 +1041,13 @@ interface ActiveAdvisor { signature: string; } +/** Runtime-only advisor compaction metadata. It never enters the model-facing summary text. */ +interface AdvisorCompactionSummaryMessage extends CompactionSummaryMessage { + firstKeptEntryId?: string; + /** First message index eligible to anchor provider usage after this compaction. */ + advisorUsageAnchorStartIndex?: number; +} + /** Resolved advisor config ready to instantiate as an {@link ActiveAdvisor}. */ interface AdvisorRuntimeDescriptor { config: AdvisorConfig; @@ -2849,10 +2860,23 @@ export class AgentSession { if (contextWindow <= 0) return false; const messages = agent.state.messages; - let contextTokens = incomingTokens; + const estimateOptions = { excludeEncryptedReasoning: true } as const; + let storedConversationTokens = 0; for (const message of messages) { - contextTokens += estimateTokens(message); + storedConversationTokens += estimateTokens(message, estimateOptions); } + // Provider usage (including cache reads and generated output) is the + // trustworthy anchor for accumulated context. Add only the trailing incoming + // delta to that arm. Floor it by a full local estimate — fixed advisor system + // prompt, tool schemas, stored messages, and incoming delta — so provider + // under-reporting or payload transforms cannot suppress maintenance. + const providerContextTokens = this.#estimateAdvisorContextTokens(messages) + incomingTokens; + const localContextTokens = + countTokens(agent.state.systemPrompt) + + estimateToolSchemaTokens(agent.state.tools) + + storedConversationTokens + + incomingTokens; + const contextTokens = compactionContextTokens(providerContextTokens, localContextTokens); if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) { return false; @@ -2876,6 +2900,7 @@ export class AgentSession { const timestamp = String(message.timestamp || Date.now()); if (message.role === "compactionSummary") { + const advisorSummary = message as AdvisorCompactionSummaryMessage; return { type: "compaction", id, @@ -2883,9 +2908,7 @@ export class AgentSession { timestamp, summary: message.summary, shortSummary: message.shortSummary, - firstKeptEntryId: - (message as CompactionSummaryMessage & { firstKeptEntryId?: string }).firstKeptEntryId || - `msg-${i + 1}`, + firstKeptEntryId: advisorSummary.firstKeptEntryId || `msg-${i + 1}`, tokensBefore: message.tokensBefore, } satisfies CompactionEntry; } @@ -2979,11 +3002,15 @@ export class AgentSession { const firstKeptEntryId = compactResult.firstKeptEntryId; const tokensBefore = compactResult.tokensBefore; - // Rebuild messages with the compaction summary + // The retained messages still carry provider usage from before this + // compaction. Record their exact array boundary on the in-memory summary so + // only assistants appended afterward can become the next usage anchor. + const advisorUsageAnchorStartIndex = preparation.recentMessages.length + 1; const summaryMessage = { ...createCompactionSummaryMessage(summary, tokensBefore, new Date().toISOString(), shortSummary), firstKeptEntryId, - } as CompactionSummaryMessage & { firstKeptEntryId?: string }; + advisorUsageAnchorStartIndex, + } satisfies AdvisorCompactionSummaryMessage; agent.replaceMessages([summaryMessage, ...preparation.recentMessages]); return false; @@ -16426,37 +16453,51 @@ export class AgentSession { } /** - * Estimate the advisor's current context tokens. When the advisor has a - * recent non-aborted assistant message with usage, use that prompt's token - * count and add a trailing estimate for messages after it. Otherwise estimate - * every message. + * Estimate the advisor's current context tokens. A successful provider usage + * after the latest advisor compaction is ground truth for the prompt plus its + * generated output; only messages after that anchor are estimated. Usage from + * retained pre-compaction messages is stale and must not immediately retrigger + * maintenance on the newly compacted context. */ #estimateAdvisorContextTokens(messages: AgentMessage[]): number { - let lastUsageIndex: number | null = null; - let lastUsage: AssistantMessage["usage"] | undefined; + let usageAnchorStartIndex = 0; for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg.role === "assistant") { - const assistantMsg = msg as AssistantMessage; - if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) { - lastUsage = assistantMsg.usage; - lastUsageIndex = i; - break; - } + const message = messages[i]; + if (message.role !== "compactionSummary") continue; + const advisorSummary = message as AdvisorCompactionSummaryMessage; + // Advisor summaries created before this runtime-only boundary existed have + // no trustworthy way to distinguish retained from newly appended messages. + // Conservatively ignore every current assistant until the next compaction. + usageAnchorStartIndex = advisorSummary.advisorUsageAnchorStartIndex ?? messages.length; + break; + } + + let lastUsageIndex: number | undefined; + let lastUsage: AssistantMessage["usage"] | undefined; + for (let i = messages.length - 1; i >= usageAnchorStartIndex; i--) { + const message = messages[i]; + if (message.role !== "assistant") continue; + const assistant = message as AssistantMessage; + if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error" && assistant.usage) { + lastUsage = assistant.usage; + lastUsageIndex = i; + break; } } - if (!lastUsage || lastUsageIndex === null) { + + const estimateOptions = { excludeEncryptedReasoning: true } as const; + if (!lastUsage || lastUsageIndex === undefined) { let estimated = 0; for (const message of messages) { - estimated += estimateTokens(message); + estimated += estimateTokens(message, estimateOptions); } return estimated; } let trailingTokens = 0; for (let i = lastUsageIndex + 1; i < messages.length; i++) { - trailingTokens += estimateTokens(messages[i]); + trailingTokens += estimateTokens(messages[i], estimateOptions); } - return calculatePromptTokens(lastUsage) + trailingTokens; + return calculateContextTokens(lastUsage) + trailingTokens; } /** diff --git a/packages/coding-agent/test/advisor-context-maintenance.test.ts b/packages/coding-agent/test/advisor-context-maintenance.test.ts new file mode 100644 index 000000000..c121501ec --- /dev/null +++ b/packages/coding-agent/test/advisor-context-maintenance.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { Agent, type AgentMessage, type CompactionSummaryMessage, countTokens } from "@oh-my-pi/pi-agent-core"; +import { calculateContextTokens, estimateTokens, resolveThresholdTokens } from "@oh-my-pi/pi-agent-core/compaction"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { createMockModel, type MockModel } from "@oh-my-pi/pi-ai/providers/mock"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { estimateToolSchemaTokens } from "@oh-my-pi/pi-coding-agent/modes/utils/context-usage"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +const CONTEXT_WINDOW = 372_000; +const CACHE_READ_TOKENS = 371_200; +const INPUT_TOKENS = 200; +const OUTPUT_TOKENS = 150; + +interface MaintenanceHarness { + advisor: Agent; + advisorMock: MockModel; + settings: Settings; +} + +interface AdvisorCompactionSummaryFixture extends CompactionSummaryMessage { + advisorUsageAnchorStartIndex?: number; +} + +describe("AgentSession advisor context maintenance", () => { + let tempDir: TempDir; + let authStorage: AuthStorage; + let session: AgentSession; + + beforeEach(async () => { + tempDir = TempDir.createSync("@pi-advisor-context-maintenance-"); + authStorage = await AuthStorage.create(tempDir.join("auth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await session?.dispose(); + authStorage.close(); + await tempDir.remove(); + }); + + function createHarness(): MaintenanceHarness { + const primaryMock = createMockModel({ + provider: "anthropic", + responses: [{ content: ["primary complete"] }], + }); + const advisorMock = createMockModel({ + provider: "anthropic", + contextWindow: CONTEXT_WINDOW, + responses: [{ content: ["advisor reviewed current update"] }], + }); + const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); + const settings = Settings.isolated({ + "advisor.syncBacklog": "1", + "compaction.enabled": true, + "compaction.strategy": "context-full", + "contextPromotion.enabled": false, + }); + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model: primaryMock, systemPrompt: [], tools: [] }, + streamFn: primaryMock.stream, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + advisorTools: [], + advisorStreamFn: advisorMock.stream, + }); + settings.setModelRole("advisor", "anthropic/claude-sonnet-4-5"); + expect(session.setAdvisorEnabled(true)).toBe(true); + const advisor = session.getAdvisorAgent(); + if (!advisor) throw new Error("Expected advisor agent to be active"); + advisor.setModel(advisorMock); + + // Keep maintenance on the no-summary recovery branch without blocking the + // primary prompt's own credential preflight. + vi.spyOn(modelRegistry, "getApiKey").mockImplementation(async model => + model === primaryMock ? "test-key" : undefined, + ); + return { advisor, advisorMock, settings }; + } + + function usageAnchor(advisorMock: MockModel, timestamp: number): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: "prior advisor output" }], + api: advisorMock.api, + provider: advisorMock.provider, + model: advisorMock.id, + usage: { + input: INPUT_TOKENS, + output: OUTPUT_TOKENS, + cacheRead: CACHE_READ_TOKENS, + cacheWrite: 0, + totalTokens: CACHE_READ_TOKENS + INPUT_TOKENS + OUTPUT_TOKENS, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp, + }; + } + + function compactionSummary(timestamp: number): AdvisorCompactionSummaryFixture { + return { + role: "compactionSummary", + summary: "bounded advisor summary", + tokensBefore: CACHE_READ_TOKENS + INPUT_TOKENS + OUTPUT_TOKENS, + timestamp, + // `[summary, retained]` is the compacted array; index 2 is the first + // position eligible for a newly appended provider-usage anchor. + advisorUsageAnchorStartIndex: 2, + }; + } + + it("maintains a 371,200-token cached advisor context before the 372,000-token window", async () => { + const { advisor, advisorMock, settings } = createHarness(); + const anchor = usageAnchor(advisorMock, Date.now() - 1_000); + advisor.state.messages.push(anchor); + + await session.prompt("small current update"); + + expect(advisorMock.calls).toHaveLength(1); + const advisorCall = advisorMock.calls[0]; + const update = advisorCall.context.messages.find(message => message.role === "user"); + if (!update) throw new Error("Expected the advisor's incremental update"); + const threshold = resolveThresholdTokens(CONTEXT_WINDOW, settings.getGroup("compaction")); + const providerAndUpdateTokens = calculateContextTokens(anchor.usage) + estimateTokens(update as AgentMessage); + expect(calculateContextTokens(anchor.usage)).toBe(CACHE_READ_TOKENS + INPUT_TOKENS + OUTPUT_TOKENS); + expect(providerAndUpdateTokens).toBeGreaterThan(threshold); + + // Provider usage triggers maintenance, but recovery sends only the bounded + // current update into the reset advisor context. + expect(JSON.stringify(advisorCall.context.messages)).toContain("small current update"); + expect(JSON.stringify(advisor.state.messages)).not.toContain("prior advisor output"); + }); + + it("includes advisor system prompt and tool schemas in the local maintenance floor", async () => { + const { advisor, advisorMock, settings } = createHarness(); + const seed: AgentMessage = { role: "user", content: "small stored advisor message", timestamp: 1 }; + advisor.state.messages.push(seed); + const storedTokens = estimateTokens(seed, { excludeEncryptedReasoning: true }); + const fixedPrefixTokens = countTokens(advisor.state.systemPrompt) + estimateToolSchemaTokens(advisor.state.tools); + const threshold = storedTokens + Math.floor(fixedPrefixTokens / 2); + settings.set("compaction.thresholdTokens", threshold); + + await session.prompt("tiny local-floor update"); + + const advisorCall = advisorMock.calls[0]; + const update = advisorCall.context.messages.find(message => message.role === "user"); + if (!update) throw new Error("Expected the advisor's incremental update"); + const messagesOnlyTokens = storedTokens + estimateTokens(update as AgentMessage); + expect(messagesOnlyTokens).toBeLessThan(threshold); + expect(messagesOnlyTokens + fixedPrefixTokens).toBeGreaterThan(threshold); + expect(JSON.stringify(advisor.state.messages)).not.toContain("small stored advisor message"); + }); + + it("ignores retained provider usage that predates the latest advisor compaction", async () => { + const { advisor, advisorMock } = createHarness(); + const compactedAt = Date.now(); + const summary = compactionSummary(compactedAt); + const retained = usageAnchor(advisorMock, compactedAt); + retained.content = [{ type: "text", text: "retained pre-compaction output" }]; + advisor.state.messages.push(summary, retained); + + await session.prompt("post-compaction update"); + + expect(advisorMock.calls).toHaveLength(1); + const sentContext = JSON.stringify(advisorMock.calls[0].context.messages); + expect(sentContext).toContain("retained pre-compaction output"); + expect(sentContext).toContain("post-compaction update"); + }); + + it("accepts equal-timestamp usage appended after the explicit compaction boundary", async () => { + const { advisor, advisorMock } = createHarness(); + const compactedAt = Date.now(); + const summary = compactionSummary(compactedAt); + const retained = usageAnchor(advisorMock, compactedAt); + retained.content = [{ type: "text", text: "retained pre-compaction output" }]; + const fresh = usageAnchor(advisorMock, compactedAt); + fresh.content = [{ type: "text", text: "fresh post-compaction output" }]; + advisor.state.messages.push(summary, retained, fresh); + + await session.prompt("equal-timestamp post-compaction update"); + + expect(advisorMock.calls).toHaveLength(1); + const sentContext = JSON.stringify(advisorMock.calls[0].context.messages); + expect(sentContext).toContain("equal-timestamp post-compaction update"); + expect(sentContext).not.toContain("retained pre-compaction output"); + expect(sentContext).not.toContain("fresh post-compaction output"); + }); +}); diff --git a/packages/stats/CHANGELOG.md b/packages/stats/CHANGELOG.md index 7cf491436..d9c35128b 100644 --- a/packages/stats/CHANGELOG.md +++ b/packages/stats/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Recent Errors now honors the selected dashboard time range before returning the newest 50 failures ([#5282](https://github.com/can1357/oh-my-pi/issues/5282)) + ## [16.4.7] - 2026-07-12 ### Fixed diff --git a/packages/stats/package.json b/packages/stats/package.json index 73f9a7cc3..005cacdc0 100644 --- a/packages/stats/package.json +++ b/packages/stats/package.json @@ -55,6 +55,7 @@ "@types/bun": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "linkedom": "catalog:", "postcss": "catalog:" }, "engines": { diff --git a/packages/stats/src/aggregator.ts b/packages/stats/src/aggregator.ts index 553033314..fb37e9305 100644 --- a/packages/stats/src/aggregator.ts +++ b/packages/stats/src/aggregator.ts @@ -444,9 +444,10 @@ export async function getRecentRequests(limit?: number): Promise return dbGetRecentRequests(limit); } -export async function getRecentErrors(limit?: number): Promise { +export async function getRecentErrors(range?: string | null, limit?: number): Promise { await initDb(); - return dbGetRecentErrors(limit); + const { cutoff } = getTimeRangeConfig(range); + return dbGetRecentErrors(limit, cutoff); } export async function getRequestDetails(id: number): Promise { diff --git a/packages/stats/src/client/api.ts b/packages/stats/src/client/api.ts index eef020b20..bf7985073 100644 --- a/packages/stats/src/client/api.ts +++ b/packages/stats/src/client/api.ts @@ -59,8 +59,14 @@ export async function getRecentRequests(limit = 50, signal?: AbortSignal): Promi return fetchJson(`${API_BASE}/stats/recent?limit=${limit}`, { signal }); } -export async function getRecentErrors(limit = 50, signal?: AbortSignal): Promise { - return fetchJson(`${API_BASE}/stats/errors?limit=${limit}`, { signal }); +export async function getRecentErrors( + range: TimeRange = "24h", + limit = 50, + signal?: AbortSignal, +): Promise { + return fetchJson(`${API_BASE}/stats/errors?range=${encodeURIComponent(range)}&limit=${limit}`, { + signal, + }); } export async function getRequestDetails(id: number, signal?: AbortSignal): Promise { diff --git a/packages/stats/src/client/routes/ErrorsRoute.tsx b/packages/stats/src/client/routes/ErrorsRoute.tsx index eb469bcca..273e531af 100644 --- a/packages/stats/src/client/routes/ErrorsRoute.tsx +++ b/packages/stats/src/client/routes/ErrorsRoute.tsx @@ -12,12 +12,12 @@ export interface ErrorsRouteProps { onRequestClick: (id: number) => void; } -export function ErrorsRoute({ active, refreshTrigger, onRequestClick }: ErrorsRouteProps) { +export function ErrorsRoute({ active, range, refreshTrigger, onRequestClick }: ErrorsRouteProps) { const { data: recentErrors, error, loading, - } = useResource(["recent-errors-dense", refreshTrigger], signal => getRecentErrors(50, signal), { + } = useResource(["recent-errors-dense", range, refreshTrigger], signal => getRecentErrors(range, 50, signal), { pollMs: 30000, enabled: active, }); diff --git a/packages/stats/src/db.ts b/packages/stats/src/db.ts index e913989a3..7f24d1e72 100644 --- a/packages/stats/src/db.ts +++ b/packages/stats/src/db.ts @@ -832,15 +832,18 @@ export function getRecentRequests(limit = 100): MessageStats[] { return (stmt.all(limit) as any[]).map(rowToMessageStats); } -export function getRecentErrors(limit = 100): MessageStats[] { +export function getRecentErrors(limit = 100, cutoff?: number | null): MessageStats[] { if (!db) return []; + const hasCutoff = cutoff !== undefined && cutoff !== null; const stmt = db.prepare(` - SELECT * FROM messages + SELECT * FROM messages WHERE stop_reason = 'error' - ORDER BY timestamp DESC + ${hasCutoff ? "AND timestamp >= ?" : ""} + ORDER BY timestamp DESC LIMIT ? `); - return (stmt.all(limit) as any[]).map(rowToMessageStats); + const rows = hasCutoff ? stmt.all(cutoff, limit) : stmt.all(limit); + return rows.map(rowToMessageStats); } export function getMessageById(id: number): MessageStats | null { diff --git a/packages/stats/src/server.ts b/packages/stats/src/server.ts index 17fc9fc40..607de3f88 100644 --- a/packages/stats/src/server.ts +++ b/packages/stats/src/server.ts @@ -184,7 +184,7 @@ const ensureClientBuild = async () => { /** * Handle API requests. */ -async function handleApi(req: Request): Promise { +export async function handleApi(req: Request): Promise { const url = new URL(req.url); const path = url.pathname; @@ -229,7 +229,7 @@ async function handleApi(req: Request): Promise { if (path === "/api/stats/errors") { const limit = url.searchParams.get("limit"); - const stats = await getRecentErrors(limit ? parseInt(limit, 10) : undefined); + const stats = await getRecentErrors(range, limit ? parseInt(limit, 10) : undefined); return Response.json(stats); } diff --git a/packages/stats/test/errors-range.test.ts b/packages/stats/test/errors-range.test.ts new file mode 100644 index 000000000..ce341c166 --- /dev/null +++ b/packages/stats/test/errors-range.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; +import { initDb, insertMessageStats } from "../src/db"; +import { handleApi } from "../src/server"; +import type { MessageStats } from "../src/types"; +import { installStatsTestIsolation } from "./helpers/temp-agent"; + +const HOUR_MS = 60 * 60 * 1000; + +installStatsTestIsolation("@pi-stats-errors-range-"); + +function makeError(timestamp: number, entryId: string): MessageStats { + return { + sessionFile: "/tmp/errors-range-session.jsonl", + entryId, + folder: "/tmp/project", + model: "gpt-5.4", + provider: "openai-codex", + api: "openai-codex-responses", + timestamp, + duration: 1000, + ttft: 100, + stopReason: "error", + errorMessage: `failure ${entryId}`, + usage: { + input: 1000, + output: 500, + cacheRead: 200, + cacheWrite: 0, + totalTokens: 1700, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + agentType: "main", + }; +} + +async function readMessages(response: Response): Promise { + expect(response.status).toBe(200); + return response.json() as Promise; +} + +describe("Recent Errors range", () => { + it("filters by the mapped range before returning the newest 50 errors", async () => { + await initDb(); + const now = Date.now(); + const recentErrors = Array.from({ length: 50 }, (_, index) => makeError(now - index * 1000, `recent-${index}`)); + const oldError = makeError(now - 48 * HOUR_MS, "outside-24h"); + insertMessageStats([...recentErrors, oldError]); + + const dayErrors = await readMessages( + await handleApi(new Request("http://stats.test/api/stats/errors?range=24h&limit=50")), + ); + expect(dayErrors).toHaveLength(50); + expect(dayErrors.map(error => error.entryId)).toEqual(recentErrors.map(error => error.entryId)); + expect(dayErrors.some(error => error.entryId === oldError.entryId)).toBe(false); + + const allErrors = await readMessages( + await handleApi(new Request("http://stats.test/api/stats/errors?range=all&limit=51")), + ); + expect(allErrors).toHaveLength(51); + expect(allErrors.at(-1)?.entryId).toBe(oldError.entryId); + + const defaultErrors = await readMessages( + await handleApi(new Request("http://stats.test/api/stats/errors?limit=51")), + ); + expect(defaultErrors).toHaveLength(50); + expect(defaultErrors.some(error => error.entryId === oldError.entryId)).toBe(false); + + const fallbackErrors = await readMessages( + await handleApi(new Request("http://stats.test/api/stats/errors?range=unknown&limit=51")), + ); + expect(fallbackErrors.map(error => error.entryId)).toEqual(defaultErrors.map(error => error.entryId)); + }); +}); diff --git a/packages/stats/test/errors-route-range.test.tsx b/packages/stats/test/errors-route-range.test.tsx new file mode 100644 index 000000000..05d168f22 --- /dev/null +++ b/packages/stats/test/errors-route-range.test.tsx @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { parseHTML } from "linkedom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { ErrorsRoute } from "../src/client/routes/ErrorsRoute"; + +type FetchInput = string | URL | Request; +type FetchInit = RequestInit | BunFetchRequestInit; + +const originalGlobals = new Map(); +let root: Root | null = null; + +function installGlobal(name: string, value: unknown): void { + originalGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, value, writable: true }); +} + +function restoreGlobals(): void { + for (const [name, descriptor] of originalGlobals) { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + Reflect.deleteProperty(globalThis, name); + } + } + originalGlobals.clear(); +} + +afterEach(async () => { + const activeRoot = root; + if (activeRoot) { + await act(async () => { + activeRoot.unmount(); + }); + root = null; + } + vi.restoreAllMocks(); + restoreGlobals(); +}); + +describe("ErrorsRoute range", () => { + it("requests the selected range again when the range changes", async () => { + const domWindow = parseHTML('
').window; + installGlobal("window", domWindow); + installGlobal("document", domWindow.document); + installGlobal("navigator", domWindow.navigator); + installGlobal("Node", domWindow.Node); + installGlobal("Element", domWindow.Element); + installGlobal("HTMLElement", domWindow.HTMLElement); + installGlobal("HTMLIFrameElement", domWindow.HTMLIFrameElement); + installGlobal("SVGElement", domWindow.SVGElement); + installGlobal("IS_REACT_ACT_ENVIRONMENT", true); + + const requestedUrls: string[] = []; + const fetchStub = Object.assign( + async (input: FetchInput, _init?: FetchInit) => { + requestedUrls.push(input instanceof Request ? input.url : input.toString()); + return Response.json([]); + }, + { preconnect: globalThis.fetch.preconnect }, + ); + vi.spyOn(globalThis, "fetch").mockImplementation(fetchStub); + + const container = domWindow.document.getElementById("root"); + if (!container) throw new Error("Expected test root"); + root = createRoot(container as unknown as Element); + + await act(async () => { + root?.render( {}} />); + }); + expect(requestedUrls).toEqual(["/api/stats/errors?range=24h&limit=50"]); + + await act(async () => { + root?.render( {}} />); + }); + expect(requestedUrls).toEqual([ + "/api/stats/errors?range=24h&limit=50", + "/api/stats/errors?range=7d&limit=50", + ]); + }); +}); diff --git a/packages/stats/tsconfig.client.json b/packages/stats/tsconfig.client.json index 2a4594ea1..7b97643c6 100644 --- a/packages/stats/tsconfig.client.json +++ b/packages/stats/tsconfig.client.json @@ -1,7 +1,8 @@ { "extends": "../tsconfig.workspace.json", "include": [ - "src/client" + "src/client", + "test/errors-route-range.test.tsx" ], "compilerOptions": { "jsx": "react-jsx",