From a4d8860a6c78b4567b95051aeac55578d91df7ea Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 12 Aug 2026 02:31:48 +0200 Subject: [PATCH] feat: added google reasoning controls mcp stream resumption and tar support - Added Google provider thinking configuration parameters and force-reasoning-off controls. - Implemented MCP SSE stream resumption using Last-Event-ID and `SSEResumeError`. - Added support for TAR old-GNU sparse extension blocks, path length checks, and archive entry overrides. - Restricted external thinking support to specific models and added semver fallback parsing. --- packages/ai/src/providers/google-shared.ts | 19 +- packages/ai/src/stream.ts | 19 +- .../google-gemini-cli-variant-routing.test.ts | 2 +- packages/ai/test/google-reasoning-off.test.ts | 67 ++++++ packages/ai/test/requires-effort.test.ts | 10 +- packages/catalog/src/identity/classify.ts | 12 +- packages/catalog/test/identity-family.test.ts | 15 ++ packages/coding-agent/CHANGELOG.md | 2 +- .../src/cli/gallery-fixtures/agentic.ts | 3 +- .../coding-agent/src/mcp/transports/http.ts | 179 +++++++++++---- .../src/modes/components/tool-activity.ts | 12 + packages/coding-agent/src/tools/think.ts | 26 ++- packages/coding-agent/src/utils/zip.ts | 205 ++++++++++++------ .../agent-session-tool-rebuild-skip.test.ts | 12 +- .../test/mcp-http-transport.test.ts | 148 +++++++++++++ .../tool-activity-visibility.test.ts | 15 ++ .../test/sdk-tool-activation.test.ts | 14 +- packages/coding-agent/test/tools.test.ts | 111 +++++++++- packages/snapcompact/test/snapcompact.test.ts | 6 + 19 files changed, 722 insertions(+), 155 deletions(-) create mode 100644 packages/ai/test/google-reasoning-off.test.ts diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index eef5a3d68..bfce898f7 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -858,13 +858,18 @@ export function buildGoogleGenerateContentParams( return requireSupportedEffort(model, reasoning); } +function resolveGoogleThinkingOff(model: Model): NonNullable { + const thinking: NonNullable = { enabled: false }; + if (!model.reasoning || !model.thinking) return thinking; + if (model.thinking.mode === "budget" && (!model.thinking.requiresEffort || model.thinking.suppressWhenOff)) { + thinking.budgetTokens = 0; + } else if (model.thinking.mode === "google-level" && model.thinking.suppressWhenOff) { + thinking.level = "MINIMAL"; + } + return thinking; +} + const castApi = (api: OptionsForApi): OptionsForApi => api as OptionsForApi; /** @@ -1428,13 +1439,13 @@ function normalizeMandatoryReasoningOptions( !model.reasoning || !model.thinking?.requiresEffort || model.thinking.suppressWhenOff || - (options?.reasoning !== undefined && !options.disableReasoning) + (options?.reasoning !== undefined && !options.disableReasoning && !options.forceReasoningOff) ) { return options; } const floor = minimumSupportedEffort(model); if (floor === undefined) return options; - return { ...options, reasoning: floor, disableReasoning: undefined }; + return { ...options, reasoning: floor, disableReasoning: undefined, forceReasoningOff: undefined }; } function supportsExplicitOpenAIResponsesPromptCache(compat: unknown): boolean { @@ -1732,7 +1743,7 @@ function mapOptionsForApi( return castApi<"google-generative-ai">({ ...base, serviceTier: options?.serviceTier, - thinking: { enabled: false }, + thinking: resolveGoogleThinkingOff(model), toolChoice: mapGoogleToolChoice(options?.toolChoice), cachedContent: options?.cachedContent, }); @@ -1838,7 +1849,7 @@ function mapOptionsForApi( return castApi<"google-vertex">({ ...base, serviceTier: options?.serviceTier, - thinking: { enabled: false }, + thinking: resolveGoogleThinkingOff(model), toolChoice: mapGoogleToolChoice(options?.toolChoice), cachedContent: options?.cachedContent, }); diff --git a/packages/ai/test/google-gemini-cli-variant-routing.test.ts b/packages/ai/test/google-gemini-cli-variant-routing.test.ts index b956a88f6..3c04e8f43 100644 --- a/packages/ai/test/google-gemini-cli-variant-routing.test.ts +++ b/packages/ai/test/google-gemini-cli-variant-routing.test.ts @@ -150,7 +150,7 @@ describe("google-gemini-cli effort-tier variant routing", () => { }); }); - it("routes to the explicit off wire shape when an external work log replaces reasoning", async () => { + it("routes to the explicit off wire shape when an external scratchpad replaces reasoning", async () => { const off = await captureRequest(collapsedFlashModel(), Effort.High, { forceReasoningOff: true }); expect(off.body.model).toBe("gemini-3.5-flash-extra-low"); expect(off.body.request?.generationConfig?.thinkingConfig).toEqual({ diff --git a/packages/ai/test/google-reasoning-off.test.ts b/packages/ai/test/google-reasoning-off.test.ts new file mode 100644 index 000000000..4d0012fbd --- /dev/null +++ b/packages/ai/test/google-reasoning-off.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "bun:test"; +import { Effort, type FetchImpl } from "@oh-my-pi/pi-ai"; +import { streamSimple } from "@oh-my-pi/pi-ai/stream"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; + +interface CapturedPayload { + config?: { + thinkingConfig?: { + includeThoughts?: boolean; + thinkingBudget?: number; + }; + }; +} + +const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], +}; + +const model: Model<"google-generative-ai"> = buildModel({ + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com", + reasoning: true, + thinking: { + mode: "budget", + efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High], + }, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 65_536, +}); + +async function capturePayload(flag: "disableReasoning" | "forceReasoningOff"): Promise { + let captured: CapturedPayload | undefined; + const fetchMock: FetchImpl = async () => + new Response("", { status: 200, headers: { "content-type": "text/event-stream" } }); + + await streamSimple(model, context, { + apiKey: "test-key", + reasoning: Effort.High, + [flag]: true, + fetch: fetchMock, + onPayload: payload => { + captured = payload as CapturedPayload; + }, + }).result(); + + if (!captured) throw new Error("Google request payload was not captured"); + return captured; +} + +describe("Google reasoning disablement", () => { + for (const flag of ["disableReasoning", "forceReasoningOff"] as const) { + it(`sends an explicit zero thinking budget for ${flag}`, async () => { + const payload = await capturePayload(flag); + + expect(payload.config?.thinkingConfig).toEqual({ + includeThoughts: false, + thinkingBudget: 0, + }); + }); + } +}); diff --git a/packages/ai/test/requires-effort.test.ts b/packages/ai/test/requires-effort.test.ts index 4d3cfd9d3..3dad74d08 100644 --- a/packages/ai/test/requires-effort.test.ts +++ b/packages/ai/test/requires-effort.test.ts @@ -35,7 +35,7 @@ function openRouterModel(thinking: ThinkingConfig): Model<"openai-completions"> async function captureBody( model: Model<"openai-completions">, - options: { reasoning?: Effort; disableReasoning?: boolean }, + options: { reasoning?: Effort; disableReasoning?: boolean; forceReasoningOff?: boolean }, ): Promise { let requestBody: string | undefined; const fetchMock: FetchImpl = (_input, init) => { @@ -67,6 +67,14 @@ describe("thinking.requiresEffort clamping", () => { expect(body.reasoning).toEqual({ effort: "minimal" }); }); + it("clamps forceReasoningOff when the endpoint cannot disable reasoning", async () => { + const body = await captureBody(openRouterModel(MANDATORY_THINKING), { + reasoning: Effort.High, + forceReasoningOff: true, + }); + expect(body.reasoning).toEqual({ effort: "minimal" }); + }); + it("keeps explicit efforts untouched", async () => { const body = await captureBody(openRouterModel(MANDATORY_THINKING), { reasoning: Effort.High }); expect(body.reasoning).toEqual({ effort: "high" }); diff --git a/packages/catalog/src/identity/classify.ts b/packages/catalog/src/identity/classify.ts index 03ac2e64e..46e6ae187 100644 --- a/packages/catalog/src/identity/classify.ts +++ b/packages/catalog/src/identity/classify.ts @@ -181,7 +181,9 @@ function createSemVer(major: number, minor: number, patch = 0): SemVer { return { major, minor, patch }; } -// extend this table if we need anything more than 9.10 +// Fast path for the common 1–2 component versions; anything the table misses +// (large minors, 3-part versions) parses dynamically below so no future +// version ever classifies as unknown (the failure class #8256 fixed). const precomputeTable: Record = {}; for (let major = 0; major <= 9; major++) { for (let minor = 0; minor <= 10; minor++) { @@ -192,8 +194,14 @@ for (let major = 0; major <= 9; major++) { precomputeTable[`${major}`] = createSemVer(major, 0, 0); } +const SEMVER_PATTERN = /^(\d{1,2})(?:[.-](\d{1,2}))?(?:[.-](\d{1,2}))?$/; + export function parseSemVer(version: string): SemVer | null { - return precomputeTable[version] ?? null; + const hit = precomputeTable[version]; + if (hit) return hit; + const match = SEMVER_PATTERN.exec(version); + if (!match) return null; + return createSemVer(Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)); } export function semverGte(left: SemVer | string, right: SemVer | string): boolean { diff --git a/packages/catalog/test/identity-family.test.ts b/packages/catalog/test/identity-family.test.ts index e34bcd4de..70ee878fd 100644 --- a/packages/catalog/test/identity-family.test.ts +++ b/packages/catalog/test/identity-family.test.ts @@ -76,6 +76,21 @@ describe("parseAnthropicModel", () => { }); expect(parseAnthropicModel("anthropic--claude-4.8-haiku")).toBeNull(); }); + + test("parses versions past the precompute table instead of classifying the model unknown", () => { + // The semver precompute table gates parsing; a too-small bound silently + // downgraded `claude-opus-5-11`-shaped ids to unknown (#8256 class). + expect(parseAnthropicModel("claude-opus-5-11")).toEqual({ + family: "anthropic", + kind: "opus", + version: { major: 5, minor: 11, patch: 0 }, + }); + expect(parseAnthropicModel("claude-sonnet-4.25")).toEqual({ + family: "anthropic", + kind: "sonnet", + version: { major: 4, minor: 25, patch: 0 }, + }); + }); }); describe("supportsAdaptiveThinkingDisplay", () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2940798f3..5920193cd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,7 +13,7 @@ ### Changed -- Renamed the `think` tool's `thoughts` parameter to `notes` and restricted tool availability to models supporting external thinking +- Restricted the `think` tool to GPT Responses, Claude, and Gemini transports that can replace native reasoning, and kept its streamed scratchpad input named `thoughts`. - `omp cleanse` default subagent cap raised from 8 to 32 (`--agents`/`-n` still overrides). ### Fixed diff --git a/packages/coding-agent/src/cli/gallery-fixtures/agentic.ts b/packages/coding-agent/src/cli/gallery-fixtures/agentic.ts index 89d27fc33..769df12eb 100644 --- a/packages/coding-agent/src/cli/gallery-fixtures/agentic.ts +++ b/packages/coding-agent/src/cli/gallery-fixtures/agentic.ts @@ -368,7 +368,8 @@ export const agenticFixtures: Record = { thoughts: "The retry loop re-reads the config after every failure, which explains the doubled latency.", }, args: { - thoughts: "The retry loop re-reads the config after every failure, which explains the doubled latency. Cache the parsed config outside the loop, then re-check the invalidation path.", + thoughts: + "The retry loop re-reads the config after every failure, which explains the doubled latency. Cache the parsed config outside the loop, then re-check the invalidation path.", }, result: { content: [{ type: "text", text: "------" }], diff --git a/packages/coding-agent/src/mcp/transports/http.ts b/packages/coding-agent/src/mcp/transports/http.ts index ccec75634..488a1491c 100644 --- a/packages/coding-agent/src/mcp/transports/http.ts +++ b/packages/coding-agent/src/mcp/transports/http.ts @@ -30,6 +30,13 @@ interface SSEResumeState { retryMs: number; } +/** + * Failure resuming an accepted request's logical SSE stream. Carries a + * never-replay contract: by resume time the server has accepted (and possibly + * executed) the originating POST, so auth-retry paths must not re-send it. + */ +class SSEResumeError extends Error {} + /** Wait for the server-provided SSE retry interval while remaining abortable. */ async function waitForSSERetry(ms: number, signal: AbortSignal): Promise { if (signal.aborted) throw signal.reason; @@ -195,7 +202,7 @@ export class HttpTransport implements MCPTransport { // If the stream ends unexpectedly (server restart, network drop), // fire onClose so the manager can trigger reconnection. const signal = connection.signal; - void this.#readSSEStream(response.body!, signal).finally(() => { + void this.#runSSEListener(response.body!, signal).finally(() => { const wasConnected = this.#connected; if (this.#sseConnection === connection) this.#sseConnection = null; if (wasConnected) this.onClose?.(); @@ -215,6 +222,95 @@ export class HttpTransport implements MCPTransport { } } + /** + * Read the long-lived GET SSE stream, resuming with `Last-Event-ID` when + * the server closes the physical connection mid-stream (2025-11-25 permits + * polling-style servers). Returns only when the logical stream ends — the + * caller fires `onClose` and the manager's reconnect path takes over. A + * resume cycle that delivers no events before dropping again ends the + * stream rather than retrying forever against a broken server. + */ + async #runSSEListener(initialBody: ReadableStream, signal: AbortSignal): Promise { + const resume: SSEResumeState = { lastEventId: null, retryMs: DEFAULT_SSE_RETRY_MS }; + let body = initialBody; + let progressed = true; + for (;;) { + try { + for await (const event of readSseEvents(body, signal)) { + progressed = true; + if (event.id !== undefined) resume.lastEventId = event.id || null; + if (event.retry !== undefined) resume.retryMs = event.retry; + if (event.data === "") continue; + if (!this.#connected) return; + this.#dispatchSSEMessage(JSON.parse(event.data) as JsonRpcMessage | JsonRpcMessage[]); + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") return; + logger.debug("HTTP SSE stream error", { + url: this.config.url, + error: error instanceof Error ? error.message : String(error), + }); + if (resume.lastEventId === null) { + if (error instanceof Error) this.onError?.(error); + return; + } + } + if (!this.#connected || signal.aborted || resume.lastEventId === null || !progressed) return; + progressed = false; + try { + const response = await this.#fetchSSEResume(resume, signal); + body = response.body as ReadableStream; + } catch (error) { + if (!(error instanceof Error && error.name === "AbortError")) { + logger.debug("HTTP SSE listener resume failed", { + url: this.config.url, + error: error instanceof Error ? error.message : String(error), + }); + } + return; + } + } + } + + /** + * Resume a logical SSE stream via GET + `Last-Event-ID`, honoring the + * server-provided retry interval and refreshing auth once on 401/403. + * Failures throw {@link SSEResumeError} so `request()` never replays the + * originating POST in response. + */ + async #fetchSSEResume(resume: SSEResumeState, signal: AbortSignal): Promise { + if (resume.lastEventId === null) { + throw new SSEResumeError("SSE stream ended without a resumable event ID"); + } + await waitForSSERetry(resume.retryMs, signal); + const generated: Record = { + Accept: "text/event-stream", + "Last-Event-ID": resume.lastEventId, + }; + if (this.#sessionId) generated["Mcp-Session-Id"] = this.#sessionId; + let response = await this.#fetch({ method: "GET", signal }, generated); + if (this.onAuthError && (response.status === 401 || response.status === 403)) { + await response.body?.cancel(); + const newHeaders = await this.onAuthError(); + if (!newHeaders) { + throw new SSEResumeError(`HTTP ${response.status} resuming MCP SSE stream: auth refresh failed`); + } + // Persist refreshed headers so subsequent requests use them directly + this.config = { ...this.config, headers: newHeaders }; + response = await this.#fetch({ method: "GET", signal }, generated); + } + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new SSEResumeError(`HTTP ${response.status} resuming MCP SSE stream: ${text}`); + } + const contentType = response.headers.get("Content-Type") ?? ""; + if (!contentType.includes("text/event-stream") || !response.body) { + await response.body?.cancel(); + throw new SSEResumeError(`MCP SSE resume returned unsupported Content-Type: ${contentType || "(missing)"}`); + } + return response; + } + /** Route an SSE message (or batch) to the appropriate handler. */ #dispatchSSEMessage(message: JsonRpcMessage | JsonRpcMessage[]): void { if (Array.isArray(message)) { @@ -240,9 +336,12 @@ export class HttpTransport implements MCPTransport { try { return await this.#executeRequest(method, params, options); } catch (error) { - // Retry once on auth failure if onAuthError is wired + // Retry once on auth failure if onAuthError is wired. Never replay + // after an SSE resume failure: the server already accepted the + // original POST and may have executed it — replaying could run a + // state-changing tool twice. const status = error instanceof Error ? AIError.status(error) : undefined; - if (this.onAuthError && (status === 401 || status === 403)) { + if (!(error instanceof SSEResumeError) && this.onAuthError && (status === 401 || status === 403)) { const newHeaders = await this.onAuthError(); if (newHeaders) { // Persist refreshed headers so subsequent requests use them directly @@ -355,53 +454,49 @@ export class HttpTransport implements MCPTransport { try { for (;;) { if (!current.body) throw new Error("SSE response did not include a body"); - for await (const event of readSseEvents(current.body, signal)) { - if (event.id !== undefined) resume.lastEventId = event.id || null; - if (event.retry !== undefined) resume.retryMs = event.retry; - if (event.data === "") continue; - const raw = JSON.parse(event.data) as JsonRpcMessage | JsonRpcMessage[]; - const messages = Array.isArray(raw) ? raw : [raw]; - for (const message of messages) { - if ( - !captured && - "id" in message && - message.id === expectedId && - ("result" in message || "error" in message) - ) { - captured = true; - operation.clear(); - if (message.error) { - reject(new Error(`MCP error ${message.error.code}: ${message.error.message}`)); - } else { - resolve(message.result as T); + try { + for await (const event of readSseEvents(current.body, signal)) { + if (event.id !== undefined) resume.lastEventId = event.id || null; + if (event.retry !== undefined) resume.retryMs = event.retry; + if (event.data === "") continue; + const raw = JSON.parse(event.data) as JsonRpcMessage | JsonRpcMessage[]; + const messages = Array.isArray(raw) ? raw : [raw]; + for (const message of messages) { + if ( + !captured && + "id" in message && + message.id === expectedId && + ("result" in message || "error" in message) + ) { + captured = true; + operation.clear(); + if (message.error) { + reject(new Error(`MCP error ${message.error.code}: ${message.error.message}`)); + } else { + resolve(message.result as T); + } + continue; } - continue; + if (!this.#connected) continue; + this.#dispatchSSEMessage(message); } - if (!this.#connected) continue; - this.#dispatchSSEMessage(message); } + } catch (error) { + // An abrupt drop (socket reset, body-read failure) is as + // resumable as a server-initiated close once an event ID + // exists; the request timeout still bounds the total wait. + if (captured) return; + if (signal.aborted || resume.lastEventId === null) throw error; + logger.debug("MCP SSE response stream dropped; resuming", { + url: this.config.url, + error: error instanceof Error ? error.message : String(error), + }); } if (captured) return; if (resume.lastEventId === null) { throw new Error(`No response received for request ID ${expectedId}`); } - - await waitForSSERetry(resume.retryMs, signal); - const generated: Record = { - Accept: "text/event-stream", - "Last-Event-ID": resume.lastEventId, - }; - if (this.#sessionId) generated["Mcp-Session-Id"] = this.#sessionId; - current = await this.#fetch({ method: "GET", signal }, generated); - if (!current.ok) { - const text = await current.text(); - throw new Error(`HTTP ${current.status} resuming MCP SSE stream: ${text}`); - } - const contentType = current.headers.get("Content-Type") ?? ""; - if (!contentType.includes("text/event-stream")) { - await current.body?.cancel(); - throw new Error(`MCP SSE resume returned unsupported Content-Type: ${contentType || "(missing)"}`); - } + current = await this.#fetchSSEResume(resume, signal); } } catch (error) { if (captured) return; diff --git a/packages/coding-agent/src/modes/components/tool-activity.ts b/packages/coding-agent/src/modes/components/tool-activity.ts index 2c0386995..5772a9b7f 100644 --- a/packages/coding-agent/src/modes/components/tool-activity.ts +++ b/packages/coding-agent/src/modes/components/tool-activity.ts @@ -26,6 +26,18 @@ export class ToolActivityContainer extends Container implements ToolActivityComp this.invalidate(); } + /** + * Forward Ctrl+O expansion to wrapped children. The transcript's expansion + * traversal only visits top-level children, so the wrapper must proxy or + * wrapped renderers would freeze at their insertion-time expansion state. + */ + setExpanded(expanded: boolean): void { + for (const child of this.children) { + const expandable = child as Partial<{ setExpanded(expanded: boolean): void }>; + if (typeof expandable.setExpanded === "function") expandable.setExpanded(expanded); + } + } + override render(width: number): readonly string[] { if (!this.#visible) return []; return super.render(width); diff --git a/packages/coding-agent/src/tools/think.ts b/packages/coding-agent/src/tools/think.ts index db603acf0..f9470233f 100644 --- a/packages/coding-agent/src/tools/think.ts +++ b/packages/coding-agent/src/tools/think.ts @@ -7,14 +7,26 @@ import { getMarkdownTheme, type Theme } from "../modes/theme/theme"; /** Whether a model transport can suppress native reasoning while private scratchpad thoughts are active. */ export function supportsExternalThinking(model: Model | null | undefined): boolean { + if (!model) return false; + const requiresThinking = + model.api === "anthropic-messages" && + model.compat !== undefined && + "requiresThinkingEnabled" in model.compat && + model.compat.requiresThinkingEnabled === true; + if ( + model.reasoning && + (requiresThinking || (model.thinking?.requiresEffort && !model.thinking.suppressWhenOff)) + ) { + return false; + } + if (model.api === "google-generative-ai" || model.api === "google-gemini-cli" || model.api === "google-vertex") { + return !model.reasoning || model.thinking?.mode === "budget" || model.thinking?.suppressWhenOff === true; + } return ( - model?.api === "openai-responses" || - model?.api === "azure-openai-responses" || - model?.api === "openai-codex-responses" || - model?.api === "anthropic-messages" || - model?.api === "google-generative-ai" || - model?.api === "google-gemini-cli" || - model?.api === "google-vertex" + model.api === "openai-responses" || + model.api === "azure-openai-responses" || + model.api === "openai-codex-responses" || + model.api === "anthropic-messages" ); } diff --git a/packages/coding-agent/src/utils/zip.ts b/packages/coding-agent/src/utils/zip.ts index 4af926b69..2c806f18c 100644 --- a/packages/coding-agent/src/utils/zip.ts +++ b/packages/coding-agent/src/utils/zip.ts @@ -219,11 +219,14 @@ function upsertArchiveEntry(map: Map, entry: ArchiveI return; } + // Same-kind duplicate: the later record wins (tar append/update semantics, + // matching system tar extraction and whole-archive materialization), while + // earlier metadata fills any gaps the newer record leaves. map.set(entry.path, { - ...existing, - size: existing.size || entry.size, - mtimeMs: existing.mtimeMs ?? entry.mtimeMs, - storage: existing.storage ?? entry.storage, + ...entry, + size: entry.size || existing.size, + mtimeMs: entry.mtimeMs ?? existing.mtimeMs, + storage: entry.storage ?? existing.storage, }); } @@ -628,6 +631,14 @@ const TAR_LINKNAME_OFFSET = 157; const TAR_LINKNAME_LENGTH = 100; const TAR_PREFIX_OFFSET = 345; const TAR_PREFIX_LENGTH = 155; +// Old-GNU sparse header: `isextended` flag inside the main header and inside +// each 512-byte sparse-map continuation block that follows it. +const TAR_GNU_SPARSE_ISEXTENDED_OFFSET = 482; +const TAR_GNU_SPARSE_CONT_ISEXTENDED_OFFSET = 504; +// PATH_MAX-style bound on member paths and link targets. Real archives never +// exceed it (system tar cannot extract them), and it caps every prefix walk +// below so crafted multi-hundred-KiB PAX paths cannot pin the CPU. +const TAR_MAX_PATH_BYTES = 4096; const GZIP_MAGIC_0 = 0x1f; const GZIP_MAGIC_1 = 0x8b; const TAR_TEXT_DECODER = new TextDecoder(); @@ -692,7 +703,11 @@ function tarChecksumMatches(buffer: Uint8Array, offset: number): boolean { return stored === unsigned || stored === signed; } -/** Parse a PAX extended-header payload into its `key → value` records. */ +/** + * Parse a PAX extended-header payload into its `key → value` records. Only + * keys the indexer consumes are retained; a crafted header packed with + * millions of unique throwaway records must not amplify into heap. + */ function parsePaxRecords(data: Uint8Array): Map { const attrs = new Map(); let pos = 0; @@ -715,7 +730,10 @@ function parsePaxRecords(data: Uint8Array): Map { const record = data.subarray(space + 1, pos + length - 1); const eq = record.indexOf(0x3d); if (eq >= 0) { - attrs.set(TAR_TEXT_DECODER.decode(record.subarray(0, eq)), TAR_TEXT_DECODER.decode(record.subarray(eq + 1))); + const key = TAR_TEXT_DECODER.decode(record.subarray(0, eq)); + if (key === "path" || key === "linkpath" || key === "size" || key.startsWith("GNU.sparse.")) { + attrs.set(key, TAR_TEXT_DECODER.decode(record.subarray(eq + 1))); + } } pos += length; } @@ -826,6 +844,16 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { if (Number.isFinite(parsed) && parsed >= 0) displaySize = parsed; } const sparse = typeFlag === "S" || paxDeclaresSparse(pax); + // Old-GNU sparse members chain extra 512-byte sparse-map blocks between + // the main header and the stored data; they are not counted in `size`. + // Consume the chain so the data offset and the next header line up. + if (typeFlag === "S" && buffer[offset - TAR_BLOCK_SIZE + TAR_GNU_SPARSE_ISEXTENDED_OFFSET] === 1) { + let extended = true; + while (extended && offset + TAR_BLOCK_SIZE <= buffer.length) { + extended = buffer[offset + TAR_GNU_SPARSE_CONT_ISEXTENDED_OFFSET] === 1; + offset += TAR_BLOCK_SIZE; + } + } const dataOffset = offset; const memberDataBlocks = Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; offset += memberDataBlocks; @@ -836,6 +864,9 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { const isDirectory = typeFlag === "5" || name.endsWith("/"); const normalizedPath = normalizeArchiveEntryPath(name); if (!normalizedPath) continue; + if (normalizedPath.length > TAR_MAX_PATH_BYTES) { + throw new ToolError(`Archive member path exceeds ${TAR_MAX_PATH_BYTES} bytes`); + } const mtimeMs = mtime > 0 ? mtime * 1000 : undefined; if (isDirectory) { @@ -861,7 +892,7 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { size: 0, mtimeMs, }; - if (targetPath === undefined) { + if (targetPath === undefined || targetPath.length > TAR_MAX_PATH_BYTES) { if (kind === "hard link") { throw new ToolError(`Archive hard link '${normalizedPath}' has an invalid target`); } @@ -902,78 +933,110 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { // Link records carry no data. Resolve file targets after all headers are // indexed; directory symlinks remain one alias node and are traversed lazily // by ArchiveReader so N files behind M aliases never inflate the index to - // N×M entries during a root listing. + // N×M entries during a root listing. Resolution is a work queue keyed on + // blocking links (not a rescan-all fixpoint) so crafted archives with huge + // link chains stay linear in dependency edges. if (pendingLinks.size > 0) { const entriesByPath = new Map(); for (const entry of entries) entriesByPath.set(entry.path, entry); - const unresolved = new Set(pendingLinks.keys()); - - // True while the target itself or any directory on its path is a link - // that has not been classified yet; such targets must wait a pass so a - // file symlink routed through a directory alias is not misjudged - // dangling before the alias resolves. - const dependsOnUnresolvedLink = (targetPath: string): boolean => { - const parts = targetPath.split("/"); - for (let end = parts.length; end > 0; end--) { - const prefixEntry = entriesByPath.get(parts.slice(0, end).join("/")); - if (prefixEntry && unresolved.has(prefixEntry)) return true; + // Every proper ancestor of a member path: O(1) directory-target checks + // instead of scanning all entries per unresolved link. + const directoryPrefixes = new Set(); + for (const entry of entries) { + const memberPath = entry.path; + for (let cut = memberPath.lastIndexOf("/"); cut > 0; cut = memberPath.lastIndexOf("/", cut - 1)) { + const prefix = memberPath.slice(0, cut); + if (directoryPrefixes.has(prefix)) break; + directoryPrefixes.add(prefix); } - return false; + } + const unresolved = new Set(pendingLinks.keys()); + // Links deferred behind a still-unclassified link, re-queued when it + // settles, so a file symlink routed through a directory alias is not + // misjudged dangling before the alias resolves. + const dependents = new Map(); + + // The first still-unresolved link on `targetPath` (the target itself or + // any directory on its path), or null when the target is settled. + const findUnresolvedBlocker = (targetPath: string): ArchiveIndexEntry | null => { + for (let end = targetPath.length; end > 0; end = targetPath.lastIndexOf("/", end - 1)) { + const prefixEntry = entriesByPath.get(targetPath.slice(0, end)); + if (prefixEntry && unresolved.has(prefixEntry)) return prefixEntry; + } + return null; }; - while (unresolved.size > 0) { - let resolved = 0; - for (const entry of unresolved) { - const pending = pendingLinks.get(entry)!; - if (dependsOnUnresolvedLink(pending.targetPath)) continue; + const queue = [...unresolved]; + while (queue.length > 0) { + const entry = queue.pop()!; + if (!unresolved.has(entry)) continue; + const pending = pendingLinks.get(entry)!; - // Targets may route through directory aliases classified in an - // earlier pass; rewrite before the exact-path lookup. - let targetPath = pending.targetPath; + // Targets may route through directory aliases classified earlier; + // rewrite before the exact-path lookup. A cyclic alias chain falls + // through to the dangling-symlink path. + let blocker = findUnresolvedBlocker(pending.targetPath); + let targetPath = pending.targetPath; + if (blocker === null) { try { targetPath = resolveDirectoryAliasPath(entriesByPath, targetPath); - } catch { - // Cyclic alias chain: fall through to the dangling-symlink path. + } catch {} + if (targetPath !== pending.targetPath) blocker = findUnresolvedBlocker(targetPath); + } + if (blocker !== null && blocker !== entry) { + const waiting = dependents.get(blocker); + if (waiting) { + waiting.push(entry); + } else { + dependents.set(blocker, [entry]); } - if (targetPath !== pending.targetPath && dependsOnUnresolvedLink(targetPath)) continue; + continue; + } + unresolved.delete(entry); + const settled = dependents.get(entry); + if (settled) { + dependents.delete(entry); + queue.push(...settled); + } + if (blocker === entry) { + // The target passes through the link itself (`a -> a/b`): + // inherently cyclic, so it can never become a usable alias even + // when real members exist beneath the target prefix. + if (pending.kind === "hard link") { + throw new ToolError(`Archive hard link '${entry.path}' has a cyclic target '${pending.targetPath}'`); + } + entry.storage = { type: "tar-link", targetPath: pending.targetPath }; + continue; + } - const target = entriesByPath.get(targetPath); - if (target?.storage && !target.isDirectory) { - entry.size = target.size; - entry.storage = target.storage; - unresolved.delete(entry); - resolved++; + const target = entriesByPath.get(targetPath); + if (target?.storage && !target.isDirectory && !unresolved.has(target)) { + entry.size = target.size; + entry.storage = target.storage; + continue; + } + + // An empty target is the archive root, which is always a directory. + const targetIsDirectory = + targetPath === "" || target?.isDirectory === true || directoryPrefixes.has(targetPath); + if (!targetIsDirectory) { + if (pending.kind === "symlink") { + entry.storage = { type: "tar-link", targetPath: pending.targetPath }; continue; } - - // An empty target is the archive root, which is always a directory. - const targetPrefix = `${targetPath}/`; - const targetIsDirectory = - targetPath === "" || - target?.isDirectory === true || - entries.some(candidate => candidate.path.startsWith(targetPrefix)); - if (!targetIsDirectory) { - if (pending.kind === "symlink") { - entry.storage = { type: "tar-link", targetPath: pending.targetPath }; - unresolved.delete(entry); - resolved++; - continue; - } - const reason = target ? "unreadable member" : "missing member"; - throw new ToolError(`Archive hard link '${entry.path}' targets ${reason} '${pending.targetPath}'`); - } - if (pending.kind === "hard link") { - throw new ToolError(`Archive hard link '${entry.path}' targets directory '${pending.targetPath}'`); - } - - entry.isDirectory = true; - entry.storage = { type: "tar-link", targetPath: pending.targetPath }; - unresolved.delete(entry); - resolved++; + const reason = target ? "unreadable member" : "missing member"; + throw new ToolError(`Archive hard link '${entry.path}' targets ${reason} '${pending.targetPath}'`); } - if (resolved === 0) { - throw new ToolError("Archive contains cyclic or unsupported links"); + if (pending.kind === "hard link") { + throw new ToolError(`Archive hard link '${entry.path}' targets directory '${pending.targetPath}'`); } + + entry.isDirectory = true; + entry.storage = { type: "tar-link", targetPath: pending.targetPath }; + } + // Links never dequeued sit in a dependency cycle (a -> b/x, b -> a/y). + if (unresolved.size > 0) { + throw new ToolError("Archive contains cyclic or unsupported links"); } } @@ -1012,15 +1075,13 @@ const MAX_LINK_RESOLUTION_DEPTH = 40; function resolveDirectoryAliasPath(entries: ReadonlyMap, archivePath: string): string { let resolvedPath = archivePath; const seen = new Set(); - for (let depth = 0; depth < MAX_LINK_RESOLUTION_DEPTH && !seen.has(resolvedPath); depth++) { + for (let rewrites = 0; !seen.has(resolvedPath); ) { seen.add(resolvedPath); - const parts = resolvedPath.split("/"); let replacement: string | undefined; - for (let end = parts.length; end > 0; end--) { - const prefix = parts.slice(0, end).join("/"); - const entry = entries.get(prefix); + for (let end = resolvedPath.length; end > 0; end = resolvedPath.lastIndexOf("/", end - 1)) { + const entry = entries.get(resolvedPath.slice(0, end)); if (!entry?.isDirectory || entry.storage?.type !== "tar-link") continue; - const suffix = parts.slice(end).join("/"); + const suffix = resolvedPath.slice(end + 1); replacement = suffix ? entry.storage.targetPath ? `${entry.storage.targetPath}/${suffix}` @@ -1029,6 +1090,10 @@ function resolveDirectoryAliasPath(entries: ReadonlyMap MAX_LINK_RESOLUTION_DEPTH) break; resolvedPath = replacement; } throw new ToolError(`Archive path '${archivePath}' crosses a cyclic symlink`); diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index fb4f4d93f..7856cce05 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -73,7 +73,7 @@ function mountNoticesIn(messages: Message[]): string[] { typeof content === "string" ? content : content.flatMap(part => (part.type === "text" ? [part.text] : [])).join(""); - return text.includes("The xd:// device inventory changed.") ? [text] : []; + return text.includes("xd:// device inventory changed.") ? [text] : []; }); } @@ -938,10 +938,10 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { expect(contexts).toHaveLength(2); const mountNotices = mountNoticesIn(contexts[1]); expect(mountNotices).toHaveLength(1); - expect(mountNotices[0]).toContain("became available"); + expect(mountNotices[0]).toContain("Available tools."); expect(mountNotices[0]).toContain("xd://mcp__nucleus_search"); expect(mountNotices[0]).toContain("xd://mcp__nucleus_fetch"); - expect(mountNotices[0]).not.toContain("No longer mounted"); + expect(mountNotices[0]).not.toContain("Unmounted; writes fail:"); // A later unmount is likewise held for the following user prompt. await session.refreshMCPTools([search]); @@ -950,9 +950,9 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { await session.prompt("third"); const allNotices = mountNoticesIn(contexts[2]); expect(allNotices).toHaveLength(2); - expect(allNotices[1]).toContain("No longer mounted"); + expect(allNotices[1]).toContain("Unmounted; writes fail:"); expect(allNotices[1]).toContain("xd://mcp__nucleus_fetch"); - expect(allNotices[1]).not.toContain("became available"); + expect(allNotices[1]).not.toContain("Available tools."); }); it("caps dynamic xd:// mount-notice summaries", async () => { @@ -1009,7 +1009,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { expect(notices).toHaveLength(1); expect(notices[0]).toContain("xd://mcp__nucleus_search"); expect(notices[0]).not.toContain("mcp__nucleus_fetch"); - expect(notices[0]).not.toContain("No longer mounted"); + expect(notices[0]).not.toContain("Unmounted; writes fail:"); }); it.each([ diff --git a/packages/coding-agent/test/mcp-http-transport.test.ts b/packages/coding-agent/test/mcp-http-transport.test.ts index 09915e993..ce7c2a940 100644 --- a/packages/coding-agent/test/mcp-http-transport.test.ts +++ b/packages/coding-agent/test/mcp-http-transport.test.ts @@ -213,4 +213,152 @@ describe("MCP Streamable HTTP POST response resumption", () => { expect(observed.protocolVersion).toBe("2025-11-25"); expect(observed.resumedAt - observed.postClosedAt).toBeGreaterThanOrEqual(15); }); + it("refreshes auth on a 401 resume GET without replaying the POST", async () => { + const observed = { posts: 0, gets: 0, auth: [] as (string | null)[], lastEventId: null as string | null }; + server = Bun.serve({ + port: 0, + fetch(req) { + if (req.method === "POST") { + observed.posts++; + return new Response("id: stream-1\nretry: 10\ndata:\n\n", { + headers: { "Content-Type": "text/event-stream" }, + }); + } + observed.gets++; + observed.auth.push(req.headers.get("Authorization")); + observed.lastEventId = req.headers.get("Last-Event-ID"); + if (req.headers.get("Authorization") !== "Bearer fresh") { + return new Response("expired", { status: 401 }); + } + return new Response( + 'id: stream-2\ndata: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"resumed","inputSchema":{"type":"object"}}]}}\n\n', + { headers: { "Content-Type": "text/event-stream" } }, + ); + }, + }); + if (!server) throw new Error("Test server was not started"); + const transport = new HttpTransport({ + type: "http", + url: `http://127.0.0.1:${server.port}/mcp`, + timeout: GUARD_TIMEOUT_MS, + headers: { Authorization: "Bearer stale" }, + }); + transport.onAuthError = async () => ({ Authorization: "Bearer fresh" }); + await transport.connect(); + + await expect(withPendingGuard(transport.request("tools/list"), "request")).resolves.toEqual({ + tools: [{ name: "resumed", inputSchema: { type: "object" } }], + }); + // One POST only: replaying it after the server accepted the request + // could double-execute a state-changing tool. + expect(observed.posts).toBe(1); + expect(observed.gets).toBe(2); + expect(observed.auth).toEqual(["Bearer stale", "Bearer fresh"]); + expect(observed.lastEventId).toBe("stream-1"); + }); + + it("resumes after an abrupt stream drop once an event ID exists", async () => { + // Bun.serve cannot produce a genuine mid-body transport failure in-process + // (stream errors surface as clean EOF client-side), so speak raw HTTP: a + // chunked response without the terminal chunk, closed mid-body, makes the + // client's body read throw. + const observed = { posts: 0, lastEventId: null as string | null }; + const sseChunk = (payload: string): string => + `HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n${payload.length.toString(16)}\r\n${payload}\r\n`; + const listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(socket, data) { + const request = new TextDecoder().decode(data); + if (request.startsWith("POST")) { + observed.posts++; + // Priming event, then close without the terminal 0-chunk. + socket.write(sseChunk("id: stream-1\nretry: 10\ndata:\n\n")); + socket.end(); + return; + } + if (!request.startsWith("GET")) return; + observed.lastEventId = /^Last-Event-ID:\s*(.+)$/im.exec(request)?.[1]?.trim() ?? null; + socket.write( + `${sseChunk( + 'id: stream-2\ndata: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"resumed","inputSchema":{"type":"object"}}]}}\n\n', + )}0\r\n\r\n`, + ); + socket.end(); + }, + }, + }); + try { + const transport = new HttpTransport({ + type: "http", + url: `http://127.0.0.1:${listener.port}/mcp`, + timeout: GUARD_TIMEOUT_MS, + }); + await transport.connect(); + + await expect(withPendingGuard(transport.request("tools/list"), "request")).resolves.toEqual({ + tools: [{ name: "resumed", inputSchema: { type: "object" } }], + }); + expect(observed.posts).toBe(1); + expect(observed.lastEventId).toBe("stream-1"); + } finally { + listener.stop(true); + } + }); +}); + +describe("MCP Streamable HTTP GET listener resumption", () => { + it("resumes the long-lived GET stream with Last-Event-ID instead of reconnecting", async () => { + const observed = { gets: 0, lastEventIds: [] as (string | null)[] }; + server = Bun.serve({ + port: 0, + fetch(req) { + if (req.method !== "GET") { + return Response.json({ jsonrpc: "2.0", id: 1, result: {} }); + } + observed.gets++; + observed.lastEventIds.push(req.headers.get("Last-Event-ID")); + if (observed.gets === 1) { + // Polling-style server: deliver one notification with an + // event ID, then close the physical connection. + return new Response( + 'id: poll-1\nretry: 10\ndata: {"jsonrpc":"2.0","method":"notifications/first"}\n\n', + { headers: { "Content-Type": "text/event-stream" } }, + ); + } + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode('id: poll-2\ndata: {"jsonrpc":"2.0","method":"notifications/second"}\n\n'), + ); + // Held open: the logical stream continues. + }, + }), + { headers: { "Content-Type": "text/event-stream" } }, + ); + }, + }); + const transport = await connectedTransport(); + const notifications: string[] = []; + let closed = false; + const secondNotification = Promise.withResolvers(); + transport.onNotification = method => { + notifications.push(method); + if (notifications.length === 2) secondNotification.resolve(); + }; + transport.onClose = () => { + closed = true; + }; + + await transport.startSSEListener(); + await withPendingGuard(secondNotification.promise, "resumed notification"); + + expect(notifications).toEqual(["notifications/first", "notifications/second"]); + expect(observed.lastEventIds).toEqual([null, "poll-1"]); + // The resume replaced the manager-level reconnect: no close fired. + expect(closed).toBe(false); + await transport.close(); + }); }); diff --git a/packages/coding-agent/test/modes/components/tool-activity-visibility.test.ts b/packages/coding-agent/test/modes/components/tool-activity-visibility.test.ts index e1c45b48e..f041efac5 100644 --- a/packages/coding-agent/test/modes/components/tool-activity-visibility.test.ts +++ b/packages/coding-agent/test/modes/components/tool-activity-visibility.test.ts @@ -50,4 +50,19 @@ describe("tool activity visibility", () => { expect(restored).toContain("tool warning"); expect(restored).toContain("late activity"); }); + + it("forwards Ctrl+O expansion to wrapped expandable components", () => { + // The transcript expansion traversal only visits top-level children; + // without forwarding, a wrapped renderer freezes at insertion-time state. + const states: boolean[] = []; + class ExpandableText extends Text { + setExpanded(expanded: boolean): void { + states.push(expanded); + } + } + const wrapper = new ToolActivityContainer(new ExpandableText("activity", 1, 0)); + wrapper.setExpanded(true); + wrapper.setExpanded(false); + expect(states).toEqual([true, false]); + }); }); diff --git a/packages/coding-agent/test/sdk-tool-activation.test.ts b/packages/coding-agent/test/sdk-tool-activation.test.ts index ef28a21a5..1de587789 100644 --- a/packages/coding-agent/test/sdk-tool-activation.test.ts +++ b/packages/coding-agent/test/sdk-tool-activation.test.ts @@ -109,10 +109,7 @@ describe("createAgentSession defaultInactive tool activation", () => { workspaceTree: { rootPath: tempDir, rendered: "", truncated: false, totalLines: 0, agentsMdFiles: [] }, }); - const requireBundledModel = ( - provider: "anthropic" | "google-antigravity" | "openai" | "xai", - id: string, - ): Model => { + const requireBundledModel = (provider: "anthropic" | "google" | "openai" | "xai", id: string): Model => { const bundled = getBundledModel(provider, id); if (!bundled) throw new Error(`Expected ${provider}/${id} model to exist`); return bundled; @@ -190,7 +187,8 @@ describe("createAgentSession defaultInactive tool activation", () => { const unsupported = requireBundledModel("xai", "grok-4"); const fable = requireBundledModel("anthropic", "claude-fable-5"); const responses = requireBundledModel("openai", "gpt-5"); - const gemini = requireBundledModel("google-antigravity", "gemini-3.6-flash"); + const gemini = requireBundledModel("google", "gemini-2.5-flash"); + const mandatoryGemini = requireBundledModel("google", "gemini-2.5-pro"); const { session } = await createAgentSession({ ...baseOptions(tempDir), settings, @@ -199,7 +197,7 @@ describe("createAgentSession defaultInactive tool activation", () => { const authStorage = session.modelRegistry.authStorage; authStorage.setRuntimeApiKey("anthropic", "test-key"); authStorage.setRuntimeApiKey("openai", "test-key"); - authStorage.setRuntimeApiKey("google-antigravity", "test-key"); + authStorage.setRuntimeApiKey("google", "test-key"); authStorage.setRuntimeApiKey("xai", "test-key"); try { @@ -214,6 +212,8 @@ describe("createAgentSession defaultInactive tool activation", () => { expect(session.getActiveToolNames()).toContain("think"); await session.setModel(gemini); expect(session.getActiveToolNames()).toContain("think"); + await session.setModel(mandatoryGemini); + expect(session.getActiveToolNames()).not.toContain("think"); await session.setModel(unsupported); expect(session.getActiveToolNames()).not.toContain("think"); @@ -249,7 +249,7 @@ describe("createAgentSession defaultInactive tool activation", () => { fetch: async request => { requestTexts.push(await request.text()); if (requestTexts.length === 1) { - const argumentsJson = JSON.stringify({ notes: "Checked the request before answering." }); + const argumentsJson = JSON.stringify({ thoughts: "Checked the request before answering." }); return sse([ { type: "response.output_item.added", diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index f4bd64744..44a4d4d5f 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -179,6 +179,35 @@ function createSparsePaxTarArchive(realName: string, realSize: number, storedDat return Buffer.concat(parts); } +/** + * Build an old-GNU sparse archive: an `S` member whose header sets the + * `isextended` flag (byte 482), followed by one 512-byte sparse-map + * continuation block that is not counted in the member's declared size, + * then the stored data and a regular member. + */ +function createOldGnuSparseTarArchive(): Buffer { + const storedData = Buffer.from("sparse-extent\n", "utf-8"); + const header = Buffer.alloc(512, 0); + writeTarString(header, 0, 100, "data/old-sparse.bin"); + writeTarOctal(header, 100, 8, 0o644); + writeTarOctal(header, 124, 12, storedData.length); + writeTarOctal(header, 136, 12, Math.floor(Date.now() / 1000)); + header[156] = "S".charCodeAt(0); + writeTarString(header, 257, 6, "ustar"); + writeTarString(header, 263, 2, "00"); + header[482] = 1; // sparse map continues in extension blocks + tarChecksum(header); + + // Final continuation block: its own isextended byte (504) stays 0. + const continuation = Buffer.alloc(512, 0); + const parts: Buffer[] = [header, continuation, storedData]; + const remainder = storedData.length % 512; + if (remainder !== 0) parts.push(Buffer.alloc(512 - remainder, 0)); + // createTarArchive appends the member and the end-of-archive terminator. + parts.push(createTarArchive([{ path: "data/after.txt", content: "after sparse\n" }])); + return Buffer.concat(parts); +} + const CRC32_TABLE = (() => { const table = new Uint32Array(256); for (let index = 0; index < 256; index++) { @@ -940,21 +969,91 @@ describe("Coding Agent Tools", () => { expect(getTextOutput(linkedResult)).toContain("export const linked = true"); }); - it("should reject directory symlinks targeting their own subtree instead of looping", async () => { + it("should degrade directory symlinks targeting their own subtree to dangling links", async () => { const archivePath = path.join(testDir, "self-cycle-symlink.tar"); fs.writeFileSync( archivePath, createTarArchive([ - { path: "a/b/f.txt", content: "unreachable\n" }, - // `a -> a/b` grows the resolved path on every rewrite; the - // pre-fix resolver looped forever on this shape. + { path: "a/b/f.txt", content: "still readable\n" }, + // `a -> a/b` is inherently cyclic; the pre-fix resolver looped + // forever growing the rewritten path. The link now dangles + // while real members underneath stay readable. { path: "a", content: "", typeFlag: "2", linkName: "a/b" }, ]), ); + const memberResult = await readTool.execute("test-call-tar-self-cycle-member", { + path: `${archivePath}:a/b/f.txt`, + }); + expect(getTextOutput(memberResult)).toContain("still readable"); + await expect(readTool.execute("test-call-tar-self-cycle-link", { path: `${archivePath}:a` })).rejects.toThrow( + /cannot be materialized/, + ); + }); + + it("should resolve alias chains up to the rewrite bound and reject deeper ones", async () => { + const buildChain = (length: number): ArchiveFixtureEntry[] => { + const chain: ArchiveFixtureEntry[] = [{ path: "real/f.txt", content: "deep\n" }]; + for (let i = 0; i < length; i++) { + chain.push({ + path: `a${i}`, + content: "", + typeFlag: "2", + linkName: i === length - 1 ? "real" : `a${i + 1}`, + }); + } + return chain; + }; + + const okPath = path.join(testDir, "alias-chain-40.tar"); + fs.writeFileSync(okPath, createTarArchive(buildChain(40))); + const okResult = await readTool.execute("test-call-tar-alias-chain-40", { path: `${okPath}:a0/f.txt` }); + expect(getTextOutput(okResult)).toContain("deep"); + + const deepPath = path.join(testDir, "alias-chain-41.tar"); + fs.writeFileSync(deepPath, createTarArchive(buildChain(41))); await expect( - readTool.execute("test-call-tar-self-cycle", { path: `${archivePath}:a/b/f.txt` }), - ).rejects.toThrow(/cyclic or unsupported links/); + readTool.execute("test-call-tar-alias-chain-41", { path: `${deepPath}:a0/f.txt` }), + ).rejects.toThrow(/cyclic symlink/); + }); + + it("should let the later duplicate tar member win", async () => { + const archivePath = path.join(testDir, "duplicate-member.tar"); + // tar -rf append/update semantics: extraction yields the last member. + fs.writeFileSync( + archivePath, + createTarArchive([ + { path: "dup/file.txt", content: "first\n" }, + { path: "dup/file.txt", content: "second\n" }, + ]), + ); + + const result = await readTool.execute("test-call-tar-duplicate-member", { + path: `${archivePath}:dup/file.txt`, + }); + expect(getTextOutput(result)).toContain("second"); + expect(getTextOutput(result)).not.toContain("first"); + }); + + it("should skip old-GNU sparse extension blocks between header and data", async () => { + const archivePath = path.join(testDir, "old-gnu-sparse.tar"); + fs.writeFileSync(archivePath, createOldGnuSparseTarArchive()); + + // Pre-fix the continuation block was parsed as the next header and + // the whole archive rejected as corrupt. + const rootResult = await readTool.execute("test-call-tar-old-gnu-sparse-root", { + path: `${archivePath}:data`, + }); + expect(getTextOutput(rootResult)).toContain("old-sparse.bin"); + expect(getTextOutput(rootResult)).toContain("after.txt"); + + const afterResult = await readTool.execute("test-call-tar-old-gnu-sparse-after", { + path: `${archivePath}:data/after.txt`, + }); + expect(getTextOutput(afterResult)).toContain("after sparse"); + await expect( + readTool.execute("test-call-tar-old-gnu-sparse-member", { path: `${archivePath}:data/old-sparse.bin` }), + ).rejects.toThrow(/sparse file and cannot be read/); }); it("should resolve tar symlinks whose target is the archive root", async () => { diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index c785b8ee1..8d975ad1a 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -315,6 +315,12 @@ describe("shape resolution", () => { // high-res tier rather than falling back to the 1568px default (#8256). expect(snapcompact.resolveShape({ api: "anthropic-messages", id: "claude-opus-5" }).frameSize).toBe(1932); expect(snapcompact.resolveShape({ api: "anthropic-messages", id: "claude-opus-6" }).frameSize).toBe(1932); + // Mixed-case gateway ids matched the pre-catalog-parser /i regex; the + // parser input is normalized so they stay on the high-res tier. + expect(snapcompact.resolveShape({ api: "anthropic-messages", id: "CLAUDE-OPUS-5" }).frameSize).toBe(1932); + // Minor versions past the old 9.10 semver-table bound must not fall + // back to the 1568px default (same staleness class as #8256). + expect(snapcompact.resolveShape({ api: "anthropic-messages", id: "claude-opus-5-11" }).frameSize).toBe(1932); // Opus lines below 4.7 downscale, so they keep the safe family default. expect(snapcompact.resolveShape({ api: "anthropic-messages", id: "claude-opus-4-6" })).toBe( snapcompact.SHAPES.anthropic,