mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
docs: refresh otel config baseline
This commit is contained in:
parent
86f67ef076
commit
efa2ef07ab
17 changed files with 1000 additions and 224 deletions
|
|
@ -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> | 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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -51,14 +51,11 @@ type ParentState = {
|
|||
type ChildState = {
|
||||
childThreadId: string;
|
||||
parentThreadId: string;
|
||||
assistantMessagesByTurn: Map<string, ChildAssistantMessages>;
|
||||
transcriptPath?: string;
|
||||
transcriptPollAttempt: number;
|
||||
transcriptPollTimer?: ReturnType<typeof setTimeout>;
|
||||
transcriptTerminal: boolean;
|
||||
idle: boolean;
|
||||
lastAgentMessage?: string;
|
||||
lastAgentMessageAt?: number;
|
||||
agentMessageCompletionDelivered: boolean;
|
||||
pendingCompletion?: CodexNativeSubagentCompletion;
|
||||
pendingCompletionEventAt?: number;
|
||||
completionDeliveryAttempt: number;
|
||||
|
|
@ -67,6 +64,13 @@ type ChildState = {
|
|||
noFinalCompletionFallbackTimer?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type ChildAssistantMessages = {
|
||||
texts: Map<string, string>;
|
||||
order: string[];
|
||||
commentaryIds: Set<string>;
|
||||
finalMessageIds: Set<string>;
|
||||
};
|
||||
|
||||
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<string, string>(),
|
||||
order: [],
|
||||
commentaryIds: new Set<string>(),
|
||||
finalMessageIds: new Set<string>(),
|
||||
};
|
||||
childState.assistantMessagesByTurn.set(turnId, assistantMessages);
|
||||
return assistantMessages;
|
||||
}
|
||||
|
||||
private async handleChildTurnCompletion(notification: CodexServerNotification): Promise<void> {
|
||||
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<void> {
|
||||
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<string, ChildAssistantMessages>(),
|
||||
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<void> {
|
||||
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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, "\\$&");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof Sdk> | undefined;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
await new Promise<void>((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<PluginReleasePlanItemWithPackageState> => {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
|
||||
async function fetchJsonWithRetry(url: string): Promise<unknown> {
|
||||
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<number> {
|
||||
export async function fetchStatusWithRetry(url: string, method: "GET" | "HEAD"): Promise<number> {
|
||||
const { response } = await fetchWithRetry(url, { method, redirect: "manual" }, 5);
|
||||
return response.status;
|
||||
try {
|
||||
return response.status;
|
||||
} finally {
|
||||
await cancelResponseBody(response);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyNpmPackage(
|
||||
|
|
|
|||
|
|
@ -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] };
|
||||
}
|
||||
|
|
|
|||
90
src/cli/program/command-suggestions.ts
Normal file
90
src/cli/program/command-suggestions.ts
Normal file
|
|
@ -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<string, string>([
|
||||
["upgrade", "update"],
|
||||
["udpate", "update"],
|
||||
]);
|
||||
|
||||
const MAX_SUGGESTIONS = 3;
|
||||
|
||||
function uniqueSortedCommandNames(commands: Iterable<string>): 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<number>({ 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<string> = 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}`;
|
||||
}
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<Uint8Array>({
|
||||
cancel() {
|
||||
canceled.push("retry");
|
||||
},
|
||||
}),
|
||||
{ status: 500 },
|
||||
),
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue