diff --git a/extensions/codex/src/app-server/native-subagent-monitor.test.ts b/extensions/codex/src/app-server/native-subagent-monitor.test.ts index 024ac3952f9..3e4dfa27769 100644 --- a/extensions/codex/src/app-server/native-subagent-monitor.test.ts +++ b/extensions/codex/src/app-server/native-subagent-monitor.test.ts @@ -11,7 +11,7 @@ import { CodexNativeSubagentMonitor, registerCodexNativeSubagentMonitor, } from "./native-subagent-monitor.js"; -import type { CodexServerNotification } from "./protocol.js"; +import type { CodexServerNotification, JsonValue } from "./protocol.js"; function createClient() { const handlers = new Set<(notification: CodexServerNotification) => Promise | void>(); @@ -158,6 +158,27 @@ function nativeCompletionNotification(params: { }; } +function childTurnCompletedNotification(params: { + status: "completed" | "failed" | "interrupted"; + error?: string; + turnId?: string; + items?: JsonValue[]; +}): CodexServerNotification { + const turnId = params.turnId ?? "child-turn"; + return { + method: "turn/completed", + params: { + threadId: "child-thread", + turn: { + id: turnId, + status: params.status, + ...(params.items ? { items: params.items } : {}), + ...(params.error ? { error: { message: params.error } } : {}), + }, + }, + }; +} + describe("CodexNativeSubagentMonitor", () => { it("keeps native subagent task mirroring alive on the shared client", async () => { const client = createClient(); @@ -314,12 +335,10 @@ describe("CodexNativeSubagentMonitor", () => { ); }); - it("delivers child agent-message completion when a native subagent becomes idle", async () => { + it("delivers a completed child turn with its final agent message", async () => { const client = createClient(); const runtime = createRuntime(); - const monitor = new CodexNativeSubagentMonitor(client, runtime, { - codexHome: "/tmp/codex-home", - }); + const monitor = new CodexNativeSubagentMonitor(client, runtime); monitor.registerParent({ parentThreadId: "parent-thread", requesterSessionKey: "agent:main:discord:channel:C123", @@ -329,17 +348,36 @@ describe("CodexNativeSubagentMonitor", () => { await notifyChildStarted(client); await client.notify({ - method: "item/completed", + method: "item/started", params: { threadId: "child-thread", + turnId: "child-turn", item: { type: "agentMessage", id: "msg-child-final", phase: "final_answer", - text: "child final result", + text: "", }, }, }); + await client.notify({ + method: "item/agentMessage/delta", + params: { + threadId: "child-thread", + turnId: "child-turn", + itemId: "msg-child-final", + delta: "child ", + }, + }); + await client.notify({ + method: "item/agentMessage/delta", + params: { + threadId: "child-thread", + turnId: "child-turn", + itemId: "msg-child-final", + delta: "final result", + }, + }); expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled(); @@ -351,6 +389,9 @@ describe("CodexNativeSubagentMonitor", () => { }, }); + expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled(); + await client.notify(childTurnCompletedNotification({ status: "completed" })); + expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith( expect.objectContaining({ runId: "codex-thread:child-thread", @@ -362,7 +403,7 @@ describe("CodexNativeSubagentMonitor", () => { expect.objectContaining({ childSessionId: "child-thread", status: "succeeded", - statusLabel: "agent_message", + statusLabel: "turn_completed", result: "child final result", }), ); @@ -370,12 +411,10 @@ describe("CodexNativeSubagentMonitor", () => { client.close(); }); - it("does not deliver commentary-only child messages as native subagent completion", async () => { + it("does not deliver a commentary delta when the completion snapshot is absent", async () => { const client = createClient(); const runtime = createRuntime(); - const monitor = new CodexNativeSubagentMonitor(client, runtime, { - codexHome: "/tmp/codex-home", - }); + const monitor = new CodexNativeSubagentMonitor(client, runtime); monitor.registerParent({ parentThreadId: "parent-thread", requesterSessionKey: "agent:main:discord:channel:C123", @@ -384,10 +423,66 @@ describe("CodexNativeSubagentMonitor", () => { }); await notifyChildStarted(client); + await client.notify({ + method: "item/started", + params: { + threadId: "child-thread", + turnId: "child-turn", + item: { + type: "agentMessage", + id: "msg-child-commentary", + phase: "commentary", + text: "", + }, + }, + }); + await client.notify({ + method: "item/agentMessage/delta", + params: { + threadId: "child-thread", + turnId: "child-turn", + itemId: "msg-child-commentary", + delta: "checking now", + }, + }); + await client.notify(childTurnCompletedNotification({ status: "completed" })); + + expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith( + expect.objectContaining({ + childSessionId: "child-thread", + result: "Codex native subagent completed without a final assistant message.", + }), + ); + + client.close(); + }); + + it("does not complete commentary-only child messages before a terminal turn", async () => { + const client = createClient(); + const runtime = createRuntime(); + const monitor = new CodexNativeSubagentMonitor(client, runtime); + monitor.registerParent({ + parentThreadId: "parent-thread", + requesterSessionKey: "agent:main:discord:channel:C123", + taskRuntimeScope: createTaskScope(), + agentId: "main", + }); + + await notifyChildStarted(client); + await client.notify({ + method: "item/agentMessage/delta", + params: { + threadId: "child-thread", + turnId: "child-turn", + itemId: "msg-child-commentary", + delta: "checking now", + }, + }); await client.notify({ method: "item/completed", params: { threadId: "child-thread", + turnId: "child-turn", item: { type: "agentMessage", id: "msg-child-commentary", @@ -407,6 +502,162 @@ describe("CodexNativeSubagentMonitor", () => { expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled(); expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled(); + await client.notify(childTurnCompletedNotification({ status: "completed" })); + + expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith( + expect.objectContaining({ + childSessionId: "child-thread", + result: "Codex native subagent completed without a final assistant message.", + }), + ); + + client.close(); + }); + + it("delivers a completed child turn with its snapshot-only final message", async () => { + const client = createClient(); + const runtime = createRuntime(); + const monitor = new CodexNativeSubagentMonitor(client, runtime); + monitor.registerParent({ + parentThreadId: "parent-thread", + requesterSessionKey: "agent:main:discord:channel:C123", + taskRuntimeScope: createTaskScope(), + agentId: "main", + }); + + await notifyChildStarted(client); + await client.notify( + childTurnCompletedNotification({ + status: "completed", + items: [ + { + id: "msg-child-snapshot", + type: "agentMessage", + phase: "final_answer", + text: "snapshot final result", + }, + ], + }), + ); + + expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith( + expect.objectContaining({ + childSessionId: "child-thread", + result: "snapshot final result", + }), + ); + + client.close(); + }); + + it("reconciles transcript text for a completed child turn without a final message", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-subagent-")); + const codexHome = path.join(tempDir, "codex-home"); + const transcriptDir = path.join(codexHome, "sessions", "2026", "06", "09"); + await fs.mkdir(transcriptDir, { recursive: true }); + await fs.writeFile( + path.join(transcriptDir, "rollout-2026-06-09T10-11-12-child-thread.jsonl"), + [ + JSON.stringify({ + type: "session_meta", + payload: { + source: { + subagent: { + thread_spawn: { + parent_thread_id: "parent-thread", + depth: 1, + }, + }, + }, + }, + }), + JSON.stringify({ + timestamp: "2026-06-09T10:12:00.000Z", + type: "event_msg", + payload: { + type: "task_complete", + last_agent_message: "child turn transcript result", + completed_at: 1781009520, + }, + }), + "", + ].join("\n"), + ); + const client = createClient(); + const runtime = createRuntime(); + const monitor = new CodexNativeSubagentMonitor(client, runtime, { + codexHome, + transcriptPollDelaysMs: [60_000], + }); + monitor.registerParent({ + parentThreadId: "parent-thread", + requesterSessionKey: "agent:main:discord:channel:C123", + taskRuntimeScope: createTaskScope(), + agentId: "main", + }); + + await notifyChildStarted(client); + await client.notify(childTurnCompletedNotification({ status: "completed" })); + + expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith( + expect.objectContaining({ + childSessionId: "child-thread", + statusLabel: "task_complete", + result: "child turn transcript result", + }), + ); + + client.close(); + }); + + it("does not reuse an interrupted child turn's message after resuming", async () => { + const client = createClient(); + const runtime = createRuntime(); + const monitor = new CodexNativeSubagentMonitor(client, runtime); + monitor.registerParent({ + parentThreadId: "parent-thread", + requesterSessionKey: "agent:main:discord:channel:C123", + taskRuntimeScope: createTaskScope(), + agentId: "main", + }); + + await notifyChildStarted(client); + await client.notify({ + method: "item/completed", + params: { + threadId: "child-thread", + turnId: "child-turn", + item: { + type: "agentMessage", + id: "msg-child-partial", + text: "partial child result", + }, + }, + }); + await client.notify({ + method: "thread/status/changed", + params: { + threadId: "child-thread", + status: { type: "idle" }, + }, + }); + await client.notify(childTurnCompletedNotification({ status: "interrupted" })); + + expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled(); + + await client.notify( + childTurnCompletedNotification({ status: "completed", turnId: "resumed-child-turn" }), + ); + + expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(1); + expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith( + expect.objectContaining({ + childSessionId: "child-thread", + status: "succeeded", + result: "Codex native subagent completed without a final assistant message.", + }), + ); + client.close(); }); diff --git a/extensions/codex/src/app-server/native-subagent-monitor.ts b/extensions/codex/src/app-server/native-subagent-monitor.ts index a5bfdd6732b..cc67da55dee 100644 --- a/extensions/codex/src/app-server/native-subagent-monitor.ts +++ b/extensions/codex/src/app-server/native-subagent-monitor.ts @@ -51,14 +51,11 @@ type ParentState = { type ChildState = { childThreadId: string; parentThreadId: string; + assistantMessagesByTurn: Map; transcriptPath?: string; transcriptPollAttempt: number; transcriptPollTimer?: ReturnType; transcriptTerminal: boolean; - idle: boolean; - lastAgentMessage?: string; - lastAgentMessageAt?: number; - agentMessageCompletionDelivered: boolean; pendingCompletion?: CodexNativeSubagentCompletion; pendingCompletionEventAt?: number; completionDeliveryAttempt: number; @@ -67,6 +64,13 @@ type ChildState = { noFinalCompletionFallbackTimer?: ReturnType; }; +type ChildAssistantMessages = { + texts: Map; + order: string[]; + commentaryIds: Set; + finalMessageIds: Set; +}; + type TranscriptCompletion = CodexNativeSubagentCompletion & { parentThreadId?: string; completedAt?: number; @@ -215,10 +219,9 @@ export class CodexNativeSubagentMonitor { }); } } - const childThreadId = this.recordChildAgentMessage(notification); - const idleChildThreadId = this.recordChildIdle(notification); + this.captureChildAssistantMessage(notification); + await this.handleChildTurnCompletion(notification); await this.handleCompletionNotification(notification); - await this.processChildAgentMessageCompletion(childThreadId ?? idleChildThreadId); } private ensureParentTaskRuntime(state: ParentState): void { @@ -299,7 +302,11 @@ export class CodexNativeSubagentMonitor { nativeCompletion.agentPath, ); const childState = childThreadId ? this.childStates.get(childThreadId) : undefined; - if (!childState || childState.parentThreadId !== state.parentThreadId) { + if ( + !childState || + childState.parentThreadId !== state.parentThreadId || + childState.transcriptTerminal + ) { embeddedAgentLog.warn( "Ignoring Codex native subagent completion for unknown child thread", { @@ -310,21 +317,151 @@ export class CodexNativeSubagentMonitor { continue; } const completion = toThreadCompletion(nativeCompletion, childState.childThreadId); - if (shouldWaitForTranscriptCompletion(completion, this.codexHome)) { - // Codex can notify `completed: null` before the child transcript exposes - // its final assistant message; poll briefly before delivering the no-final fallback. - const eventAt = Date.now(); - const reconciled = await this.reconcileChildTranscript(childState.childThreadId); - if (!reconciled) { - this.scheduleTranscriptPoll(childState); - this.scheduleNoFinalCompletionFallback(state, childState, completion, eventAt); - } - continue; - } - await this.processCompletion(state, completion); + await this.processChildCompletion(state, childState, completion); } } + private captureChildAssistantMessage(notification: CodexServerNotification): void { + const params = isJsonObject(notification.params) ? notification.params : undefined; + const childThreadId = readString(params, "threadId")?.trim(); + const childState = childThreadId ? this.childStates.get(childThreadId) : undefined; + if (!childState || childState.transcriptTerminal) { + return; + } + if (notification.method === "item/agentMessage/delta") { + const turnId = readString(params, "turnId"); + const itemId = readString(params, "itemId"); + const delta = readString(params, "delta"); + if (turnId && itemId && delta) { + this.recordChildAssistantMessage(childState, turnId, itemId, delta); + } + return; + } + if (notification.method !== "item/started" && notification.method !== "item/completed") { + return; + } + const turnId = readString(params, "turnId"); + const item = isJsonObject(params?.item) ? params.item : undefined; + this.captureChildAssistantMessageItem(childState, turnId, item); + } + + private captureChildAssistantMessageItem( + childState: ChildState, + turnId: string | undefined, + item: JsonObject | undefined, + ): void { + if (readString(item, "type") !== "agentMessage") { + return; + } + const itemId = readString(item, "id"); + if (!turnId || !itemId) { + return; + } + const assistantMessages = this.getChildAssistantMessages(childState, turnId); + const phase = readString(item, "phase"); + if (phase === "commentary") { + assistantMessages.commentaryIds.add(itemId); + } else { + assistantMessages.finalMessageIds.add(itemId); + } + const text = readString(item, "text"); + if (text) { + this.recordChildAssistantMessage(childState, turnId, itemId, text, { replace: true }); + } + } + + private captureChildTurnAssistantMessages(childState: ChildState, turn: JsonObject): void { + const turnId = readString(turn, "id"); + if (!turnId || !Array.isArray(turn.items)) { + return; + } + for (const item of turn.items) { + this.captureChildAssistantMessageItem( + childState, + turnId, + isJsonObject(item) ? item : undefined, + ); + } + } + + private recordChildAssistantMessage( + childState: ChildState, + turnId: string, + itemId: string, + text: string, + options: { replace?: boolean } = {}, + ): void { + const assistantMessages = this.getChildAssistantMessages(childState, turnId); + if (!assistantMessages.texts.has(itemId)) { + assistantMessages.order.push(itemId); + } + const existing = assistantMessages.texts.get(itemId) ?? ""; + assistantMessages.texts.set(itemId, options.replace ? text : `${existing}${text}`); + } + + private getChildAssistantMessages( + childState: ChildState, + turnId: string, + ): ChildAssistantMessages { + const existing = childState.assistantMessagesByTurn.get(turnId); + if (existing) { + return existing; + } + const assistantMessages: ChildAssistantMessages = { + texts: new Map(), + order: [], + commentaryIds: new Set(), + finalMessageIds: new Set(), + }; + childState.assistantMessagesByTurn.set(turnId, assistantMessages); + return assistantMessages; + } + + private async handleChildTurnCompletion(notification: CodexServerNotification): Promise { + if (notification.method !== "turn/completed") { + return; + } + const params = isJsonObject(notification.params) ? notification.params : undefined; + const childThreadId = readString(params, "threadId")?.trim(); + const childState = childThreadId ? this.childStates.get(childThreadId) : undefined; + const state = childState ? this.parentStates.get(childState.parentThreadId) : undefined; + const turn = isJsonObject(params?.turn) ? params.turn : undefined; + if (childState && turn && readString(turn, "status") === "interrupted") { + const turnId = readString(turn, "id"); + if (turnId) { + childState.assistantMessagesByTurn.delete(turnId); + } + return; + } + if (childState && turn) { + this.captureChildTurnAssistantMessages(childState, turn); + } + const completion = childState && turn ? toChildTurnCompletion(childState, turn) : undefined; + if (!state || !childState || childState.transcriptTerminal || !completion) { + return; + } + await this.processChildCompletion(state, childState, completion); + } + + private async processChildCompletion( + state: ParentState, + childState: ChildState, + completion: CodexNativeSubagentCompletion, + ): Promise { + if (shouldWaitForTranscriptCompletion(completion, this.codexHome)) { + // Codex can notify `completed: null` before the child transcript exposes + // its final assistant message; poll briefly before delivering the no-final fallback. + const eventAt = Date.now(); + const reconciled = await this.reconcileChildTranscript(childState.childThreadId); + if (!reconciled) { + this.scheduleTranscriptPoll(childState); + this.scheduleNoFinalCompletionFallback(state, childState, completion, eventAt); + } + return; + } + await this.processCompletion(state, completion); + } + async reconcileChildTranscript( childThreadId: string, options: { allowTreeScan?: boolean } = {}, @@ -557,10 +694,9 @@ export class CodexNativeSubagentMonitor { childState = { childThreadId: normalizedChildThreadId, parentThreadId: normalizedParentThreadId, + assistantMessagesByTurn: new Map(), transcriptPollAttempt: 0, transcriptTerminal: false, - idle: false, - agentMessageCompletionDelivered: false, completionDeliveryAttempt: 0, }; this.childStates.set(normalizedChildThreadId, childState); @@ -570,83 +706,6 @@ export class CodexNativeSubagentMonitor { } } - private recordChildAgentMessage(notification: CodexServerNotification): string | undefined { - if (notification.method !== "item/completed") { - return undefined; - } - const params = isJsonObject(notification.params) ? notification.params : undefined; - const item = isJsonObject(params?.item) ? params.item : undefined; - if (!params || !item || readString(item, "type") !== "agentMessage") { - return undefined; - } - const childThreadId = readString(params, "threadId")?.trim(); - const childState = childThreadId ? this.childStates.get(childThreadId) : undefined; - if (!childState || childState.transcriptTerminal) { - return undefined; - } - // Codex app-server can report the child final answer as the child thread's - // own agentMessage without also emitting a parent subagent notification. - // Pair it with idle below so commentary does not become a false terminal. - const phase = readString(item, "phase"); - if (phase === "commentary") { - return undefined; - } - const text = readString(item, "text")?.trim(); - if (!text) { - return undefined; - } - childState.lastAgentMessage = text; - childState.lastAgentMessageAt = Date.now(); - return childState.childThreadId; - } - - private recordChildIdle(notification: CodexServerNotification): string | undefined { - if (notification.method !== "thread/status/changed") { - return undefined; - } - const params = isJsonObject(notification.params) ? notification.params : undefined; - if (!params || !isJsonObject(params.status) || readString(params.status, "type") !== "idle") { - return undefined; - } - const childThreadId = readString(params, "threadId")?.trim(); - const childState = childThreadId ? this.childStates.get(childThreadId) : undefined; - if (!childState || childState.transcriptTerminal) { - return undefined; - } - childState.idle = true; - return childState.childThreadId; - } - - private async processChildAgentMessageCompletion( - childThreadId: string | undefined, - ): Promise { - const childState = childThreadId ? this.childStates.get(childThreadId) : undefined; - if ( - !childState || - !childState.idle || - childState.transcriptTerminal || - childState.agentMessageCompletionDelivered || - !childState.lastAgentMessage - ) { - return; - } - const state = this.parentStates.get(childState.parentThreadId); - if (!state) { - return; - } - childState.agentMessageCompletionDelivered = true; - await this.processCompletion( - state, - { - childThreadId: childState.childThreadId, - status: "succeeded", - statusLabel: "agent_message", - result: childState.lastAgentMessage, - }, - childState.lastAgentMessageAt, - ); - } - private ensureChildState(parentThreadId: string, childThreadId: string): ChildState { this.registerChildThread(parentThreadId, childThreadId); return this.childStates.get(childThreadId.trim())!; @@ -970,6 +1029,62 @@ function buildCompletionDedupeKey( return `${parentThreadId}:${completion.childThreadId}:${completion.status}:${hash}`; } +function toChildTurnCompletion( + childState: ChildState, + turn: JsonObject, +): CodexNativeSubagentCompletion | undefined { + const status = readString(turn, "status"); + if (status === "completed") { + const turnId = readString(turn, "id"); + const result = turnId ? lastChildAssistantMessage(childState, turnId) : undefined; + return { + childThreadId: childState.childThreadId, + status: "succeeded", + statusLabel: result ? "turn_completed" : "completed_without_final_message", + result: result ?? "Codex native subagent completed without a final assistant message.", + }; + } + if (status === "failed") { + return { + childThreadId: childState.childThreadId, + status: "failed", + statusLabel: "turn_failed", + result: readTurnErrorMessage(turn) ?? "Codex native subagent failed.", + }; + } + return undefined; +} + +function lastChildAssistantMessage(childState: ChildState, turnId: string): string | undefined { + const assistantMessages = childState.assistantMessagesByTurn.get(turnId); + if (!assistantMessages) { + return undefined; + } + for (let index = assistantMessages.order.length - 1; index >= 0; index -= 1) { + const itemId = assistantMessages.order[index]; + if ( + assistantMessages.finalMessageIds.has(itemId) && + !assistantMessages.commentaryIds.has(itemId) + ) { + const text = normalizeOptionalString(assistantMessages.texts.get(itemId)); + if (text) { + return text; + } + } + } + return undefined; +} + +function readTurnErrorMessage(turn: JsonObject): string | undefined { + const error = isJsonObject(turn.error) ? turn.error : undefined; + return ( + normalizeOptionalString(readString(error, "message")) ?? + normalizeOptionalString( + isJsonObject(error?.codexErrorInfo) ? readString(error.codexErrorInfo, "message") : undefined, + ) + ); +} + function buildParentAgentPathKey(parentThreadId: string, agentPath: string): string { return `${parentThreadId}\0${agentPath}`; } diff --git a/extensions/copilot/src/auth-bridge.test.ts b/extensions/copilot/src/auth-bridge.test.ts index 716b912ea49..e27d67cf839 100755 --- a/extensions/copilot/src/auth-bridge.test.ts +++ b/extensions/copilot/src/auth-bridge.test.ts @@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { COPILOT_DEFAULT_AGENT_ID, COPILOT_TOKEN_PROFILE_ERROR, - normalizeCopilotHomePath, resolveCopilotAuth, sanitizeAgentId, tokenFingerprint, @@ -508,18 +507,3 @@ describe("resolveCopilotAuth - defaults wiring", () => { ); }); }); - -describe("normalizeCopilotHomePath", () => { - it("resolves to absolute and strips trailing separators", () => { - const normalized = normalizeCopilotHomePath("./foo/bar/"); - expect(normalized).toBe(resolve("./foo/bar")); - expect(normalized.endsWith("/")).toBe(false); - expect(normalized.endsWith("\\")).toBe(false); - }); - - it("is idempotent", () => { - const once = normalizeCopilotHomePath("/some/path/"); - const twice = normalizeCopilotHomePath(once); - expect(twice).toBe(once); - }); -}); diff --git a/extensions/copilot/src/auth-bridge.ts b/extensions/copilot/src/auth-bridge.ts index 489bd266438..b1ce7c7d05b 100755 --- a/extensions/copilot/src/auth-bridge.ts +++ b/extensions/copilot/src/auth-bridge.ts @@ -1,7 +1,7 @@ // Copilot plugin module implements auth bridge behavior. import { createHash } from "node:crypto"; import { homedir as osHomedir } from "node:os"; -import { join, normalize, resolve, sep } from "node:path"; +import { join, resolve } from "node:path"; /** * Pure functional auth resolver for the copilot agent runtime. @@ -307,16 +307,3 @@ export function tokenFingerprint(token: string): string { function readString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } - -/** - * Normalize a copilotHome path for cross-platform pool keying. - * Re-exported so attempt.ts / runtime.ts can share the same - * normalization without re-implementing. - */ -export function normalizeCopilotHomePath(value: string): string { - return normalize(resolve(value)).replace(new RegExp(`${escapeForRegex(sep)}+$`), ""); -} - -function escapeForRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/extensions/copilot/src/sdk-loader.test.ts b/extensions/copilot/src/sdk-loader.test.ts index 34d9ab25943..ebaa1453ec9 100755 --- a/extensions/copilot/src/sdk-loader.test.ts +++ b/extensions/copilot/src/sdk-loader.test.ts @@ -4,7 +4,6 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - COPILOT_SDK_FALLBACK_DIR, COPILOT_SDK_SPEC, resetCopilotSdkCacheForTests, loadCopilotSdk, @@ -201,10 +200,6 @@ describe("sdk-loader", () => { expect(primaryImport).toHaveBeenCalledTimes(2); }); - it("default fallback dir points at ~/.openclaw/npm-runtime/copilot", () => { - expect(COPILOT_SDK_FALLBACK_DIR).toMatch(/\.openclaw[\\/]+npm-runtime[\\/]+copilot$/); - }); - it("resolves the fallback dir from OPENCLAW_STATE_DIR for relocated profiles", () => { expect( resolveCopilotSdkFallbackDir({ @@ -220,9 +215,6 @@ describe("sdk-loader", () => { }); describe("sdk dependency constants", () => { - it("COPILOT_SDK_FALLBACK_DIR keeps the legacy fallback path stable", () => { - expect(COPILOT_SDK_FALLBACK_DIR).toMatch(/\.openclaw[\\/]+npm-runtime[\\/]+copilot$/); - }); it("COPILOT_SDK_SPEC pins the canonical SDK spec", () => { expect(COPILOT_SDK_SPEC).toBe("@github/copilot-sdk@1.0.0-beta.9"); }); diff --git a/extensions/copilot/src/sdk-loader.ts b/extensions/copilot/src/sdk-loader.ts index 1f94044ded0..41b8cef561c 100755 --- a/extensions/copilot/src/sdk-loader.ts +++ b/extensions/copilot/src/sdk-loader.ts @@ -10,8 +10,6 @@ export function resolveCopilotSdkFallbackDir(env: NodeJS.ProcessEnv = process.en return path.join(resolveStateDir(env), "npm-runtime", "copilot"); } -export const COPILOT_SDK_FALLBACK_DIR = resolveCopilotSdkFallbackDir(); - export const COPILOT_SDK_SPEC = "@github/copilot-sdk@1.0.0-beta.9"; let cached: Promise | undefined; diff --git a/scripts/lib/plugin-clawhub-release.ts b/scripts/lib/plugin-clawhub-release.ts index 9151928a23e..b719a058ef4 100644 --- a/scripts/lib/plugin-clawhub-release.ts +++ b/scripts/lib/plugin-clawhub-release.ts @@ -89,6 +89,8 @@ type ClawHubPublishablePluginPackageFilters = { const CLAWHUB_DEFAULT_REGISTRY = "https://clawhub.ai"; const CLAWHUB_REQUEST_TIMEOUT_MS = 30_000; const CLAWHUB_RESPONSE_BODY_MAX_BYTES = 64 * 1024; +const CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS = [1_000, 3_000, 10_000] as const; +const CLAWHUB_MAX_RETRY_AFTER_MS = 60_000; const OPENCLAW_PLUGIN_CLAWHUB_REPOSITORY = "openclaw/openclaw"; const OPENCLAW_PLUGIN_CLAWHUB_WORKFLOW_FILENAME = "plugin-clawhub-release.yml"; const SAFE_EXTENSION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/; @@ -482,43 +484,79 @@ async function hasClawHubTrustedPublisher( `/api/v1/packages/${encodeURIComponent(packageName)}/trusted-publisher`, getRegistryBaseUrl(options.registryBaseUrl), ); - const request = await fetchClawHubRequest(url, { - fetchImpl: options.fetchImpl, - requestTimeoutMs: options.requestTimeoutMs, - }); - const { response } = request; + for (let attempt = 0; ; attempt += 1) { + const request = await fetchClawHubRequest(url, { + fetchImpl: options.fetchImpl, + requestTimeoutMs: options.requestTimeoutMs, + }); + const { response } = request; - try { - if (!response.ok) { - throw new Error( - `Failed to query ClawHub trusted publisher for ${packageName}: ${response.status} ${response.statusText}`, - ); - } - - let trustedPublisherDetail: ClawHubTrustedPublisherDetail; - const text = await readBoundedResponseText( - response, - `ClawHub trusted publisher ${packageName}`, - CLAWHUB_RESPONSE_BODY_MAX_BYTES, - { - signal: request.signal, - timeoutPromise: request.timeoutPromise, - }, - ); + const retryRateLimit = + response.status === 429 && attempt < CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS.length; try { - trustedPublisherDetail = JSON.parse(text) as ClawHubTrustedPublisherDetail; - } catch (error) { - throw new Error(`Failed to parse ClawHub trusted publisher ${packageName} response.`, { - cause: error, - }); + if (!retryRateLimit) { + if (!response.ok) { + throw new Error( + `Failed to query ClawHub trusted publisher for ${packageName}: ${response.status} ${response.statusText}`, + ); + } + + let trustedPublisherDetail: ClawHubTrustedPublisherDetail; + const text = await readBoundedResponseText( + response, + `ClawHub trusted publisher ${packageName}`, + CLAWHUB_RESPONSE_BODY_MAX_BYTES, + { + signal: request.signal, + timeoutPromise: request.timeoutPromise, + }, + ); + try { + trustedPublisherDetail = JSON.parse(text) as ClawHubTrustedPublisherDetail; + } catch (error) { + throw new Error(`Failed to parse ClawHub trusted publisher ${packageName} response.`, { + cause: error, + }); + } + + return isOpenClawPluginTrustedPublisher(trustedPublisherDetail.trustedPublisher); + } + } finally { + request.clearTimeout(); } - return isOpenClawPluginTrustedPublisher(trustedPublisherDetail.trustedPublisher); - } finally { - request.clearTimeout(); + await response.body?.cancel().catch(() => undefined); + await delay(clawHubRetryDelayMs(response, attempt)); } } +function clawHubRetryDelayMs(response: Response, attempt: number): number { + const retryAfter = response.headers.get("retry-after")?.trim(); + if (retryAfter) { + const retryAfterSeconds = Number(retryAfter); + if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) { + const retryAfterMs = Math.round(retryAfterSeconds * 1_000); + if (retryAfterMs <= CLAWHUB_MAX_RETRY_AFTER_MS) { + return retryAfterMs; + } + } + const retryAfterAt = Date.parse(retryAfter); + if (Number.isFinite(retryAfterAt)) { + const retryAfterMs = Math.max(0, retryAfterAt - Date.now()); + if (retryAfterMs <= CLAWHUB_MAX_RETRY_AFTER_MS) { + return retryAfterMs; + } + } + } + return CLAWHUB_RATE_LIMIT_RETRY_DELAYS_MS[attempt] ?? 0; +} + +async function delay(ms: number): Promise { + await new Promise((resolveDelay) => { + setTimeout(resolveDelay, ms); + }); +} + function isOpenClawPluginTrustedPublisher(value: unknown): boolean { if (!value || typeof value !== "object" || Array.isArray(value)) { return false; @@ -581,42 +619,41 @@ export async function collectPluginClawHubReleasePlan(params?: { assertPluginReleaseVersionFloors(selectedPublishable, "Plugin ClawHub release plan"); } - const planned = await Promise.all( - selectedPublishable.map(async (plugin): Promise => { - const packageExists = await doesClawHubPackageExist(plugin.packageName, { - registryBaseUrl: params?.registryBaseUrl, - fetchImpl: params?.fetchImpl, - requestTimeoutMs: params?.requestTimeoutMs, - }); - const hasTrustedPublisher = packageExists - ? await hasClawHubTrustedPublisher(plugin.packageName, { - registryBaseUrl: params?.registryBaseUrl, - fetchImpl: params?.fetchImpl, - requestTimeoutMs: params?.requestTimeoutMs, - }) - : false; - const alreadyPublished = packageExists - ? await isPluginVersionPublishedOnClawHub(plugin.packageName, plugin.version, { - registryBaseUrl: params?.registryBaseUrl, - fetchImpl: params?.fetchImpl, - requestTimeoutMs: params?.requestTimeoutMs, - }) - : false; + const planned: PluginReleasePlanItemWithPackageState[] = []; + for (const plugin of selectedPublishable) { + const packageExists = await doesClawHubPackageExist(plugin.packageName, { + registryBaseUrl: params?.registryBaseUrl, + fetchImpl: params?.fetchImpl, + requestTimeoutMs: params?.requestTimeoutMs, + }); + const hasTrustedPublisher = packageExists + ? await hasClawHubTrustedPublisher(plugin.packageName, { + registryBaseUrl: params?.registryBaseUrl, + fetchImpl: params?.fetchImpl, + requestTimeoutMs: params?.requestTimeoutMs, + }) + : false; + const alreadyPublished = packageExists + ? await isPluginVersionPublishedOnClawHub(plugin.packageName, plugin.version, { + registryBaseUrl: params?.registryBaseUrl, + fetchImpl: params?.fetchImpl, + requestTimeoutMs: params?.requestTimeoutMs, + }) + : false; - return { - extensionId: plugin.extensionId, - packageDir: plugin.packageDir, - packageName: plugin.packageName, - version: plugin.version, - channel: plugin.channel, - publishTag: plugin.publishTag, - packageExists, - hasTrustedPublisher, - alreadyPublished, - artifactName: formatClawHubPackageArtifactName(plugin), - }; - }), - ); + planned.push({ + extensionId: plugin.extensionId, + packageDir: plugin.packageDir, + packageName: plugin.packageName, + version: plugin.version, + channel: plugin.channel, + publishTag: plugin.publishTag, + packageExists, + hasTrustedPublisher, + alreadyPublished, + artifactName: formatClawHubPackageArtifactName(plugin), + }); + } const all = planned.map(stripPackageReleaseState); return { diff --git a/scripts/lib/release-beta-verifier.ts b/scripts/lib/release-beta-verifier.ts index 46e65499fac..0d85a833109 100644 --- a/scripts/lib/release-beta-verifier.ts +++ b/scripts/lib/release-beta-verifier.ts @@ -279,6 +279,7 @@ async function fetchWithRetry( if (response.status !== 429 && response.status < 500) { return { response, signal }; } + await cancelResponseBody(response); lastError = new Error(`HTTP ${response.status}`); } catch (error) { lastError = error; @@ -293,6 +294,10 @@ async function fetchWithRetry( throw new Error(`${url} did not return a stable response: ${message}`); } +async function cancelResponseBody(response: Response): Promise { + await response.body?.cancel().catch(() => undefined); +} + async function fetchJsonWithRetry(url: string): Promise { const { response, signal } = await fetchWithRetry( url, @@ -314,9 +319,13 @@ export async function readBoundedJsonResponse( return parseJson(await readBoundedResponseText(response, label, maxBytes, options), label); } -async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promise { +export async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promise { const { response } = await fetchWithRetry(url, { method, redirect: "manual" }, 5); - return response.status; + try { + return response.status; + } finally { + await cancelResponseBody(response); + } } async function verifyNpmPackage( diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index d1aff83091b..b26d5b971bc 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -516,6 +516,13 @@ export function resolveNpmCommandInvocation( if (platform === "win32" && name.endsWith(".exe")) { return { command: npmExecPath, args: npmArgs }; } + if (platform === "win32" && name === "npm") { + return { + command: params.comSpec ?? process.env.ComSpec ?? "cmd.exe", + args: ["/d", "/s", "/c", buildCmdExeCommandLine(`${npmExecPath}.cmd`, npmArgs)], + windowsVerbatimArguments: true, + }; + } if (name.endsWith(".js") || name.endsWith(".cjs") || name.endsWith(".mjs")) { return { command: nodeExecPath, args: [npmExecPath, ...npmArgs] }; } diff --git a/src/cli/program/command-suggestions.ts b/src/cli/program/command-suggestions.ts new file mode 100644 index 00000000000..933d6d31e10 --- /dev/null +++ b/src/cli/program/command-suggestions.ts @@ -0,0 +1,90 @@ +import { formatCliCommand } from "../command-format.js"; +import { getCoreCliCommandNames } from "./core-command-descriptors.js"; +import { getSubCliEntries } from "./subcli-descriptors.js"; + +const EXPLICIT_COMMAND_ALIASES = new Map([ + ["upgrade", "update"], + ["udpate", "update"], +]); + +const MAX_SUGGESTIONS = 3; + +function uniqueSortedCommandNames(commands: Iterable): string[] { + return [...new Set([...commands].filter(Boolean))].toSorted((left, right) => + left.localeCompare(right), + ); +} + +export function getKnownCliCommandNames(): string[] { + return uniqueSortedCommandNames([ + ...getCoreCliCommandNames(), + ...getSubCliEntries().map((entry) => entry.name), + ]); +} + +export function levenshteinDistance(left: string, right: string): number { + if (left === right) { + return 0; + } + if (left.length === 0) { + return right.length; + } + if (right.length === 0) { + return left.length; + } + + let previous = Array.from({ length: right.length + 1 }, (_, index) => index); + let current = Array.from({ length: right.length + 1 }); + + for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) { + current[0] = leftIndex + 1; + for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) { + const substitutionCost = left[leftIndex] === right[rightIndex] ? 0 : 1; + current[rightIndex + 1] = Math.min( + current[rightIndex] + 1, + previous[rightIndex + 1] + 1, + previous[rightIndex] + substitutionCost, + ); + } + [previous, current] = [current, previous]; + } + + return previous[right.length] ?? 0; +} + +export function suggestCliCommands( + input: string, + candidates: Iterable = getKnownCliCommandNames(), +): string[] { + const normalizedInput = input.trim().toLowerCase(); + if (!normalizedInput) { + return []; + } + + const knownCommands = uniqueSortedCommandNames(candidates); + const explicitAlias = EXPLICIT_COMMAND_ALIASES.get(normalizedInput); + if (explicitAlias && knownCommands.includes(explicitAlias)) { + return [explicitAlias]; + } + + const maxDistance = Math.max(1, Math.floor(normalizedInput.length * 0.4)); + return knownCommands + .map((command) => ({ command, distance: levenshteinDistance(normalizedInput, command) })) + .filter(({ command, distance }) => command !== normalizedInput && distance <= maxDistance) + .toSorted( + (left, right) => left.distance - right.distance || left.command.localeCompare(right.command), + ) + .slice(0, MAX_SUGGESTIONS) + .map(({ command }) => command); +} + +export function formatCliCommandSuggestions(input: string): string | undefined { + const suggestions = suggestCliCommands(input); + if (suggestions.length === 0) { + return undefined; + } + const commandLines = suggestions + .map((command) => ` ${formatCliCommand(`openclaw ${command}`)}`) + .join("\n"); + return `Did you mean this?\n${commandLines}`; +} diff --git a/src/cli/program/error-output.test.ts b/src/cli/program/error-output.test.ts index 987809a8012..58a4825d2b1 100644 --- a/src/cli/program/error-output.test.ts +++ b/src/cli/program/error-output.test.ts @@ -13,6 +13,42 @@ describe("formatCliParseErrorOutput", () => { ); }); + it("suggests close known commands for unknown commands", () => { + const output = formatCliParseErrorOutput("error: unknown command 'upate'\n", { + argv: ["node", "openclaw", "upate"], + }); + + expect(output).toBe( + 'OpenClaw does not know the command "upate".\nDid you mean this?\n openclaw update\nTry: openclaw --help\nPlugin command? openclaw plugins list\nDocs: https://docs.openclaw.ai/cli\n', + ); + }); + + it("suggests explicit aliases for common adjacent terminology", () => { + const output = formatCliParseErrorOutput("error: unknown command 'upgrade'\n", { + argv: ["node", "openclaw", "upgrade"], + }); + + expect(output).toContain("Did you mean this?\n openclaw update\n"); + }); + + it("preserves active profile context in command suggestions", () => { + const originalProfile = process.env.OPENCLAW_PROFILE; + process.env.OPENCLAW_PROFILE = "work"; + try { + const output = formatCliParseErrorOutput("error: unknown command 'doctr'\n", { + argv: ["node", "openclaw", "doctr"], + }); + + expect(output).toContain("Did you mean this?\n openclaw --profile work doctor\n"); + } finally { + if (originalProfile === undefined) { + delete process.env.OPENCLAW_PROFILE; + } else { + process.env.OPENCLAW_PROFILE = originalProfile; + } + } + }); + it("points unknown options at the active command help", () => { const output = formatCliParseErrorOutput("error: unknown option '--wat'\n", { argv: ["node", "openclaw", "channels", "status", "--wat"], diff --git a/src/cli/program/error-output.ts b/src/cli/program/error-output.ts index 966d0b3d780..96e5b7f3880 100644 --- a/src/cli/program/error-output.ts +++ b/src/cli/program/error-output.ts @@ -3,6 +3,7 @@ import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { getCommandPathWithRootOptions } from "../argv.js"; import { formatCliCommand } from "../command-format.js"; +import { formatCliCommandSuggestions } from "./command-suggestions.js"; type FormatCliParseErrorOptions = { argv?: string[]; @@ -53,6 +54,7 @@ export function formatCliParseErrorOutput( const command = unknownCommand[1] ?? ""; return lines( theme.error(`OpenClaw does not know the command ${quote(command)}.`), + formatCliCommandSuggestions(command), formatHelpHint(options.argv, { root: true }), `${theme.muted("Plugin command?")} ${theme.command(formatCliCommand("openclaw plugins list"))}`, formatDocsHint(), diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 33cddf9bafa..394cfdc3184 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -2148,6 +2148,41 @@ describe("runCli exit behavior", () => { expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled(); }); + it("suggests close known commands for unowned command roots before proxy startup", async () => { + await expect(runCli(["node", "openclaw", "upate"])).rejects.toThrow( + "Did you mean this?\n openclaw update", + ); + + expect(startProxyMock).not.toHaveBeenCalled(); + expect(tryRouteCliMock).not.toHaveBeenCalled(); + expect(buildProgramMock).not.toHaveBeenCalled(); + expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled(); + }); + + it("keeps suggestions out of plugin-policy diagnostics", async () => { + resolveManifestCommandAliasOwnerMock.mockReturnValueOnce({ + pluginId: "codex", + kind: "runtime-slash", + cliCommand: "plugins", + }); + + let error: unknown; + try { + await runCli(["node", "openclaw", "codex"]); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("runtime slash command"); + expect((error as Error).message).toContain("/codex"); + expect((error as Error).message).not.toContain("Did you mean this?"); + expect(startProxyMock).not.toHaveBeenCalled(); + expect(tryRouteCliMock).not.toHaveBeenCalled(); + expect(buildProgramMock).not.toHaveBeenCalled(); + expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled(); + }); + it("rejects unowned command roots even when --help is appended (regression for #81077)", async () => { await expect(runCli(["node", "openclaw", "foo", "--help"])).rejects.toThrow( 'No built-in command or plugin CLI metadata owns "foo"', diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index e0e0b5bd071..433ef54dee5 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -33,6 +33,7 @@ import { } from "./gateway-run-argv.js"; import { hasJsonOutputFlag, withConsoleLogsRoutedToStderrForJson } from "./json-output-mode.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js"; +import { formatCliCommandSuggestions } from "./program/command-suggestions.js"; import { getCoreCliCommandNames } from "./program/core-command-descriptors.js"; import { getSubCliEntries } from "./program/subcli-descriptors.js"; import { @@ -600,14 +601,25 @@ async function resolveUnownedCliPrimaryMessage(params: { const { resolveManifestCommandAliasOwner, resolveManifestToolOwner } = await loadManifestCommandAliasesRuntimeModule(); const cliCommandSurfaceOwner = await resolveCliCommandSurfaceOwner(params); - return ( - resolveMissingPluginCommandMessageFromPolicy(params.primary, params.config, { + const pluginPolicyMessage = resolveMissingPluginCommandMessageFromPolicy( + params.primary, + params.config, + { resolveCommandAliasOwner: resolveManifestCommandAliasOwner, resolveToolOwner: resolveManifestToolOwner, resolveCliCommandSurfaceOwner: () => cliCommandSurfaceOwner, - }) ?? - `Unknown command: openclaw ${params.primary}. No built-in command or plugin CLI metadata owns "${params.primary}".` + }, ); + if (pluginPolicyMessage) { + return pluginPolicyMessage; + } + const suggestion = formatCliCommandSuggestions(params.primary); + return [ + `Unknown command: openclaw ${params.primary}. No built-in command or plugin CLI metadata owns "${params.primary}".`, + suggestion, + ] + .filter(Boolean) + .join("\n"); } async function bootstrapCliProxyCaptureAndDispatcher( diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 388522474e5..6499fc65136 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -349,6 +349,21 @@ describe("resolveNpmCommandInvocation", () => { }); }); + it("wraps bare Windows npm_execpath through npm.cmd", () => { + expect( + resolveNpmCommandInvocation({ + comSpec: "C:\\Windows\\System32\\cmd.exe", + npmArgs: ["view", "openclaw@beta", "version"], + npmExecPath: "npm", + platform: "win32", + }), + ).toEqual({ + command: "C:\\Windows\\System32\\cmd.exe", + args: ["/d", "/s", "/c", "npm.cmd view openclaw@beta version"], + windowsVerbatimArguments: true, + }); + }); + it("wraps Windows npm_execpath command shims", () => { expect( resolveNpmCommandInvocation({ diff --git a/test/plugin-clawhub-release.test.ts b/test/plugin-clawhub-release.test.ts index 98ef8301563..6d86d3232c1 100644 --- a/test/plugin-clawhub-release.test.ts +++ b/test/plugin-clawhub-release.test.ts @@ -9,7 +9,7 @@ import { writeFileSync, } from "node:fs"; import { delimiter, join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildOpenClawReleaseClawHubPlan, buildOpenClawReleaseClawHubRuntimeState, @@ -452,6 +452,166 @@ describe("collectPluginClawHubReleasePlan", () => { expect(canceled).toEqual(["package", "version"]); }); + it("retries a rate-limited trusted publisher lookup", async () => { + const repoDir = createTempPluginRepo(); + let trustedPublisherRequests = 0; + let rateLimitedBodyCanceled = false; + let firstTrustedPublisherRequestAt: number | undefined; + let retryTrustedPublisherRequestAt: number | undefined; + const fetchImpl: typeof fetch = async (input) => { + const requestUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const pathname = new URL(requestUrl).pathname; + if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin") { + return new Response("{}", { status: 200 }); + } + if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher") { + trustedPublisherRequests += 1; + if (trustedPublisherRequests === 1) { + firstTrustedPublisherRequestAt = Date.now(); + return new Response( + new ReadableStream({ + cancel() { + rateLimitedBodyCanceled = true; + }, + }), + { status: 429 }, + ); + } + retryTrustedPublisherRequestAt = Date.now(); + return new Response( + JSON.stringify({ + trustedPublisher: { + repository: "openclaw/openclaw", + workflowFilename: "plugin-clawhub-release.yml", + }, + }), + { status: 200 }, + ); + } + if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/versions/2026.4.1") { + return new Response("", { status: 404 }); + } + throw new Error(`Unexpected ClawHub request to ${pathname}`); + }; + + const plan = await collectPluginClawHubReleasePlan({ + rootDir: repoDir, + selection: ["@openclaw/demo-plugin"], + fetchImpl, + registryBaseUrl: "https://clawhub.ai", + }); + + expect(trustedPublisherRequests).toBe(2); + expect(rateLimitedBodyCanceled).toBe(true); + expect(retryTrustedPublisherRequestAt).toBeGreaterThanOrEqual( + (firstTrustedPublisherRequestAt ?? Number.POSITIVE_INFINITY) + 900, + ); + expect(plan.candidates.map((plugin) => plugin.packageName)).toEqual(["@openclaw/demo-plugin"]); + }); + + it("honors an HTTP-date Retry-After header", async () => { + const repoDir = createTempPluginRepo(); + const retryAfter = "Wed, 21 Oct 2030 07:28:00 GMT"; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse(retryAfter) - 1_000); + let trustedPublisherRequests = 0; + let firstTrustedPublisherRequestAt: number | undefined; + let retryTrustedPublisherRequestAt: number | undefined; + const fetchImpl: typeof fetch = async (input) => { + const requestUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const pathname = new URL(requestUrl).pathname; + if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin") { + return new Response("{}", { status: 200 }); + } + if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher") { + trustedPublisherRequests += 1; + if (trustedPublisherRequests === 1) { + firstTrustedPublisherRequestAt = performance.now(); + return new Response("", { status: 429, headers: { "retry-after": retryAfter } }); + } + retryTrustedPublisherRequestAt = performance.now(); + return new Response( + JSON.stringify({ + trustedPublisher: { + repository: "openclaw/openclaw", + workflowFilename: "plugin-clawhub-release.yml", + }, + }), + { status: 200 }, + ); + } + if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/versions/2026.4.1") { + return new Response("", { status: 404 }); + } + throw new Error(`Unexpected ClawHub request to ${pathname}`); + }; + + try { + await collectPluginClawHubReleasePlan({ + rootDir: repoDir, + selection: ["@openclaw/demo-plugin"], + fetchImpl, + registryBaseUrl: "https://clawhub.ai", + }); + } finally { + nowSpy.mockRestore(); + } + + expect(trustedPublisherRequests).toBe(2); + expect(retryTrustedPublisherRequestAt).toBeGreaterThanOrEqual( + (firstTrustedPublisherRequestAt ?? Number.POSITIVE_INFINITY) + 900, + ); + }); + + it("falls back to the bounded retry schedule for an excessive Retry-After header", async () => { + const repoDir = createTempPluginRepo(); + let trustedPublisherRequests = 0; + let firstTrustedPublisherRequestAt: number | undefined; + let retryTrustedPublisherRequestAt: number | undefined; + const fetchImpl: typeof fetch = async (input) => { + const requestUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const pathname = new URL(requestUrl).pathname; + if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin") { + return new Response("{}", { status: 200 }); + } + if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher") { + trustedPublisherRequests += 1; + if (trustedPublisherRequests === 1) { + firstTrustedPublisherRequestAt = Date.now(); + return new Response("", { status: 429, headers: { "retry-after": "999999999999" } }); + } + retryTrustedPublisherRequestAt = Date.now(); + return new Response( + JSON.stringify({ + trustedPublisher: { + repository: "openclaw/openclaw", + workflowFilename: "plugin-clawhub-release.yml", + }, + }), + { status: 200 }, + ); + } + if (pathname === "/api/v1/packages/%40openclaw%2Fdemo-plugin/versions/2026.4.1") { + return new Response("", { status: 404 }); + } + throw new Error(`Unexpected ClawHub request to ${pathname}`); + }; + + await collectPluginClawHubReleasePlan({ + rootDir: repoDir, + selection: ["@openclaw/demo-plugin"], + fetchImpl, + registryBaseUrl: "https://clawhub.ai", + }); + + expect(trustedPublisherRequests).toBe(2); + expect(retryTrustedPublisherRequestAt).toBeGreaterThanOrEqual( + (firstTrustedPublisherRequestAt ?? Number.POSITIVE_INFINITY) + 900, + ); + }); + it("routes missing package rows to bootstrap candidates instead of normal candidates", async () => { const repoDir = createTempPluginRepo(); const { fetchImpl } = createClawHubPlanFetch({ diff --git a/test/scripts/release-beta-verifier.test.ts b/test/scripts/release-beta-verifier.test.ts index b645b8e85b3..76e111b99c1 100644 --- a/test/scripts/release-beta-verifier.test.ts +++ b/test/scripts/release-beta-verifier.test.ts @@ -1,12 +1,18 @@ // Release Beta Verifier tests cover release beta verifier script behavior. -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { + fetchStatusWithRetry, parseNpmViewFields, parseReleaseVerifyBetaArgs, readBoundedJsonResponse, runNpmViewWithRetry, } from "../../scripts/lib/release-beta-verifier.ts"; +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + describe("parseReleaseVerifyBetaArgs", () => { it("defaults beta verification to the matching tag and repo", () => { expect(parseReleaseVerifyBetaArgs(["2026.5.10-beta.3"])).toEqual({ @@ -152,6 +158,46 @@ describe("runNpmViewWithRetry", () => { }); }); +describe("fetchStatusWithRetry", () => { + it("cancels retryable and returned GET response bodies", async () => { + vi.useFakeTimers(); + const canceled: string[] = []; + const responses = [ + new Response( + new ReadableStream({ + cancel() { + canceled.push("retry"); + }, + }), + { status: 500 }, + ), + new Response( + new ReadableStream({ + cancel() { + canceled.push("final"); + }, + }), + { status: 200 }, + ), + ]; + const fetchImpl = vi.fn(async () => { + const response = responses.shift(); + if (!response) { + throw new Error("unexpected fetch call"); + } + return response; + }); + vi.stubGlobal("fetch", fetchImpl); + + const status = fetchStatusWithRetry("https://clawhub.test/api/v1/package", "GET"); + await vi.advanceTimersByTimeAsync(1000); + + await expect(status).resolves.toBe(200); + expect(canceled).toEqual(["retry", "final"]); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); + describe("readBoundedJsonResponse", () => { it("parses JSON bodies within the release verifier limit", async () => { await expect(