mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 08:12:29 +08:00
Compare commits
1 Commits
perf/codex
...
feature/sk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8242ed1a33 |
12
.github/workflows/maturity-scorecard.yml
vendored
12
.github/workflows/maturity-scorecard.yml
vendored
@@ -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}`);
|
||||
|
||||
7
.github/workflows/qa-profile-evidence.yml
vendored
7
.github/workflows/qa-profile-evidence.yml
vendored
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
53
extensions/acpx/npm-shrinkwrap.json
generated
53
extensions/acpx/npm-shrinkwrap.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {},
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
39
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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}"`);
|
||||
|
||||
@@ -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}"`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user