Compare commits

..

1 Commits

Author SHA1 Message Date
momothemage
8242ed1a33 fix(skills): guard workshop create target drift 2026-06-23 18:29:07 +08:00
160 changed files with 1312 additions and 4064 deletions

View File

@@ -132,9 +132,9 @@ jobs:
if: ${{ inputs.qa_evidence_run_id == '' }}
uses: ./.github/workflows/qa-profile-evidence.yml
with:
ref: ${{ inputs.ref }}
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }}
qa_profile: release
qa_profile: all
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -238,8 +238,8 @@ jobs:
}
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
if (evidence.profile !== "release") {
throw new Error(`qa-evidence.json profile must be release, got ${JSON.stringify(evidence.profile)}`);
if (evidence.profile !== "all") {
throw new Error(`qa-evidence.json profile must be all, got ${JSON.stringify(evidence.profile)}`);
}
const artifactDir = path.dirname(evidencePath);
@@ -256,8 +256,8 @@ jobs:
const manifestPath = path.join(artifactDir, manifestNames[0]);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const manifestProfile = manifest.qaProfile ?? evidence.profile;
if (manifestProfile !== "release") {
throw new Error(`QA evidence manifest profile must be release, got ${JSON.stringify(manifestProfile)}`);
if (manifestProfile !== "all") {
throw new Error(`QA evidence manifest profile must be all, got ${JSON.stringify(manifestProfile)}`);
}
if (manifest.targetSha !== targetSha) {
throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`);

View File

@@ -89,13 +89,6 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
// Reusable workflow jobs inherit the caller event but run as
// github-actions[bot]; selected ref validation still gates secrets.
if (context.actor === "github-actions[bot]") {
core.info("Skipping manual actor permission check for a reusable workflow call.");
core.setOutput("authorized", "true");
return;
}
if (context.eventName !== "workflow_dispatch") {
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
core.setOutput("authorized", "true");

View File

@@ -1,2 +1,2 @@
57b3f65c9d8c4edddea6ffa86584756234e761cc1cdd561e4f57c8c072baaad2 plugin-sdk-api-baseline.json
1c20edb5599d0050382a32272ff3708e969f4605a2dca3db8b5cef9ab7680bd6 plugin-sdk-api-baseline.jsonl
da3373338b7f9c5f5639ad8233a32897d2346a0babe69a77386a7bff154cdcb1 plugin-sdk-api-baseline.json
17404d885e0d64ebc8e3c99443921058a8f1aebf76a5e612eb1f0cd7817d48f0 plugin-sdk-api-baseline.jsonl

View File

@@ -154,6 +154,11 @@ openclaw skills workshop reject <proposal-id> --reason "Duplicate"
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
```
`propose-create` always targets a new sibling skill under `skills/<name>/` and
rejects drafts that reference existing workspace skill paths such as
`skills/qa-check/SKILL.md`. Use `propose-update <skill>` once per existing skill
when a change touches current skills.
## Related
- [CLI reference](/cli)

View File

@@ -27,7 +27,13 @@ plugin, ClawHub, extra-root, managed, personal-agent, or system skills.
active skills.
- **Workspace scoped:** creates target the workspace `skills/` root. Updates
are allowed only for writable workspace skills.
- **Single target:** create always proposes one new sibling skill, and update
targets one existing skill. Split multi-skill changes into separate update
proposals.
- **No clobber:** create fails if the target skill already exists.
- **Wrong-target guard:** create rejects proposal content that references
existing workspace skill paths such as `skills/trip-planning/SKILL.md`;
those changes belong in update proposals.
- **Hash bound:** update proposals bind to the current target hash and become
stale if the live skill changes before apply.
- **Scanner gated:** apply reruns scanning before writing.
@@ -88,12 +94,20 @@ openclaw skills workshop propose-create \
--proposal ./PROPOSAL.md
```
`propose-create` always creates a new sibling under `skills/<name>/`. If the
draft references an existing workspace skill path such as
`skills/trip-planning/SKILL.md`, Skill Workshop rejects it so the update does
not silently land in an unused sibling skill.
Create an update proposal for an existing workspace skill:
```bash
openclaw skills workshop propose-update trip-planning --proposal ./PROPOSAL.md
```
For changes that touch multiple existing skills, create one update proposal per
target skill.
List and inspect:
```bash
@@ -167,6 +181,10 @@ The model uses `skill_workshop`:
action: create | update | revise | list | inspect | apply | reject | quarantine
```
`action=create` is only for a brand-new workspace skill. `action=update` targets
one existing skill through `skill_name`. Agents should split multi-skill patches
into separate `action=update` calls before applying them.
Agents must use `skill_workshop` for generated skill work. They must not create
or change proposal files through `write`, `edit`, `exec`, shell commands, or
direct filesystem operations.

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.39.0",
"@zed-industries/codex-acp": "0.15.0",
"acpx": "0.11.2",
"acpx": "0.10.0",
"zod": "4.4.3"
}
},
@@ -196,9 +196,9 @@
}
},
"node_modules/@clack/core": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz",
"integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==",
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz",
"integrity": "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==",
"license": "MIT",
"dependencies": {
"fast-wrap-ansi": "^0.2.0",
@@ -209,12 +209,12 @@
}
},
"node_modules/@clack/prompts": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz",
"integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==",
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.5.1.tgz",
"integrity": "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==",
"license": "MIT",
"dependencies": {
"@clack/core": "1.3.1",
"@clack/core": "1.4.1",
"fast-string-width": "^3.0.2",
"fast-wrap-ansi": "^0.2.0",
"sisteransi": "^1.0.5"
@@ -831,15 +831,15 @@
}
},
"node_modules/acpx": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.11.2.tgz",
"integrity": "sha512-ksTmfJDVqUAJJXsNDamEno03AMZ/aAZzXk/h5nt61VsLc/jcpoDMfCVpErzuYNJjwCd0V6Zm5o6F8OoqxsjQWA==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.10.0.tgz",
"integrity": "sha512-hd48XV03gG3sd409T1lDrOKJTTz1ap4g0wrndXjxQ590tN85pBYlvfNLyerybvGRrtUGsZjNdt99r1jpIt6ukA==",
"license": "MIT",
"dependencies": {
"@agentclientprotocol/sdk": "^0.28.1",
"commander": "^15.0.0",
"skillflag": "^0.2.0",
"tsx": "^4.22.4",
"@agentclientprotocol/sdk": "^0.22.1",
"commander": "^14.0.3",
"skillflag": "^0.1.4",
"tsx": "^4.22.0",
"zod": "^4.4.3"
},
"bin": {
@@ -849,15 +849,6 @@
"node": ">=22.13.0"
}
},
"node_modules/acpx/node_modules/@agentclientprotocol/sdk": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.28.1.tgz",
"integrity": "sha512-Z2Frs6YtPhnZZ+XwFXyQkRDXY0fn8FjCalEs0W4yUhQnY4TztmNq0/RnfzWdFN3vqT3h0jTz5klzYbZHGxCDyQ==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -1059,12 +1050,12 @@
}
},
"node_modules/commander": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"license": "MIT",
"engines": {
"node": ">=22.12.0"
"node": ">=20"
}
},
"node_modules/content-disposition": {
@@ -2061,9 +2052,9 @@
"license": "MIT"
},
"node_modules/skillflag": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.2.0.tgz",
"integrity": "sha512-7ZmEpBeEoPLc+hqZ/StAnCO/hulgEPANzPyZgOM/CZ5zc3b0ApSp3URavY5POM/OKyi5d9+UC/Q21OoiYC2kJw==",
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.1.4.tgz",
"integrity": "sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==",
"license": "MIT",
"dependencies": {
"@clack/prompts": "^1.0.1",

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.39.0",
"@zed-industries/codex-acp": "0.15.0",
"acpx": "0.11.2",
"acpx": "0.10.0",
"zod": "4.4.3"
},
"devDependencies": {

View File

@@ -251,15 +251,6 @@ describe("prepareAcpxCodexAuthConfig", () => {
expect(wrapper).not.toMatch(
/forceKillTimer = setTimeout\(\(\) => killChildTree\("SIGKILL"\), 1_500\);\s*forceKillTimer\.unref\?\.\(\);\s*process\.exit\(1\);/s,
);
// Orphan detection must trigger on any PPID change, not only when the new
// PPID is init (1). Systemd user services and container init reparent
// orphaned processes to a session manager or container init (PID != 1),
// and the older `process.ppid !== 1` guard would silently leak the codex
// adapter tree there.
expect(wrapper).not.toContain("process.ppid !== 1");
expect(wrapper).toMatch(
/setInterval\(\(\) => \{[\s\S]*?if \(process\.ppid === originalParentPid\) \{\s*return;\s*\}/,
);
});
it("uses the bundled Claude ACP dependency by default when it is installed", async () => {

View File

@@ -475,13 +475,7 @@ const parentWatcher =
process.platform === "win32"
? undefined
: setInterval(() => {
// Orphan detection: parent PID changed means our original parent died.
// The new parent could be PID 1 (init) on bare-metal hosts, OR a
// systemd user-session manager, OR a container init, OR a session
// leader — depending on environment. Previously this only triggered
// on PPID == 1, which missed all systemd-managed deployments and
// leaked codex-acp adapter trees on every gateway restart.
if (process.ppid === originalParentPid) {
if (process.ppid === originalParentPid || process.ppid !== 1) {
return;
}
if (orphanCleanupStarted) {

View File

@@ -2,7 +2,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { RequestedModelUnsupportedError } from "acpx/runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
AcpRuntimeError,
@@ -709,100 +708,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
});
});
it("retries without a model when ACPX reports missing model capability", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore, {
agentRegistry: {
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
list: () => ["opencode"],
},
});
const ensure = vi
.spyOn(delegate, "ensureSession")
.mockRejectedValueOnce(
new RequestedModelUnsupportedError(
"Cannot apply --model: the ACP agent did not advertise model support",
"missing-capability",
),
)
.mockResolvedValueOnce({
sessionKey: "agent:opencode:acp:test",
backend: "acpx",
runtimeSessionName: "opencode",
});
await runtime.ensureSession({
sessionKey: "agent:opencode:acp:test",
agent: "opencode",
mode: "persistent",
model: "openrouter/owl-alpha",
});
expect(ensure).toHaveBeenCalledTimes(2);
expect(readFirstEnsureSessionInput(ensure)).toMatchObject({
model: "openrouter/owl-alpha",
sessionOptions: { model: "openrouter/owl-alpha" },
});
const [, secondCall] = ensure.mock.calls;
expect(secondCall?.[0]).not.toHaveProperty("sessionOptions");
expect((secondCall?.[0] as { model?: string } | undefined)?.model).toBeUndefined();
});
it("does not retry when ACPX rejects an explicitly unsupported model id", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore, {
agentRegistry: {
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
list: () => ["opencode"],
},
});
const ensure = vi
.spyOn(delegate, "ensureSession")
.mockRejectedValueOnce(
new RequestedModelUnsupportedError(
"Cannot apply --model: the ACP agent did not advertise that model",
"unadvertised-model",
),
);
await expect(
runtime.ensureSession({
sessionKey: "agent:opencode:acp:test",
agent: "opencode",
mode: "persistent",
model: "unknown/model",
}),
).rejects.toThrow("did not advertise that model");
expect(ensure).toHaveBeenCalledTimes(1);
});
it("does not retry an unrelated error with similar wording", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore);
const ensure = vi
.spyOn(delegate, "ensureSession")
.mockRejectedValueOnce(new Error("the ACP agent did not advertise model support"));
await expect(
runtime.ensureSession({
sessionKey: "agent:main:acp:test",
agent: "main",
mode: "persistent",
model: "openrouter/owl-alpha",
}),
).rejects.toThrow("did not advertise model support");
expect(ensure).toHaveBeenCalledTimes(1);
});
it("injects Codex ACP startup config into the scoped registry", () => {
expect(testing.isCodexAcpCommand(CODEX_ACP_COMMAND)).toBe(true);
expect(testing.isCodexAcpCommand(CODEX_ACP_WRAPPER_COMMAND)).toBe(true);

View File

@@ -13,7 +13,6 @@ import {
createFileSessionStore,
decodeAcpxRuntimeHandleState,
encodeAcpxRuntimeHandleState,
isRequestedModelUnsupportedError,
type AcpAgentRegistry,
type AcpRuntimeDoctorReport,
type AcpRuntimeEvent,
@@ -587,26 +586,6 @@ function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegate
} as AcpxDelegateEnsureInput;
}
function isAcpModelCapabilityMissingError(error: unknown): boolean {
return isRequestedModelUnsupportedError(error) && error.reason === "missing-capability";
}
// ACPX owns the distinction between missing model capability and an invalid model id.
// Retry only the former so explicit model mistakes remain visible to the caller.
async function ensureDelegateSessionWithModelFallback(
delegate: BaseAcpxRuntime,
input: OpenClawRuntimeEnsureInput,
): Promise<AcpRuntimeHandle> {
try {
return await delegate.ensureSession(withAcpxSessionOptions(input));
} catch (error) {
if (!input.model || !isAcpModelCapabilityMissingError(error)) {
throw error;
}
return await delegate.ensureSession(withAcpxSessionOptions({ ...input, model: undefined }));
}
}
function quoteShellArg(value: string): string {
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
return value;
@@ -1010,7 +989,7 @@ export class AcpxRuntime implements AcpRuntime {
this.withCodexWrapperDiagnostics({
command: stableLaunchCommand,
fallbackCode: "ACP_SESSION_INIT_FAILED",
run: () => ensureDelegateSessionWithModelFallback(delegate, ensureInput),
run: () => delegate.ensureSession(withAcpxSessionOptions(ensureInput)),
}),
});
}

View File

@@ -43,39 +43,23 @@ afterAll(() => {
vi.resetModules();
});
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function malformedJsonResponse(): Response {
return new Response("{ nope", {
status: 200,
headers: { "content-type": "application/json" },
});
}
function emptyWebSearchResponse(): Response {
return jsonResponse({ web: { results: [] } });
}
function installBraveLlmContextFetch() {
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return jsonResponse({
grounding: {
generic: [
{
url: "https://example.com/context",
title: "Context",
snippets: ["snippet"],
},
],
},
sources: [],
});
return {
ok: true,
json: async () => ({
grounding: {
generic: [
{
url: "https://example.com/context",
title: "Context",
snippets: ["snippet"],
},
],
},
sources: [],
}),
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
return mockFetch;
@@ -270,7 +254,10 @@ describe("brave web search provider", () => {
it("uses configured Brave baseUrl for web search requests", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return emptyWebSearchResponse();
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
@@ -323,7 +310,12 @@ describe("brave web search provider", () => {
it("reports malformed Brave web search JSON as a provider error", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return malformedJsonResponse();
return {
ok: true,
json: async () => {
throw new SyntaxError("Unexpected token");
},
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
@@ -347,7 +339,12 @@ describe("brave web search provider", () => {
it("reports malformed Brave llm-context JSON as a provider error", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return malformedJsonResponse();
return {
ok: true,
json: async () => {
throw new SyntaxError("Unexpected token");
},
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
@@ -431,7 +428,10 @@ describe("brave web search provider", () => {
it("keeps Brave cache entries isolated by baseUrl", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return emptyWebSearchResponse();
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
@@ -573,7 +573,10 @@ describe("brave web search provider", () => {
it("sends Brave web auth in the X-Subscription-Token header", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return emptyWebSearchResponse();
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
@@ -729,7 +732,10 @@ describe("brave web search provider", () => {
it("falls back unsupported country values before calling Brave", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return emptyWebSearchResponse();
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
@@ -757,17 +763,21 @@ describe("brave web search provider", () => {
it("emits brave.http diagnostics for requests, responses, and cache events", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return jsonResponse({
web: {
results: [
{
title: "Diagnostics",
url: "https://example.com/diagnostics",
description: "debug details",
},
],
},
});
return {
ok: true,
status: 200,
json: async () => ({
web: {
results: [
{
title: "Diagnostics",
url: "https://example.com/diagnostics",
description: "debug details",
},
],
},
}),
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;

View File

@@ -15,14 +15,6 @@ function restoreEnvVar(name: string, value: string | undefined): void {
}
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function runChutesCatalog(params: { apiKey?: string; discoveryApiKey?: string }) {
const provider = await registerSingleProviderPlugin(plugin);
const result = await provider.catalog?.run({
@@ -52,9 +44,10 @@ async function withRealChutesDiscovery<T>(
delete process.env.VITEST;
delete process.env.NODE_ENV;
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ data: [{ id: "chutes/private-model" }] }));
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ id: "chutes/private-model" }] }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {

View File

@@ -15,14 +15,6 @@ function restoreEnvVar(name: string, value: string | undefined): void {
}
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function withLiveChutesDiscovery<T>(
fetchMock: ReturnType<typeof vi.fn>,
run: () => Promise<T>,
@@ -53,11 +45,12 @@ async function withLiveChutesDiscovery<T>(
function createAuthEchoFetchMock() {
return vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
const auth = readAuthorizationHeader(init);
return Promise.resolve(
jsonResponse({
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: auth ? `${auth}-model` : "public-model" }],
}),
);
});
});
}
@@ -131,8 +124,9 @@ describe("chutes-models", () => {
});
it("discoverChutesModels correctly maps API response when not in test env", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ id: "zai-org/GLM-4.7-TEE" },
{
@@ -146,7 +140,7 @@ describe("chutes-models", () => {
{ id: "new-provider/simple-model" },
],
}),
);
});
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await discoverChutesModels("test-token-real-fetch");
expect(models.length).toBeGreaterThan(0);
@@ -164,8 +158,9 @@ describe("chutes-models", () => {
});
it("falls back from malformed live token metadata", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{
id: "provider/bad-window",
@@ -179,7 +174,7 @@ describe("chutes-models", () => {
},
],
}),
);
});
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await discoverChutesModels("malformed-token-metadata");
@@ -200,10 +195,14 @@ describe("chutes-models", () => {
it("discoverChutesModels retries without auth on 401", async () => {
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
if (readAuthorizationHeader(init) === "Bearer test-token-error") {
return Promise.resolve(new Response("", { status: 401 }));
return Promise.resolve({
ok: false,
status: 401,
});
}
return Promise.resolve(
jsonResponse({
return Promise.resolve({
ok: true,
json: async () => ({
data: [
{
id: "Qwen/Qwen3-32B",
@@ -233,7 +232,7 @@ describe("chutes-models", () => {
},
],
}),
);
});
});
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await discoverChutesModels("test-token-error");
@@ -243,7 +242,10 @@ describe("chutes-models", () => {
});
it("does not cache fallback static catalog for non-OK responses", async () => {
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 503 }));
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
});
await withLiveChutesDiscovery(mockFetch, async () => {
const first = await discoverChutesModels("chutes-fallback-token");
@@ -258,24 +260,27 @@ describe("chutes-models", () => {
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
const auth = readAuthorizationHeader(init);
if (auth === "Bearer chutes-token-a") {
return Promise.resolve(
jsonResponse({
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: "private/model-a" }],
}),
);
});
}
if (auth === "Bearer chutes-token-b") {
return Promise.resolve(
jsonResponse({
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: "private/model-b" }],
}),
);
});
}
return Promise.resolve(
jsonResponse({
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: "public/model" }],
}),
);
});
});
await withLiveChutesDiscovery(mockFetch, async () => {
const modelsA = await discoverChutesModels("chutes-token-a");
@@ -320,13 +325,17 @@ describe("chutes-models", () => {
it("does not cache 401 fallback under the failed token key", async () => {
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
if (readAuthorizationHeader(init) === "Bearer failed-token") {
return Promise.resolve(new Response("", { status: 401 }));
return Promise.resolve({
ok: false,
status: 401,
});
}
return Promise.resolve(
jsonResponse({
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: "public/model" }],
}),
);
});
});
await withLiveChutesDiscovery(mockFetch, async () => {
await discoverChutesModels("failed-token");

View File

@@ -91,9 +91,6 @@ const DEFAULT_COMPLETION_DELIVERY_RETRY_DELAYS_MS = [
];
const DEFAULT_TASK_ROW_RECONCILE_INTERVAL_MS = 10_000;
const RECENT_TERMINAL_TASK_RECONCILE_GRACE_MS = 60_000;
// Codex's recorder uses this filename contract; non-canonical names keep the
// legacy substring fallback for older or test-created transcript files.
const CODEX_ROLLOUT_FILENAME_RE = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/u;
const defaultRuntime: NativeSubagentMonitorRuntime = {
createAgentHarnessTaskRuntime,
@@ -1191,9 +1188,8 @@ async function findTranscriptPaths(params: {
}): Promise<Map<string, string>> {
const sessionsDir = path.join(params.codexHome, "sessions");
const found = new Map<string, string>();
const remaining = new Set(params.childThreadIds);
const stack = [sessionsDir];
while (stack.length > 0 && remaining.size > 0) {
while (stack.length > 0 && found.size < params.childThreadIds.size) {
const dir = stack.pop()!;
let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
try {
@@ -1210,20 +1206,10 @@ async function findTranscriptPaths(params: {
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
continue;
}
const rolloutMatch = entry.name.match(CODEX_ROLLOUT_FILENAME_RE);
if (rolloutMatch) {
const childThreadId = rolloutMatch[1];
if (remaining.delete(childThreadId)) {
for (const childThreadId of params.childThreadIds) {
if (!found.has(childThreadId) && entry.name.includes(childThreadId)) {
found.set(childThreadId, entryPath);
}
continue;
}
for (const childThreadId of remaining) {
if (entry.name.includes(childThreadId)) {
found.set(childThreadId, entryPath);
remaining.delete(childThreadId);
break;
}
}
}
}
@@ -1250,13 +1236,10 @@ async function findTranscriptPath(params: {
stack.push(entryPath);
continue;
}
const rolloutMatch = entry.name.match(CODEX_ROLLOUT_FILENAME_RE);
if (
entry.isFile() &&
entry.name.endsWith(".jsonl") &&
(rolloutMatch
? rolloutMatch[1] === params.childThreadId
: entry.name.includes(params.childThreadId))
entry.name.includes(params.childThreadId)
) {
return entryPath;
}

View File

@@ -360,15 +360,6 @@ export async function mirrorCodexAppServerTranscript(params: {
sessionFile: params.sessionFile,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sessionId && params.sessionKey && params.agentId
? {
target: {
agentId: params.agentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
},
}
: {}),
message: update.message,
messageId: update.messageId,
messageSeq: update.messageSeq,

View File

@@ -44,7 +44,6 @@ import {
type SessionLike,
} from "./event-bridge.js";
import { createHooksBridge, type CopilotHooksConfig } from "./hooks-bridge.js";
import { createCopilotNativeSubagentTaskMirror } from "./native-subagent-task-mirror.js";
import {
createPermissionBridge,
rejectAllPolicy,
@@ -229,7 +228,6 @@ function deferBackgroundCompactionCleanup(params: {
handle: PooledClient;
pool: CopilotClientPool;
cleanupToolBridge?: () => void;
finalizeNativeSubagents?: () => void;
sdkSessionId?: string;
session: SessionLike;
timeoutMs: number;
@@ -252,7 +250,6 @@ function deferBackgroundCompactionCleanup(params: {
await cancelBackgroundCompactionBeforeTeardown(params.session);
params.bridge.settleCompactionWait();
}
params.finalizeNativeSubagents?.();
params.bridge.detach();
try {
await params.session.disconnect();
@@ -413,11 +410,6 @@ export async function runCopilotAttempt(
let handle: PooledClient | undefined;
let session: SessionLike | undefined;
let bridge: ReturnType<typeof attachEventBridge> | undefined;
const nativeSubagentTaskMirror = createCopilotNativeSubagentTaskMirror({
agentId: sessionAgentId,
now,
scope: input.agentHarnessTaskRuntimeScope,
});
let activeRunHandleRef: Parameters<typeof clearActiveEmbeddedRun>[1] | undefined;
let userInputBridgeRef: ReturnType<typeof createCopilotUserInputBridge> | undefined;
let cleanupToolBridge: (() => void) | undefined;
@@ -756,8 +748,6 @@ export async function runCopilotAttempt(
}
bridge = attachEventBridge(session, {
onAssistantDelta: input.onAssistantDelta,
onAgentEvent: input.onAgentEvent,
onNativeSubagentEvent: (event) => nativeSubagentTaskMirror?.handleEvent(event),
onCompactionStart: async () => {
const sessionFile = readString(input.sessionFile);
if (!sessionFile) {
@@ -823,7 +813,6 @@ export async function runCopilotAttempt(
}
const result = await session.sendAndWait(messageOptions, input.timeoutMs);
await bridge.awaitDeltaChain();
await bridge.awaitAgentEventChain();
if (!bridge.recordSendResult(result) && !aborted) {
// SDK sendAndWait returning undefined is treated as a timeout by the
// capability inventory. Do not call session.abort() here: OpenClaw may
@@ -859,7 +848,6 @@ export async function runCopilotAttempt(
} catch {
// delta-flush failure must not mask the timeout state
}
await bridge?.awaitAgentEventChain();
} else {
promptError = toError(error);
}
@@ -890,7 +878,6 @@ export async function runCopilotAttempt(
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
bridge,
cleanupToolBridge,
finalizeNativeSubagents: () => nativeSubagentTaskMirror?.finalizeActiveRuns(),
handle,
pool: deps.pool,
sdkSessionId,
@@ -919,8 +906,6 @@ export async function runCopilotAttempt(
// defines as no background agents in flight. Timeouts retain the bridge
// until that event so compaction that starts after the timer still completes.
await bridge?.awaitCompactionChain();
await bridge?.awaitAgentEventChain();
nativeSubagentTaskMirror?.finalizeActiveRuns();
cleanupToolBridge?.();
bridge?.detach();
params.abortSignal?.removeEventListener("abort", onAbort);
@@ -1033,7 +1018,6 @@ export async function runCopilotAttempt(
await dualWriteCopilotTranscriptBestEffort({
sessionFile: sessionFileForMirror,
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
sessionId: readString(input.sessionId),
agentId: readString(input.agentId),
messages: taggedMessages,
idempotencyScope: sessionIdForScope ? `copilot:${sessionIdForScope}` : undefined,

View File

@@ -96,7 +96,6 @@ function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
export interface MirrorCopilotTranscriptParams {
sessionFile: string;
sessionKey?: string;
sessionId?: string;
agentId?: string;
messages: AgentMessage[];
/**
@@ -169,20 +168,7 @@ export async function mirrorCopilotTranscript(
}
if (params.sessionKey) {
emitSessionTranscriptUpdate({
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sessionId && params.agentId
? {
target: {
agentId: params.agentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
},
}
: {}),
});
emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, sessionKey: params.sessionKey });
} else {
emitSessionTranscriptUpdate(params.sessionFile);
}

View File

@@ -15,11 +15,6 @@ const REGISTERED_EVENT_TYPES = [
"assistant.usage",
"tool.execution_start",
"tool.execution_complete",
"session.plan_changed",
"exit_plan_mode.requested",
"subagent.started",
"subagent.completed",
"subagent.failed",
"session.compaction_start",
"session.compaction_complete",
"session.idle",
@@ -460,78 +455,6 @@ describe("attachEventBridge", () => {
});
});
it("projects Copilot plan events through the generic plan stream", async () => {
const session = createFakeSession();
const onAgentEvent = vi.fn().mockResolvedValue(undefined);
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onAgentEvent,
});
session.emit(
"session.plan_changed",
makeEvent("session.plan_changed", { operation: "update" }),
);
session.emit(
"exit_plan_mode.requested",
makeEvent("exit_plan_mode.requested", {
actions: ["approve", "edit"],
planContent: "# Plan\n- inspect\n- patch",
recommendedAction: "approve",
requestId: "request-1",
summary: "Plan ready",
}),
);
await bridge.awaitAgentEventChain();
expect(onAgentEvent).toHaveBeenCalledTimes(2);
expect(onAgentEvent).toHaveBeenNthCalledWith(1, {
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
operation: "update",
},
});
expect(onAgentEvent).toHaveBeenNthCalledWith(2, {
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
explanation: "Plan ready",
steps: ["# Plan", "inspect", "patch"],
actions: ["approve", "edit"],
requestId: "request-1",
recommendedAction: "approve",
},
});
});
it("forwards native Copilot subagent lifecycle events to the adapter", () => {
const session = createFakeSession();
const onNativeSubagentEvent = vi.fn();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onNativeSubagentEvent,
});
const event = makeEvent("subagent.started", {
agentDescription: "inspect the repository",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-1",
});
session.emit("subagent.started", event);
expect(onNativeSubagentEvent).toHaveBeenCalledWith(event);
bridge.detach();
});
it("preserves all-zero usage snapshot after an invalid assistant.usage event", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {

View File

@@ -41,16 +41,6 @@ export interface SessionLike {
export interface EventBridgeOptions {
onAssistantDelta?: (payload: OnAssistantDeltaPayload) => void | Promise<void>;
onAgentEvent?: (event: {
stream: "item" | "plan";
data: Record<string, unknown>;
}) => void | Promise<void>;
onNativeSubagentEvent?: (
event: Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>,
) => void;
onCompactionComplete?: (payload: {
messagesRemoved?: number;
success: boolean;
@@ -82,7 +72,6 @@ export interface EventBridgeController {
awaitSessionIdle(): Promise<void>;
settleCompactionWait(): void;
awaitDeltaChain(): Promise<void>;
awaitAgentEventChain(): Promise<void>;
hasObservedCompaction(): boolean;
hasObservedSessionIdle(): boolean;
isCompacting(): boolean;
@@ -114,7 +103,6 @@ export function attachEventBridge(
let observedCompaction = false;
let deltaQueue = Promise.resolve();
let deltaChain = Promise.resolve();
let agentEventChain = Promise.resolve();
let compactionChain = Promise.resolve();
let compactionIdle = Promise.resolve();
let resolveCompactionIdle: (() => void) | undefined;
@@ -203,51 +191,6 @@ export function attachEventBridge(
}
});
registerListener(session, unsubscribeFns, "session.plan_changed", (event) => {
enqueueAgentEvent({
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
operation: event.data.operation,
...(event.agentId ? { agentId: event.agentId } : {}),
},
});
});
registerListener(session, unsubscribeFns, "exit_plan_mode.requested", (event) => {
const steps = splitPlanText(event.data.planContent);
enqueueAgentEvent({
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
...(event.data.summary ? { explanation: event.data.summary } : {}),
...(steps.length > 0 ? { steps } : {}),
...(event.data.actions.length > 0 ? { actions: event.data.actions } : {}),
...(event.data.requestId ? { requestId: event.data.requestId } : {}),
...(event.data.recommendedAction
? { recommendedAction: event.data.recommendedAction }
: {}),
...(event.agentId ? { agentId: event.agentId } : {}),
},
});
});
registerListener(session, unsubscribeFns, "subagent.started", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "subagent.completed", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "subagent.failed", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "session.compaction_start", (event) => {
if (!isRootCompactionEvent(event)) {
return;
@@ -333,9 +276,6 @@ export function attachEventBridge(
awaitDeltaChain() {
return deltaChain;
},
awaitAgentEventChain() {
return agentEventChain;
},
hasObservedCompaction() {
return observedCompaction;
},
@@ -394,31 +334,6 @@ export function attachEventBridge(
compactionChain = queued.catch(() => undefined);
}
function enqueueAgentEvent(event: {
stream: "item" | "plan";
data: Record<string, unknown>;
}): void {
const callback = options.onAgentEvent;
if (!callback) {
return;
}
const invoke = () => callback(event);
agentEventChain = agentEventChain.then(invoke, invoke).catch(() => undefined);
}
function forwardNativeSubagentEvent(
event: Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>,
): void {
try {
options.onNativeSubagentEvent?.(event);
} catch {
// Native task mirroring must not corrupt the Copilot turn.
}
}
async function awaitStableCompaction(): Promise<void> {
const idle = activeCompactionCount > 0 ? compactionIdle : undefined;
if (idle) {
@@ -541,13 +456,6 @@ function joinReasoning(order: string[], reasoningById: Map<string, string>): str
return order.map((reasoningId) => reasoningById.get(reasoningId) ?? "").join("");
}
function splitPlanText(text: string | undefined): string[] {
return (text ?? "")
.split(/\r?\n/)
.map((line) => line.trim().replace(/^[-*]\s+/, ""))
.filter((line) => line.length > 0);
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

View File

@@ -1,200 +0,0 @@
import type { SessionEvent } from "@github/copilot-sdk";
import type {
AgentHarnessTaskRecord,
AgentHarnessTaskRuntime,
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
import { describe, expect, it, vi } from "vitest";
import {
CopilotNativeSubagentTaskMirror,
createCopilotNativeSubagentTaskMirror,
} from "./native-subagent-task-mirror.js";
type NativeSubagentEventType = "subagent.started" | "subagent.completed" | "subagent.failed";
function makeEvent<T extends NativeSubagentEventType>(
type: T,
data: Extract<SessionEvent, { type: T }>["data"],
agentId?: string,
): Extract<SessionEvent, { type: T }> {
return {
data,
id: `${type}-id`,
parentId: null,
timestamp: "2024-01-01T00:00:00.000Z",
type,
...(agentId ? { agentId } : {}),
} as Extract<SessionEvent, { type: T }>;
}
function createRuntime() {
const task = {} as AgentHarnessTaskRecord;
return {
tryCreateRunningTaskRun: vi.fn(() => task),
recordTaskRunProgressByRunId: vi.fn(() => []),
finalizeTaskRunByRunId: vi.fn(() => []),
} satisfies Pick<
AgentHarnessTaskRuntime,
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
>;
}
describe("CopilotNativeSubagentTaskMirror", () => {
it("does not create a mirror without a host-issued task scope", () => {
expect(createCopilotNativeSubagentTaskMirror({})).toBeUndefined();
});
it("mirrors start and completion using agentId with toolCallId fallback", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror(
{ agentId: "parent-agent", now: () => 100 },
runtime,
);
mirror.handleEvent(
makeEvent(
"subagent.started",
{
agentDescription: "inspect the repository",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-1",
},
"child-1",
),
);
mirror.handleEvent(
makeEvent(
"subagent.completed",
{
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-1",
totalToolCalls: 2,
totalTokens: 30,
},
"child-1",
),
);
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
sourceId: "call-1",
agentId: "parent-agent",
runId: "copilot-agent:child-1",
label: "Researcher",
task: "inspect the repository",
notifyPolicy: "silent",
deliveryStatus: "not_applicable",
preferMetadata: true,
startedAt: 100,
lastEventAt: 100,
progressSummary: "Copilot native subagent started.",
});
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
runId: "copilot-agent:child-1",
status: "succeeded",
endedAt: 100,
lastEventAt: 100,
progressSummary: "Copilot native subagent completed.",
terminalSummary: "Copilot native subagent completed (2 tool calls, 30 tokens).",
});
});
it("uses toolCallId when the SDK omits agentId", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 200 }, runtime);
mirror.handleEvent(
makeEvent("subagent.started", {
agentDescription: "",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-2",
}),
);
mirror.handleEvent(
makeEvent("subagent.failed", {
agentDisplayName: "Researcher",
agentName: "researcher",
error: "failed",
toolCallId: "call-2",
}),
);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
expect.objectContaining({
runId: "copilot-agent:call-2",
status: "failed",
error: "failed",
}),
);
});
it("keeps parallel subagents distinct when they share a parent tool call", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 250 }, runtime);
for (const agentId of ["child-1", "child-2"]) {
mirror.handleEvent(
makeEvent(
"subagent.started",
{
agentDescription: `inspect ${agentId}`,
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-shared",
},
agentId,
),
);
}
for (const agentId of ["child-1", "child-2"]) {
mirror.handleEvent(
makeEvent(
"subagent.completed",
{
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-shared",
},
agentId,
),
);
}
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledTimes(2);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledTimes(2);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ runId: "copilot-agent:child-1" }),
);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ runId: "copilot-agent:child-2" }),
);
});
it("finalizes active tasks when the parent attempt tears down", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 300 }, runtime);
mirror.handleEvent(
makeEvent("subagent.started", {
agentDescription: "inspect",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-3",
}),
);
mirror.finalizeActiveRuns();
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
runId: "copilot-agent:call-3",
status: "cancelled",
endedAt: 300,
lastEventAt: 300,
error: "Copilot native subagent ended with its parent attempt.",
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
terminalSummary: "Copilot native subagent cancelled.",
});
});
});

View File

@@ -1,199 +0,0 @@
import type { SessionEvent } from "@github/copilot-sdk";
import {
createAgentHarnessTaskRuntime,
type AgentHarnessTaskRuntime,
type AgentHarnessTaskRuntimeScope,
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
const COPILOT_NATIVE_SUBAGENT_TASK_KIND = "copilot-native";
const COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX = "copilot-agent:";
type CopilotNativeSubagentEvent = Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>;
type TaskLifecycleRuntime = Pick<
AgentHarnessTaskRuntime,
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
>;
export function createCopilotNativeSubagentTaskMirror(params: {
agentId?: string;
now?: () => number;
scope?: AgentHarnessTaskRuntimeScope;
}): CopilotNativeSubagentTaskMirror | undefined {
if (!params.scope) {
return undefined;
}
return new CopilotNativeSubagentTaskMirror(
{
agentId: params.agentId,
now: params.now,
},
createAgentHarnessTaskRuntime({
runtime: "subagent",
taskKind: COPILOT_NATIVE_SUBAGENT_TASK_KIND,
scope: params.scope,
runIdPrefix: COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX,
}),
);
}
export class CopilotNativeSubagentTaskMirror {
private readonly runIdByAgentId = new Map<string, string>();
private readonly runIdByToolCallId = new Map<string, string>();
private readonly terminalRunIds = new Set<string>();
private readonly activeRunIds = new Set<string>();
private readonly now: () => number;
constructor(
private readonly params: { agentId?: string; now?: () => number },
private readonly runtime: TaskLifecycleRuntime,
) {
this.now = params.now ?? Date.now;
}
handleEvent(event: CopilotNativeSubagentEvent): void {
const toolCallId = event.data.toolCallId.trim();
if (!toolCallId) {
return;
}
const runId = this.resolveRunId(event);
if (event.type === "subagent.started") {
this.handleStarted(event, runId, toolCallId);
return;
}
if (event.type === "subagent.completed") {
this.handleCompleted(event, runId);
return;
}
this.handleFailed(event, runId);
}
finalizeActiveRuns(): void {
const eventAt = this.now();
for (const runId of this.activeRunIds) {
this.terminalRunIds.add(runId);
this.runtime.finalizeTaskRunByRunId({
runId,
status: "cancelled",
endedAt: eventAt,
lastEventAt: eventAt,
error: "Copilot native subagent ended with its parent attempt.",
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
terminalSummary: "Copilot native subagent cancelled.",
});
}
this.activeRunIds.clear();
}
private handleStarted(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.started" }>,
runId: string,
toolCallId: string,
): void {
const agentId = event.agentId?.trim();
const existingRunId = agentId
? this.runIdByAgentId.get(agentId)
: this.runIdByToolCallId.get(toolCallId);
if (existingRunId) {
return;
}
const eventAt = this.now();
const label = event.data.agentDisplayName.trim() || event.data.agentName.trim();
const task = event.data.agentDescription.trim() || `Copilot native subagent ${label}`;
const taskRecord = this.runtime.tryCreateRunningTaskRun({
sourceId: toolCallId,
agentId: this.params.agentId,
runId,
label: label || "Copilot subagent",
task,
notifyPolicy: "silent",
deliveryStatus: "not_applicable",
preferMetadata: true,
startedAt: eventAt,
lastEventAt: eventAt,
progressSummary: "Copilot native subagent started.",
});
if (!taskRecord) {
return;
}
if (agentId) {
this.runIdByAgentId.set(agentId, runId);
} else {
this.runIdByToolCallId.set(toolCallId, runId);
}
this.terminalRunIds.delete(runId);
this.activeRunIds.add(runId);
}
private handleCompleted(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
runId: string,
): void {
if (this.terminalRunIds.has(runId)) {
return;
}
const eventAt = this.now();
this.terminalRunIds.add(runId);
this.activeRunIds.delete(runId);
this.runtime.finalizeTaskRunByRunId({
runId,
status: "succeeded",
endedAt: eventAt,
lastEventAt: eventAt,
progressSummary: "Copilot native subagent completed.",
terminalSummary: buildCompletionSummary(event),
});
}
private handleFailed(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.failed" }>,
runId: string,
): void {
if (this.terminalRunIds.has(runId)) {
return;
}
const eventAt = this.now();
this.terminalRunIds.add(runId);
this.activeRunIds.delete(runId);
this.runtime.finalizeTaskRunByRunId({
runId,
status: "failed",
endedAt: eventAt,
lastEventAt: eventAt,
error: event.data.error,
progressSummary: "Copilot native subagent failed.",
terminalSummary: "Copilot native subagent failed.",
});
}
private resolveRunId(event: CopilotNativeSubagentEvent): string {
const agentId = event.agentId?.trim();
if (agentId) {
const existing = this.runIdByAgentId.get(agentId);
if (existing) {
return existing;
}
}
const existing = this.runIdByToolCallId.get(event.data.toolCallId);
if (existing) {
return existing;
}
const identity = agentId || event.data.toolCallId.trim();
return `${COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX}${identity}`;
}
}
function buildCompletionSummary(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
): string {
const details = [
event.data.totalToolCalls !== undefined ? `${event.data.totalToolCalls} tool calls` : undefined,
event.data.totalTokens !== undefined ? `${event.data.totalTokens} tokens` : undefined,
].filter((value): value is string => value !== undefined);
return details.length > 0
? `Copilot native subagent completed (${details.join(", ")}).`
: "Copilot native subagent completed.";
}

View File

@@ -49,14 +49,6 @@ function makeAgentModelEntry(id = "profile/live-model") {
};
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function withLiveDiscoveryTestEnv(
mockFetch: ReturnType<typeof vi.fn>,
runAssertions: () => Promise<void>,
@@ -130,9 +122,10 @@ describe("deepinfra augmentModelCatalog", () => {
it("uses config-backed API keys to enable live model catalog augmentation", async () => {
resetDeepInfraModelCacheForTest();
const mockFetch = vi
.fn()
.mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry("config/live-model")] }));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry("config/live-model")] }),
});
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
await withLiveDiscoveryTestEnv(mockFetch, async () => {
@@ -158,9 +151,10 @@ describe("deepinfra augmentModelCatalog", () => {
it("still runs live discovery when ctx.entries includes custom DeepInfra rows", async () => {
resetDeepInfraModelCacheForTest();
const mockFetch = vi
.fn()
.mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry("custom/live-model")] }));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry("custom/live-model")] }),
});
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
const seededDeepInfraCount = DEEPINFRA_MODEL_CATALOG.length + 5;
@@ -236,7 +230,10 @@ describe("deepinfra capability registration", () => {
it("uses profile-resolved API keys for live text catalog discovery", async () => {
resetDeepInfraModelCacheForTest();
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry()] }));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry()] }),
});
const captured = createCapturedPluginRegistration();
deepinfraPlugin.register(captured.api);
const provider = captured.providers[0];

View File

@@ -48,14 +48,6 @@ function makeAgentModelEntry(overrides: Record<string, unknown> = {}) {
};
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
function expectedStaticChatCatalog() {
return DEEPINFRA_MODEL_CATALOG.map((model) => {
const compat = Object.assign({}, model.compat, {
@@ -203,7 +195,10 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
});
it("fetches the openclaw-projection endpoint and parses chat-surface entries when an API key is configured", async () => {
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry()] }));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry()] }),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const models = await discoverDeepInfraModels();
@@ -233,19 +228,21 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
});
it("skips entries with no metadata or no surface tag, and deduplicates ids", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
{ id: "BAAI/bge-m3", object: "model", metadata: null },
makeAgentModelEntry({
id: "untagged/model",
metadata: { context_length: 1, max_tokens: 1, pricing: {}, tags: [] },
}),
makeAgentModelEntry(),
makeAgentModelEntry(),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
{ id: "BAAI/bge-m3", object: "model", metadata: null },
makeAgentModelEntry({
id: "untagged/model",
metadata: { context_length: 1, max_tokens: 1, pricing: {}, tags: [] },
}),
makeAgentModelEntry(),
makeAgentModelEntry(),
],
}),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const models = await discoverDeepInfraModels();
@@ -286,7 +283,7 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
});
it("falls back to the static catalog on non-2xx HTTP responses", async () => {
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 503 }));
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const models = await discoverDeepInfraModels();
@@ -297,10 +294,14 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
it("falls back without caching malformed successful model list payloads", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ data: {} }))
.mockResolvedValueOnce(
jsonResponse({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
);
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: {} }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(
@@ -327,8 +328,14 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
it("caches successful discovery responses only", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ data: [makeAgentModelEntry({ id: "first/model" })] }))
.mockResolvedValueOnce(jsonResponse({ data: [makeAgentModelEntry({ id: "second/model" })] }));
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "first/model" })] }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "second/model" })] }),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const expectedIds = expectedLiveChatCatalog([
@@ -352,10 +359,14 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
it("does not cache successful responses that produce no live catalog rows", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ data: [] }))
.mockResolvedValueOnce(
jsonResponse({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
);
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(
@@ -382,65 +393,67 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
describe("discoverDeepInfraSurfaces (per-surface bucketing)", () => {
it("buckets dynamic entries by short-alias surface tag", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
makeAgentModelEntry({
id: "anthropic/claude-sonnet-4-6",
metadata: {
description: "claude sonnet 4.6",
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
tags: ["chat", "vlm", "vision", "prompt_cache"],
},
}),
makeAgentModelEntry({
id: "BAAI/bge-m3",
metadata: {
description: "bge-m3",
pricing: { input_tokens: 0.01 },
tags: ["embed"],
},
}),
makeAgentModelEntry({
id: "black-forest-labs/FLUX-1-schnell",
metadata: {
description: "FLUX schnell",
pricing: { per_image_unit: 0.003 },
tags: ["image-gen"],
default_width: 1024,
default_height: 1024,
default_iterations: 4,
},
}),
makeAgentModelEntry({
id: "Wan-AI/Wan2.6-T2V",
metadata: {
description: "Wan T2V",
pricing: { output_seconds: 0.05 },
tags: ["video-gen"],
},
}),
makeAgentModelEntry({
id: "Qwen/Qwen3-TTS",
metadata: {
description: "Qwen3 TTS",
pricing: { input_characters: 0.65 },
tags: ["tts"],
},
}),
makeAgentModelEntry({
id: "openai/whisper-large-v3-turbo",
metadata: {
description: "whisper",
pricing: { input_seconds: 0.00004 },
tags: ["stt"],
},
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
makeAgentModelEntry({
id: "anthropic/claude-sonnet-4-6",
metadata: {
description: "claude sonnet 4.6",
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
tags: ["chat", "vlm", "vision", "prompt_cache"],
},
}),
makeAgentModelEntry({
id: "BAAI/bge-m3",
metadata: {
description: "bge-m3",
pricing: { input_tokens: 0.01 },
tags: ["embed"],
},
}),
makeAgentModelEntry({
id: "black-forest-labs/FLUX-1-schnell",
metadata: {
description: "FLUX schnell",
pricing: { per_image_unit: 0.003 },
tags: ["image-gen"],
default_width: 1024,
default_height: 1024,
default_iterations: 4,
},
}),
makeAgentModelEntry({
id: "Wan-AI/Wan2.6-T2V",
metadata: {
description: "Wan T2V",
pricing: { output_seconds: 0.05 },
tags: ["video-gen"],
},
}),
makeAgentModelEntry({
id: "Qwen/Qwen3-TTS",
metadata: {
description: "Qwen3 TTS",
pricing: { input_characters: 0.65 },
tags: ["tts"],
},
}),
makeAgentModelEntry({
id: "openai/whisper-large-v3-turbo",
metadata: {
description: "whisper",
pricing: { input_seconds: 0.00004 },
tags: ["stt"],
},
}),
],
}),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const catalog = await discoverDeepInfraSurfaces();
@@ -458,33 +471,35 @@ describe("discoverDeepInfraSurfaces (per-surface bucketing)", () => {
});
it("drops malformed live numeric metadata", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
makeAgentModelEntry({
id: "bad/chat",
metadata: {
description: "bad chat",
context_length: -1,
max_tokens: 1.5,
pricing: { input_tokens: 3, output_tokens: 15 },
tags: ["chat"],
},
}),
makeAgentModelEntry({
id: "bad/image",
metadata: {
description: "bad image",
pricing: { per_image_unit: 0.003 },
tags: ["image-gen"],
default_width: Number.POSITIVE_INFINITY,
default_height: 1024.5,
default_iterations: 0,
},
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
makeAgentModelEntry({
id: "bad/chat",
metadata: {
description: "bad chat",
context_length: -1,
max_tokens: 1.5,
pricing: { input_tokens: 3, output_tokens: 15 },
tags: ["chat"],
},
}),
makeAgentModelEntry({
id: "bad/image",
metadata: {
description: "bad image",
pricing: { per_image_unit: 0.003 },
tags: ["image-gen"],
default_width: Number.POSITIVE_INFINITY,
default_height: 1024.5,
default_iterations: 0,
},
}),
],
}),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const catalog = await discoverDeepInfraSurfaces();

View File

@@ -49,14 +49,6 @@ const surfaceEntry = (id: string, surfaceTag: string, extra: Record<string, unkn
},
});
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function withLiveFetch(mockFetch: ReturnType<typeof vi.fn>, run: () => Promise<void>) {
const env = { ...process.env };
delete process.env.NODE_ENV;
@@ -94,17 +86,19 @@ describe("DeepInfra generation catalogs", () => {
describe("listDeepInfraImageGenCatalog", () => {
it("returns null when live discovery succeeds but the response has zero image-gen entries", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const result = await listDeepInfraImageGenCatalog(withKeyCtx());
@@ -121,26 +115,28 @@ describe("listDeepInfraImageGenCatalog", () => {
});
it("projects discovered image-gen entries when a key is configured and discovery is live", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
pricing: { per_image_unit: 0.08 },
default_width: 1024,
default_height: 1024,
default_iterations: 28,
}),
surfaceEntry("ByteDance/Seedream-4", "image-gen", {
pricing: { per_image_unit: 0.03 },
}),
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
pricing: { per_image_unit: 0.08 },
default_width: 1024,
default_height: 1024,
default_iterations: 28,
}),
surfaceEntry("ByteDance/Seedream-4", "image-gen", {
pricing: { per_image_unit: 0.03 },
}),
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const result = await listDeepInfraImageGenCatalog(withKeyCtx());
@@ -165,20 +161,22 @@ describe("listDeepInfraVideoGenCatalog", () => {
// produces zero video-gen entries. We must return null so the registered
// provider's static fallback list is consulted instead of an empty
// "live" answer.
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
pricing: { per_image_unit: 0.08 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
pricing: { per_image_unit: 0.08 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const result = await listDeepInfraVideoGenCatalog(withKeyCtx());
@@ -187,18 +185,20 @@ describe("listDeepInfraVideoGenCatalog", () => {
});
it("projects discovered video-gen entries with capability shape", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
surfaceEntry("ByteDance/Seedance-2.0", "video-gen", {
pricing: { output_seconds: 0.08 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
surfaceEntry("ByteDance/Seedance-2.0", "video-gen", {
pricing: { output_seconds: 0.08 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const result = await listDeepInfraVideoGenCatalog(withKeyCtx());
@@ -214,15 +214,17 @@ describe("listDeepInfraVideoGenCatalog", () => {
describe("resolveDeepInfraVideoModelCapabilities", () => {
it("returns capabilities for a discovered video-gen model", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const caps = await resolveDeepInfraVideoModelCapabilities({
@@ -234,15 +236,17 @@ describe("resolveDeepInfraVideoModelCapabilities", () => {
});
it("strips the deepinfra/ prefix when matching", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const caps = await resolveDeepInfraVideoModelCapabilities({
@@ -253,15 +257,17 @@ describe("resolveDeepInfraVideoModelCapabilities", () => {
});
it("returns undefined for an unknown model", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const caps = await resolveDeepInfraVideoModelCapabilities({

View File

@@ -464,7 +464,11 @@ describe("fetchCopilotModelCatalog", () => {
};
it("maps Copilot /models entries to ModelDefinitionConfig with real context windows", async () => {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, sampleApiResponse));
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => sampleApiResponse,
});
const out = await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
@@ -535,7 +539,11 @@ describe("fetchCopilotModelCatalog", () => {
});
it("strips trailing slash from baseUrl when building the /models URL", async () => {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, { data: [] }));
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ data: [] }),
});
await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
@@ -547,8 +555,10 @@ describe("fetchCopilotModelCatalog", () => {
});
it("dedupes by id when API returns duplicates", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
makeResponse(200, {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: [
{
id: "gpt-5.5",
@@ -570,7 +580,7 @@ describe("fetchCopilotModelCatalog", () => {
},
],
}),
);
});
const out = await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
@@ -583,8 +593,10 @@ describe("fetchCopilotModelCatalog", () => {
});
it("falls back from malformed live token limits", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
makeResponse(200, {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: [
{
id: "gpt-bad-window",
@@ -612,7 +624,7 @@ describe("fetchCopilotModelCatalog", () => {
},
],
}),
);
});
const out = await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
@@ -634,7 +646,11 @@ describe("fetchCopilotModelCatalog", () => {
});
it("throws on non-2xx HTTP responses so the caller can fall back to the static catalog", async () => {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(401, {}));
const fetchImpl = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: async () => ({}),
});
await expect(
fetchCopilotModelCatalog({
@@ -647,7 +663,11 @@ describe("fetchCopilotModelCatalog", () => {
it("throws provider-owned errors for malformed successful /models payloads", async () => {
for (const payload of [[], { data: {} }, { data: [null] }]) {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, payload));
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => payload,
});
await expect(
fetchCopilotModelCatalog({

View File

@@ -14,7 +14,16 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
import { discoverKilocodeModels, KILOCODE_MODELS_URL } from "./provider-models.js";
type MockKilocodeFetch = ((url: string, init?: RequestInit) => Promise<Response>) & {
type MockKilocodeFetchResponse = {
ok: boolean;
status?: number;
json?: () => Promise<unknown>;
};
type MockKilocodeFetch = ((
url: string,
init?: RequestInit,
) => Promise<MockKilocodeFetchResponse>) & {
mock: { calls: unknown[][] };
};
@@ -106,14 +115,6 @@ function makeAutoModel(overrides: Record<string, unknown> = {}) {
});
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function withFetchPathTest(mockFetch: MockKilocodeFetch, runAssertions: () => Promise<void>) {
const release = vi.fn(async () => {});
vi.stubEnv("NODE_ENV", "");
@@ -164,11 +165,13 @@ describe("discoverKilocodeModels", () => {
describe("discoverKilocodeModels (fetch path)", () => {
it("parses gateway models with correct pricing conversion", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [makeAutoModel(), makeGatewayModel()],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [makeAutoModel(), makeGatewayModel()],
}),
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
@@ -214,7 +217,10 @@ describe("discoverKilocodeModels (fetch path)", () => {
});
it("falls back to static catalog on HTTP error", async () => {
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models).toStrictEqual(EXPECTED_STATIC_KILOCODE_MODELS);
@@ -223,7 +229,10 @@ describe("discoverKilocodeModels (fetch path)", () => {
it("falls back to static catalog for malformed successful model list payloads", async () => {
for (const payload of [[], { data: {} }, { data: [null] }]) {
const mockFetch = vi.fn().mockResolvedValue(jsonResponse(payload));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(payload),
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models).toStrictEqual(EXPECTED_STATIC_KILOCODE_MODELS);
@@ -232,22 +241,24 @@ describe("discoverKilocodeModels (fetch path)", () => {
});
it("falls back from malformed live token metadata", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
makeGatewayModel({
id: "some/bad-window",
context_length: -1,
top_provider: { max_completion_tokens: 8192.5 },
}),
makeGatewayModel({
id: "some/bad-output",
context_length: Number.POSITIVE_INFINITY,
top_provider: { max_completion_tokens: 0 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
makeGatewayModel({
id: "some/bad-window",
context_length: -1,
top_provider: { max_completion_tokens: 8192.5 },
}),
makeGatewayModel({
id: "some/bad-output",
context_length: Number.POSITIVE_INFINITY,
top_provider: { max_completion_tokens: 0 },
}),
],
}),
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
@@ -264,11 +275,13 @@ describe("discoverKilocodeModels (fetch path)", () => {
});
it("ensures kilo/auto is present even when API doesn't return it", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [makeGatewayModel()],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [makeGatewayModel()],
}),
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(requireModelById(models, "kilo/auto").id).toBe("kilo/auto");
@@ -288,7 +301,10 @@ describe("discoverKilocodeModels (fetch path)", () => {
supported_parameters: ["max_tokens", "temperature"],
});
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [textOnlyModel] }));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [textOnlyModel] }),
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
const textModel = requireModelById(models, "some/text-model");
@@ -303,11 +319,13 @@ describe("discoverKilocodeModels (fetch path)", () => {
pricing: undefined,
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [malformedAutoModel, makeAutoModel(), makeGatewayModel()],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [malformedAutoModel, makeAutoModel(), makeGatewayModel()],
}),
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
const auto = requireModelById(models, "kilo/auto");

View File

@@ -31,21 +31,6 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
};
});
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function malformedJsonResponse(): Response {
return new Response("{ nope", {
status: 200,
headers: { "content-type": "application/json" },
});
}
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
vi.resetModules();
@@ -87,26 +72,33 @@ describe("lmstudio-models", () => {
loadedContextLength?: number;
maxContextLength?: number;
}) =>
vi.fn(async (url: string | URL, _init?: RequestInit) => {
vi.fn(async (url: string | URL, init?: RequestInit) => {
const key = params?.key ?? "qwen3-8b-instruct";
if (String(url).endsWith("/api/v1/models")) {
return jsonResponse({
models: [
{
type: "llm",
key,
max_context_length: params?.maxContextLength,
variants: params?.variants,
selected_variant: params?.selectedVariant,
loaded_instances: params?.loadedContextLength
? [{ id: "inst-1", config: { context_length: params.loadedContextLength } }]
: [],
},
],
});
return {
ok: true,
json: async () => ({
models: [
{
type: "llm",
key,
max_context_length: params?.maxContextLength,
variants: params?.variants,
selected_variant: params?.selectedVariant,
loaded_instances: params?.loadedContextLength
? [{ id: "inst-1", config: { context_length: params.loadedContextLength } }]
: [],
},
],
}),
};
}
if (String(url).endsWith("/api/v1/models/load")) {
return jsonResponse({ status: "loaded" });
return {
ok: true,
json: async () => ({ status: "loaded" }),
requestInit: init,
};
}
throw new Error(`Unexpected fetch URL: ${String(url)}`);
});
@@ -304,8 +296,9 @@ describe("lmstudio-models", () => {
});
it("discovers llm models and maps metadata", async () => {
const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) =>
jsonResponse({
const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => ({
ok: true,
json: async () => ({
models: [
{
type: "llm",
@@ -337,7 +330,7 @@ describe("lmstudio-models", () => {
},
],
}),
);
}));
const models = await discoverLmstudioModels({
baseUrl: "http://localhost:1234/v1",
@@ -393,7 +386,13 @@ describe("lmstudio-models", () => {
});
it("reports malformed model list JSON with an owned error", async () => {
const fetchMock = vi.fn(async () => malformedJsonResponse());
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError("bad json");
},
}));
const result = await fetchLmstudioModels({
baseUrl: "http://localhost:1234/v1",
@@ -406,7 +405,11 @@ describe("lmstudio-models", () => {
it("reports wrong-shaped model list payloads with owned errors", async () => {
for (const payload of [[], { models: {} }, { models: [null] }]) {
const fetchMock = vi.fn(async () => jsonResponse(payload));
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => payload,
}));
const result = await fetchLmstudioModels({
baseUrl: "http://localhost:1234/v1",
@@ -421,9 +424,12 @@ describe("lmstudio-models", () => {
it("caps oversized direct fetch timeouts before discovering models", async () => {
const timeoutController = new AbortController();
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal);
const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) =>
jsonResponse({ models: [] }),
);
const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => ({
ok: true,
status: 200,
requestInit: init,
json: async () => ({ models: [] }),
}));
const result = await fetchLmstudioModels({
baseUrl: "http://localhost:1234/v1",
@@ -515,17 +521,20 @@ describe("lmstudio-models", () => {
const variantKey = `${canonicalKey}@q4_k_m`;
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return jsonResponse({
models: [
{
type: "llm",
key: canonicalKey,
variants: [variantKey],
selected_variant: variantKey,
loaded_instances: [],
},
],
});
return {
ok: true,
json: async () => ({
models: [
{
type: "llm",
key: canonicalKey,
variants: [variantKey],
selected_variant: variantKey,
loaded_instances: [],
},
],
}),
};
}
if (String(url).endsWith("/api/v1/models/load")) {
return new Response("load failed", { status: 503 });
@@ -566,12 +575,20 @@ describe("lmstudio-models", () => {
it("reports malformed model load JSON with an owned error", async () => {
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return jsonResponse({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
});
return {
ok: true,
json: async () => ({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
}),
};
}
if (String(url).endsWith("/api/v1/models/load")) {
return malformedJsonResponse();
return {
ok: true,
json: async () => {
throw new SyntaxError("bad json");
},
};
}
throw new Error(`Unexpected fetch URL: ${String(url)}`);
});
@@ -591,9 +608,12 @@ describe("lmstudio-models", () => {
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return jsonResponse({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
});
return {
ok: true,
json: async () => ({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
}),
};
}
if (String(url).endsWith("/api/v1/models/load")) {
return tracked.response;

View File

@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { emitSessionTranscriptUpdate } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
resolveSessionTranscriptsDirForAgent,
type OpenClawConfig,
@@ -34,25 +33,6 @@ type SyncParams = {
progress?: (update: MemorySyncProgressUpdate) => void;
};
type MemorySessionTranscriptUpdate = {
agentId?: string;
sessionFile?: string;
sessionKey?: string;
target?: {
agentId: string;
sessionId: string;
sessionKey: string;
};
};
type MemoryTranscriptUpdateSubscriber = (
listener: (update: MemorySessionTranscriptUpdate) => void,
) => () => void;
const MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY = Symbol.for(
"openclaw.memoryCore.sessionTranscriptUpdateSubscriber",
);
type SourceStateRow = { path: string; hash: string; mtime: number; size: number };
class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
@@ -131,27 +111,10 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
return Array.from(this.sessionsDirtyFiles);
}
getPendingSessionTargets(): MemorySyncParams["sessions"] {
return Array.from(this.sessionPendingTargets.values());
}
getPendingSessionFiles(): string[] {
return Array.from(this.sessionPendingFiles);
}
isSessionsDirty(): boolean {
return this.sessionsDirty;
}
startTranscriptListener(): void {
this.ensureSessionListener();
}
stopTranscriptListener(): void {
this.sessionUnsubscribe?.();
this.sessionUnsubscribe = null;
}
protected computeProviderKey(): string {
return "test";
}
@@ -199,8 +162,6 @@ describe("session startup catch-up", () => {
});
afterEach(async () => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllEnvs();
await fs.rm(stateDir, { recursive: true, force: true });
});
@@ -395,84 +356,4 @@ describe("session startup catch-up", () => {
expect(harness.indexedPaths).toEqual([]);
});
it("queues transcript update identity without requiring a session file", async () => {
vi.useFakeTimers();
const harness = new SessionStartupCatchupHarness([]);
const originalSubscriber = (globalThis as Record<symbol, unknown>)[
MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY
];
let transcriptListener: ((update: MemorySessionTranscriptUpdate) => void) | undefined;
(globalThis as Record<symbol, unknown>)[MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY] = ((
listener,
) => {
transcriptListener = listener;
return () => {
if (transcriptListener === listener) {
transcriptListener = undefined;
}
};
}) satisfies MemoryTranscriptUpdateSubscriber;
harness.startTranscriptListener();
try {
transcriptListener?.({
target: {
agentId: "main",
sessionId: "thread",
sessionKey: "agent:main:thread",
},
});
expect(harness.getPendingSessionTargets()).toEqual([
{ agentId: "main", sessionId: "thread", sessionKey: "agent:main:thread" },
]);
} finally {
harness.stopTranscriptListener();
if (originalSubscriber === undefined) {
delete (globalThis as Record<symbol, unknown>)[
MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY
];
} else {
(globalThis as Record<symbol, unknown>)[MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY] =
originalSubscriber;
}
}
});
it("keeps canonical path transcript update compatibility", async () => {
vi.useFakeTimers();
const session = await writeSessionFile("thread.jsonl");
const harness = new SessionStartupCatchupHarness([]);
harness.startTranscriptListener();
emitSessionTranscriptUpdate({
sessionFile: session.filePath,
sessionKey: "agent:main:thread",
});
expect(harness.getPendingSessionFiles()).toEqual([session.filePath]);
expect(harness.getPendingSessionTargets()).toEqual([]);
harness.stopTranscriptListener();
});
it("prefers transcript update path compatibility before identity", async () => {
vi.useFakeTimers();
const session = await writeSessionFile("thread.jsonl");
const harness = new SessionStartupCatchupHarness([]);
harness.startTranscriptListener();
emitSessionTranscriptUpdate({
sessionFile: session.filePath,
target: {
agentId: "main",
sessionId: "identity-target",
sessionKey: "agent:main:identity-target",
},
});
expect(harness.getPendingSessionFiles()).toEqual([session.filePath]);
expect(harness.getPendingSessionTargets()).toEqual([]);
harness.stopTranscriptListener();
});
});

View File

@@ -170,27 +170,9 @@ const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
]);
const log = createSubsystemLogger("memory");
const MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY = Symbol.for(
"openclaw.memoryCore.sessionTranscriptUpdateSubscriber",
);
const TEST_MEMORY_WATCH_FACTORY_KEY = Symbol.for("openclaw.test.memoryWatchFactory");
const TEST_MEMORY_NATIVE_WATCH_FACTORY_KEY = Symbol.for("openclaw.test.memoryNativeWatchFactory");
type MemorySessionTranscriptUpdate = {
agentId?: string;
sessionFile?: string;
sessionKey?: string;
target?: {
agentId: string;
sessionId: string;
sessionKey: string;
};
};
type MemoryTranscriptUpdateSubscriber = (
listener: (update: MemorySessionTranscriptUpdate) => void,
) => () => void;
function memoryTableExists(db: DatabaseSync, tableName: string): boolean {
return Boolean(
db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName),
@@ -209,18 +191,6 @@ type LinuxMemoryDirectoryWatcher = {
ino: number;
};
function subscribeMemorySessionTranscriptUpdates(
listener: (update: MemorySessionTranscriptUpdate) => void,
): () => void {
const injected = (globalThis as Record<symbol, unknown>)[
MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY
];
if (typeof injected === "function") {
return (injected as MemoryTranscriptUpdateSubscriber)(listener);
}
return onSessionTranscriptUpdate(listener);
}
function resolveMemoryWatchFactory(): typeof chokidar.watch {
if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") {
const override = (globalThis as Record<PropertyKey, unknown>)[TEST_MEMORY_WATCH_FACTORY_KEY];
@@ -1452,22 +1422,20 @@ export abstract class MemoryManagerSyncOps {
if (!this.sources.has("sessions") || this.sessionUnsubscribe) {
return;
}
this.sessionUnsubscribe = subscribeMemorySessionTranscriptUpdates((update) => {
this.sessionUnsubscribe = onSessionTranscriptUpdate((update) => {
if (this.closed) {
return;
}
const sessionFile = update.sessionFile;
if (sessionFile && isSessionArchiveArtifactName(path.basename(sessionFile))) {
return;
}
if (sessionFile && this.isSessionFileForAgent(sessionFile)) {
this.scheduleSessionDirty(sessionFile);
if (!this.isSessionFileForAgent(sessionFile)) {
return;
}
const target = this.resolveSessionTranscriptUpdateSyncTarget(update);
if (target) {
this.scheduleSessionDirty(target);
return;
}
this.scheduleSessionDirty(sessionFile);
});
}
@@ -1735,30 +1703,13 @@ export abstract class MemoryManagerSyncOps {
return resolvedFile.startsWith(`${resolvedDir}${path.sep}`);
}
private resolveSessionTranscriptUpdateSyncTarget(
update: MemorySessionTranscriptUpdate,
): MemorySessionSyncTarget | null {
if (update.sessionFile && isSessionArchiveArtifactName(path.basename(update.sessionFile))) {
return null;
}
if (update.target) {
const agentId = update.target.agentId.trim();
const sessionId = update.target.sessionId.trim();
const sessionKey = update.target.sessionKey.trim();
if (!agentId || !sessionId || normalizeAgentId(agentId) !== normalizeAgentId(this.agentId)) {
return null;
}
return {
agentId,
sessionId,
...(sessionKey ? { sessionKey } : {}),
};
}
if (!update.sessionFile) {
return null;
}
private resolveSessionTranscriptUpdateSyncTarget(update: {
agentId?: string;
sessionFile: string;
sessionKey?: string;
}): MemorySessionSyncTarget | null {
const parsed = parseCanonicalSessionSyncTargetFromPath(update.sessionFile);
if (!parsed) {
if (!parsed || isSessionArchiveArtifactName(path.basename(update.sessionFile))) {
return null;
}
const agentId = update.agentId?.trim() || parsed.agentId;

View File

@@ -36,25 +36,20 @@ function requireFirstFetchParams(): {
return fetchParams as { auditContext?: string; url?: string };
}
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
describe("nextcloud talk room info", () => {
it("resolves direct rooms from the room info endpoint", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuard.mockResolvedValue({
response: jsonResponse({
ocs: {
data: {
type: 1,
response: {
ok: true,
json: async () => ({
ocs: {
data: {
type: 1,
},
},
},
}),
}),
},
release,
});
@@ -81,13 +76,16 @@ describe("nextcloud talk room info", () => {
it("normalizes signed decimal room type strings through the shared parser", async () => {
fetchWithSsrFGuard.mockResolvedValue({
response: jsonResponse({
ocs: {
data: {
type: "+01",
response: {
ok: true,
json: async () => ({
ocs: {
data: {
type: "+01",
},
},
},
}),
}),
},
release: vi.fn(async () => {}),
});
@@ -108,13 +106,16 @@ describe("nextcloud talk room info", () => {
it("does not coerce partial room type strings", async () => {
fetchWithSsrFGuard.mockResolvedValue({
response: jsonResponse({
ocs: {
data: {
type: "1direct",
response: {
ok: true,
json: async () => ({
ocs: {
data: {
type: "1direct",
},
},
},
}),
}),
},
release: vi.fn(async () => {}),
});
@@ -135,13 +136,16 @@ describe("nextcloud talk room info", () => {
it("does not classify negative room types as group rooms", async () => {
fetchWithSsrFGuard.mockResolvedValue({
response: jsonResponse({
ocs: {
data: {
type: -1,
response: {
ok: true,
json: async () => ({
ocs: {
data: {
type: -1,
},
},
},
}),
}),
},
release: vi.fn(async () => {}),
});

View File

@@ -393,11 +393,6 @@ async function mirrorTelegramAssistantReplyToTranscript(params: {
sessionFile,
sessionKey: params.sessionKey,
agentId: params.route.agentId,
target: {
agentId: params.route.agentId,
sessionId: sessionEntry.sessionId,
sessionKey: params.sessionKey,
},
message: appendedMessage,
messageId,
});

View File

@@ -8,10 +8,8 @@ import {
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMessageTelegramMock = vi.fn();
const reactMessageTelegramMock = vi.fn();
vi.mock("./send.js", () => ({
reactMessageTelegram: (...args: unknown[]) => reactMessageTelegramMock(...args),
sendMessageTelegram: (...args: unknown[]) => sendMessageTelegramMock(...args),
}));
@@ -28,7 +26,6 @@ function requireTelegramMessageAdapter(): TelegramMessageAdapter {
describe("telegram channel message adapter", () => {
beforeEach(() => {
reactMessageTelegramMock.mockReset();
sendMessageTelegramMock.mockReset();
});

View File

@@ -55,14 +55,6 @@ async function withLiveDiscovery<T>(run: () => Promise<T>): Promise<T> {
}
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
afterEach(() => {
clearLiveCatalogCacheForTests();
fetchWithSsrFGuardMock.mockReset();
@@ -134,7 +126,11 @@ describe("vercel ai gateway provider catalog", () => {
clearLiveCatalogCacheForTests();
fetchWithSsrFGuardMock.mockReset();
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: jsonResponse(payload),
response: {
ok: true,
status: 200,
json: async () => payload,
},
release: async () => {},
});
@@ -148,24 +144,28 @@ describe("vercel ai gateway provider catalog", () => {
it("falls back from malformed live token metadata", async () => {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: jsonResponse({
data: [
{
id: "anthropic/claude-opus-4.6",
name: "Claude Opus 4.6",
context_window: -1,
max_tokens: 128_000.5,
tags: ["vision", "reasoning"],
},
{
id: "custom/provider-model",
name: "Custom model",
context_window: Number.POSITIVE_INFINITY,
max_tokens: 0,
tags: ["reasoning"],
},
],
}),
response: {
ok: true,
status: 200,
json: async () => ({
data: [
{
id: "anthropic/claude-opus-4.6",
name: "Claude Opus 4.6",
context_window: -1,
max_tokens: 128_000.5,
tags: ["vision", "reasoning"],
},
{
id: "custom/provider-model",
name: "Custom model",
context_window: Number.POSITIVE_INFINITY,
max_tokens: 0,
tags: ["reasoning"],
},
],
}),
},
release: async () => {},
});

View File

@@ -3,43 +3,30 @@ import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCodeExecutionTool } from "./code-execution.js";
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function malformedJsonResponse(): Response {
return new Response("{ nope", {
status: 200,
headers: { "content-type": "application/json" },
});
}
function installCodeExecutionFetch(payload?: Record<string, unknown>) {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(
jsonResponse(
payload ?? {
output: [
{ type: "code_interpreter_call" },
{
type: "message",
content: [
{
type: "output_text",
text: "Mean: 42",
annotations: [{ type: "url_citation", url: "https://example.com/data.csv" }],
},
],
},
],
citations: ["https://example.com/data.csv"],
},
),
),
Promise.resolve({
ok: true,
json: () =>
Promise.resolve(
payload ?? {
output: [
{ type: "code_interpreter_call" },
{
type: "message",
content: [
{
type: "output_text",
text: "Mean: 42",
annotations: [{ type: "url_citation", url: "https://example.com/data.csv" }],
},
],
},
],
citations: ["https://example.com/data.csv"],
},
),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
@@ -191,7 +178,10 @@ describe("xai code_execution tool", () => {
it("reports malformed code_execution JSON as a provider error", async () => {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(malformedJsonResponse()),
Promise.resolve({
ok: true,
json: () => Promise.reject(new SyntaxError("Unexpected token")),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
const tool = createCodeExecutionTool({
@@ -219,7 +209,10 @@ describe("xai code_execution tool", () => {
it("rejects code_execution success JSON without answer text", async () => {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(jsonResponse({ output: [{ type: "code_interpreter_call" }] })),
Promise.resolve({
ok: true,
json: () => Promise.resolve({ output: [{ type: "code_interpreter_call" }] }),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
const tool = createCodeExecutionTool({

View File

@@ -83,30 +83,20 @@ const {
resolveXaiWebSearchTimeoutSeconds,
} = testing;
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
function textResponse(body: string, init: ResponseInit = {}): Response {
return new Response(body, init);
}
function installXaiWebSearchFetch() {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(
jsonResponse({
output: [
{
type: "message",
content: [{ type: "output_text", text: "Grounded Grok answer" }],
},
],
}),
),
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
output: [
{
type: "message",
content: [{ type: "output_text", text: "Grounded Grok answer" }],
},
],
}),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
@@ -374,17 +364,24 @@ describe("xai web search config resolution", () => {
});
const mockFetch = vi
.fn()
.mockResolvedValueOnce(textResponse("expired", { status: 401, statusText: "Unauthorized" }))
.mockResolvedValueOnce(
jsonResponse({
output: [
{
type: "message",
content: [{ type: "output_text", text: "Fresh OAuth Grok answer" }],
},
],
}),
);
.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: "Unauthorized",
text: () => Promise.resolve("expired"),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
output: [
{
type: "message",
content: [{ type: "output_text", text: "Fresh OAuth Grok answer" }],
},
],
}),
} as Response);
global.fetch = withFetchPreconnect(mockFetch);
const provider = createXaiWebSearchProvider();
const tool = provider.createTool({
@@ -443,17 +440,24 @@ describe("xai web search config resolution", () => {
});
const mockFetch = vi
.fn()
.mockResolvedValueOnce(textResponse("revoked", { status: 401, statusText: "Unauthorized" }))
.mockResolvedValueOnce(
jsonResponse({
output: [
{
type: "message",
content: [{ type: "output_text", text: "API key fallback Grok answer" }],
},
],
}),
);
.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: "Unauthorized",
text: () => Promise.resolve("revoked"),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
output: [
{
type: "message",
content: [{ type: "output_text", text: "API key fallback Grok answer" }],
},
],
}),
} as Response);
global.fetch = withFetchPreconnect(mockFetch);
const provider = createXaiWebSearchProvider();
const tool = provider.createTool({
@@ -538,17 +542,24 @@ describe("xai web search config resolution", () => {
});
const mockFetch = vi
.fn()
.mockResolvedValueOnce(textResponse("revoked", { status: 401, statusText: "Unauthorized" }))
.mockResolvedValueOnce(
jsonResponse({
output: [
{
type: "message",
content: [{ type: "output_text", text: "Profile API key Grok answer" }],
},
],
}),
);
.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: "Unauthorized",
text: () => Promise.resolve("revoked"),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
output: [
{
type: "message",
content: [{ type: "output_text", text: "Profile API key Grok answer" }],
},
],
}),
} as Response);
global.fetch = withFetchPreconnect(mockFetch);
const provider = createXaiWebSearchProvider();
const tool = provider.createTool({
@@ -599,19 +610,24 @@ describe("xai web search config resolution", () => {
});
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
textResponse("stale api key", { status: 401, statusText: "Unauthorized" }),
)
.mockResolvedValueOnce(
jsonResponse({
output: [
{
type: "message",
content: [{ type: "output_text", text: "Env fallback Grok answer" }],
},
],
}),
);
.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: "Unauthorized",
text: () => Promise.resolve("stale api key"),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
output: [
{
type: "message",
content: [{ type: "output_text", text: "Env fallback Grok answer" }],
},
],
}),
} as Response);
global.fetch = withFetchPreconnect(mockFetch);
const provider = createXaiWebSearchProvider();
const tool = provider.createTool({
@@ -832,12 +848,10 @@ describe("xai web search config resolution", () => {
it("reports malformed xAI web search JSON as a provider error", async () => {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(
new Response("{ nope", {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
Promise.resolve({
ok: true,
json: () => Promise.reject(new SyntaxError("Unexpected token")),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
const provider = createXaiWebSearchProvider();
@@ -867,7 +881,10 @@ describe("xai web search config resolution", () => {
it("rejects xAI web search success JSON without answer text", async () => {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(jsonResponse({ output: [] })),
Promise.resolve({
ok: true,
json: () => Promise.resolve({ output: [] }),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
const provider = createXaiWebSearchProvider();

View File

@@ -3,35 +3,29 @@ import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createXSearchTool } from "./x-search.js";
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
function installXSearchFetch(payload?: Record<string, unknown>) {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(
jsonResponse(
payload ?? {
output: [
{
type: "message",
content: [
{
type: "output_text",
text: "Found X posts",
annotations: [{ type: "url_citation", url: "https://x.com/openclaw/status/1" }],
},
],
},
],
citations: ["https://x.com/openclaw/status/1"],
},
),
),
Promise.resolve({
ok: true,
json: () =>
Promise.resolve(
payload ?? {
output: [
{
type: "message",
content: [
{
type: "output_text",
text: "Found X posts",
annotations: [{ type: "url_citation", url: "https://x.com/openclaw/status/1" }],
},
],
},
],
citations: ["https://x.com/openclaw/status/1"],
},
),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
@@ -327,12 +321,10 @@ describe("xai x_search tool", () => {
it("reports malformed x_search JSON as a provider error", async () => {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(
new Response("{ nope", {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
Promise.resolve({
ok: true,
json: () => Promise.reject(new SyntaxError("Unexpected token")),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
const tool = createXSearchTool({
@@ -363,7 +355,10 @@ describe("xai x_search tool", () => {
it("rejects x_search success JSON without answer text", async () => {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve(jsonResponse({ output: [] })),
Promise.resolve({
ok: true,
json: () => Promise.resolve({ output: [] }),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
const tool = createXSearchTool({

39
pnpm-lock.yaml generated
View File

@@ -313,8 +313,8 @@ importers:
specifier: 0.15.0
version: 0.15.0
acpx:
specifier: 0.11.2
version: 0.11.2
specifier: 0.10.0
version: 0.10.0
zod:
specifier: 4.4.3
version: 4.4.3
@@ -1995,11 +1995,6 @@ packages:
peerDependencies:
zod: ^3.25.0 || ^4.0.0
'@agentclientprotocol/sdk@0.28.1':
resolution: {integrity: sha512-Z2Frs6YtPhnZZ+XwFXyQkRDXY0fn8FjCalEs0W4yUhQnY4TztmNq0/RnfzWdFN3vqT3h0jTz5klzYbZHGxCDyQ==}
peerDependencies:
zod: ^3.25.0 || ^4.0.0
'@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156':
resolution: {integrity: sha512-IkjcS9dqAUlD4Nb62L9AZtmAXCa+FV4ul8lIlyXXUprh3nlecbKsWOXVd/GORrzAhMmynJaX4+iV1JiutFKXUA==}
cpu: [arm64]
@@ -4611,8 +4606,8 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acpx@0.11.2:
resolution: {integrity: sha512-ksTmfJDVqUAJJXsNDamEno03AMZ/aAZzXk/h5nt61VsLc/jcpoDMfCVpErzuYNJjwCd0V6Zm5o6F8OoqxsjQWA==}
acpx@0.10.0:
resolution: {integrity: sha512-hd48XV03gG3sd409T1lDrOKJTTz1ap4g0wrndXjxQ590tN85pBYlvfNLyerybvGRrtUGsZjNdt99r1jpIt6ukA==}
engines: {node: '>=22.13.0'}
hasBin: true
@@ -5017,10 +5012,6 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@15.0.0:
resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==}
engines: {node: '>=22.12.0'}
commander@5.1.0:
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
engines: {node: '>= 6'}
@@ -7160,8 +7151,8 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
skillflag@0.2.0:
resolution: {integrity: sha512-7ZmEpBeEoPLc+hqZ/StAnCO/hulgEPANzPyZgOM/CZ5zc3b0ApSp3URavY5POM/OKyi5d9+UC/Q21OoiYC2kJw==}
skillflag@0.1.4:
resolution: {integrity: sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==}
engines: {node: '>=18'}
hasBin: true
@@ -7893,10 +7884,6 @@ snapshots:
dependencies:
zod: 4.4.3
'@agentclientprotocol/sdk@0.28.1(zod@4.4.3)':
dependencies:
zod: 4.4.3
'@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156':
optional: true
@@ -10656,11 +10643,11 @@ snapshots:
acorn@8.16.0: {}
acpx@0.11.2:
acpx@0.10.0:
dependencies:
'@agentclientprotocol/sdk': 0.28.1(zod@4.4.3)
commander: 15.0.0
skillflag: 0.2.0
'@agentclientprotocol/sdk': 0.22.1(zod@4.4.3)
commander: 14.0.3
skillflag: 0.1.4
tsx: 4.22.4
zod: 4.4.3
transitivePeerDependencies:
@@ -11067,8 +11054,6 @@ snapshots:
commander@14.0.3: {}
commander@15.0.0: {}
commander@5.1.0: {}
constantinople@4.0.1:
@@ -13769,9 +13754,9 @@ snapshots:
sisteransi@1.0.5: {}
skillflag@0.2.0:
skillflag@0.1.4:
dependencies:
'@clack/prompts': 1.4.0
'@clack/prompts': 1.5.1
tar-stream: 3.2.0
transitivePeerDependencies:
- bare-abort-controller

View File

@@ -471,16 +471,9 @@ function parseRepeatableFlag(flag: string): string[] {
}
function validateCliArgs(argv: readonly string[] = process.argv.slice(2)): void {
const seenSingleValueFlags = new Set<string>();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (VALUE_FLAGS.has(arg)) {
if (arg !== "--case") {
if (seenSingleValueFlags.has(arg)) {
throw new Error(`${arg} was provided more than once`);
}
seenSingleValueFlags.add(arg);
}
const value = argv[index + 1];
if (!value || value.startsWith("-")) {
throw new Error(`${arg} requires a value`);
@@ -540,12 +533,7 @@ function parsePresets(raw: string | undefined): string[] {
function resolveCases(options: { presets: string[]; caseIds: string[] }): CommandCase[] {
const byId = new Map(COMMAND_CASES.map((commandCase) => [commandCase.id, commandCase]));
if (options.caseIds.length > 0) {
const seenIds = new Set<string>();
return options.caseIds.map((id) => {
if (seenIds.has(id)) {
throw new Error(`Duplicate --case "${id}"`);
}
seenIds.add(id);
const commandCase = byId.get(id);
if (!commandCase) {
throw new Error(`Unknown --case "${id}"`);

View File

@@ -253,19 +253,12 @@ function readRequiredFlagValue(argv: string[], index: number, flag: string): str
}
function validateCliArgs(argv: string[]): void {
const seenSingleValueFlags = new Set<string>();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index] ?? "";
if (BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (VALUE_FLAGS.has(arg)) {
if (arg !== "--case") {
if (seenSingleValueFlags.has(arg)) {
throw new CliArgumentError(`${arg} was provided more than once`);
}
seenSingleValueFlags.add(arg);
}
readRequiredFlagValue(argv, index, arg);
index += 1;
continue;
@@ -344,13 +337,8 @@ function resolveCases(caseIds: string[]): GatewayBenchCase[] {
if (caseIds.length === 0) {
return [GATEWAY_CASES[0]];
}
const seenIds = new Set<string>();
const byId = new Map(GATEWAY_CASES.map((benchCase) => [benchCase.id, benchCase]));
return caseIds.map((id) => {
if (seenIds.has(id)) {
throw new CliArgumentError(`Duplicate --case "${id}"`);
}
seenIds.add(id);
const benchCase = byId.get(id);
if (!benchCase) {
throw new Error(`Unknown --case "${id}"`);

View File

@@ -206,19 +206,12 @@ function readRequiredFlagValue(argv: string[], index: number, flag: string): str
}
function validateCliArgs(argv: string[]): void {
const seenSingleValueFlags = new Set<string>();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index] ?? "";
if (BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (VALUE_FLAGS.has(arg)) {
if (arg !== "--case") {
if (seenSingleValueFlags.has(arg)) {
throw new CliArgumentError(`${arg} was provided more than once`);
}
seenSingleValueFlags.add(arg);
}
readRequiredFlagValue(argv, index, arg);
index += 1;
continue;
@@ -289,13 +282,6 @@ function resolveCases(caseIds: string[]): GatewayBenchCase[] {
if (caseIds.length === 0) {
return [...GATEWAY_CASES];
}
const seenIds = new Set<string>();
for (const id of caseIds) {
if (seenIds.has(id)) {
throw new CliArgumentError(`Duplicate --case "${id}"`);
}
seenIds.add(id);
}
const byId = new Map(GATEWAY_CASES.map((benchCase) => [benchCase.id, benchCase]));
return caseIds.map((id) => {
const benchCase = byId.get(id);

View File

@@ -40,17 +40,12 @@ function readValue(argv: string[], index: number, flag: string): string {
}
function validateCliArgs(argv: string[]): void {
const seenValueFlags = new Set<string>();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index] ?? "";
if (BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (VALUE_FLAGS.has(arg)) {
if (seenValueFlags.has(arg)) {
throw new CliArgumentError(`${arg} was provided more than once`);
}
seenValueFlags.add(arg);
readValue(argv, index, arg);
index += 1;
continue;

View File

@@ -134,17 +134,12 @@ function hasFlag(flag: string, argv = process.argv.slice(2)): boolean {
}
function validateArgs(argv: string[]): void {
const seenValueFlags = new Set<string>();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index] ?? "";
if (BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (VALUE_FLAGS.has(arg)) {
if (seenValueFlags.has(arg)) {
throw new CliUsageError(`${arg} was provided more than once`);
}
seenValueFlags.add(arg);
const value = argv[index + 1];
if (!value || value.startsWith("-")) {
throw new CliUsageError(`${arg} requires a value`);

View File

@@ -31,9 +31,7 @@ function positiveIntegerFlag(flag, key) {
throw new Error(`${flag} requires a value`);
}
return {
flag,
nextIndex: index + 1,
repeatable: false,
apply(target) {
target[key] = parsePositiveInteger(rawValue, flag);
},

View File

@@ -247,7 +247,6 @@ function buildPrivateQaEnv(env, qaState) {
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1",
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD ?? "1",
OPENCLAW_TEST_DISABLE_UPDATE_CHECK: env.OPENCLAW_TEST_DISABLE_UPDATE_CHECK ?? "1",
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: env.PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN ?? "false",
};
}
@@ -265,11 +264,7 @@ function createQaState(outputDir) {
async function runGatewayCpuScenarios(options, params = {}) {
const repoRoot = params.cwd ?? process.cwd();
const inputEnv = params.env ?? process.env;
const baseEnv = {
...inputEnv,
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: inputEnv.PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN ?? "false",
};
const baseEnv = params.env ?? process.env;
fs.mkdirSync(options.outputDir, { recursive: true });
const startupOutput = path.join(options.outputDir, "gateway-startup-bench.json");
@@ -289,7 +284,7 @@ async function runGatewayCpuScenarios(options, params = {}) {
"startup build",
process.execPath,
["scripts/ensure-cli-startup-build.mjs"],
{ env: baseEnv },
{},
params,
);
steps.push(startupBuild);
@@ -310,7 +305,7 @@ async function runGatewayCpuScenarios(options, params = {}) {
startupOutput,
...options.startupCases.flatMap((id) => ["--case", id]),
],
{ env: baseEnv },
{},
params,
)
: { name: "startup bench", signal: null, status: 1 },

View File

@@ -176,7 +176,7 @@ export function parseArgs(argv) {
const arg = args[i];
const next = args[i + 1];
const readValue = () => {
if (!next || next.startsWith("-")) {
if (!next) {
throw new Error(`Missing value for ${arg}`);
}
i += 1;

View File

@@ -190,8 +190,6 @@ export function parseArgs(argv) {
if (options.qaScenarios.length === 0) {
options.qaScenarios = [...DEFAULT_QA_SCENARIOS];
}
assertNoDuplicateValues(options.pluginIds, "--plugin");
assertNoDuplicateValues(options.qaScenarios, "--qa-scenario");
return options;
}
@@ -246,20 +244,6 @@ function normalizeCsv(raw) {
: [];
}
function assertNoDuplicateValues(values, label) {
const seen = new Set();
for (const value of values) {
const normalized = value.trim();
if (!normalized) {
continue;
}
if (seen.has(normalized)) {
throw new Error(`Duplicate ${label} value: ${normalized}`);
}
seen.add(normalized);
}
}
function readOptionalPositiveIntEnv(name) {
const raw = process.env[name];
return raw ? parsePositiveInt(raw, name) : undefined;

View File

@@ -249,46 +249,38 @@ export function parseArgs(argv) {
jsonPath: null,
markdownPath: null,
};
const seen = new Set();
const setOnce = (flag, key, value) => {
if (seen.has(flag)) {
throw new Error(`${flag} was provided more than once.`);
}
seen.add(flag);
options[key] = value;
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
continue;
}
if (arg === "--root") {
setOnce(arg, "rootDir", readRequiredValue(argv, index, "--root"));
options.rootDir = readRequiredValue(argv, index, "--root");
index += 1;
continue;
}
if (arg === "--base-ref") {
setOnce(arg, "baseRef", readRequiredValue(argv, index, "--base-ref"));
options.baseRef = readRequiredValue(argv, index, "--base-ref");
index += 1;
continue;
}
if (arg === "--base-lockfile") {
setOnce(arg, "baseLockfile", readRequiredValue(argv, index, "--base-lockfile"));
options.baseLockfile = readRequiredValue(argv, index, "--base-lockfile");
index += 1;
continue;
}
if (arg === "--head-lockfile") {
setOnce(arg, "headLockfile", readRequiredValue(argv, index, "--head-lockfile"));
options.headLockfile = readRequiredValue(argv, index, "--head-lockfile");
index += 1;
continue;
}
if (arg === "--json") {
setOnce(arg, "jsonPath", readRequiredValue(argv, index, "--json"));
options.jsonPath = readRequiredValue(argv, index, "--json");
index += 1;
continue;
}
if (arg === "--markdown") {
setOnce(arg, "markdownPath", readRequiredValue(argv, index, "--markdown"));
options.markdownPath = readRequiredValue(argv, index, "--markdown");
index += 1;
continue;
}
@@ -297,9 +289,6 @@ export function parseArgs(argv) {
if (!options.baseRef && !options.baseLockfile) {
throw new Error("Expected --base-ref <git-ref> or --base-lockfile <path>.");
}
if (options.baseRef && options.baseLockfile) {
throw new Error("Use either --base-ref or --base-lockfile, not both.");
}
return options;
}

View File

@@ -412,14 +412,6 @@ export function parseArgs(argv) {
jsonPath: null,
markdownPath: null,
};
const seen = new Set();
const setOnce = (flag, key, value) => {
if (seen.has(flag)) {
throw new Error(`${flag} was provided more than once.`);
}
seen.add(flag);
options[key] = value;
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
@@ -430,10 +422,6 @@ export function parseArgs(argv) {
continue;
}
if (arg === "--json") {
if (seen.has(arg)) {
throw new Error(`${arg} was provided more than once.`);
}
seen.add(arg);
options.asJson = true;
if (argv[index + 1] && !argv[index + 1].startsWith("-")) {
options.jsonPath = argv[++index];
@@ -441,7 +429,7 @@ export function parseArgs(argv) {
continue;
}
if (arg === "--markdown") {
setOnce(arg, "markdownPath", readArtifactPath(argv, index, arg));
options.markdownPath = readArtifactPath(argv, index, arg);
index += 1;
continue;
}

View File

@@ -49,14 +49,9 @@ function usage(): string {
}
function validateArgs(argv: readonly string[]): void {
const seen = new Set<string>();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index] ?? "";
if (BOOLEAN_FLAGS.has(arg)) {
if (seen.has(arg)) {
throw new GatewaySmokeArgError(`${arg} was provided more than once`);
}
seen.add(arg);
continue;
}
if (VALUE_FLAGS.has(arg)) {
@@ -64,10 +59,6 @@ function validateArgs(argv: readonly string[]): void {
if (!value || value.startsWith("-")) {
throw new GatewaySmokeArgError(`${arg} requires a value`);
}
if (seen.has(arg)) {
throw new GatewaySmokeArgError(`${arg} was provided more than once`);
}
seen.add(arg);
index += 1;
continue;
}

View File

@@ -382,19 +382,6 @@ function runInfo(runId, repo) {
);
}
function safePathSegment(value) {
return (
String(value ?? "")
.replace(/[^a-zA-Z0-9_.-]+/gu, "-")
.replace(/^-+|-+$/gu, "")
.slice(0, 80) || "run"
);
}
function defaultOutputDir(input) {
return fs.mkdtempSync(path.join(os.tmpdir(), `openclaw-docker-e2e-rerun-${safePathSegment(input)}-`));
}
function printEntries(entries, ref, workflow, runValue) {
if (runValue) {
console.log(`Run: ${runValue.url}`);
@@ -470,7 +457,8 @@ function main() {
const repo = options.repo || detectRepo();
const runLocal = runInfo(options.input, repo);
const ref = options.ref || runLocal.headSha || runLocal.headBranch;
const outputDir = options.dir || defaultOutputDir(options.input);
const outputDir =
options.dir || path.join(os.tmpdir(), `openclaw-docker-e2e-rerun-${options.input}`);
const artifactNames = downloadDockerArtifacts(options.input, repo, outputDir);
const files = findFiles(outputDir, new Set(["failures.json", "summary.json"]));
const entries = mergeByLane(

View File

@@ -25,8 +25,6 @@ function parseArgs(argv) {
options.limit = readLimit(argv[(index += 1)]);
} else if (arg?.startsWith("--limit=")) {
options.limit = readLimit(arg.slice("--limit=".length));
} else if (arg?.startsWith("-")) {
throw new Error(`unknown argument: ${arg}\n${usage()}`);
} else if (!options.file) {
options.file = arg;
} else {

View File

@@ -35,9 +35,9 @@ set -e
if [ "$status" -ne 0 ]; then
echo "Docker OpenClaw bundle MCP tool availability smoke failed"
docker_e2e_print_log "$RUN_LOG"
cat "$RUN_LOG"
exit "$status"
fi
docker_e2e_print_log "$RUN_LOG"
cat "$RUN_LOG"
echo "OK"

View File

@@ -31,7 +31,7 @@ set -e
if [ "$status" -ne 0 ]; then
echo "Docker commitments safety smoke failed"
docker_e2e_print_log "$RUN_LOG"
cat "$RUN_LOG"
exit "$status"
fi

View File

@@ -35,9 +35,9 @@ set -e
if [ "$status" -ne 0 ]; then
echo "Docker Crestodian first-run smoke failed"
docker_e2e_print_log "$RUN_LOG"
cat "$RUN_LOG"
exit "$status"
fi
docker_e2e_print_log "$RUN_LOG"
cat "$RUN_LOG"
echo "OK"

View File

@@ -35,9 +35,9 @@ set -e
if [ "$status" -ne 0 ]; then
echo "Docker Crestodian planner fallback smoke failed"
docker_e2e_print_log "$RUN_LOG"
cat "$RUN_LOG"
exit "$status"
fi
docker_e2e_print_log "$RUN_LOG"
cat "$RUN_LOG"
echo "OK"

View File

@@ -35,9 +35,9 @@ set -e
if [ "$status" -ne 0 ]; then
echo "Docker Crestodian rescue smoke failed"
docker_e2e_print_log "$RUN_LOG"
cat "$RUN_LOG"
exit "$status"
fi
docker_e2e_print_log "$RUN_LOG"
cat "$RUN_LOG"
echo "OK"

View File

@@ -51,7 +51,6 @@ export function validateMcpCodeModeResult(
if (options.requireExec) {
assert(options.plannedTools?.includes("exec"), "agent did not call code-mode exec");
}
assert(mentions.apiFileList > 0, "session log lacks API.list usage");
assert(mentions.apiFileRead > 0, "session log lacks API.read usage");
assert(mentions.mcpNamespace > 0, "session log lacks MCP.fixture usage");
assert(mentions.mcpTool > 0, "session log lacks fixture__lookup_note call");

View File

@@ -109,17 +109,14 @@ export function windowsProviderOnlyPluginIsolationScript(options: PluginIsolatio
return `$env:OPENCLAW_PARALLELS_PLUGIN_ISOLATION = @'
${payloadJson}
'@
$isolationScriptPath = Join-Path ([System.IO.Path]::GetTempPath()) ('openclaw-parallels-plugin-isolation-' + [guid]::NewGuid().ToString('N') + '.cjs')
try {
$isolationScriptPath = Join-Path ([System.IO.Path]::GetTempPath()) 'openclaw-parallels-plugin-isolation.cjs'
@'
${providerOnlyPluginIsolationNodeSource()}
'@ | Set-Content -Path $isolationScriptPath -Encoding UTF8
node.exe $isolationScriptPath
if ($LASTEXITCODE -ne 0) { throw "plugin isolation failed with exit code $LASTEXITCODE" }
} finally {
Remove-Item $isolationScriptPath -Force -ErrorAction SilentlyContinue
Remove-Item Env:OPENCLAW_PARALLELS_PLUGIN_ISOLATION -Force -ErrorAction SilentlyContinue
}`;
Remove-Item $isolationScriptPath -Force -ErrorAction SilentlyContinue
Remove-Item Env:OPENCLAW_PARALLELS_PLUGIN_ISOLATION -Force -ErrorAction SilentlyContinue`;
}
function providerOnlyPluginIsolationNodeScript(options: PluginIsolationOptions): string {

View File

@@ -190,9 +190,6 @@ export function parsePlatformList(value: string): Set<Platform> {
const result = new Set<Platform>();
for (const entry of normalized.split(",")) {
if (entry === "macos" || entry === "windows" || entry === "linux") {
if (result.has(entry)) {
die(`duplicate --platform entry: ${entry}`);
}
result.add(entry);
} else {
die(`invalid --platform entry: ${entry}`);

View File

@@ -40,24 +40,16 @@ set -e
if [ "$status" -ne 0 ]; then
echo "Docker plugin binding command escape smoke failed"
docker_e2e_print_log "$RUN_LOG"
cat "$RUN_LOG"
exit "$status"
fi
if ! node - "$RUN_LOG" <<'NODE'
const fs = require("node:fs");
const logPath = process.argv[2];
const scanBytes = 65536;
const stat = fs.statSync(logPath);
const length = Math.min(stat.size, scanBytes);
const buffer = Buffer.alloc(length);
const fd = fs.openSync(logPath, "r");
try {
fs.readSync(fd, buffer, 0, length, stat.size - length);
} finally {
fs.closeSync(fd);
}
const text = buffer.toString("utf8").replace(/\x1B\[[0-?]*[ -/]*[@-~]/gu, "");
const text = fs
.readFileSync(logPath, "utf8")
.replace(/\x1B\[[0-?]*[ -/]*[@-~]/gu, "");
if (!/(?:^|\n)\s*Tests\s+3 passed\b/u.test(text)) {
console.error("expected focused Vitest summary for exactly 3 passed tests");
@@ -67,7 +59,7 @@ if (!/(?:^|\n)\s*Tests\s+3 passed\b/u.test(text)) {
NODE
then
echo "Docker plugin binding command escape smoke did not stay focused"
docker_e2e_print_log "$RUN_LOG"
cat "$RUN_LOG"
exit 1
fi

View File

@@ -32,7 +32,7 @@ set -e
if [ "$status" -ne 0 ]; then
echo "Docker session runtime context smoke failed"
docker_e2e_print_log "$RUN_LOG"
cat "$RUN_LOG"
exit "$status"
fi

View File

@@ -326,20 +326,13 @@ export function parseArgs(argvInput: string[]): Options {
argv = argv.slice(0, commandSeparator);
}
let expectWasPassed = false;
const seenSingleValueOptions = new Set<string>();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = (options: { repeatable?: boolean } = {}) => {
const readValue = () => {
const value = argv[index + 1];
if (isMissingOptionValue(value)) {
usage();
}
if (!options.repeatable) {
if (seenSingleValueOptions.has(arg)) {
throw new Error(`${arg} was provided more than once`);
}
seenSingleValueOptions.add(arg);
}
index += 1;
return value;
};
@@ -358,7 +351,7 @@ export function parseArgs(argvInput: string[]): Options {
opts.expect = [];
expectWasPassed = true;
}
opts.expect.push(readValue({ repeatable: true }));
opts.expect.push(readValue());
} else if (arg === "--gateway-port") {
opts.gatewayPort = parseTcpPort(readValue(), "--gateway-port");
} else if (arg === "--id") {
@@ -2004,7 +1997,6 @@ const FULL_ARTIFACT_JSON_NAMES = new Set([
"telegram-user-crabbox-session-summary.json",
]);
const FULL_ARTIFACT_FILE_EXTENSIONS = new Set([".gif", ".log", ".md", ".mp4", ".png"]);
const FULL_ARTIFACT_PROOF_REPORT = "telegram-user-crabbox-proof.md";
const TIMESTAMPED_PROBE_ARTIFACT_JSON = /^probe-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.json$/u;
function isFullArtifactJsonName(name: string) {
@@ -2012,10 +2004,6 @@ function isFullArtifactJsonName(name: string) {
}
export function stageFullSessionArtifacts(outputDir: string) {
if (!fs.existsSync(path.join(outputDir, FULL_ARTIFACT_PROOF_REPORT))) {
throw new Error(`Missing proof report. Run finish first: ${FULL_ARTIFACT_PROOF_REPORT}`);
}
const publishDir = path.join(outputDir, "publish-full-artifacts");
fs.rmSync(publishDir, { force: true, recursive: true });
fs.mkdirSync(publishDir, { recursive: true });

View File

@@ -34,16 +34,6 @@ type Options = {
quiet: boolean;
};
const VALUE_FLAGS = new Set([
"--iters",
"--batches",
"--snap-dir",
"--mode",
"--max-rss-growth-mb",
"--max-tracked-retention",
"--scope-bytes",
]);
function readValue(raw: string | undefined, flag: string): string {
const value = raw?.trim() ?? "";
if (!value || value.startsWith("-")) {
@@ -63,16 +53,9 @@ function parseArgs(argv: string[]): Options {
scopeBytes: 2_000_000,
quiet: false,
};
const seenValueFlags = new Set<string>();
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = argv[i + 1];
if (VALUE_FLAGS.has(arg)) {
if (seenValueFlags.has(arg)) {
fail(`${arg} was provided more than once`);
}
seenValueFlags.add(arg);
}
switch (arg) {
case "--iters":
opts.iters = parsePositiveInt(next, arg);

View File

@@ -414,14 +414,6 @@ export function parseArgs(argv) {
githubOutput: process.env.GITHUB_OUTPUT,
githubStepSummary: process.env.GITHUB_STEP_SUMMARY,
};
const seen = new Set();
const setOnce = (flag, key, value) => {
if (seen.has(flag)) {
throw new Error(`${flag} was provided more than once.`);
}
seen.add(flag);
options[key] = value;
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
@@ -431,37 +423,37 @@ export function parseArgs(argv) {
return { ...options, help: true };
}
if (arg === "--root") {
setOnce(arg, "rootDir", readOptionValue(argv, index, arg));
options.rootDir = readOptionValue(argv, index, arg);
index += 1;
continue;
}
if (arg === "--output-dir") {
setOnce(arg, "outputDir", readOptionValue(argv, index, arg));
options.outputDir = readOptionValue(argv, index, arg);
index += 1;
continue;
}
if (arg === "--release-ref") {
setOnce(arg, "releaseRef", readOptionValue(argv, index, arg));
options.releaseRef = readOptionValue(argv, index, arg);
index += 1;
continue;
}
if (arg === "--npm-dist-tag") {
setOnce(arg, "npmDistTag", readOptionValue(argv, index, arg));
options.npmDistTag = readOptionValue(argv, index, arg);
index += 1;
continue;
}
if (arg === "--base-ref") {
setOnce(arg, "baseRef", readOptionValue(argv, index, arg));
options.baseRef = readOptionValue(argv, index, arg);
index += 1;
continue;
}
if (arg === "--github-output") {
setOnce(arg, "githubOutput", readOptionValue(argv, index, arg, { allowEmpty: true }));
options.githubOutput = readOptionValue(argv, index, arg, { allowEmpty: true });
index += 1;
continue;
}
if (arg === "--github-step-summary") {
setOnce(arg, "githubStepSummary", readOptionValue(argv, index, arg, { allowEmpty: true }));
options.githubStepSummary = readOptionValue(argv, index, arg, { allowEmpty: true });
index += 1;
continue;
}

View File

@@ -143,9 +143,7 @@ export function stringFlag(flag, key, options = {}) {
return null;
}
return {
flag,
nextIndex: option.nextIndex,
repeatable: false,
apply(target) {
target[key] = option.value;
},
@@ -163,9 +161,7 @@ export function stringListFlag(flag, key, options = {}) {
return null;
}
return {
flag,
nextIndex: option.nextIndex,
repeatable: true,
apply(target) {
target[key] ??= [];
target[key].push(option.value);
@@ -175,7 +171,7 @@ export function stringListFlag(flag, key, options = {}) {
};
}
function createAssignedValueFlag(flag, consumeOption) {
function createAssignedValueFlag(consumeOption) {
return {
consume(argv, index, args) {
const option = consumeOption(argv, index, args);
@@ -183,9 +179,7 @@ function createAssignedValueFlag(flag, consumeOption) {
return null;
}
return {
flag,
nextIndex: option.nextIndex,
repeatable: false,
apply(target) {
target[option.key] = option.value;
},
@@ -196,7 +190,7 @@ function createAssignedValueFlag(flag, consumeOption) {
/** Create a flag spec that parses and assigns a safe integer value. */
export function intFlag(flag, key, options) {
return createAssignedValueFlag(flag, (argv, index) => {
return createAssignedValueFlag((argv, index) => {
const option = consumeIntFlag(argv, index, flag, options);
return option ? { ...option, key } : null;
});
@@ -204,7 +198,7 @@ export function intFlag(flag, key, options) {
/** Create a flag spec that parses and assigns a finite floating-point value. */
export function floatFlag(flag, key, options) {
return createAssignedValueFlag(flag, (argv, index) => {
return createAssignedValueFlag((argv, index) => {
const option = consumeFloatFlag(argv, index, flag, options);
return option ? { ...option, key } : null;
});
@@ -218,9 +212,7 @@ export function booleanFlag(flag, key, value = true) {
return null;
}
return {
flag,
nextIndex: index,
repeatable: false,
apply(target) {
target[key] = value;
},
@@ -232,7 +224,6 @@ export function booleanFlag(flag, key, value = true) {
/** Apply flag specs to argv and return the mutated parsed args object. */
export function parseFlagArgs(argv, args, specs, options = {}) {
const ignoreDoubleDash = options.ignoreDoubleDash ?? true;
const seenFlags = new Set();
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--" && ignoreDoubleDash) {
@@ -244,15 +235,6 @@ export function parseFlagArgs(argv, args, specs, options = {}) {
if (!option) {
continue;
}
if (typeof option.flag !== "string" || !option.flag) {
throw new Error("parseFlagArgs specs must declare a flag for consumed options");
}
if (option.repeatable !== true) {
if (seenFlags.has(option.flag)) {
throw new Error(`${option.flag} was provided more than once`);
}
seenFlags.add(option.flag);
}
option.apply(args);
i = option.nextIndex;
handled = true;

View File

@@ -29,9 +29,7 @@ export function budgetFloatFlag(flag, key) {
throw new Error(`${flag} requires a value`);
}
return {
flag,
nextIndex: index + 1,
repeatable: false,
apply(target) {
const parsed = parseBudgetNumber(value, flag);
if (parsed === null) {

View File

@@ -19,31 +19,23 @@ export function parseReportCliArgs(argv) {
jsonPath: null,
markdownPath: null,
};
const seen = new Set();
const setOnce = (flag, key, value) => {
if (seen.has(flag)) {
throw new Error(`${flag} was provided more than once.`);
}
seen.add(flag);
options[key] = value;
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
continue;
}
if (arg === "--root") {
setOnce(arg, "rootDir", readReportOptionValue(argv, index, arg));
options.rootDir = readReportOptionValue(argv, index, arg);
index += 1;
continue;
}
if (arg === "--json") {
setOnce(arg, "jsonPath", readReportOptionValue(argv, index, arg));
options.jsonPath = readReportOptionValue(argv, index, arg);
index += 1;
continue;
}
if (arg === "--markdown") {
setOnce(arg, "markdownPath", readReportOptionValue(argv, index, arg));
options.markdownPath = readReportOptionValue(argv, index, arg);
index += 1;
continue;
}

View File

@@ -56,18 +56,6 @@ function parsePositiveInt(value, flag) {
return parsed;
}
function parseMethodList(value) {
const methods = value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
const duplicate = methods.find((method, index) => methods.indexOf(method) !== index);
if (duplicate) {
throw new Error(`--methods contains duplicate gateway method: ${duplicate}`);
}
return methods;
}
export function parseArgs(argv) {
const args = {
help: false,
@@ -96,7 +84,10 @@ export function parseArgs(argv) {
continue;
}
if (arg === "--methods") {
args.methods = parseMethodList(readFlagValue(argv, index, arg));
args.methods = readFlagValue(argv, index, arg)
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
index += 1;
continue;
}

View File

@@ -133,45 +133,28 @@ export function parseArgs(argv) {
skipBuild: false,
sourceDir: ROOT_DIR,
};
const seen = new Set();
const setOnce = (flag, key, value) => {
if (seen.has(flag)) {
throw new Error(`${flag} was provided more than once`);
}
seen.add(flag);
options[key] = value;
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--output-dir") {
setOnce("--output-dir", "outputDir", readOptionValue(argv, index, arg));
options.outputDir = readOptionValue(argv, index, arg);
index += 1;
} else if (arg?.startsWith("--output-dir=")) {
setOnce(
"--output-dir",
"outputDir",
readEqualsOptionValue(arg.slice("--output-dir=".length), "--output-dir"),
);
options.outputDir = readEqualsOptionValue(arg.slice("--output-dir=".length), "--output-dir");
} else if (arg === "--output-name") {
setOnce("--output-name", "outputName", readOptionValue(argv, index, arg));
options.outputName = readOptionValue(argv, index, arg);
index += 1;
} else if (arg?.startsWith("--output-name=")) {
setOnce(
options.outputName = readEqualsOptionValue(
arg.slice("--output-name=".length),
"--output-name",
"outputName",
readEqualsOptionValue(arg.slice("--output-name=".length), "--output-name"),
);
} else if (arg === "--skip-build") {
setOnce(arg, "skipBuild", true);
options.skipBuild = true;
} else if (arg === "--source-dir") {
setOnce("--source-dir", "sourceDir", readOptionValue(argv, index, arg));
options.sourceDir = readOptionValue(argv, index, arg);
index += 1;
} else if (arg?.startsWith("--source-dir=")) {
setOnce(
"--source-dir",
"sourceDir",
readEqualsOptionValue(arg.slice("--source-dir=".length), "--source-dir"),
);
options.sourceDir = readEqualsOptionValue(arg.slice("--source-dir=".length), "--source-dir");
} else {
throw new Error(`unknown argument: ${arg}`);
}

View File

@@ -132,17 +132,12 @@ function parseNonNegativeInt(flag: string, fallback: number, args = process.argv
}
function validateCliArgs(args = process.argv.slice(2)): void {
const seenValueFlags = new Set<string>();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
if (BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (VALUE_FLAGS.has(arg)) {
if (seenValueFlags.has(arg)) {
throw new CliArgumentError(`${arg} was provided more than once`);
}
seenValueFlags.add(arg);
const value = args[index + 1];
if (!value || value.startsWith("-")) {
throw new CliArgumentError(`${arg} requires a value`);

View File

@@ -14,46 +14,7 @@ import {
} from "./lib/plugin-sdk-entries.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
function usage() {
return `Usage: node scripts/plugin-sdk-surface-report.mjs [--check]
Reports plugin SDK export surface metadata.
Options:
--check Fail when SDK surface budgets are exceeded.
-h, --help Show this help.
`;
}
function parseArgs(argv) {
const args = { check: false, help: false };
for (const arg of argv) {
if (arg === "--check") {
args.check = true;
continue;
}
if (arg === "--help" || arg === "-h") {
args.help = true;
continue;
}
throw new Error(`Unknown plugin SDK surface report option: ${arg}`);
}
return args;
}
let cliArgs;
try {
cliArgs = parseArgs(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
if (cliArgs.help) {
process.stdout.write(usage());
process.exit(0);
}
const checkOnly = cliArgs.check;
const checkOnly = process.argv.includes("--check");
const publicEntrypointSet = new Set(publicPluginSdkEntrypoints);
const localOnlyEntrypointSet = new Set(privateLocalOnlyPluginSdkEntrypoints);
const deprecatedPublicEntrypointSet = new Set(deprecatedPublicPluginSdkEntrypoints);

View File

@@ -136,6 +136,27 @@ wait_for_pr_head_sha() {
return 1
}
is_author_email_merge_error() {
local msg="$1"
printf '%s\n' "$msg" | rg -qi 'author.?email|email.*associated|associated.*email|invalid.*email'
}
merge_author_email_candidates() {
local reviewer="$1"
local reviewer_id="$2"
local gh_email
gh_email=$(gh api user --jq '.email // ""' 2>/dev/null || true)
local git_email
git_email=$(git config user.email 2>/dev/null || true)
printf '%s\n' \
"$gh_email" \
"$git_email" \
"${reviewer_id}+${reviewer}@users.noreply.github.com" \
"${reviewer}@users.noreply.github.com" | awk 'NF && !seen[$0]++'
}
pr_contributor_allows_human_trailers() {
local contrib="${1:-}"
local normalized

View File

@@ -187,7 +187,13 @@ merge_run() {
source .local/prep.env
local pr_meta_json
pr_meta_json=$(gh pr view "$pr" --json state,isDraft)
pr_meta_json=$(gh pr view "$pr" --json number,title,state,isDraft,author)
local pr_title
pr_title=$(printf '%s\n' "$pr_meta_json" | jq -r .title)
local pr_number
pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
local contrib
contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
local is_draft
is_draft=$(printf '%s\n' "$pr_meta_json" | jq -r .isDraft)
if [ "$is_draft" = "true" ]; then
@@ -195,6 +201,45 @@ merge_run() {
exit 1
fi
local reviewer
reviewer=$(gh api user --jq .login)
local reviewer_id
reviewer_id=$(gh api user --jq .id)
local contrib_coauthor_email="${COAUTHOR_EMAIL:-}"
if [ -z "$contrib_coauthor_email" ] || [ "$contrib_coauthor_email" = "null" ]; then
if contrib_coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then
:
else
contrib_coauthor_email=""
fi
fi
local reviewer_email_candidates=()
local reviewer_email_candidate
while IFS= read -r reviewer_email_candidate; do
[ -n "$reviewer_email_candidate" ] || continue
reviewer_email_candidates+=("$reviewer_email_candidate")
done < <(merge_author_email_candidates "$reviewer" "$reviewer_id")
if [ "${#reviewer_email_candidates[@]}" -eq 0 ]; then
echo "Unable to resolve a candidate merge author email for reviewer $reviewer"
exit 1
fi
local reviewer_email="${reviewer_email_candidates[0]}"
local reviewer_coauthor_email="${reviewer_id}+${reviewer}@users.noreply.github.com"
{
echo "Merged via squash."
echo
echo "Prepared head SHA: $PREP_HEAD_SHA"
if [ -n "$contrib_coauthor_email" ]; then
echo "Co-authored-by: $contrib <$contrib_coauthor_email>"
fi
echo "Co-authored-by: $reviewer <$reviewer_coauthor_email>"
echo "Reviewed-by: @$reviewer"
} > .local/merge-body.txt
delete_remote_pr_head_branch_after_merge() {
local head_json
head_json=$(gh pr view "$pr" --json headRefName,headRepository,headRepositoryOwner,isCrossRepository,maintainerCanModify)
@@ -224,19 +269,48 @@ merge_run() {
return 0
}
if ! gh pr merge "$pr" \
--rebase \
--match-head-commit "$PREP_HEAD_SHA" \
>.local/merge-output.log 2>&1
then
print_relevant_log_excerpt .local/merge-output.log
exit 1
run_merge_with_email() {
local email="$1"
local merge_output_file
merge_output_file=$(mktemp)
if gh pr merge "$pr" \
--squash \
--match-head-commit "$PREP_HEAD_SHA" \
--author-email "$email" \
--subject "$pr_title (#$pr_number)" \
--body-file .local/merge-body.txt \
>"$merge_output_file" 2>&1
then
rm -f "$merge_output_file"
return 0
fi
MERGE_ERR_MSG=$(cat "$merge_output_file")
print_relevant_log_excerpt "$merge_output_file"
rm -f "$merge_output_file"
return 1
}
local MERGE_ERR_MSG=""
local selected_merge_author_email="$reviewer_email"
if ! run_merge_with_email "$selected_merge_author_email"; then
if is_author_email_merge_error "$MERGE_ERR_MSG" && [ "${#reviewer_email_candidates[@]}" -ge 2 ]; then
selected_merge_author_email="${reviewer_email_candidates[1]}"
echo "Retrying merge once with fallback author email: $selected_merge_author_email"
run_merge_with_email "$selected_merge_author_email" || {
echo "Merge failed after fallback retry."
exit 1
}
else
echo "Merge failed."
exit 1
fi
fi
local state
state=$(gh pr view "$pr" --json state --jq .state)
if [ "$state" != "MERGED" ]; then
echo "Landing not finalized yet (state=$state), waiting up to 15 minutes..."
echo "Merge not finalized yet (state=$state), waiting up to 15 minutes..."
local i
for i in $(seq 1 90); do
sleep 10
@@ -252,20 +326,20 @@ merge_run() {
exit 1
fi
local landed_sha
landed_sha=$(gh pr view "$pr" --json mergeCommit --jq '.mergeCommit.oid')
if [ -z "$landed_sha" ] || [ "$landed_sha" = "null" ]; then
echo "Landed commit SHA missing."
local merge_sha
merge_sha=$(gh pr view "$pr" --json mergeCommit --jq '.mergeCommit.oid')
if [ -z "$merge_sha" ] || [ "$merge_sha" = "null" ]; then
echo "Merge commit SHA missing."
exit 1
fi
local repo_nwo
repo_nwo=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
local landed_sha_url=""
if gh api repos/:owner/:repo/commits/"$landed_sha" >/dev/null 2>&1; then
landed_sha_url="https://github.com/$repo_nwo/commit/$landed_sha"
local merge_sha_url=""
if gh api repos/:owner/:repo/commits/"$merge_sha" >/dev/null 2>&1; then
merge_sha_url="https://github.com/$repo_nwo/commit/$merge_sha"
else
echo "Landed commit is not resolvable via repository commit endpoint: $landed_sha"
echo "Merge commit is not resolvable via repository commit endpoint: $merge_sha"
exit 1
fi
@@ -284,16 +358,29 @@ merge_run() {
exit 1
fi
local commit_body
commit_body=$(gh api repos/:owner/:repo/commits/"$merge_sha" --jq .commit.message)
if [ -n "$contrib_coauthor_email" ]; then
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $contrib <" || { echo "Missing PR author co-author trailer"; exit 1; }
else
echo "Skipping PR author co-author trailer check for bot/app author $contrib."
fi
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $reviewer <" || { echo "Missing reviewer co-author trailer"; exit 1; }
local ok=0
local comment_output=""
local attempt
for attempt in 1 2 3; do
if comment_output=$(
{
echo "Merged via rebase."
echo "Merged via squash."
echo
echo "- Prepared head SHA: [$PREP_HEAD_SHA]($prep_sha_url)"
echo "- Landed commit: [$landed_sha]($landed_sha_url)"
echo "- Merge commit: [$merge_sha]($merge_sha_url)"
if pr_contributor_allows_human_trailers "$contrib"; then
echo
echo "Thanks @$contrib!"
fi
} | gh pr comment "$pr" -F - 2>&1
); then
ok=1
@@ -322,7 +409,8 @@ merge_run() {
pr_url=$(gh pr view "$pr" --json url --jq .url)
echo "merge-run complete for PR #$pr"
echo "landed commit: $landed_sha"
echo "merge commit: $merge_sha"
echo "merge author email: $selected_merge_author_email"
echo "completion comment: $comment_url"
echo "$pr_url"
}

View File

@@ -3,7 +3,6 @@
// Profiles peak RSS for built bundled plugin entrypoints and emits a JSON
// report suitable for extension memory budget review.
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -29,13 +28,6 @@ const parentSignalHandlers = new Map();
let parentSignalHandlersInstalled = false;
let parentSignalShutdownStarted = false;
function defaultJsonReportPath() {
return path.join(
os.tmpdir(),
`openclaw-extension-memory-${process.pid}-${Date.now()}-${randomUUID()}.json`,
);
}
function printHelp() {
console.log(`Usage: node scripts/profile-extension-memory.mjs [options]
@@ -437,7 +429,7 @@ async function main() {
const tmpHome = mkdtempSync(path.join(os.tmpdir(), "openclaw-extension-memory-"));
const hookPath = path.join(tmpHome, "measure-rss.mjs");
const jsonPath = options.jsonPath ?? defaultJsonReportPath();
const jsonPath = options.jsonPath ?? path.join(os.tmpdir(), "openclaw-extension-memory.json");
writeFileSync(
hookPath,

View File

@@ -44,17 +44,11 @@ export function parseQaE2eArgs(argv: readonly string[]): QaE2eArgs {
const args = argv[0] === "--" ? argv.slice(1) : argv;
let outputPath = "";
let positionalMode = false;
const setOutputPath = (value: string) => {
if (outputPath) {
throw new Error("qa:e2e output path was provided more than once");
}
outputPath = value;
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
if (positionalMode) {
if (!outputPath && arg.trim()) {
setOutputPath(arg.trim());
outputPath = arg.trim();
continue;
}
throw new Error(`Unexpected qa:e2e argument: ${arg}`);
@@ -71,7 +65,7 @@ export function parseQaE2eArgs(argv: readonly string[]): QaE2eArgs {
if (!inlineOutput) {
throw new Error("--output requires a value");
}
setOutputPath(inlineOutput);
outputPath = inlineOutput;
continue;
}
if (arg === "--output") {
@@ -79,7 +73,7 @@ export function parseQaE2eArgs(argv: readonly string[]): QaE2eArgs {
if (!value || value.startsWith("-")) {
throw new Error("--output requires a value");
}
setOutputPath(value);
outputPath = value;
index += 1;
continue;
}
@@ -89,7 +83,7 @@ export function parseQaE2eArgs(argv: readonly string[]): QaE2eArgs {
if (outputPath) {
throw new Error(`Unexpected qa:e2e argument: ${arg}`);
}
setOutputPath(arg.trim());
outputPath = arg.trim();
}
return outputPath ? { help: false, outputPath } : { help: false };
}

View File

@@ -84,31 +84,19 @@ function parseOptions(argv: readonly string[]): ProducerOptions {
let artifactBase = "";
let repoRoot = process.cwd();
let skipVisualProof = false;
const seen = new Set<string>();
const recordOnce = (flag: string) => {
if (seen.has(flag)) {
throw new Error(`${flag} was provided more than once`);
}
seen.add(flag);
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--artifact-base") {
const value = readOptionValue(argv, index, arg);
recordOnce(arg);
artifactBase = value;
artifactBase = readOptionValue(argv, index, arg);
index += 1;
continue;
}
if (arg === "--repo-root") {
const value = readOptionValue(argv, index, arg);
recordOnce(arg);
repoRoot = value;
repoRoot = readOptionValue(argv, index, arg);
index += 1;
continue;
}
if (arg === "--skip-visual-proof") {
recordOnce(arg);
skipVisualProof = true;
continue;
}

View File

@@ -87,72 +87,64 @@ export function parseArgs(argv) {
windowsNodeInstallerDigests: "",
outputDir: "",
};
const seen = new Set();
const setOnce = (flag, key, value) => {
if (seen.has(flag)) {
throw new Error(`${flag} was provided more than once`);
}
seen.add(flag);
options[key] = value;
};
parseArgv: for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
switch (arg) {
case "--":
break parseArgv;
case "--tag":
setOnce(arg, "tag", requireValue(args, ++index, arg));
options.tag = requireValue(args, ++index, arg);
break;
case "--workflow-ref":
setOnce(arg, "workflowRef", requireValue(args, ++index, arg));
options.workflowRef = requireValue(args, ++index, arg);
break;
case "--repo":
setOnce(arg, "repo", requireValue(args, ++index, arg));
options.repo = requireValue(args, ++index, arg);
break;
case "--full-release-run":
setOnce(arg, "fullReleaseRunId", requireValue(args, ++index, arg));
options.fullReleaseRunId = requireValue(args, ++index, arg);
break;
case "--npm-preflight-run":
setOnce(arg, "npmPreflightRunId", requireValue(args, ++index, arg));
options.npmPreflightRunId = requireValue(args, ++index, arg);
break;
case "--windows-node-tag":
setOnce(arg, "windowsNodeTag", requireValue(args, ++index, arg));
options.windowsNodeTag = requireValue(args, ++index, arg);
break;
case "--skip-dispatch":
setOnce(arg, "skipDispatch", true);
options.skipDispatch = true;
break;
case "--skip-local-generated-check":
setOnce(arg, "skipLocalGeneratedCheck", true);
options.skipLocalGeneratedCheck = true;
break;
case "--skip-parallels":
setOnce(arg, "skipParallels", true);
options.skipParallels = true;
break;
case "--skip-telegram":
setOnce(arg, "skipTelegram", true);
options.skipTelegram = true;
break;
case "--telegram-provider-mode":
setOnce(arg, "telegramProviderMode", requireValue(args, ++index, arg));
options.telegramProviderMode = requireValue(args, ++index, arg);
break;
case "--provider":
setOnce(arg, "provider", requireValue(args, ++index, arg));
options.provider = requireValue(args, ++index, arg);
break;
case "--mode":
setOnce(arg, "mode", requireValue(args, ++index, arg));
options.mode = requireValue(args, ++index, arg);
break;
case "--release-profile":
setOnce(arg, "releaseProfile", requireValue(args, ++index, arg));
options.releaseProfile = requireValue(args, ++index, arg);
break;
case "--npm-dist-tag":
setOnce(arg, "npmDistTag", requireValue(args, ++index, arg));
options.npmDistTag = requireValue(args, ++index, arg);
break;
case "--plugin-publish-scope":
setOnce(arg, "pluginPublishScope", requireValue(args, ++index, arg));
options.pluginPublishScope = requireValue(args, ++index, arg);
break;
case "--plugins":
setOnce(arg, "plugins", requireValue(args, ++index, arg));
options.plugins = requireValue(args, ++index, arg);
break;
case "--output-dir":
setOnce(arg, "outputDir", requireValue(args, ++index, arg));
options.outputDir = requireValue(args, ++index, arg);
break;
case "-h":
case "--help":

View File

@@ -96,14 +96,6 @@ export function parseArgs(argv) {
trustedSourceId: "",
trustedSourcePolicy: TRUSTED_PACKAGE_SOURCE_POLICY,
};
const seen = new Set();
const setOnce = (flag, key, value) => {
if (seen.has(flag)) {
throw new Error(`${flag} was provided more than once`);
}
seen.add(flag);
options[key] = value;
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = (name, readOptions = {}) => {
@@ -118,29 +110,29 @@ export function parseArgs(argv) {
return value;
};
if (arg === "--artifact-dir") {
setOnce(arg, "artifactDir", readValue(arg));
options.artifactDir = readValue(arg);
} else if (arg === "--github-output") {
setOnce(arg, "githubOutput", readValue(arg));
options.githubOutput = readValue(arg);
} else if (arg === "--metadata") {
setOnce(arg, "metadata", readValue(arg));
options.metadata = readValue(arg);
} else if (arg === "--output-dir") {
setOnce(arg, "outputDir", readValue(arg));
options.outputDir = readValue(arg);
} else if (arg === "--output-name") {
setOnce(arg, "outputName", readValue(arg));
options.outputName = readValue(arg);
} else if (arg === "--package-sha256") {
setOnce(arg, "packageSha256", readValue(arg, { allowEmpty: true }).toLowerCase());
options.packageSha256 = readValue(arg, { allowEmpty: true }).toLowerCase();
} else if (arg === "--package-ref") {
setOnce(arg, "packageRef", readValue(arg, { allowEmpty: true }));
options.packageRef = readValue(arg, { allowEmpty: true });
} else if (arg === "--package-spec") {
setOnce(arg, "packageSpec", readValue(arg, { allowEmpty: true }));
options.packageSpec = readValue(arg, { allowEmpty: true });
} else if (arg === "--package-url") {
setOnce(arg, "packageUrl", readValue(arg, { allowEmpty: true }));
options.packageUrl = readValue(arg, { allowEmpty: true });
} else if (arg === "--source") {
setOnce(arg, "source", readValue(arg));
options.source = readValue(arg);
} else if (arg === "--trusted-source-id") {
setOnce(arg, "trustedSourceId", readValue(arg, { allowEmpty: true }));
options.trustedSourceId = readValue(arg, { allowEmpty: true });
} else if (arg === "--trusted-source-policy") {
setOnce(arg, "trustedSourcePolicy", readValue(arg));
options.trustedSourcePolicy = readValue(arg);
} else if (arg === "--help" || arg === "-h") {
options.help = true;
} else {

View File

@@ -14,7 +14,6 @@ LAUNCH_AGENT="${HOME}/Library/LaunchAgents/ai.openclaw.mac.plist"
LOCK_KEY="$(printf '%s' "${ROOT_DIR}" | shasum -a 256 | cut -c1-8)"
LOCK_DIR="${TMPDIR:-/tmp}/openclaw-restart-${LOCK_KEY}"
LOCK_PID_FILE="${LOCK_DIR}/pid"
LOCK_HELD=0
WAIT_FOR_LOCK=0
LOG_PATH="${OPENCLAW_RESTART_LOG:-${TMPDIR:-/tmp}/openclaw-restart-${LOCK_KEY}.log}"
NO_SIGN=0
@@ -39,14 +38,7 @@ run_step() {
}
cleanup() {
if [[ "${LOCK_HELD}" != "1" || ! -d "${LOCK_DIR}" ]]; then
return 0
fi
local owner_pid=""
if [[ -f "${LOCK_PID_FILE}" ]]; then
owner_pid="$(cat "${LOCK_PID_FILE}" 2>/dev/null || true)"
fi
if [[ -z "${owner_pid}" || "${owner_pid}" == "$$" ]]; then
if [[ -d "${LOCK_DIR}" ]]; then
rm -rf "${LOCK_DIR}"
fi
}
@@ -54,7 +46,6 @@ cleanup() {
acquire_lock() {
while true; do
if mkdir "${LOCK_DIR}" 2>/dev/null; then
LOCK_HELD=1
echo "$$" > "${LOCK_PID_FILE}"
return 0
fi

View File

@@ -95,14 +95,6 @@ export function parseTestGroupReportArgs(argv) {
topFiles: 25,
vitestArgs: [],
};
const seenSingleValueFlags = new Set();
const setSingleValueFlag = (flag, apply) => {
if (seenSingleValueFlags.has(flag)) {
throw new Error(`${flag} was provided more than once`);
}
seenSingleValueFlags.add(flag);
apply();
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
@@ -132,11 +124,10 @@ export function parseTestGroupReportArgs(argv) {
continue;
}
if (arg === "--compare") {
const before = readRequiredValue(argv, index, "--compare");
const after = readRequiredValue(argv, index + 1, "--compare");
setSingleValueFlag(arg, () => {
args.compare = { before, after };
});
args.compare = {
before: readRequiredValue(argv, index, "--compare"),
after: readRequiredValue(argv, index + 1, "--compare"),
};
index += 2;
continue;
}
@@ -146,66 +137,42 @@ export function parseTestGroupReportArgs(argv) {
continue;
}
if (arg === "--group-by") {
const value = readRequiredValue(argv, index, "--group-by");
setSingleValueFlag(arg, () => {
args.groupBy = value;
});
args.groupBy = readRequiredValue(argv, index, "--group-by");
index += 1;
continue;
}
if (arg === "--output") {
const value = readRequiredValue(argv, index, "--output");
setSingleValueFlag(arg, () => {
args.output = value;
});
args.output = readRequiredValue(argv, index, "--output");
index += 1;
continue;
}
if (arg === "--limit") {
const value = readPositiveIntValue(argv, index, "--limit");
setSingleValueFlag(arg, () => {
args.limit = value;
});
args.limit = readPositiveIntValue(argv, index, "--limit");
index += 1;
continue;
}
if (arg === "--max-test-ms") {
const value = readPositiveIntValue(argv, index, "--max-test-ms");
setSingleValueFlag(arg, () => {
args.maxTestMs = value;
});
args.maxTestMs = readPositiveIntValue(argv, index, "--max-test-ms");
index += 1;
continue;
}
if (arg === "--timeout-ms") {
const value = readPositiveIntValue(argv, index, "--timeout-ms");
setSingleValueFlag(arg, () => {
args.timeoutMs = value;
});
args.timeoutMs = readPositiveIntValue(argv, index, "--timeout-ms");
index += 1;
continue;
}
if (arg === "--kill-grace-ms") {
const value = readPositiveIntValue(argv, index, "--kill-grace-ms");
setSingleValueFlag(arg, () => {
args.killGraceMs = value;
});
args.killGraceMs = readPositiveIntValue(argv, index, "--kill-grace-ms");
index += 1;
continue;
}
if (arg === "--concurrency") {
const value = readPositiveIntValue(argv, index, "--concurrency");
setSingleValueFlag(arg, () => {
args.concurrency = value;
});
args.concurrency = readPositiveIntValue(argv, index, "--concurrency");
index += 1;
continue;
}
if (arg === "--top-files") {
const value = readPositiveIntValue(argv, index, "--top-files");
setSingleValueFlag(arg, () => {
args.topFiles = value;
});
args.topFiles = readPositiveIntValue(argv, index, "--top-files");
index += 1;
continue;
}

View File

@@ -1,6 +1,5 @@
// Shared helpers for running Vitest JSON reports and reading duration data.
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -60,10 +59,6 @@ function validateVitestJsonReport(reportPath) {
return null;
}
function defaultVitestJsonReportPath(prefix) {
return path.join(os.tmpdir(), `${prefix}-${process.pid}-${Date.now()}-${randomUUID()}.json`);
}
/**
* Runs Vitest with the JSON reporter unless an existing report was supplied.
*/
@@ -72,7 +67,7 @@ export function runVitestJsonReport({
reportPath = "",
prefix = "openclaw-vitest-report",
}) {
const resolvedReportPath = reportPath || defaultVitestJsonReportPath(prefix);
const resolvedReportPath = reportPath || path.join(os.tmpdir(), `${prefix}-${Date.now()}.json`);
if (!(reportPath && fs.existsSync(resolvedReportPath))) {
const run = spawnSync(

View File

@@ -636,10 +636,6 @@ export function assertToolSearchLaneResults(params: {
code.sessionLogToolMentions[targetTool] > 0,
`code lane did not bridge-call ${targetTool}`,
);
assert(
!code.providerPlannedTools.includes(targetTool),
`code lane exposed direct provider tool ${targetTool}`,
);
assert(
normal.providerDeclaredToolCount > code.providerDeclaredToolCount,
`expected Tool Search to expose fewer tools to provider: normal=${normal.providerDeclaredToolCount} code=${code.providerDeclaredToolCount}`,
@@ -652,10 +648,6 @@ export function assertToolSearchLaneResults(params: {
code.sessionLogToolMentions.tool_search_code > 0 && code.sessionLogToolMentions[targetTool] > 0,
"code lane session log did not record bridge and target tool mentions",
);
assert(
!normal.providerPlannedTools.includes("tool_search_code"),
"normal lane unexpectedly used Tool Search bridge",
);
}
export async function main() {

View File

@@ -28,31 +28,23 @@ function readOptionValue(argv, index, optionName) {
export function parseArgs(argv) {
const args = { repo: "", sha: "", output: "", changelogOnly: false };
const seen = new Set();
const setOnce = (flag, key, value) => {
if (seen.has(flag)) {
throw new Error(`${flag} was provided more than once.`);
}
seen.add(flag);
args[key] = value;
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--repo":
setOnce(arg, "repo", readOptionValue(argv, index, arg));
args.repo = readOptionValue(argv, index, arg);
index += 1;
break;
case "--sha":
setOnce(arg, "sha", readOptionValue(argv, index, arg));
args.sha = readOptionValue(argv, index, arg);
index += 1;
break;
case "--output":
setOnce(arg, "output", readOptionValue(argv, index, arg));
args.output = readOptionValue(argv, index, arg);
index += 1;
break;
case "--changelog-only":
setOnce(arg, "changelogOnly", true);
args.changelogOnly = true;
break;
default:
throw new Error(`Unknown option: ${arg}`);
@@ -106,16 +98,13 @@ function latestRun(runs) {
function preferredCiRun(runs) {
const scheduledRuns = runs.filter((run) => run.event === "pull_request");
const latestScheduledRun = latestRun(scheduledRuns);
const failedScheduledRun = latestRun(
scheduledRuns.filter(
(run) =>
run.status === "completed" && !["success", "cancelled", "skipped"].includes(run.conclusion),
),
const failedScheduledRun = scheduledRuns.find(
(run) => run.status === "completed" && run.conclusion !== "success",
);
if (failedScheduledRun && latestScheduledRun?.status !== "completed") {
if (failedScheduledRun) {
return failedScheduledRun;
}
const latestScheduledRun = latestRun(scheduledRuns);
if (latestScheduledRun?.status === "completed") {
return latestScheduledRun;
}

View File

@@ -942,15 +942,6 @@ function truncateOversizedToolResultsInExistingSessionManager(params: {
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sessionId && params.sessionKey && params.agentId
? {
target: {
agentId: params.agentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
},
}
: {}),
});
}
@@ -1022,15 +1013,6 @@ async function truncateOversizedToolResultsInTranscriptState(params: {
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sessionId && params.sessionKey && params.agentId
? {
target: {
agentId: params.agentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
},
}
: {}),
});
}
@@ -1097,7 +1079,6 @@ export async function truncateOversizedToolResultsInSession(params: {
sessionFile,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: params.agentId,
});
} catch (err) {
const errMsg = formatErrorMessage(err);

View File

@@ -155,8 +155,10 @@ function requireString(value: string | undefined, label: string): string {
beforeAll(async () => {
({ onSessionTranscriptUpdate } = await import("../../sessions/transcript-events.js"));
({ installSessionToolResultGuard } = await import("../session-tool-result-guard.js"));
({ rewriteTranscriptEntriesInRuntimeTranscript, rewriteTranscriptEntriesInSessionManager } =
await import("./transcript-rewrite.js"));
({
rewriteTranscriptEntriesInRuntimeTranscript,
rewriteTranscriptEntriesInSessionManager,
} = await import("./transcript-rewrite.js"));
});
beforeEach(() => {
@@ -392,13 +394,7 @@ describe("rewriteTranscriptEntriesInRuntimeTranscript", () => {
expect(listener).toHaveBeenCalledWith({
agentId: "main",
sessionFile: resolvedSessionFile,
sessionId,
sessionKey: "agent:main:test",
target: {
agentId: "main",
sessionId,
sessionKey: "agent:main:test",
},
});
const rewrittenSession = SessionManager.open(sessionFile);
@@ -415,4 +411,5 @@ describe("rewriteTranscriptEntriesInRuntimeTranscript", () => {
cleanup();
}
});
});

View File

@@ -18,7 +18,6 @@ import {
import { SessionManager } from "../sessions/index.js";
import { log } from "./logger.js";
import {
persistTranscriptStateMutation,
readTranscriptFileState,
type TranscriptFileState,
type TranscriptPersistedEntry,
@@ -464,11 +463,6 @@ export async function rewriteTranscriptEntriesInRuntimeTranscript(params: {
sessionFile: target.sessionFile,
sessionKey: target.sessionKey,
agentId: target.agentId,
target: {
agentId: target.agentId,
sessionId: target.sessionId,
sessionKey: target.sessionKey,
},
});
log.info(
`[transcript-rewrite] rewrote ${result.rewrittenEntries} entr` +
@@ -491,71 +485,3 @@ export async function rewriteTranscriptEntriesInRuntimeTranscript(params: {
await sessionLock?.release();
}
}
/**
* Rewrites a named transcript file artifact. Runtime callers should prefer
* rewriteTranscriptEntriesInRuntimeTranscript with agent/session scope.
*/
export async function rewriteTranscriptEntriesInSessionFile(params: {
sessionFile: string;
sessionId?: string;
sessionKey?: string;
agentId?: string;
request: TranscriptRewriteRequest;
config?: SessionWriteLockAcquireTimeoutConfig;
}): Promise<TranscriptRewriteResult> {
let sessionLock: Awaited<ReturnType<typeof acquireSessionWriteLock>> | undefined;
try {
sessionLock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
...resolveSessionWriteLockOptions(params.config),
});
const state = await readTranscriptFileState(params.sessionFile);
const result = rewriteTranscriptEntriesInState({
state,
replacements: params.request.replacements,
...(params.request.allowedRewriteSuffixEntryIds
? { allowedRewriteSuffixEntryIds: params.request.allowedRewriteSuffixEntryIds }
: {}),
});
if (result.changed) {
await persistTranscriptStateMutation({
sessionFile: params.sessionFile,
state,
appendedEntries: result.appendedEntries,
});
emitSessionTranscriptUpdate({
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sessionId && params.sessionKey && params.agentId
? {
target: {
agentId: params.agentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
},
}
: {}),
});
log.info(
`[transcript-rewrite] rewrote ${result.rewrittenEntries} entr` +
`${result.rewrittenEntries === 1 ? "y" : "ies"} ` +
`bytesFreed=${result.bytesFreed} ` +
`sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`,
);
}
return result;
} catch (err) {
const reason = formatErrorMessage(err);
log.warn(`[transcript-rewrite] failed: ${reason}`);
return {
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
reason,
};
} finally {
await sessionLock?.release();
}
}

View File

@@ -259,6 +259,30 @@ describe("skill_workshop tool", () => {
);
});
it("rejects create proposals that look like existing skill patches", async () => {
const workspaceDir = await tempDirs.make("openclaw-skill-workshop-tool-");
const existingSkillDir = path.join(workspaceDir, "skills", "weather-planner");
await fs.mkdir(existingSkillDir, { recursive: true });
await fs.writeFile(
path.join(existingSkillDir, "SKILL.md"),
"---\nname: weather-planner\ndescription: Existing weather skill\n---\n\n# Weather\n",
"utf8",
);
const tool = createSkillWorkshopTool({ workspaceDir, config: {}, agentId: "main" });
await expect(
tool.execute("call-create", {
action: "create",
name: "Weather Planner Hardening",
description: "Patch existing weather planner",
proposal_content:
"# Weather patch\n\nUpdate skills/weather-planner/SKILL.md with alert checks.\n",
}),
).rejects.toThrow(
"Create proposal content references existing workspace skill paths: skills/weather-planner",
);
});
it("applies, rejects, and quarantines proposals through the workshop service", async () => {
const workspaceDir = await tempDirs.make("openclaw-skill-workshop-tool-");
const tool = createSkillWorkshopTool({ workspaceDir, config: {}, agentId: "main" });

View File

@@ -55,7 +55,7 @@ const SkillWorkshopToolSchema = Type.Object(
{
action: stringEnum(SKILL_WORKSHOP_ACTIONS, {
description:
"create for a new skill proposal, update for an existing skill, revise for a pending proposal, list or inspect proposals for proposal discovery, apply/reject/quarantine for explicit proposal lifecycle actions.",
"create for a brand-new sibling skill proposal, update for one existing skill, revise for a pending proposal, list or inspect proposals for proposal discovery, apply/reject/quarantine for explicit proposal lifecycle actions. Split multi-skill changes into separate update calls.",
}),
proposal_id: Type.Optional(
Type.String({
@@ -66,7 +66,7 @@ const SkillWorkshopToolSchema = Type.Object(
name: Type.Optional(
Type.String({
description:
"Skill/proposal name. Required for action=create; optional resolver for action=inspect or action=revise when proposal_id is unknown.",
"Skill/proposal name. Required for action=create; create always targets a new workspace skills/<name>/ sibling. Optional resolver for action=inspect or action=revise when proposal_id is unknown.",
}),
),
query: Type.Optional(Type.String({ description: "Optional query for action=list." })),
@@ -90,12 +90,12 @@ const SkillWorkshopToolSchema = Type.Object(
}),
),
skill_name: Type.Optional(
Type.String({ description: "Existing skill name or key for action=update." }),
Type.String({ description: "Single existing skill name or key for action=update." }),
),
proposal_content: Type.Optional(
Type.String({
description:
"Full proposed procedure markdown for action=create, action=update, or action=revise. It will be stored as PROPOSAL.md. Keep under configured skills.workshop.maxSkillBytes; default max is 40000 bytes.",
"Full proposed procedure markdown for action=create, action=update, or action=revise. It will be stored as PROPOSAL.md. action=create must describe only the new skill and rejects existing skills/<name>/ paths; use action=update separately for existing skills. Keep under configured skills.workshop.maxSkillBytes; default max is 40000 bytes.",
}),
),
support_files: Type.Optional(
@@ -138,7 +138,7 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA
name: "skill_workshop",
displaySummary: "Propose a reusable skill",
description:
"Create, update, revise, list, inspect, apply, reject, or quarantine Skill Workshop proposals when reusable procedures should be captured, improved, or explicitly approved.",
"Create, update, revise, list, inspect, apply, reject, or quarantine Skill Workshop proposals when reusable procedures should be captured, improved, or explicitly approved. Create always proposes a new sibling skill; update targets one existing skill.",
parameters: SkillWorkshopToolSchema,
execute: async (_toolCallId, args) => {
const params = asToolParamsRecord(args);

View File

@@ -1399,14 +1399,8 @@ describe("session accessor file-backed seam", () => {
agentId: "main",
message: appended.message,
messageId: appended.messageId,
sessionId: scope.sessionId,
sessionFile: transcriptPath,
sessionKey: scope.sessionKey,
target: {
agentId: "main",
sessionId: scope.sessionId,
sessionKey: scope.sessionKey,
},
},
]);
});

View File

@@ -10,10 +10,7 @@ import { formatErrorMessage } from "../../infra/errors.js";
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import type {
SessionTranscriptUpdate,
SessionTranscriptUpdateTarget,
} from "../../sessions/transcript-events.js";
import type { SessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { getRuntimeConfig } from "../io.js";
import type { OpenClawConfig } from "../types.openclaw.js";
import { formatSessionArchiveTimestamp } from "./artifacts.js";
@@ -913,7 +910,6 @@ export async function publishTranscriptUpdate(
emitSessionTranscriptUpdate({
...update,
sessionFile: transcript.sessionFile,
...(transcript.target ? { target: transcript.target } : {}),
});
}
@@ -1764,47 +1760,11 @@ function resolveAccessStorePath(scope: SessionAccessScope): string {
});
}
type ResolvedTranscriptAccess = {
async function resolveTranscriptAccess(scope: SessionTranscriptWriteScope): Promise<{
sessionFile: string;
target?: SessionTranscriptUpdateTarget;
};
function projectTranscriptUpdateTarget(
target: Pick<Partial<SessionTranscriptRuntimeTarget>, "agentId" | "sessionId" | "sessionKey">,
): SessionTranscriptUpdateTarget | undefined {
if (!target.agentId || !target.sessionId || !target.sessionKey) {
return undefined;
}
return {
agentId: target.agentId,
sessionId: target.sessionId,
sessionKey: target.sessionKey,
};
}
async function resolveTranscriptAccess(
scope: SessionTranscriptWriteScope,
): Promise<ResolvedTranscriptAccess> {
}> {
if (scope.sessionFile?.trim()) {
const scopeSessionKey = scope.sessionKey?.trim();
const agentId = scopeSessionKey
? (scope.agentId ?? resolveAgentIdFromSessionKey(scopeSessionKey))
: undefined;
return {
sessionFile: scope.sessionFile,
...(agentId && scope.sessionId && scopeSessionKey
? {
target: projectTranscriptUpdateTarget({
agentId,
sessionId: scope.sessionId,
sessionKey: scopeSessionKey,
}),
}
: {}),
};
}
if (!scope.sessionId) {
throw new Error(`Cannot resolve transcript scope without a session id: ${scope.sessionKey}`);
return { sessionFile: scope.sessionFile };
}
// Past this point resolution goes through the session entry, so the owning
// key is mandatory; explicit-artifact writes returned above never need it.
@@ -1814,16 +1774,14 @@ async function resolveTranscriptAccess(
"Cannot resolve a transcript write scope without a session key or explicit session file",
);
}
const target = await resolveSessionTranscriptRuntimeTarget({
if (!scope.sessionId) {
throw new Error(`Cannot resolve transcript scope without a session id: ${scopeSessionKey}`);
}
return await resolveSessionTranscriptRuntimeTarget({
...scope,
sessionId: scope.sessionId,
sessionKey: scopeSessionKey,
});
const updateTarget = projectTranscriptUpdateTarget(target);
return {
sessionFile: target.sessionFile,
...(updateTarget ? { target: updateTarget } : {}),
};
}
async function resolveTranscriptTurnTarget(

View File

@@ -3,7 +3,6 @@
import os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeNetworkInterfacesSnapshot } from "../test-helpers/network-interfaces.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import {
__resetContainerCacheForTest,
defaultGatewayBindMode,
@@ -25,12 +24,22 @@ import {
const flyMachineEnvKeys = ["FLY_MACHINE_ID", "FLY_APP_NAME"] as const;
function clearFlyMachineEnvForTest(): () => void {
const envSnapshot = captureEnv([...flyMachineEnvKeys]);
const previousEnv = new Map<(typeof flyMachineEnvKeys)[number], string | undefined>();
for (const key of flyMachineEnvKeys) {
deleteTestEnvValue(key);
previousEnv.set(key, process.env[key]);
delete process.env[key];
}
return () => envSnapshot.restore();
return () => {
for (const key of flyMachineEnvKeys) {
const value = previousEnv.get(key);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
};
}
function useClearedFlyMachineEnv() {
@@ -578,8 +587,8 @@ describe("isContainerEnvironment", () => {
});
vi.spyOn(fs, "readFileSync").mockReturnValue("10:cpuset:/\n9:perf_event:/\n8:memory:/\n0::/\n");
setTestEnvValue("FLY_MACHINE_ID", "3d8d5459a03038");
setTestEnvValue("FLY_APP_NAME", "openclaw-test");
process.env.FLY_MACHINE_ID = "3d8d5459a03038";
process.env.FLY_APP_NAME = "openclaw-test";
expect(isContainerEnvironment()).toBe(true);
});

View File

@@ -154,7 +154,6 @@ import { ADMIN_SCOPE } from "../method-scopes.js";
import { chatAbortMarkerTimestampMs, type ChatRunTiming } from "../server-chat-state.js";
import { getMaxChatHistoryMessagesBytes, MAX_PAYLOAD_BYTES } from "../server-constants.js";
import { resolveSessionHistoryTailReadOptions } from "../session-history-state.js";
import { persistGatewaySessionLifecycleEvent } from "../session-lifecycle-state.js";
import { readSessionTranscriptIndex } from "../session-transcript-index.fs.js";
import {
capArrayByJsonBytes,
@@ -3747,14 +3746,6 @@ export const chatHandlers: GatewayRequestHandlers = {
const deliveredReplies: Array<{ payload: ReplyPayload; kind: "block" | "final" }> = [];
let appendedWebchatAgentMedia = false;
let agentRunStarted = false;
let pendingDispatchLifecycleError:
| {
endedAt: number;
error: string;
sessionId: string;
startedAt: number;
}
| undefined;
const userTurnRecorder: UserTurnTranscriptRecorder = createUserTurnTranscriptRecorder({
input: baseUserTurnInput,
resolveInput: () => userTurnInputPromise,
@@ -4966,7 +4957,6 @@ export const chatHandlers: GatewayRequestHandlers = {
);
})
.catch(async (err: unknown) => {
const errorMessage = String(err);
const emitAfterError =
userTurnRecorder.hasPersisted() || userTurnRecorder.isBlocked()
? Promise.resolve()
@@ -4976,19 +4966,7 @@ export const chatHandlers: GatewayRequestHandlers = {
`webchat user transcript update failed after error: ${formatForLog(transcriptErr)}`,
);
});
if (
!agentRunStarted &&
!activeRunAbort.controller.signal.aborted &&
!context.chatAbortedRuns.has(clientRunId)
) {
pendingDispatchLifecycleError = {
endedAt: Date.now(),
error: errorMessage,
sessionId: activeRunAbort.entry?.sessionId ?? backingSessionId ?? clientRunId,
startedAt: activeRunAbort.entry?.startedAtMs ?? now,
};
}
const error = errorShape(ErrorCodes.UNAVAILABLE, errorMessage);
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
setGatewayDedupeEntry({
dedupe: context.dedupe,
key: `chat:${clientRunId}`,
@@ -4998,7 +4976,7 @@ export const chatHandlers: GatewayRequestHandlers = {
payload: {
runId: clientRunId,
status: "error" as const,
summary: errorMessage,
summary: String(err),
},
error,
},
@@ -5008,7 +4986,7 @@ export const chatHandlers: GatewayRequestHandlers = {
runId: clientRunId,
sessionKey,
agentId,
errorMessage,
errorMessage: String(err),
});
})
.finally(() => {
@@ -5016,53 +4994,6 @@ export const chatHandlers: GatewayRequestHandlers = {
clearAgentRunContext(clientRunId, lifecycleGeneration);
clearActiveChatSendDedupeRun(context.dedupe, activeChatSendDedupeKey, clientRunId);
context.removeChatRun(clientRunId, clientRunId, sessionKey);
if (!pendingDispatchLifecycleError) {
return;
}
const persistDispatchLifecycleError = async () => {
const dispatchError = pendingDispatchLifecycleError;
if (!dispatchError) {
return;
}
const hasActiveRun = hasTrackedActiveSessionRun({
context,
requestedKey: rawSessionKey,
canonicalKey: sessionKey,
...(sessionKey === "global" && agentId ? { agentId } : {}),
defaultAgentId: resolveDefaultAgentId(cfg),
});
if (hasActiveRun) {
return;
}
try {
await persistGatewaySessionLifecycleEvent({
sessionKey,
...(sessionKey === "global" && agentId ? { agentId } : {}),
event: {
runId: clientRunId,
sessionId: dispatchError.sessionId,
lifecycleGeneration,
ts: dispatchError.endedAt,
data: {
phase: "error",
startedAt: dispatchError.startedAt,
endedAt: dispatchError.endedAt,
error: dispatchError.error,
},
},
});
emitSessionsChanged(context, {
sessionKey,
...(agentId ? { agentId } : {}),
reason: "chat.dispatch-error",
});
} catch (persistErr: unknown) {
context.logGateway.warn(
`webchat session lifecycle persist failed after error: ${formatForLog(persistErr)}`,
);
}
};
void persistDispatchLifecycleError();
});
} catch (err) {
activeRunAbort.cleanup({ force: true });

View File

@@ -8,7 +8,6 @@ import { jsonResult } from "../../agents/tools/common.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { captureEnv, setTestEnvValue } from "../../test-utils/env.js";
import type { GatewayRequestContext } from "./types.js";
type ResolveOutboundTarget = typeof import("../../infra/outbound/targets.js").resolveOutboundTarget;
@@ -214,13 +213,17 @@ async function runMessageActionRequest(
}
async function withTempOpenClawStateDir<T>(test: (stateDir: string) => Promise<T>): Promise<T> {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
const previous = process.env.OPENCLAW_STATE_DIR;
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "gateway-send-state-"));
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
process.env.OPENCLAW_STATE_DIR = stateDir;
try {
return await test(stateDir);
} finally {
envSnapshot.restore();
if (previous === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previous;
}
await fs.rm(stateDir, { recursive: true, force: true });
}
}

View File

@@ -12,11 +12,10 @@ import {
resetTaskRegistryForTests,
} from "../../tasks/runtime-internal.js";
import type { TaskRecord } from "../../tasks/task-registry.types.js";
import { captureEnv, setTestEnvValue } from "../../test-utils/env.js";
import { tasksHandlers } from "./tasks.js";
import type { RespondFn } from "./types.js";
const stateDirEnvSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR;
type TaskResponsePayload = {
tasks?: Array<Record<string, unknown>>;
task?: Record<string, unknown>;
@@ -36,13 +35,17 @@ function createTaskRecord(params: Parameters<typeof createTaskRecordOrNull>[0]):
beforeEach(async () => {
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-tasks-"));
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
process.env.OPENCLAW_STATE_DIR = stateDir;
resetTaskRegistryForTests();
});
afterEach(async () => {
resetTaskRegistryForTests();
stateDirEnvSnapshot.restore();
if (ORIGINAL_STATE_DIR === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR;
}
await fs.rm(stateDir, { recursive: true, force: true });
});

View File

@@ -10,7 +10,7 @@ import { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
import { resetAgentRunContextForTest } from "../infra/agent-events.js";
import { PROXY_ENV_KEYS } from "../infra/net/proxy-env.js";
import { clearGatewaySubagentRuntime } from "../plugins/runtime/index.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { captureEnv } from "../test-utils/env.js";
import { startGatewayServer } from "./server.js";
import { getFreeGatewayPort } from "./test-helpers.e2e.js";
@@ -76,12 +76,12 @@ describe("gateway network runtime", () => {
const testDispatcher = new Agent();
setGlobalDispatcher(testDispatcher);
for (const key of NETWORK_GATEWAY_ENV_KEYS) {
deleteTestEnvValue(key);
delete process.env[key];
}
process.env.HTTPS_PROXY = "http://127.0.0.1:9";
setTestEnvValue("HOME", tempHome);
setTestEnvValue("OPENCLAW_STATE_DIR", path.join(tempHome, ".openclaw"));
process.env.HOME = tempHome;
process.env.OPENCLAW_STATE_DIR = path.join(tempHome, ".openclaw");
process.env.OPENCLAW_SKIP_CHANNELS = "1";
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
@@ -100,7 +100,7 @@ describe("gateway network runtime", () => {
configPath,
`${JSON.stringify({ gateway: { auth: { mode: "token", token } } }, null, 2)}\n`,
);
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
process.env.OPENCLAW_CONFIG_PATH = configPath;
server = await startGatewayServer(await getFreeGatewayPort(), {
bind: "loopback",

View File

@@ -2,7 +2,7 @@
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
import { onSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
import { onInternalSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import type { ChatAbortControllerEntry, RestartRecoveryCandidate } from "./chat-abort.js";
import type {
ChatRunState,
@@ -226,7 +226,7 @@ export function startGatewayEventSubscriptions(params: {
params.broadcast("heartbeat", evt, { dropIfSlow: true });
});
const transcriptUnsub = onInternalSessionTranscriptUpdate((evt) => {
const transcriptUnsub = onSessionTranscriptUpdate((evt) => {
void getTranscriptUpdateHandler().then((handler) => handler(evt));
});

View File

@@ -6,7 +6,7 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { getRuntimeConfig } from "../config/io.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { SessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
import type { InternalSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import type { SessionTranscriptUpdate } from "../sessions/transcript-events.js";
import type { ChatAbortControllerEntry } from "./chat-abort.js";
import { projectChatDisplayMessage } from "./chat-display-projection.js";
import type { GatewayBroadcastToConnIdsFn } from "./server-broadcast-types.js";
@@ -92,7 +92,7 @@ export function createTranscriptUpdateBroadcastHandler(params: {
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
}) {
let broadcastQueue = Promise.resolve();
return (update: InternalSessionTranscriptUpdate): void => {
return (update: SessionTranscriptUpdate): void => {
// Preserve transcript update order even when counting messages requires an
// async read from the session file.
broadcastQueue = broadcastQueue
@@ -108,22 +108,19 @@ async function handleTranscriptUpdateBroadcast(
sessionMessageSubscribers: SessionMessageSubscribers;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
},
update: InternalSessionTranscriptUpdate,
update: SessionTranscriptUpdate,
): Promise<void> {
const sessionKey =
update.target?.sessionKey ??
update.sessionKey ??
(update.sessionFile ? resolveSessionKeyForTranscriptFile(update.sessionFile) : undefined);
const sessionKey = update.sessionKey ?? resolveSessionKeyForTranscriptFile(update.sessionFile);
if (!sessionKey || update.message === undefined) {
return;
}
const effectiveAgentId = update.target?.agentId ?? update.agentId;
const effectiveAgentId = update.agentId;
const defaultGlobalAgentId =
sessionKey === "global"
? normalizeAgentId(resolveDefaultAgentId(getRuntimeConfig()))
: undefined;
const visibleAgentId =
effectiveAgentId ??
update.agentId ??
(effectiveAgentId && effectiveAgentId !== defaultGlobalAgentId ? effectiveAgentId : undefined);
const connIds = new Set<string>();
for (const connId of params.sessionEventSubscribers.getAll()) {

View File

@@ -692,73 +692,6 @@ describe("gateway server chat", () => {
});
});
test("marks a running webchat session failed when dispatch rejects before a reply", async () => {
await withMainSessionStore(async (dir) => {
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
sessionFile: path.join(dir, "sess-main.jsonl"),
updatedAt: 1_000,
status: "running",
startedAt: 900,
},
},
});
const subscribeRes = await rpcReq(ws, "sessions.subscribe", {});
expect(subscribeRes.ok).toBe(true);
dispatchInboundMessageMock.mockRejectedValueOnce(new Error("provider rejected request"));
const errorPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "error" &&
o.payload?.runId === "idem-dispatch-error-1",
8_000,
);
const sessionChangedPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "sessions.changed" &&
o.payload?.reason === "chat.dispatch-error" &&
o.payload?.sessionKey === "agent:main:main",
8_000,
);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "run: pwd",
idempotencyKey: "idem-dispatch-error-1",
});
expect(res.ok).toBe(true);
await errorPromise;
const sessionChanged = await sessionChangedPromise;
expectRecordFields(sessionChanged.payload, {
sessionId: "sess-main",
status: "failed",
hasActiveRun: false,
});
const sessionsRes = await rpcReq<{ sessions?: unknown[] }>(ws, "sessions.list", {});
expect(sessionsRes.ok).toBe(true);
const session = sessionsRes.payload?.sessions?.find(
(row): row is Record<string, unknown> =>
Boolean(row) &&
typeof row === "object" &&
(row as { key?: unknown }).key === "agent:main:main",
);
const actualSession = expectRecordFields(session, {
status: "failed",
hasActiveRun: false,
});
expect(typeof actualSession.startedAt).toBe("number");
expect(typeof actualSession.endedAt).toBe("number");
expect(typeof actualSession.runtimeMs).toBe("number");
});
});
test("chat.history hides assistant NO_REPLY-only entries", async () => {
const historyMessages = await loadChatHistoryWithMessages(buildNoReplyHistoryFixture());
const textValues = collectHistoryTextValues(historyMessages);

View File

@@ -6,7 +6,6 @@ import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { resolveDefaultAgentDir } from "../agents/agent-scope.js";
import { AUTH_PROFILE_FILENAME } from "../agents/auth-profiles/constants.js";
import { deleteTestEnvValue } from "../test-utils/env.js";
import { testing as controlPlaneRateLimitTesting } from "./control-plane-rate-limit.js";
import {
connectOk,
@@ -148,7 +147,7 @@ async function expectSchemaLookupInvalid(pathValue: unknown) {
}
async function writeUnresolvedAuthProfileTokenRef(missingEnvVar: string) {
deleteTestEnvValue(missingEnvVar);
delete process.env[missingEnvVar];
const authStorePath = path.join(resolveDefaultAgentDir({}), AUTH_PROFILE_FILENAME);
await fs.mkdir(path.dirname(authStorePath), { recursive: true });
await fs.writeFile(
@@ -178,7 +177,7 @@ beforeEach(() => {
describe("gateway config methods", () => {
it("rejects config.set when SecretRef resolution fails", async () => {
const missingEnvVar = `OPENCLAW_MISSING_SECRETREF_${Date.now()}`;
deleteTestEnvValue(missingEnvVar);
delete process.env[missingEnvVar];
const current = await getCurrentConfigObject();
const nextConfig = configWithGatewayTokenSecretRef(current.config, missingEnvVar);
@@ -806,7 +805,7 @@ describe("gateway config methods", () => {
it("rejects config.patch when merged SecretRefs cannot resolve", async () => {
const missingEnvVar = `OPENCLAW_MISSING_SECRETREF_PATCH_${Date.now()}`;
deleteTestEnvValue(missingEnvVar);
delete process.env[missingEnvVar];
const beforeHash = await getConfigHash();
const res = await rpcReq<{ ok?: boolean; error?: { message?: string } }>(
requireWs(),
@@ -838,7 +837,7 @@ describe("gateway config methods", () => {
describe("gateway config.apply", () => {
it("rejects config.apply when SecretRef resolution fails", async () => {
const missingEnvVar = `OPENCLAW_MISSING_SECRETREF_APPLY_${Date.now()}`;
deleteTestEnvValue(missingEnvVar);
delete process.env[missingEnvVar];
const current = await getCurrentConfigObject();
const nextConfig = configWithGatewayTokenSecretRef(current.config, missingEnvVar);

Some files were not shown because too many files have changed in this diff Show More