mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 17:31:31 +08:00
Compare commits
55 Commits
temp/pr-95
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54e5105c12 | ||
|
|
b9a7bf83a4 | ||
|
|
5c5a8a49d7 | ||
|
|
35be382e56 | ||
|
|
bdf75474b9 | ||
|
|
f13a10c798 | ||
|
|
2ba9d6eabe | ||
|
|
6f17c4cc6d | ||
|
|
e6f3912347 | ||
|
|
c6d9977902 | ||
|
|
c67bb1c5aa | ||
|
|
b8b2f5d98f | ||
|
|
6456790287 | ||
|
|
f247ef320a | ||
|
|
b08555ef55 | ||
|
|
7fe287b0d3 | ||
|
|
f77a74dec7 | ||
|
|
0c1f963532 | ||
|
|
530658dc29 | ||
|
|
8cc5b371f1 | ||
|
|
afa97a4b10 | ||
|
|
d9482063a9 | ||
|
|
89eb493d1d | ||
|
|
387b5337ec | ||
|
|
03a71f3b46 | ||
|
|
2220f43f69 | ||
|
|
9ce4c92736 | ||
|
|
8625b8a92b | ||
|
|
5571c786d3 | ||
|
|
b9d254f2b0 | ||
|
|
9f675920bf | ||
|
|
78b33b86d3 | ||
|
|
3ca3b97a21 | ||
|
|
ceb69221ec | ||
|
|
729de383bc | ||
|
|
d1026a3a1a | ||
|
|
2fbce1c036 | ||
|
|
741bac9fdf | ||
|
|
8cf0d7dd33 | ||
|
|
25e2017062 | ||
|
|
cb301cd16f | ||
|
|
d8e6ee04d0 | ||
|
|
493e418fa5 | ||
|
|
4bde68ed38 | ||
|
|
a289146344 | ||
|
|
95093303c8 | ||
|
|
607b2e9663 | ||
|
|
aed6f0a14e | ||
|
|
e20edd753b | ||
|
|
a89e65c167 | ||
|
|
f0afbd7e32 | ||
|
|
d9a38130b1 | ||
|
|
f2eca94391 | ||
|
|
4e9dc6b5d5 | ||
|
|
1711d0123c |
@@ -4,6 +4,14 @@ set -euo pipefail
|
||||
repo="openclaw/openclaw"
|
||||
months="12"
|
||||
include_global="0"
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(git -C "$script_dir/../../../.." rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -z "$repo_root" ]; then
|
||||
repo_root="$(cd "$script_dir/../../../.." && pwd)"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$repo_root/scripts/lib/plain-gh.sh"
|
||||
|
||||
usage() {
|
||||
printf 'Usage: %s [--repo owner/repo] [--months N] [--global] <github-login> [login...]\n' "$0"
|
||||
@@ -18,6 +26,10 @@ need() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
|
||||
}
|
||||
|
||||
gh() {
|
||||
gh_plain "$@"
|
||||
}
|
||||
|
||||
date_utc_relative_months() {
|
||||
local count="$1"
|
||||
if date -u -v-"${count}"m +%Y-%m-%dT00:00:00Z >/dev/null 2>&1; then
|
||||
@@ -131,7 +143,8 @@ done
|
||||
exit 2
|
||||
}
|
||||
|
||||
need gh
|
||||
OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)" || die "missing required command: gh"
|
||||
export OPENCLAW_GH_BIN
|
||||
need jq
|
||||
|
||||
since_ts=$(date_utc_relative_months "$months")
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* Usage: node secret-scanning.mjs <command> [options]
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { spawnPlainGh } from "../../../../scripts/lib/plain-gh.mjs";
|
||||
|
||||
const REPO = "openclaw/openclaw";
|
||||
const REPO_URL = `https://github.com/${REPO}`;
|
||||
@@ -29,7 +29,7 @@ function tmpFile(purpose) {
|
||||
}
|
||||
|
||||
function gh(args, { json = true, allowFailure = false } = {}) {
|
||||
const proc = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
||||
const proc = spawnPlainGh(args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
||||
if (proc.status !== 0 && !allowFailure) {
|
||||
fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import { plainGhEnv, resolvePlainGhBin } from "../../../../scripts/lib/plain-gh.mjs";
|
||||
|
||||
const runId = process.argv[2];
|
||||
const repo = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw";
|
||||
@@ -15,8 +16,9 @@ if (!runId) {
|
||||
}
|
||||
|
||||
function gh(args) {
|
||||
return execFileSync("gh", args, {
|
||||
return execFileSync(resolvePlainGhBin(), args, {
|
||||
encoding: "utf8",
|
||||
env: plainGhEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
@@ -32,14 +34,15 @@ function githubRestJson(pathSuffix) {
|
||||
"-lc",
|
||||
[
|
||||
"set -euo pipefail",
|
||||
'token="$(gh auth token)"',
|
||||
'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"',
|
||||
'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"',
|
||||
].join("\n"),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
...plainGhEnv(),
|
||||
OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(),
|
||||
OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`,
|
||||
},
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
|
||||
@@ -244,6 +244,11 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$ROLLBACK_DRILL_ID" || -z "$ROLLBACK_DRILL_DATE" ]]; then
|
||||
if [[ "$EVENT_NAME" == "push" ]]; then
|
||||
echo "::warning::Stable closeout skipped: rollback drill repository variables are missing; manual dispatch remains required to complete closeout."
|
||||
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Stable closeout requires repository variables RELEASE_ROLLBACK_DRILL_ID and RELEASE_ROLLBACK_DRILL_DATE, or explicit manual overrides." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
172fe4e143964c0a20525428ff3e6c7631856a7d51c6ad48959a35c72363a410 plugin-sdk-api-baseline.json
|
||||
a4c18ea9f0b0d2c22183bf8c082e757b7f9852b4c518c8b8cb62a21a9dd766e9 plugin-sdk-api-baseline.jsonl
|
||||
05ce13ad6d2ef72af943a61a023e26f58d01e37a04f76e279a933df9b6aed05b plugin-sdk-api-baseline.json
|
||||
628a6ac85acd5ed71236b07d5760e211b9c0698ea529d5b3101c20579926b0ea plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -389,7 +389,7 @@ If the homeserver requires UIA to upload cross-signing keys, OpenClaw tries no-a
|
||||
Useful flags:
|
||||
|
||||
- `--recovery-key-stdin` (pair with `printf '%s\n' "$MATRIX_RECOVERY_KEY" | …`) or `--recovery-key <key>`
|
||||
- `--force-reset-cross-signing` to discard the current cross-signing identity (intentional only)
|
||||
- `--force-reset-cross-signing` to discard the current cross-signing identity (intentional only; requires the active recovery key to be stored or supplied with `--recovery-key-stdin`)
|
||||
|
||||
### Room-key backup
|
||||
|
||||
|
||||
@@ -515,7 +515,7 @@ API key auth, and dynamic model resolution.
|
||||
|
||||
- `openclaw/plugin-sdk/provider-model-shared` - `ProviderReplayFamily`, `buildProviderReplayFamilyHooks(...)`, and the raw replay builders (`buildOpenAICompatibleReplayPolicy`, `buildAnthropicReplayPolicyForModel`, `buildGoogleGeminiReplayPolicy`, `buildHybridAnthropicOrOpenAIReplayPolicy`). Also exports Gemini replay helpers (`sanitizeGoogleGeminiReplayHistory`, `resolveTaggedReasoningOutputMode`) and endpoint/model helpers (`resolveProviderEndpoint`, `normalizeProviderId`, `normalizeGooglePreviewModelId`).
|
||||
- `openclaw/plugin-sdk/provider-stream` - `ProviderStreamFamily`, `buildProviderStreamFamilyHooks(...)`, `composeProviderStreamWrappers(...)`, plus the shared OpenAI/Codex wrappers (`createOpenAIAttributionHeadersWrapper`, `createOpenAIFastModeWrapper`, `createOpenAIServiceTierWrapper`, `createOpenAIResponsesContextManagementWrapper`, `createCodexNativeWebSearchWrapper`), DeepSeek V4 OpenAI-compatible wrapper (`createDeepSeekV4OpenAICompatibleThinkingWrapper`), Anthropic Messages thinking prefill cleanup (`createAnthropicThinkingPrefillPayloadWrapper`), plain-text tool-call compat (`createPlainTextToolCallCompatWrapper`), and shared proxy/provider wrappers (`createOpenRouterWrapper`, `createToolStreamWrapper`, `createMinimaxFastModeWrapper`).
|
||||
- `openclaw/plugin-sdk/provider-stream-shared` - lightweight payload and event wrappers for hot provider paths, including `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPayloadPatchStreamWrapper`, and `createPlainTextToolCallCompatWrapper`.
|
||||
- `openclaw/plugin-sdk/provider-stream-shared` - lightweight payload and event wrappers for hot provider paths, including `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPayloadPatchStreamWrapper`, `createPlainTextToolCallCompatWrapper`, and `setQwenChatTemplateThinking(...)`.
|
||||
- `openclaw/plugin-sdk/provider-tools` - `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks("deepseek" | "gemini" | "openai")`, and underlying provider schema helpers.
|
||||
|
||||
For Gemini-family providers, keep the reasoning-output mode aligned with
|
||||
|
||||
@@ -164,7 +164,7 @@ and pairing-path families.
|
||||
| `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, and DeepSeek/Gemini/OpenAI schema cleanup + diagnostics |
|
||||
| `plugin-sdk/provider-usage` | Provider usage snapshot types, shared usage fetch helpers, and provider fetchers such as `fetchClaudeUsage` |
|
||||
| `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, plain-text tool-call compat, and shared Anthropic/Bedrock/DeepSeek V4/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
|
||||
| `plugin-sdk/provider-stream-shared` | Public shared provider stream wrapper helpers including `composeProviderStreamWrappers`, `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPlainTextToolCallCompatWrapper`, `createPayloadPatchStreamWrapper`, `createToolStreamWrapper`, and Anthropic/DeepSeek/OpenAI-compatible stream utilities |
|
||||
| `plugin-sdk/provider-stream-shared` | Public shared provider stream wrapper helpers including `composeProviderStreamWrappers`, `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPlainTextToolCallCompatWrapper`, `createPayloadPatchStreamWrapper`, `createToolStreamWrapper`, `setQwenChatTemplateThinking`, and Anthropic/DeepSeek/OpenAI-compatible stream utilities |
|
||||
| `plugin-sdk/provider-transport-runtime` | Native provider transport helpers such as guarded fetch, transport message transforms, and writable transport event streams |
|
||||
| `plugin-sdk/provider-onboard` | Onboarding config patch helpers |
|
||||
| `plugin-sdk/global-singleton` | Process-local singleton/map/cache helpers |
|
||||
|
||||
@@ -191,10 +191,11 @@ release state.
|
||||
closeout requires both assets and a matching checksum. A partial manifest
|
||||
replays its recorded `main` SHA and rollback drill to regenerate identical
|
||||
bytes, then attaches the missing checksum; an invalid pair, or a checksum
|
||||
without a manifest, stays blocking. A missing or more-than-90-day-old drill
|
||||
record blocks a new evidence-backed closeout; private recovery commands
|
||||
remain in the maintainer-only runbook. Use manual dispatch only to repair or
|
||||
replay an evidence-backed stable closeout.
|
||||
without a manifest, stays blocking. A push-triggered run without rollback
|
||||
drill repository variables skips without completing closeout; a missing or
|
||||
more-than-90-day-old drill record still blocks manual evidence-backed
|
||||
closeout. Private recovery commands remain in the maintainer-only runbook.
|
||||
Use manual dispatch only to repair or replay an evidence-backed stable closeout.
|
||||
A legacy fallback correction tag may reuse base-package evidence only when
|
||||
the correction tag resolves to the same source commit as the base stable tag.
|
||||
A correction with different source must publish and verify its own package
|
||||
|
||||
@@ -844,6 +844,79 @@ describe("active-memory plugin", () => {
|
||||
expect(runEmbeddedAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not run for dreaming-narrative cron session keys", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:dreaming-narrative-light-abc123",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(runEmbeddedAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not run when a session id resolves to a dreaming-narrative cron session key", async () => {
|
||||
hoisted.sessionStore["agent:main:dreaming-narrative-light-abc123"] = {
|
||||
sessionId: "dreaming-session",
|
||||
updatedAt: 1,
|
||||
};
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionId: "dreaming-session",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(runEmbeddedAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows non-canonical session keys that merely contain the dreaming-narrative substring", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:webchat:dreaming-narrative-room",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
// Real session keys that happen to contain "dreaming-narrative" in a
|
||||
// non-canonical way (not {light|rem|deep} phase suffix) must remain eligible.
|
||||
// The session key "agent:main:webchat:dreaming-narrative-room" is a real chat
|
||||
// whose topic happens to contain the string — not a dreaming cron key.
|
||||
expect(runEmbeddedAgent).toHaveBeenCalledTimes(1);
|
||||
expect(result).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows real webchat session keys whose peer id starts with a phased dreaming-narrative prefix", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:webchat:dreaming-narrative-light-room",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
// A real webchat session key whose peer id begins with a phased dreaming-narrative
|
||||
// phrase must not be excluded. Only the canonical bare or agent-prefixed key
|
||||
// shape (dreaming-narrative-<phase>-<hash> directly after agentId or bare) should
|
||||
// be rejected — not the same phrase appearing deeper in the key as a peer id.
|
||||
expect(runEmbeddedAgent).toHaveBeenCalledTimes(1);
|
||||
expect(result).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults to direct-style sessions only", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should we order?", messages: [] },
|
||||
|
||||
@@ -1155,6 +1155,19 @@ function isEligibleInteractiveSession(ctx: {
|
||||
if (ctx.trigger !== "user") {
|
||||
return false;
|
||||
}
|
||||
// Exclude only canonical dreaming-narrative session keys (bare or agent-prefixed).
|
||||
// Canonical forms: "dreaming-narrative-<phase>-<hash>" or
|
||||
// "agent:<agentId>:dreaming-narrative-<phase>-<hash>".
|
||||
// A colon-delimited match would also exclude real chat session ids whose peer id
|
||||
// begins with a phased dreaming-narrative phrase (e.g.
|
||||
// "agent:main:feishu:group:dreaming-narrative-light-room").
|
||||
const sessionKey = ctx.sessionKey ?? "";
|
||||
if (
|
||||
/^dreaming-narrative-(light|rem|deep)-/i.test(sessionKey) ||
|
||||
/^agent:[^:]+:dreaming-narrative-(light|rem|deep)-/i.test(sessionKey)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!ctx.sessionKey && !ctx.sessionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -3617,7 +3630,12 @@ export default definePluginEntry({
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
if (!isEligibleInteractiveSession(ctx)) {
|
||||
if (
|
||||
!isEligibleInteractiveSession({
|
||||
...ctx,
|
||||
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
|
||||
})
|
||||
) {
|
||||
await persistPluginStatusLines({
|
||||
api,
|
||||
agentId: effectiveAgentId,
|
||||
|
||||
@@ -278,6 +278,11 @@ describe("CodexAppServerEventProjector", () => {
|
||||
const { onAssistantMessageStart, onPartialReply, projector } =
|
||||
await createProjectorWithAssistantHooks();
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
item: { type: "agentMessage", id: "msg-1", phase: "final_answer", text: "" },
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(agentMessageDelta("hel"));
|
||||
await projector.handleNotification(agentMessageDelta("lo"));
|
||||
await projector.handleNotification(
|
||||
@@ -305,7 +310,10 @@ describe("CodexAppServerEventProjector", () => {
|
||||
const result = projector.buildResult(buildEmptyToolTelemetry());
|
||||
|
||||
expect(onAssistantMessageStart).toHaveBeenCalledTimes(1);
|
||||
expect(onPartialReply).not.toHaveBeenCalled();
|
||||
expect(onPartialReply.mock.calls.map((call) => call[0])).toEqual([
|
||||
{ text: "hel", delta: "hel" },
|
||||
{ text: "hello", delta: "lo" },
|
||||
]);
|
||||
expect(result.assistantTexts).toEqual(["hello"]);
|
||||
expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["user", "assistant"]);
|
||||
expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "hello" }]);
|
||||
@@ -321,7 +329,13 @@ describe("CodexAppServerEventProjector", () => {
|
||||
});
|
||||
|
||||
it("streams final-answer assistant deltas into partial replies", async () => {
|
||||
const { onPartialReply, projector } = await createProjectorWithAssistantHooks();
|
||||
const onAgentEvent = vi.fn();
|
||||
const onPartialReply = vi.fn();
|
||||
const projector = await createProjector({
|
||||
...(await createParams()),
|
||||
onAgentEvent,
|
||||
onPartialReply,
|
||||
});
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
@@ -341,6 +355,79 @@ describe("CodexAppServerEventProjector", () => {
|
||||
{ text: "hel", delta: "hel" },
|
||||
{ text: "hello", delta: "lo" },
|
||||
]);
|
||||
expect(
|
||||
onAgentEvent.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter((event) => event.stream === "assistant"),
|
||||
).toEqual([
|
||||
{ stream: "assistant", data: { text: "hel", delta: "hel" } },
|
||||
{ stream: "assistant", data: { text: "hello", delta: "lo" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("streams assistant deltas when the app-server omits the item phase", async () => {
|
||||
// Newer Codex app-servers (>= 0.139) stream agentMessage deltas without a
|
||||
// "final_answer" phase. These surface on the replaceable agent-event path;
|
||||
// legacy append-oriented partial callbacks stay quiet.
|
||||
const onAgentEvent = vi.fn();
|
||||
const onPartialReply = vi.fn();
|
||||
const params = await createParams();
|
||||
const projector = await createProjector({
|
||||
...params,
|
||||
onAgentEvent,
|
||||
onPartialReply,
|
||||
});
|
||||
|
||||
await projector.handleNotification(agentMessageDelta("hel", "msg-final"));
|
||||
await projector.handleNotification(agentMessageDelta("lo", "msg-final"));
|
||||
|
||||
expect(onPartialReply).not.toHaveBeenCalled();
|
||||
expect(onAgentEvent.mock.calls.map((call) => call[0])).toEqual([
|
||||
{ stream: "assistant", data: { text: "hel", delta: "hel", replaceable: true } },
|
||||
{ stream: "assistant", data: { text: "hello", delta: "lo", replaceable: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks partial replacement when an unphased intermediate item is superseded by a final item", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const onPartialReply = vi.fn();
|
||||
const params = await createParams();
|
||||
const projector = await createProjector({
|
||||
...params,
|
||||
onAgentEvent,
|
||||
onPartialReply,
|
||||
});
|
||||
|
||||
await projector.handleNotification(agentMessageDelta("coordination ", "msg-intermediate"));
|
||||
await projector.handleNotification(agentMessageDelta("draft", "msg-intermediate"));
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
item: { type: "agentMessage", id: "msg-final", phase: "final_answer", text: "" },
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(agentMessageDelta("final ", "msg-final"));
|
||||
await projector.handleNotification(agentMessageDelta("answer", "msg-final"));
|
||||
|
||||
expect(onPartialReply).not.toHaveBeenCalled();
|
||||
expect(
|
||||
onAgentEvent.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter((event) => event.stream === "assistant"),
|
||||
).toEqual([
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { text: "coordination ", delta: "coordination ", replaceable: true },
|
||||
},
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { text: "coordination draft", delta: "draft", replaceable: true },
|
||||
},
|
||||
{
|
||||
stream: "assistant",
|
||||
data: { text: "final ", delta: "", replace: true, replaceable: true },
|
||||
},
|
||||
{ stream: "assistant", data: { text: "final answer", delta: "answer", replaceable: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("suppresses mirrored user prompt when the inbound message was already persisted", async () => {
|
||||
@@ -1041,6 +1128,8 @@ describe("CodexAppServerEventProjector", () => {
|
||||
const result = projector.buildResult(buildEmptyToolTelemetry());
|
||||
|
||||
expect(onAssistantMessageStart).toHaveBeenCalledTimes(1);
|
||||
// Phase-less snapshots stay on the replaceable agent-event path so legacy
|
||||
// append-only channel previews do not render superseded coordination text.
|
||||
expect(onPartialReply).not.toHaveBeenCalled();
|
||||
expect(result.assistantTexts).toEqual([
|
||||
"release fixes first. please drop affected PRs, failing checks, and blockers here.",
|
||||
|
||||
@@ -196,6 +196,8 @@ export class CodexAppServerEventProjector {
|
||||
private assistantStarted = false;
|
||||
private reasoningStarted = false;
|
||||
private reasoningEnded = false;
|
||||
private streamedPartialAssistantItemId: string | undefined;
|
||||
private streamedPartialAssistantItemReplaceable = false;
|
||||
private completedTurn: CodexTurn | undefined;
|
||||
private promptError: unknown;
|
||||
private promptErrorSource: EmbeddedRunAttemptResult["promptErrorSource"] = null;
|
||||
@@ -521,13 +523,46 @@ export class CodexAppServerEventProjector {
|
||||
this.assistantTextByItem.set(itemId, text);
|
||||
if (this.isCommentaryAssistantItem(itemId)) {
|
||||
this.emitCommentaryProgress({ itemId, text });
|
||||
} else if (this.shouldStreamAssistantPartial(itemId)) {
|
||||
await this.params.onPartialReply?.({ text, delta });
|
||||
} else {
|
||||
const knownFinalAnswer = this.shouldStreamAssistantPartial(itemId);
|
||||
const replace =
|
||||
this.streamedPartialAssistantItemId !== undefined &&
|
||||
this.streamedPartialAssistantItemId !== itemId;
|
||||
// Codex defines final_answer as terminal text. Replacement mode is for
|
||||
// phase-unknown/provisional items; append-only consumers cannot retract
|
||||
// bytes after a known terminal answer has started.
|
||||
if (replace && (!knownFinalAnswer || this.streamedPartialAssistantItemReplaceable)) {
|
||||
this.streamedPartialAssistantItemReplaceable = true;
|
||||
} else if (this.streamedPartialAssistantItemId === undefined) {
|
||||
this.streamedPartialAssistantItemReplaceable = !knownFinalAnswer;
|
||||
}
|
||||
this.streamedPartialAssistantItemId = itemId;
|
||||
const replaceable = this.streamedPartialAssistantItemReplaceable;
|
||||
const replacement = replace && replaceable;
|
||||
const streamPayload = {
|
||||
text,
|
||||
delta: replacement ? "" : delta,
|
||||
...(replacement ? { replace: true as const } : {}),
|
||||
};
|
||||
this.emitAgentEvent({
|
||||
stream: "assistant",
|
||||
data: {
|
||||
...streamPayload,
|
||||
...(replaceable ? { replaceable: true as const } : {}),
|
||||
},
|
||||
});
|
||||
// Legacy channel preview callbacks are append-oriented and do not all
|
||||
// understand replacement snapshots. Keep them on the known final-answer
|
||||
// path; replaceable snapshots stay on the typed agent-event path.
|
||||
if (knownFinalAnswer && !replaceable) {
|
||||
await this.params.onPartialReply?.(streamPayload);
|
||||
}
|
||||
}
|
||||
// Codex app-server can emit multiple agentMessage items per turn, including
|
||||
// intermediate coordination/progress prose. Keep those deltas internal until
|
||||
// their phase identifies terminal answer text or turn completion chooses the
|
||||
// last assistant item as the user-visible reply.
|
||||
// Stream non-commentary assistant deltas as partial replies and assistant
|
||||
// agent events so live surfaces (TUI, WebChat) render incremental answer
|
||||
// text via gateway emitChatDelta. When Codex switches to a new non-commentary
|
||||
// item, mark replace:true with an empty delta so live merge and append-oriented
|
||||
// partial consumers reset to the new cumulative text instead of concatenating.
|
||||
}
|
||||
|
||||
private async handleReasoningDelta(
|
||||
|
||||
@@ -185,8 +185,14 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
(event) => event.stream === "lifecycle" && event.data.phase === "start",
|
||||
);
|
||||
expect(typeof lifecycleStart?.data.startedAt).toBe("number");
|
||||
const assistantEvent = agentEvents.find((event) => event.stream === "assistant");
|
||||
expect(assistantEvent?.data).toEqual({ text: "hello back" });
|
||||
const assistantEvents = agentEvents.filter((event) => event.stream === "assistant");
|
||||
expect(assistantEvents).toHaveLength(2);
|
||||
expect(assistantEvents[0]?.data).toEqual({
|
||||
text: "hello back",
|
||||
delta: "hello back",
|
||||
replaceable: true,
|
||||
});
|
||||
expect(assistantEvents[1]?.data).toEqual({ text: "hello back" });
|
||||
const lifecycleEnd = agentEvents.find(
|
||||
(event) => event.stream === "lifecycle" && event.data.phase === "end",
|
||||
);
|
||||
@@ -202,10 +208,16 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
expect(startIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(assistantIndex).toBeGreaterThan(startIndex);
|
||||
expect(endIndex).toBeGreaterThan(assistantIndex);
|
||||
const globalAssistantEvent = globalAgentEvents.find((event) => event.stream === "assistant");
|
||||
expect(globalAssistantEvent?.runId).toBe("run-1");
|
||||
expect(globalAssistantEvent?.sessionKey).toBe("agent:main:session-1");
|
||||
expect(globalAssistantEvent?.data).toEqual({ text: "hello back" });
|
||||
const globalAssistantEvents = globalAgentEvents.filter((event) => event.stream === "assistant");
|
||||
expect(globalAssistantEvents).toHaveLength(2);
|
||||
expect(globalAssistantEvents[0]?.runId).toBe("run-1");
|
||||
expect(globalAssistantEvents[0]?.sessionKey).toBe("agent:main:session-1");
|
||||
expect(globalAssistantEvents[0]?.data).toEqual({
|
||||
text: "hello back",
|
||||
delta: "hello back",
|
||||
replaceable: true,
|
||||
});
|
||||
expect(globalAssistantEvents[1]?.data).toEqual({ text: "hello back" });
|
||||
const globalEndEvent = globalAgentEvents.find(
|
||||
(event) => event.stream === "lifecycle" && event.data.phase === "end",
|
||||
);
|
||||
|
||||
@@ -2720,6 +2720,8 @@ export async function runCodexAppServerAttempt(
|
||||
}
|
||||
turnIdRef.current = turn.turn.id;
|
||||
const activeTurnId = turn.turn.id;
|
||||
let assistantStreamEventEmitted = false;
|
||||
let assistantStreamNeedsTerminalSnapshot = false;
|
||||
emitExecutionPhaseOnce("turn_accepted", { phase: "turn_accepted" });
|
||||
userInputBridgeRef.current = createCodexUserInputBridge({
|
||||
paramsForRun: params,
|
||||
@@ -2734,7 +2736,16 @@ export async function runCodexAppServerAttempt(
|
||||
imagesCount: params.images?.length ?? 0,
|
||||
});
|
||||
projectorRef.current = new CodexAppServerEventProjector(
|
||||
dynamicToolParams,
|
||||
{
|
||||
...dynamicToolParams,
|
||||
onAgentEvent: (event) => {
|
||||
if (event.stream === "assistant" && typeof event.data.delta === "string") {
|
||||
assistantStreamEventEmitted = true;
|
||||
assistantStreamNeedsTerminalSnapshot ||= event.data.replaceable === true;
|
||||
}
|
||||
return dynamicToolParams.onAgentEvent?.(event);
|
||||
},
|
||||
},
|
||||
thread.threadId,
|
||||
activeTurnId,
|
||||
{
|
||||
@@ -3002,7 +3013,12 @@ export async function runCodexAppServerAttempt(
|
||||
turnId: activeTurnId,
|
||||
});
|
||||
const terminalAssistantText = collectTerminalAssistantText(result);
|
||||
if (terminalAssistantText && !finalAborted && !finalPromptError) {
|
||||
if (
|
||||
terminalAssistantText &&
|
||||
(!assistantStreamEventEmitted || assistantStreamNeedsTerminalSnapshot) &&
|
||||
!finalAborted &&
|
||||
!finalPromptError
|
||||
) {
|
||||
void emitCodexAppServerEvent(params, {
|
||||
stream: "assistant",
|
||||
data: { text: terminalAssistantText },
|
||||
|
||||
@@ -30,6 +30,34 @@ beforeEach(() => {
|
||||
accessMocks.applyGoogleChatInboundAccessPolicy.mockReset();
|
||||
});
|
||||
|
||||
function createInboundClassificationHarness() {
|
||||
const resolveAgentRoute = vi.fn(() => ({
|
||||
agentId: "agent-1",
|
||||
accountId: "work",
|
||||
sessionKey: "session-1",
|
||||
}));
|
||||
const buildContext = vi.fn((payload: unknown) => payload);
|
||||
const runTurn = vi.fn();
|
||||
const core = {
|
||||
logging: { shouldLogVerbose: () => false },
|
||||
channel: {
|
||||
routing: { resolveAgentRoute },
|
||||
session: {
|
||||
resolveStorePath: () => "/tmp/openclaw-googlechat-test",
|
||||
readSessionUpdatedAt: () => undefined,
|
||||
recordInboundSession: vi.fn(),
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: () => ({}),
|
||||
formatAgentEnvelope: ({ body }: { body: string }) => body,
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
||||
},
|
||||
inbound: { buildContext, run: runTurn },
|
||||
},
|
||||
} as unknown as GoogleChatCoreRuntime;
|
||||
return { buildContext, core, resolveAgentRoute, runTurn };
|
||||
}
|
||||
|
||||
describe("googlechat monitor bot loop protection", () => {
|
||||
it("maps accepted bot-authored messages to shared channel-turn facts", () => {
|
||||
expect(
|
||||
@@ -159,6 +187,74 @@ describe("googlechat monitor bot loop protection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechat monitor inbound space classification", () => {
|
||||
const cases = [
|
||||
{ name: "legacy DM", space: { type: "DM" }, peerKind: "direct" },
|
||||
{ name: "modern direct message", space: { spaceType: "DIRECT_MESSAGE" }, peerKind: "direct" },
|
||||
{ name: "single-user bot DM", space: { singleUserBotDm: true }, peerKind: "direct" },
|
||||
{ name: "modern space", space: { spaceType: "SPACE" }, peerKind: "group" },
|
||||
{ name: "modern group chat", space: { spaceType: "GROUP_CHAT" }, peerKind: "group" },
|
||||
{
|
||||
name: "modern space over legacy DM",
|
||||
space: { type: "DM", spaceType: "SPACE" },
|
||||
peerKind: "group",
|
||||
},
|
||||
] as const;
|
||||
|
||||
it.each(cases)("$name uses the expected access and route branch", async ({ space, peerKind }) => {
|
||||
const { buildContext, core, resolveAgentRoute, runTurn } = createInboundClassificationHarness();
|
||||
const account = {
|
||||
accountId: "work",
|
||||
config: {},
|
||||
credentialSource: "inline",
|
||||
} as ResolvedGoogleChatAccount;
|
||||
const event = {
|
||||
type: "MESSAGE",
|
||||
space: { name: "spaces/CLASSIFY", ...space },
|
||||
message: {
|
||||
name: "spaces/CLASSIFY/messages/1",
|
||||
text: "hello",
|
||||
sender: { name: "users/alice", displayName: "Alice", type: "HUMAN" },
|
||||
},
|
||||
} satisfies GoogleChatEvent;
|
||||
|
||||
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
|
||||
ok: true,
|
||||
commandAuthorized: undefined,
|
||||
effectiveWasMentioned: undefined,
|
||||
groupBotLoopProtection: undefined,
|
||||
groupSystemPrompt: undefined,
|
||||
});
|
||||
|
||||
await testing.processMessageWithPipeline({
|
||||
event,
|
||||
account,
|
||||
config: {},
|
||||
runtime: { error: vi.fn(), log: vi.fn() },
|
||||
core,
|
||||
mediaMaxMb: 10,
|
||||
});
|
||||
|
||||
const isGroup = peerKind === "group";
|
||||
expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isGroup }),
|
||||
);
|
||||
expect(resolveAgentRoute).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
channel: "googlechat",
|
||||
accountId: "work",
|
||||
peer: { kind: peerKind, id: "spaces/CLASSIFY" },
|
||||
});
|
||||
expect(buildContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversation: expect.objectContaining({ kind: isGroup ? "channel" : "direct" }),
|
||||
extra: expect.objectContaining({ ChatType: isGroup ? "channel" : "direct" }),
|
||||
}),
|
||||
);
|
||||
expect(runTurn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechat monitor direct messages", () => {
|
||||
it("creates typing messages by default", async () => {
|
||||
const runTurn = vi.fn();
|
||||
|
||||
@@ -29,7 +29,7 @@ import type {
|
||||
} from "./monitor-types.js";
|
||||
import { warnAppPrincipalMisconfiguration } from "./monitor-webhook.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import type { GoogleChatAttachment, GoogleChatEvent } from "./types.js";
|
||||
import type { GoogleChatAttachment, GoogleChatEvent, GoogleChatSpace } from "./types.js";
|
||||
|
||||
setGoogleChatWebhookEventProcessor(processGoogleChatEvent);
|
||||
|
||||
@@ -62,6 +62,20 @@ function resolveGoogleChatTimestampMs(eventTime?: string): number | undefined {
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function isGoogleChatGroupSpace(space: GoogleChatSpace): boolean {
|
||||
const spaceType = (space.spaceType ?? "").toUpperCase();
|
||||
// Google Chat deprecates `type` in favor of `spaceType`; known modern
|
||||
// values must win if the fields disagree. Fall back to the bot-DM flag and
|
||||
// legacy type so incomplete payloads retain their existing direct routing.
|
||||
if (spaceType === "DIRECT_MESSAGE") {
|
||||
return false;
|
||||
}
|
||||
if (spaceType === "SPACE" || spaceType === "GROUP_CHAT") {
|
||||
return true;
|
||||
}
|
||||
return space.singleUserBotDm !== true && (space.type ?? "").toUpperCase() !== "DM";
|
||||
}
|
||||
|
||||
function resolveGoogleChatBotLoopProtection(params: {
|
||||
allowBots: boolean;
|
||||
isBotSender: boolean;
|
||||
@@ -186,8 +200,7 @@ async function processMessageWithPipeline(params: {
|
||||
if (!spaceId) {
|
||||
return;
|
||||
}
|
||||
const spaceType = (space.type ?? "").toUpperCase();
|
||||
const isGroup = spaceType !== "DM";
|
||||
const isGroup = isGoogleChatGroupSpace(space);
|
||||
const sender = message.sender ?? event.user;
|
||||
const senderId = sender?.name ?? "";
|
||||
const senderName = sender?.displayName ?? "";
|
||||
|
||||
@@ -3,6 +3,10 @@ export type GoogleChatSpace = {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
type?: string;
|
||||
/** Current Google Chat field that replaces the deprecated `type` field. */
|
||||
spaceType?: string;
|
||||
/** True when the space is a 1:1 DM between a user and the Chat app. */
|
||||
singleUserBotDm?: boolean;
|
||||
};
|
||||
|
||||
export type GoogleChatUser = {
|
||||
|
||||
@@ -1647,7 +1647,10 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
.description("Enable Matrix E2EE, bootstrap verification, and print next steps")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--recovery-key <key>", "Recovery key to apply before bootstrap")
|
||||
.option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap")
|
||||
.option(
|
||||
"--force-reset-cross-signing",
|
||||
"Force reset cross-signing identity before bootstrap (requires active recovery key)",
|
||||
)
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
@@ -2121,7 +2124,10 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
"Recovery key to apply before bootstrap (prefer --recovery-key-stdin)",
|
||||
)
|
||||
.option("--recovery-key-stdin", "Read the Matrix recovery key from stdin")
|
||||
.option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap")
|
||||
.option(
|
||||
"--force-reset-cross-signing",
|
||||
"Force reset cross-signing identity before bootstrap (requires active recovery key)",
|
||||
)
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
|
||||
@@ -1645,6 +1645,54 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
expect(bootstrapSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects recovery keys when secret-storage metadata cannot authenticate them", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-"));
|
||||
const recoveryKeyPath = path.join(tmpDir, "recovery-key.json");
|
||||
fs.writeFileSync(
|
||||
recoveryKeyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: "SSSSKEY",
|
||||
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const checkKey = vi.fn(async () => true);
|
||||
Object.assign(matrixJsClient, {
|
||||
secretStorage: {
|
||||
getDefaultKeyId: vi.fn(async () => "SSSSKEY"),
|
||||
getKey: vi.fn(async () => [
|
||||
"SSSSKEY",
|
||||
{
|
||||
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
|
||||
iv: "authenticated-iv",
|
||||
},
|
||||
]),
|
||||
checkKey,
|
||||
},
|
||||
});
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
recoveryKeyPath,
|
||||
});
|
||||
await (
|
||||
client as unknown as {
|
||||
ensureCryptoSupportInitialized: () => Promise<void>;
|
||||
}
|
||||
).ensureCryptoSupportInitialized();
|
||||
const bootstrapper = (
|
||||
client as unknown as {
|
||||
cryptoBootstrapper: {
|
||||
deps: { canUnlockSecretStorage: () => Promise<boolean> };
|
||||
};
|
||||
}
|
||||
).cryptoBootstrapper;
|
||||
|
||||
await expect(bootstrapper.deps.canUnlockSecretStorage()).resolves.toBe(false);
|
||||
expect(checkKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("provides secret storage callbacks and resolves stored recovery key", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-"));
|
||||
const recoveryKeyPath = path.join(tmpDir, "recovery-key.json");
|
||||
|
||||
@@ -484,6 +484,39 @@ export class MatrixClient {
|
||||
this.cryptoBootstrapper ??= new runtime.MatrixCryptoBootstrapper<MatrixRawEvent>({
|
||||
getUserId: () => this.getUserId(),
|
||||
getPassword: () => this.password,
|
||||
canUnlockSecretStorage: async () => {
|
||||
const secretStorage = (
|
||||
this.client as {
|
||||
secretStorage?: Partial<
|
||||
Pick<MatrixJsClient["secretStorage"], "checkKey" | "getDefaultKeyId" | "getKey">
|
||||
>;
|
||||
}
|
||||
).secretStorage;
|
||||
// Partial test/runtime facades can omit secretStorage; forced reset must fail closed
|
||||
// without turning missing recovery access into a noisy caught TypeError.
|
||||
if (
|
||||
!secretStorage ||
|
||||
typeof secretStorage.getDefaultKeyId !== "function" ||
|
||||
typeof secretStorage.getKey !== "function" ||
|
||||
typeof secretStorage.checkKey !== "function"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const defaultKeyId = await secretStorage.getDefaultKeyId();
|
||||
if (!defaultKeyId) {
|
||||
return false;
|
||||
}
|
||||
const keyTuple = await secretStorage.getKey(defaultKeyId);
|
||||
const key = this.recoveryKeyStore.getSecretStorageKeyCandidate(defaultKeyId);
|
||||
if (!keyTuple || !key) {
|
||||
return false;
|
||||
}
|
||||
const keyInfo = keyTuple[1];
|
||||
if (!keyInfo.iv?.trim() || !keyInfo.mac?.trim()) {
|
||||
return false;
|
||||
}
|
||||
return await secretStorage.checkKey(key, keyInfo);
|
||||
},
|
||||
getDeviceId: () => this.client.getDeviceId(),
|
||||
verificationManager: this.verificationManager,
|
||||
recoveryKeyStore: this.recoveryKeyStore,
|
||||
@@ -747,13 +780,9 @@ export class MatrixClient {
|
||||
"Cross-signing/bootstrap is incomplete for an already owner-signed device; skipping automatic reset and preserving the current identity. Restore the recovery key or run an explicit verification bootstrap if repair is needed.",
|
||||
);
|
||||
} else {
|
||||
// No password guard: passwordless token-auth bots should still attempt repair.
|
||||
// UIA failures inside bootstrap() are caught below and logged as warnings.
|
||||
// Forced reset validates the active SSSS recovery key before rotating local keys.
|
||||
// Missing or stale recovery material fails without mutating crypto state.
|
||||
try {
|
||||
// The repair path already force-resets cross-signing; allow secret storage
|
||||
// recreation so the new keys can be persisted. Without this, a device that
|
||||
// lost its recovery key enters a permanent failure loop because the new
|
||||
// cross-signing keys have nowhere to be stored.
|
||||
const repaired = await cryptoBootstrapper.bootstrap(
|
||||
crypto,
|
||||
MATRIX_AUTOMATIC_REPAIR_BOOTSTRAP_OPTIONS,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Matrix tests cover crypto bootstrap plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
import { MatrixCryptoBootstrapper, type MatrixCryptoBootstrapperDeps } from "./crypto-bootstrap.js";
|
||||
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
|
||||
import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js";
|
||||
|
||||
type BootstrapCrossSigningMock = Mock<MatrixCryptoBootstrapApi["bootstrapCrossSigning"]>;
|
||||
@@ -39,12 +40,15 @@ function createBootstrapperDeps() {
|
||||
return {
|
||||
getUserId: vi.fn(async () => "@bot:example.org"),
|
||||
getPassword: vi.fn<() => string | undefined>(() => "super-secret-password"),
|
||||
canUnlockSecretStorage: vi.fn(async () => true),
|
||||
getDeviceId: vi.fn(() => "DEVICE123"),
|
||||
verificationManager: {
|
||||
trackVerificationRequest: vi.fn(),
|
||||
},
|
||||
recoveryKeyStore: {
|
||||
bootstrapSecretStorageWithRecoveryKey: vi.fn(async () => {}),
|
||||
bootstrapSecretStorageWithRecoveryKey: vi.fn<
|
||||
MatrixRecoveryKeyStore["bootstrapSecretStorageWithRecoveryKey"]
|
||||
>(async () => {}),
|
||||
},
|
||||
decryptBridge: {
|
||||
bindCryptoRetrySignals: vi.fn(),
|
||||
@@ -132,17 +136,6 @@ function createForcedResetHarness(bootstrapCrossSigning: BootstrapCrossSigningMo
|
||||
});
|
||||
}
|
||||
|
||||
function expectForcedResetCrossSigningCalls(
|
||||
bootstrapCrossSigning: BootstrapCrossSigningMock,
|
||||
params: { setupNewCall: number; totalCalls: number },
|
||||
) {
|
||||
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(params.totalCalls);
|
||||
expectBootstrapCrossSigningCall(bootstrapCrossSigning, params.setupNewCall, {
|
||||
setupNewCrossSigning: true,
|
||||
});
|
||||
expectBootstrapCrossSigningCall(bootstrapCrossSigning, params.totalCalls);
|
||||
}
|
||||
|
||||
async function bootstrapWithVerificationRequestListener(overrides?: {
|
||||
deps?: Partial<ReturnType<typeof createBootstrapperDeps>>;
|
||||
crypto?: Partial<MatrixCryptoBootstrapApi>;
|
||||
@@ -406,32 +399,72 @@ describe("MatrixCryptoBootstrapper", () => {
|
||||
);
|
||||
|
||||
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
|
||||
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("recreates secret storage and retries a forced reset when stale server SSSS blocks it", async () => {
|
||||
it("rejects forced reset before mutation when the active recovery key is unavailable", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
deps.canUnlockSecretStorage = vi.fn(async () => false);
|
||||
const bootstrapCrossSigning = vi.fn(async () => {});
|
||||
const crypto = createCryptoApi({ bootstrapCrossSigning });
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await expect(
|
||||
bootstrapper.bootstrap(crypto, {
|
||||
strict: true,
|
||||
forceResetCrossSigning: true,
|
||||
allowSecretStorageRecreateWithoutRecoveryKey: true,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Forced cross-signing reset requires the active Matrix recovery key; supply it before retrying",
|
||||
);
|
||||
|
||||
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
|
||||
expect(bootstrapCrossSigning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed without recreating SSSS when forced reset cannot unlock it", async () => {
|
||||
const bootstrapCrossSigning = vi
|
||||
.fn<() => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("getSecretStorageKey callback returned falsey"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
.mockRejectedValueOnce(new Error("getSecretStorageKey callback returned falsey"));
|
||||
const { deps, crypto, bootstrapper } = createForcedResetHarness(bootstrapCrossSigning);
|
||||
|
||||
await bootstrapper.bootstrap(crypto, {
|
||||
strict: true,
|
||||
await expect(
|
||||
bootstrapper.bootstrap(crypto, {
|
||||
strict: true,
|
||||
forceResetCrossSigning: true,
|
||||
allowSecretStorageRecreateWithoutRecoveryKey: true,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Forced cross-signing reset cannot access secret storage; restore the Matrix recovery key before retrying",
|
||||
);
|
||||
|
||||
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
|
||||
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(1);
|
||||
expectBootstrapCrossSigningCall(bootstrapCrossSigning, 1, { setupNewCrossSigning: true });
|
||||
});
|
||||
|
||||
it("does not repair SSSS after a non-strict forced reset failure", async () => {
|
||||
const bootstrapCrossSigning = vi.fn(async () => {
|
||||
throw new Error("getSecretStorageKey callback returned falsey");
|
||||
});
|
||||
const { deps, crypto, bootstrapper } = createForcedResetHarness(bootstrapCrossSigning);
|
||||
|
||||
const result = await bootstrapper.bootstrap(crypto, {
|
||||
strict: false,
|
||||
forceResetCrossSigning: true,
|
||||
allowSecretStorageRecreateWithoutRecoveryKey: true,
|
||||
});
|
||||
|
||||
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
|
||||
crypto,
|
||||
{
|
||||
allowSecretStorageRecreateWithoutRecoveryKey: true,
|
||||
forceNewSecretStorage: true,
|
||||
},
|
||||
);
|
||||
expectForcedResetCrossSigningCalls(bootstrapCrossSigning, {
|
||||
setupNewCall: 2,
|
||||
totalCalls: 3,
|
||||
expect(result).toEqual({
|
||||
crossSigningReady: false,
|
||||
crossSigningPublished: false,
|
||||
ownDeviceVerified: null,
|
||||
});
|
||||
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
|
||||
expect(bootstrapCrossSigning).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("re-exports cross-signing keys after forced reset creates secret storage", async () => {
|
||||
@@ -444,16 +477,16 @@ describe("MatrixCryptoBootstrapper", () => {
|
||||
allowSecretStorageRecreateWithoutRecoveryKey: true,
|
||||
});
|
||||
|
||||
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledOnce();
|
||||
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
|
||||
crypto,
|
||||
{
|
||||
expect.objectContaining({
|
||||
allowSecretStorageRecreateWithoutRecoveryKey: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expectForcedResetCrossSigningCalls(bootstrapCrossSigning, {
|
||||
setupNewCall: 1,
|
||||
totalCalls: 2,
|
||||
});
|
||||
// No redundant second bootstrapCrossSigning call — no double reset (gh-78396).
|
||||
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(1);
|
||||
expectBootstrapCrossSigningCall(bootstrapCrossSigning, 1, { setupNewCrossSigning: true });
|
||||
});
|
||||
|
||||
it("trusts the fresh own identity after a forced cross-signing reset", async () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { isMatrixDeviceOwnerVerified } from "./verification-status.js";
|
||||
export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
|
||||
getUserId: () => Promise<string>;
|
||||
getPassword?: () => string | undefined;
|
||||
canUnlockSecretStorage: () => Promise<boolean>;
|
||||
getDeviceId: () => string | null | undefined;
|
||||
verificationManager: MatrixVerificationManager;
|
||||
recoveryKeyStore: MatrixRecoveryKeyStore;
|
||||
@@ -51,11 +52,17 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
options: MatrixCryptoBootstrapOptions = {},
|
||||
): Promise<MatrixCryptoBootstrapResult> {
|
||||
const strict = options.strict === true;
|
||||
const deferSecretStorageBootstrapUntilAfterCrossSigning =
|
||||
options.forceResetCrossSigning === true;
|
||||
const forceReset = options.forceResetCrossSigning === true;
|
||||
const deferSecretStorageBootstrapUntilAfterCrossSigning = forceReset;
|
||||
if (forceReset && !(await this.deps.canUnlockSecretStorage())) {
|
||||
throw new Error(
|
||||
"Forced cross-signing reset requires the active Matrix recovery key; supply it before retrying",
|
||||
);
|
||||
}
|
||||
// Register verification listeners before expensive bootstrap work so incoming requests
|
||||
// are not missed during startup.
|
||||
this.registerVerificationRequestHandler(crypto);
|
||||
|
||||
if (!deferSecretStorageBootstrapUntilAfterCrossSigning) {
|
||||
await this.bootstrapSecretStorage(crypto, {
|
||||
strict,
|
||||
@@ -63,30 +70,33 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
|
||||
});
|
||||
}
|
||||
let crossSigning = await this.bootstrapCrossSigning(crypto, {
|
||||
forceResetCrossSigning: options.forceResetCrossSigning === true,
|
||||
|
||||
const crossSigning = await this.bootstrapCrossSigning(crypto, {
|
||||
forceResetCrossSigning: forceReset,
|
||||
allowAutomaticCrossSigningReset: options.allowAutomaticCrossSigningReset !== false,
|
||||
allowSecretStorageRecreateWithoutRecoveryKey:
|
||||
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
|
||||
// A repair retry would generate another identity after the SDK already rotated local keys.
|
||||
// Fail closed instead; the server identity and existing recovery material remain authoritative.
|
||||
allowSecretStorageRecreateWithoutRecoveryKey: forceReset
|
||||
? false
|
||||
: options.allowSecretStorageRecreateWithoutRecoveryKey === true,
|
||||
strict,
|
||||
});
|
||||
// Forced repair may need password UIA to upload new cross-signing keys. Delay any
|
||||
// secret-storage repair/recreation until after that step succeeds so passwordless bots do
|
||||
// not partially mutate SSSS on homeservers that require password-based UIA.
|
||||
|
||||
if (forceReset && (!crossSigning.ready || !crossSigning.published)) {
|
||||
return {
|
||||
crossSigningReady: crossSigning.ready,
|
||||
crossSigningPublished: crossSigning.published,
|
||||
ownDeviceVerified: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Second SSSS pass to pick up cross-signing keys published during bootstrap.
|
||||
await this.bootstrapSecretStorage(crypto, {
|
||||
strict,
|
||||
allowSecretStorageRecreateWithoutRecoveryKey:
|
||||
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
|
||||
});
|
||||
if (deferSecretStorageBootstrapUntilAfterCrossSigning) {
|
||||
crossSigning = await this.bootstrapCrossSigning(crypto, {
|
||||
forceResetCrossSigning: false,
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
allowSecretStorageRecreateWithoutRecoveryKey:
|
||||
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
|
||||
strict,
|
||||
});
|
||||
}
|
||||
|
||||
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, {
|
||||
strict,
|
||||
});
|
||||
@@ -234,6 +244,12 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
}
|
||||
LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", err);
|
||||
if (options.strict) {
|
||||
if (isRepairableSecretStorageAccessError(err)) {
|
||||
throw new Error(
|
||||
"Forced cross-signing reset cannot access secret storage; restore the Matrix recovery key before retrying",
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
return { ready: false, published: false };
|
||||
|
||||
@@ -148,6 +148,7 @@ describe("MatrixRecoveryKeyStore", () => {
|
||||
expect(fs.existsSync(recoveryKeyPath)).toBe(false);
|
||||
expect(fs.existsSync(`${recoveryKeyPath}.migrated`)).toBe(true);
|
||||
const callbacks = store.buildCryptoCallbacks();
|
||||
expect(store.getSecretStorageKeyCandidate("SSSS")).toEqual(new Uint8Array([1, 2, 3, 4]));
|
||||
const resolved = await callbacks.getSecretStorageKey?.(
|
||||
{ keys: { SSSS: { name: "test" } } },
|
||||
"m.cross_signing.master",
|
||||
@@ -155,6 +156,12 @@ describe("MatrixRecoveryKeyStore", () => {
|
||||
|
||||
expect(resolved?.[0]).toBe("SSSS");
|
||||
expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]);
|
||||
|
||||
const resolvedFromMultipleKeys = await callbacks.getSecretStorageKey?.(
|
||||
{ keys: { OLD: { name: "old" }, SSSS: { name: "active" } } },
|
||||
"m.cross_signing.master",
|
||||
);
|
||||
expect(resolvedFromMultipleKeys?.[0]).toBe("SSSS");
|
||||
});
|
||||
|
||||
it("keeps a readable legacy recovery key usable when SQLite migration fails", async () => {
|
||||
@@ -233,6 +240,15 @@ describe("MatrixRecoveryKeyStore", () => {
|
||||
expect(saved.privateKeyBase64).toBe(Buffer.from([9, 8, 7]).toString("base64"));
|
||||
});
|
||||
|
||||
it("does not authorize destructive reset from an ephemeral cached key", () => {
|
||||
const store = new MatrixRecoveryKeyStore();
|
||||
const callbacks = store.buildCryptoCallbacks();
|
||||
|
||||
callbacks.cacheSecretStorageKey?.("KEY123", { name: "openclaw" }, new Uint8Array([9, 8, 7]));
|
||||
|
||||
expect(store.getSecretStorageKeyCandidate("KEY123")).toBeNull();
|
||||
});
|
||||
|
||||
it("creates and persists a recovery key when secret storage is missing", async () => {
|
||||
const { store, createRecoveryKeyFromPassphrase, bootstrapSecretStorage } =
|
||||
await runSecretStorageBootstrapScenario({
|
||||
|
||||
@@ -135,6 +135,27 @@ export class MatrixRecoveryKeyStore {
|
||||
};
|
||||
}
|
||||
|
||||
getSecretStorageKeyCandidate(keyId: string): Uint8Array | null {
|
||||
const normalizedKeyId = keyId.trim();
|
||||
if (!normalizedKeyId) {
|
||||
return null;
|
||||
}
|
||||
const staged = this.resolveStagedSecretStorageKey([normalizedKeyId]);
|
||||
if (staged) {
|
||||
return staged[1];
|
||||
}
|
||||
const stored = this.loadStoredRecoveryKey();
|
||||
if (!stored?.privateKeyBase64) {
|
||||
return null;
|
||||
}
|
||||
const privateKey = new Uint8Array(Buffer.from(stored.privateKeyBase64, "base64"));
|
||||
if (privateKey.length === 0) {
|
||||
return null;
|
||||
}
|
||||
this.rememberSecretStorageKey(normalizedKeyId, privateKey, stored.keyInfo);
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
private resolveEncodedRecoveryKeyInput(params: {
|
||||
encodedPrivateKey: string;
|
||||
keyId?: string | null;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { responseWithRelease } from "../response-with-release.js";
|
||||
import type { MSTeamsAttachmentLike } from "./types.js";
|
||||
|
||||
type InlineImageCandidate =
|
||||
@@ -576,52 +577,6 @@ export async function resolveAndValidateIP(
|
||||
|
||||
/** Maximum number of redirects to follow in safeFetch. */
|
||||
const MAX_SAFE_REDIRECTS = 5;
|
||||
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
||||
|
||||
function responseWithRelease(response: Response, release: () => Promise<void>): Response {
|
||||
let released = false;
|
||||
const releaseOnce = async () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
await release();
|
||||
};
|
||||
|
||||
if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
|
||||
void releaseOnce();
|
||||
return response;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const next = await reader.read();
|
||||
if (next.done) {
|
||||
controller.close();
|
||||
await releaseOnce();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(next.value);
|
||||
} catch (err) {
|
||||
await releaseOnce();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
void reader.cancel(reason).catch(() => {});
|
||||
await releaseOnce();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a URL with redirect: "manual", validating each redirect target
|
||||
* against the hostname allowlist and optional DNS-resolved IP (anti-SSRF).
|
||||
|
||||
@@ -4,13 +4,13 @@ import { fetchWithSsrFGuard, type MSTeamsConfig } from "../runtime-api.js";
|
||||
import { GRAPH_ROOT } from "./attachments/shared.js";
|
||||
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
|
||||
import { createMSTeamsHttpError } from "./http-error.js";
|
||||
import { responseWithRelease } from "./response-with-release.js";
|
||||
import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { readAccessToken } from "./token-response.js";
|
||||
import { resolveDelegatedAccessToken, resolveMSTeamsCredentials } from "./token.js";
|
||||
import { buildUserAgent } from "./user-agent.js";
|
||||
|
||||
const GRAPH_BETA = "https://graph.microsoft.com/beta";
|
||||
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
||||
|
||||
export type GraphUser = {
|
||||
id?: string;
|
||||
@@ -31,50 +31,6 @@ type GraphChannel = {
|
||||
|
||||
export type GraphResponse<T> = { value?: T[] };
|
||||
|
||||
function responseWithRelease(response: Response, release: () => Promise<void>): Response {
|
||||
let released = false;
|
||||
const releaseOnce = async () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
await release();
|
||||
};
|
||||
|
||||
if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
|
||||
void releaseOnce();
|
||||
return response;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const next = await reader.read();
|
||||
if (next.done) {
|
||||
controller.close();
|
||||
await releaseOnce();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(next.value);
|
||||
} catch (error) {
|
||||
await releaseOnce();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
void reader.cancel(reason).catch(() => undefined);
|
||||
await releaseOnce();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeQuery(value?: string | null): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
45
extensions/msteams/src/response-with-release.ts
Normal file
45
extensions/msteams/src/response-with-release.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
||||
|
||||
export function responseWithRelease(response: Response, release: () => Promise<void>): Response {
|
||||
let released = false;
|
||||
const releaseOnce = async () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
await release();
|
||||
};
|
||||
|
||||
if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
|
||||
void releaseOnce();
|
||||
return response;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const next = await reader.read();
|
||||
if (next.done) {
|
||||
controller.close();
|
||||
await releaseOnce();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(next.value);
|
||||
} catch (error) {
|
||||
await releaseOnce();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
void reader.cancel(reason).catch(() => undefined);
|
||||
await releaseOnce();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { isMap, isScalar, isSeq, type Node, type Pair } from "yaml";
|
||||
import type { MdAst } from "./ast.js";
|
||||
import type { JsoncValue } from "./jsonc/ast.js";
|
||||
import type { JsonlAst, JsonlLine } from "./jsonl/ast.js";
|
||||
import { pickJsonlLine } from "./jsonl/line.js";
|
||||
import type { OcPath } from "./oc-path.js";
|
||||
import {
|
||||
MAX_TRAVERSAL_DEPTH,
|
||||
@@ -364,7 +365,7 @@ const jsonlOps: WalkOps<JsonlAst> = {
|
||||
}
|
||||
},
|
||||
lookup(ast, key) {
|
||||
const line = pickLine(ast, key);
|
||||
const line = pickJsonlLine(ast, key);
|
||||
if (line === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -440,37 +441,6 @@ function topLevelLeafText(value: JsoncValue, key: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickLine(ast: JsonlAst, addr: string): JsonlLine | null {
|
||||
if (addr === "$first") {
|
||||
for (const l of ast.lines) {
|
||||
if (l.kind === "value") {
|
||||
return l;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (addr === "$last") {
|
||||
for (let i = ast.lines.length - 1; i >= 0; i--) {
|
||||
const l = ast.lines[i];
|
||||
if (l !== undefined && l.kind === "value") {
|
||||
return l;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const m = /^L(\d+)$/.exec(addr);
|
||||
if (m === null || m[1] === undefined) {
|
||||
return null;
|
||||
}
|
||||
const target = Number(m[1]);
|
||||
for (const l of ast.lines) {
|
||||
if (l.line === target) {
|
||||
return l;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- YAML walker ----------------------------------------------------
|
||||
|
||||
function walkYaml(
|
||||
|
||||
33
extensions/oc-path/src/oc-path/jsonl/line.ts
Normal file
33
extensions/oc-path/src/oc-path/jsonl/line.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { POS_FIRST, POS_LAST } from "../oc-path.js";
|
||||
import type { JsonlAst, JsonlLine } from "./ast.js";
|
||||
|
||||
export function pickJsonlLine(ast: JsonlAst, addr: string): JsonlLine | null {
|
||||
if (addr === POS_FIRST) {
|
||||
for (const line of ast.lines) {
|
||||
if (line.kind === "value") {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (addr === POS_LAST) {
|
||||
for (let index = ast.lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = ast.lines[index];
|
||||
if (line !== undefined && line.kind === "value") {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const match = /^L(\d+)$/.exec(addr);
|
||||
if (match === null || match[1] === undefined) {
|
||||
return null;
|
||||
}
|
||||
const target = Number(match[1]);
|
||||
for (const line of ast.lines) {
|
||||
if (line.line === target) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -18,14 +18,9 @@
|
||||
import type { JsoncEntry, JsoncValue } from "../jsonc/ast.js";
|
||||
import { resolveJsoncValueOcPath } from "../jsonc/resolve-value.js";
|
||||
import type { OcPath } from "../oc-path.js";
|
||||
import {
|
||||
POS_FIRST,
|
||||
POS_LAST,
|
||||
isQuotedSeg,
|
||||
splitRespectingBrackets,
|
||||
unquoteSeg,
|
||||
} from "../oc-path.js";
|
||||
import { isQuotedSeg, splitRespectingBrackets, unquoteSeg } from "../oc-path.js";
|
||||
import type { JsonlAst, JsonlLine } from "./ast.js";
|
||||
import { pickJsonlLine } from "./line.js";
|
||||
|
||||
export type JsonlOcPathMatch =
|
||||
| { readonly kind: "root"; readonly node: JsonlAst }
|
||||
@@ -50,7 +45,7 @@ export function resolveJsonlOcPath(ast: JsonlAst, path: OcPath): JsonlOcPathMatc
|
||||
return { kind: "root", node: ast };
|
||||
}
|
||||
|
||||
const lineEntry = pickLine(ast, head);
|
||||
const lineEntry = pickJsonlLine(ast, head);
|
||||
if (lineEntry === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -90,34 +85,3 @@ export function resolveJsonlOcPath(ast: JsonlAst, path: OcPath): JsonlOcPathMatc
|
||||
}
|
||||
return { kind: "value", node: match.node, line: lineEntry.line, path: match.path };
|
||||
}
|
||||
|
||||
function pickLine(ast: JsonlAst, addr: string): JsonlLine | null {
|
||||
if (addr === POS_FIRST) {
|
||||
for (const l of ast.lines) {
|
||||
if (l.kind === "value") {
|
||||
return l;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (addr === POS_LAST) {
|
||||
for (let i = ast.lines.length - 1; i >= 0; i--) {
|
||||
const l = ast.lines[i];
|
||||
if (l !== undefined && l.kind === "value") {
|
||||
return l;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const m = /^L(\d+)$/.exec(addr);
|
||||
if (m === null || m[1] === undefined) {
|
||||
return null;
|
||||
}
|
||||
const target = Number(m[1]);
|
||||
for (const l of ast.lines) {
|
||||
if (l.line === target) {
|
||||
return l;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1536,6 +1536,8 @@ export async function runMatrixQaE2eeBootstrapSuccessScenario(
|
||||
): Promise<MatrixQaScenarioExecution> {
|
||||
requireMatrixQaPassword(context, "driver");
|
||||
return await withMatrixQaE2eeDriver(context, "matrix-e2ee-bootstrap-success", async (client) => {
|
||||
const initial = await client.bootstrapOwnDeviceVerification();
|
||||
assertMatrixQaBootstrapSucceeded("driver initial", initial);
|
||||
const result = await client.bootstrapOwnDeviceVerification({
|
||||
forceResetCrossSigning: true,
|
||||
});
|
||||
@@ -1550,7 +1552,7 @@ export async function runMatrixQaE2eeBootstrapSuccessScenario(
|
||||
recoveryKeyStored: result.verification.recoveryKeyStored,
|
||||
},
|
||||
details: [
|
||||
"driver bootstrap succeeded through real Matrix crypto bootstrap",
|
||||
"driver bootstrap and guarded cross-signing reset succeeded through real Matrix crypto bootstrap",
|
||||
`device verified: ${result.verification.verified ? "yes" : "no"}`,
|
||||
`cross-signing verified: ${result.verification.crossSigningVerified ? "yes" : "no"}`,
|
||||
`signed by owner: ${result.verification.signedByOwner ? "yes" : "no"}`,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
createPayloadPatchStreamWrapper,
|
||||
isOpenAICompatibleThinkingEnabled,
|
||||
setQwenChatTemplateThinking,
|
||||
} from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
|
||||
type QwenThinkingLevel = ProviderWrapStreamFnContext["thinkingLevel"];
|
||||
@@ -68,25 +69,6 @@ function patchQwenOAuthPayload(payload: Record<string, unknown>): void {
|
||||
payload.vl_high_resolution_images = true;
|
||||
}
|
||||
|
||||
function setQwenChatTemplateThinking(payload: Record<string, unknown>, enabled: boolean): void {
|
||||
const existing = payload.chat_template_kwargs;
|
||||
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
||||
const next: Record<string, unknown> = {
|
||||
...(existing as Record<string, unknown>),
|
||||
enable_thinking: enabled,
|
||||
};
|
||||
if (!Object.hasOwn(next, "preserve_thinking")) {
|
||||
next.preserve_thinking = true;
|
||||
}
|
||||
payload.chat_template_kwargs = next;
|
||||
return;
|
||||
}
|
||||
payload.chat_template_kwargs = {
|
||||
enable_thinking: enabled,
|
||||
preserve_thinking: true,
|
||||
};
|
||||
}
|
||||
|
||||
function readQwenThinkingFormatFromModel(model: Parameters<StreamFn>[0]): QwenThinkingFormat {
|
||||
if (model.api !== "openai-completions") {
|
||||
return undefined;
|
||||
|
||||
@@ -619,6 +619,37 @@ describe("createWebhookHandler", () => {
|
||||
expectBotReplySentTo("123");
|
||||
});
|
||||
|
||||
it("awaits deliver directly with no local hardcoded timeout wrapper", async () => {
|
||||
// Previously this webhook handler wrapped deliver with a hardcoded 120s
|
||||
// Promise.race that overrode the configurable agents.defaults.timeoutSeconds
|
||||
// from core. That wrapper created a setTimeout(_, 120000) on every deliver
|
||||
// call. We spy on setTimeout to prove no such call exists in the current code.
|
||||
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
||||
try {
|
||||
const deliver = vi.fn().mockResolvedValue("late reply");
|
||||
const handler = createWebhookHandler({
|
||||
account: makeAccount({ accountId: "no-hardcoded-timeout-" + Date.now() }),
|
||||
deliver,
|
||||
log,
|
||||
});
|
||||
|
||||
const res = makeRes();
|
||||
const req = makeReq("POST", validBody);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
// Collect all setTimeout delays used during this handler run
|
||||
const delays = setTimeoutSpy.mock.calls.map((call) => call[1]);
|
||||
// Every delay should be well under 120s — the old hardcoded wrapper would
|
||||
// have produced exactly one call with delay === 120000.
|
||||
const longDelays = delays.filter((d) => typeof d === "number" && d >= 120_000);
|
||||
expect(longDelays).toEqual([]);
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("sanitizes input before delivery", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue(null);
|
||||
const handler = createWebhookHandler({
|
||||
|
||||
@@ -554,7 +554,7 @@ async function processAuthorizedSynologyWebhook(params: {
|
||||
log: params.log,
|
||||
});
|
||||
|
||||
const deliverPromise = params.deliver({
|
||||
const reply = await params.deliver({
|
||||
body: params.message.body,
|
||||
from: authorizedWebhookUserId,
|
||||
senderName: params.message.payload.username,
|
||||
@@ -564,10 +564,6 @@ async function processAuthorizedSynologyWebhook(params: {
|
||||
commandAuthorized: params.message.commandAuthorized,
|
||||
chatUserId: deliveryUserId,
|
||||
});
|
||||
const timeoutPromise = new Promise<null>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Agent response timeout (120s)")), 120_000);
|
||||
});
|
||||
const reply = await Promise.race([deliverPromise, timeoutPromise]);
|
||||
if (!reply) {
|
||||
return;
|
||||
}
|
||||
|
||||
22
extensions/vercel-ai-gateway/index.test.ts
Normal file
22
extensions/vercel-ai-gateway/index.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Vercel Ai Gateway tests cover provider runtime hooks.
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
|
||||
describe("vercel ai gateway provider hooks", () => {
|
||||
it("resolves live-only model ids for the embedded runner", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "vercel-ai-gateway",
|
||||
modelId: "custom/provider-model",
|
||||
modelRegistry: { find: () => null },
|
||||
} as never);
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "custom/provider-model",
|
||||
provider: "vercel-ai-gateway",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://ai-gateway.vercel.sh",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from
|
||||
import {
|
||||
buildStaticVercelAiGatewayProvider,
|
||||
buildVercelAiGatewayProvider,
|
||||
resolveVercelAiGatewayModel,
|
||||
} from "./provider-catalog.js";
|
||||
import { resolveVercelAiGatewayThinkingProfile } from "./thinking.js";
|
||||
|
||||
@@ -37,6 +38,7 @@ export default defineSingleProviderPluginEntry({
|
||||
buildProvider: buildVercelAiGatewayProvider,
|
||||
buildStaticProvider: buildStaticVercelAiGatewayProvider,
|
||||
},
|
||||
resolveDynamicModel: ({ modelId }) => resolveVercelAiGatewayModel(modelId),
|
||||
resolveThinkingProfile: ({ modelId }) => resolveVercelAiGatewayThinkingProfile(modelId),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -142,6 +142,21 @@ function getStaticFallbackModel(id: string): ModelDefinitionConfig | undefined {
|
||||
return fallback ? buildStaticModelDefinition(fallback) : undefined;
|
||||
}
|
||||
|
||||
/** Builds runtime metadata for models returned by the live gateway catalog. */
|
||||
export function resolveVercelAiGatewayDynamicModel(modelId: string): ModelDefinitionConfig {
|
||||
return (
|
||||
getStaticFallbackModel(modelId) ?? {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: VERCEL_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: VERCEL_AI_GATEWAY_DEFAULT_MAX_TOKENS,
|
||||
cost: VERCEL_AI_GATEWAY_DEFAULT_COST,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function getStaticVercelAiGatewayModelCatalog(): ModelDefinitionConfig[] {
|
||||
return STATIC_VERCEL_AI_GATEWAY_MODEL_CATALOG.map(buildStaticModelDefinition);
|
||||
}
|
||||
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
VERCEL_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW,
|
||||
VERCEL_AI_GATEWAY_DEFAULT_MAX_TOKENS,
|
||||
} from "./api.js";
|
||||
import { resolveVercelAiGatewayDynamicModel } from "./models.js";
|
||||
import {
|
||||
buildStaticVercelAiGatewayProvider,
|
||||
buildVercelAiGatewayProvider,
|
||||
resolveVercelAiGatewayModel,
|
||||
} from "./provider-catalog.js";
|
||||
|
||||
const STATIC_MODEL_IDS = [
|
||||
@@ -83,6 +85,42 @@ describe("vercel ai gateway provider catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds runtime metadata for live-only model ids", () => {
|
||||
expect(resolveVercelAiGatewayDynamicModel("custom/provider-model")).toEqual({
|
||||
id: "custom/provider-model",
|
||||
name: "custom/provider-model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: VERCEL_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: VERCEL_AI_GATEWAY_DEFAULT_MAX_TOKENS,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("adds transport metadata for runtime model resolution", () => {
|
||||
expect(resolveVercelAiGatewayModel("custom/provider-model")).toMatchObject({
|
||||
id: "custom/provider-model",
|
||||
provider: "vercel-ai-gateway",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: VERCEL_AI_GATEWAY_BASE_URL,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves provider thinking metadata for known live-only upstream models", () => {
|
||||
expect(resolveVercelAiGatewayModel("openai/gpt-5.5")).toMatchObject({
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
});
|
||||
expect(resolveVercelAiGatewayModel("anthropic/claude-sonnet-4-6")).toMatchObject({
|
||||
input: ["text", "image"],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the static catalog for malformed successful model list payloads", async () => {
|
||||
for (const payload of [[], { data: {} }, { data: [null] }]) {
|
||||
clearLiveCatalogCacheForTests();
|
||||
|
||||
@@ -3,8 +3,43 @@ import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-sha
|
||||
import {
|
||||
discoverVercelAiGatewayModels,
|
||||
getStaticVercelAiGatewayModelCatalog,
|
||||
resolveVercelAiGatewayDynamicModel,
|
||||
VERCEL_AI_GATEWAY_BASE_URL,
|
||||
VERCEL_AI_GATEWAY_PROVIDER_ID,
|
||||
} from "./models.js";
|
||||
import { resolveVercelAiGatewayThinkingProfile } from "./thinking.js";
|
||||
|
||||
const VERCEL_AI_GATEWAY_IMAGE_MODEL_IDS = new Set([
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.5-pro",
|
||||
"openai/gpt-5.4",
|
||||
"openai/gpt-5.4-pro",
|
||||
"openai/gpt-5.4-mini",
|
||||
"openai/gpt-5.4-nano",
|
||||
"openai/gpt-5.3-codex",
|
||||
"openai/gpt-5.3-codex-spark",
|
||||
"openai/gpt-5.2",
|
||||
"openai/gpt-5.2-codex",
|
||||
"openai/gpt-5.1-codex",
|
||||
]);
|
||||
|
||||
export function resolveVercelAiGatewayModel(modelId: string) {
|
||||
const model = resolveVercelAiGatewayDynamicModel(modelId);
|
||||
const input: Array<"text" | "image"> = model.input.includes("image")
|
||||
? ["text", "image"]
|
||||
: VERCEL_AI_GATEWAY_IMAGE_MODEL_IDS.has(modelId) ||
|
||||
/^anthropic\/claude-(?:opus|sonnet|haiku)-/.test(modelId)
|
||||
? ["text", "image"]
|
||||
: ["text"];
|
||||
return {
|
||||
...model,
|
||||
reasoning: model.reasoning || Boolean(resolveVercelAiGatewayThinkingProfile(modelId)),
|
||||
input,
|
||||
api: "anthropic-messages" as const,
|
||||
provider: VERCEL_AI_GATEWAY_PROVIDER_ID,
|
||||
baseUrl: VERCEL_AI_GATEWAY_BASE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStaticVercelAiGatewayProvider(): ModelProviderConfig {
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
createPayloadPatchStreamWrapper,
|
||||
isOpenAICompatibleThinkingEnabled,
|
||||
setQwenChatTemplateThinking,
|
||||
} from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import {
|
||||
resolveVllmQwenThinkingFormatFromCompat,
|
||||
@@ -23,25 +24,6 @@ function resolveVllmQwenThinkingFormat(
|
||||
return resolveVllmQwenThinkingFormatFromCompat(ctx.model?.compat);
|
||||
}
|
||||
|
||||
function setQwenChatTemplateThinking(payload: Record<string, unknown>, enabled: boolean): void {
|
||||
const existing = payload.chat_template_kwargs;
|
||||
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
||||
const next: Record<string, unknown> = {
|
||||
...(existing as Record<string, unknown>),
|
||||
enable_thinking: enabled,
|
||||
};
|
||||
if (!Object.hasOwn(next, "preserve_thinking")) {
|
||||
next.preserve_thinking = true;
|
||||
}
|
||||
payload.chat_template_kwargs = next;
|
||||
return;
|
||||
}
|
||||
payload.chat_template_kwargs = {
|
||||
enable_thinking: enabled,
|
||||
preserve_thinking: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isVllmNemotronModel(model: { api?: unknown; provider?: unknown; id?: unknown }): boolean {
|
||||
return (
|
||||
model.api === "openai-completions" &&
|
||||
|
||||
@@ -669,4 +669,52 @@ describe("WhatsAppConnectionController", () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses messageTimeoutMs * 4 as the app-silence window for fresh connections with no inbound", async () => {
|
||||
// Verifies the watchdog respects appSilenceTimeoutMs = messageTimeoutMs * 4 on first open.
|
||||
// Transport is kept well within its own timeout so only app-silence fires.
|
||||
vi.useFakeTimers();
|
||||
const msgTimeoutMs = 100;
|
||||
const controllerLocal = new WhatsAppConnectionController({
|
||||
accountId: "work",
|
||||
authDir: "/tmp/wa-auth",
|
||||
verbose: false,
|
||||
keepAlive: true,
|
||||
heartbeatSeconds: 1,
|
||||
transportTimeoutMs: 10_000,
|
||||
messageTimeoutMs: msgTimeoutMs,
|
||||
watchdogCheckMs: 10,
|
||||
reconnectPolicy: {
|
||||
initialMs: 250,
|
||||
maxMs: 1_000,
|
||||
factor: 2,
|
||||
jitter: 0,
|
||||
maxAttempts: 5,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const sock = createSocketWithTransportEmitter();
|
||||
createWaSocketMock.mockResolvedValueOnce(sock as never);
|
||||
waitForWaConnectionMock.mockResolvedValueOnce(undefined);
|
||||
|
||||
const timeouts: string[] = [];
|
||||
await controllerLocal.openConnection({
|
||||
connectionId: "conn-app-silence",
|
||||
createListener: async () => createListenerStub() as never,
|
||||
onWatchdogTimeout: () => timeouts.push("timeout"),
|
||||
});
|
||||
|
||||
// Just before messageTimeoutMs * 4 — no force-close expected
|
||||
await vi.advanceTimersByTimeAsync(msgTimeoutMs * 4 - 20);
|
||||
expect(timeouts).toHaveLength(0);
|
||||
|
||||
// Past messageTimeoutMs * 4 — force-close must fire
|
||||
await vi.advanceTimersByTimeAsync(40);
|
||||
expect(timeouts.length).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
await controllerLocal.shutdown();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -404,7 +404,7 @@ export class WhatsAppConnectionController {
|
||||
this.heartbeatSeconds = params.heartbeatSeconds;
|
||||
this.transportTimeoutMs = params.transportTimeoutMs;
|
||||
this.messageTimeoutMs = params.messageTimeoutMs;
|
||||
this.appSilenceTimeoutMs = Math.max(params.messageTimeoutMs, params.messageTimeoutMs * 4);
|
||||
this.appSilenceTimeoutMs = params.messageTimeoutMs * 4;
|
||||
this.watchdogCheckMs = params.watchdogCheckMs;
|
||||
this.reconnectPolicy = params.reconnectPolicy;
|
||||
this.abortSignal = params.abortSignal;
|
||||
|
||||
@@ -767,6 +767,154 @@ describe("agentLoop tool termination", () => {
|
||||
expect(events.filter((event) => event.type === "tool_execution_start")).toHaveLength(1);
|
||||
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
|
||||
});
|
||||
|
||||
it("does not request another model turn after a tool aborts the run", async () => {
|
||||
const controller = new AbortController();
|
||||
let streamCalls = 0;
|
||||
const streamFn: StreamFn = () => {
|
||||
streamCalls += 1;
|
||||
if (streamCalls > 1) {
|
||||
throw new Error("model was called after abort");
|
||||
}
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
const message = makeAssistantMessage([
|
||||
{ type: "toolCall", id: "call-abort", name: "abort_tool", arguments: {} },
|
||||
]);
|
||||
stream.push({ type: "done", reason: "toolUse", message });
|
||||
stream.end();
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
const abortTool: AgentTool = {
|
||||
name: "abort_tool",
|
||||
label: "abort_tool",
|
||||
description: "Abort the active run",
|
||||
parameters: Type.Object({}, { additionalProperties: false }),
|
||||
execute: async () => {
|
||||
controller.abort(new Error("user aborted"));
|
||||
return {
|
||||
content: [{ type: "text", text: "aborted" }],
|
||||
details: { aborted: true },
|
||||
};
|
||||
},
|
||||
};
|
||||
const events: AgentEvent[] = [];
|
||||
|
||||
const messages = await runAgentLoop(
|
||||
[{ role: "user", content: "abort during tool", timestamp: 1 }],
|
||||
{
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [abortTool],
|
||||
},
|
||||
config,
|
||||
(event) => {
|
||||
events.push(event);
|
||||
},
|
||||
controller.signal,
|
||||
streamFn,
|
||||
);
|
||||
|
||||
expect(streamCalls).toBe(1);
|
||||
expect(messages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
expect(messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "aborted" });
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"agent_start",
|
||||
"turn_start",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"tool_execution_start",
|
||||
"tool_execution_end",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"turn_end",
|
||||
"turn_start",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"turn_end",
|
||||
"agent_end",
|
||||
]);
|
||||
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
|
||||
});
|
||||
|
||||
it("does not request another model turn when an async turn hook aborts the run", async () => {
|
||||
const controller = new AbortController();
|
||||
let streamCalls = 0;
|
||||
const streamFn: StreamFn = () => {
|
||||
streamCalls += 1;
|
||||
if (streamCalls > 1) {
|
||||
throw new Error("model was called after abort");
|
||||
}
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
const message = makeAssistantMessage([
|
||||
{ type: "toolCall", id: "call-hook-abort", name: "hook_abort", arguments: {} },
|
||||
]);
|
||||
stream.push({ type: "done", reason: "toolUse", message });
|
||||
stream.end();
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
const events: AgentEvent[] = [];
|
||||
|
||||
const messages = await runAgentLoop(
|
||||
[{ role: "user", content: "abort from hook", timestamp: 1 }],
|
||||
{
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [makeTool("hook_abort", [])],
|
||||
},
|
||||
{
|
||||
...config,
|
||||
prepareNextTurn: async () => {
|
||||
await Promise.resolve();
|
||||
controller.abort(new Error("user aborted"));
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
(event) => {
|
||||
events.push(event);
|
||||
},
|
||||
controller.signal,
|
||||
streamFn,
|
||||
);
|
||||
|
||||
expect(streamCalls).toBe(1);
|
||||
expect(messages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
expect(messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "aborted" });
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"agent_start",
|
||||
"turn_start",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"tool_execution_start",
|
||||
"tool_execution_end",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"turn_end",
|
||||
"turn_start",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"turn_end",
|
||||
"agent_end",
|
||||
]);
|
||||
expect(events.at(-1)).toMatchObject({ type: "agent_end" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("agentLoop thinking state", () => {
|
||||
|
||||
@@ -267,8 +267,32 @@ async function runLoop(
|
||||
let currentContext = initialContext;
|
||||
let config = initialConfig;
|
||||
let firstTurn = true;
|
||||
let turnOpen = true;
|
||||
// Check for steering messages at start (user may have typed while waiting)
|
||||
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
|
||||
const stopIfAborted = async (): Promise<boolean> => {
|
||||
if (!signal?.aborted) {
|
||||
return false;
|
||||
}
|
||||
// Persist an aborted assistant outcome so session post-processing does not
|
||||
// compact or continue from the preceding toolUse message.
|
||||
const abortedMessage = createLoopFailureMessage(
|
||||
config,
|
||||
signal.reason instanceof Error ? signal.reason : new Error("Agent run aborted"),
|
||||
true,
|
||||
);
|
||||
newMessages.push(abortedMessage);
|
||||
if (!turnOpen) {
|
||||
await emit({ type: "turn_start" });
|
||||
turnOpen = true;
|
||||
}
|
||||
await emit({ type: "message_start", message: abortedMessage });
|
||||
await emit({ type: "message_end", message: abortedMessage });
|
||||
await emit({ type: "turn_end", message: abortedMessage, toolResults: [] });
|
||||
turnOpen = false;
|
||||
await emit({ type: "agent_end", messages: newMessages });
|
||||
return true;
|
||||
};
|
||||
|
||||
// Outer loop: continues when queued follow-up messages arrive after agent would stop
|
||||
while (true) {
|
||||
@@ -276,8 +300,13 @@ async function runLoop(
|
||||
|
||||
// Inner loop: process tool calls and steering messages
|
||||
while (hasMoreToolCalls || pendingMessages.length > 0) {
|
||||
if (await stopIfAborted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firstTurn) {
|
||||
await emit({ type: "turn_start" });
|
||||
turnOpen = true;
|
||||
} else {
|
||||
firstTurn = false;
|
||||
}
|
||||
@@ -292,6 +321,10 @@ async function runLoop(
|
||||
}
|
||||
}
|
||||
|
||||
if (await stopIfAborted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stream assistant response
|
||||
const message = await streamAssistantResponse(
|
||||
currentContext,
|
||||
@@ -332,6 +365,10 @@ async function runLoop(
|
||||
}
|
||||
|
||||
await emit({ type: "turn_end", message, toolResults });
|
||||
turnOpen = false;
|
||||
if (await stopIfAborted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTurnContext = {
|
||||
message,
|
||||
@@ -357,6 +394,9 @@ async function runLoop(
|
||||
reasoning: nextReasoning,
|
||||
});
|
||||
}
|
||||
if (await stopIfAborted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
await config.shouldStopAfterTurn?.({
|
||||
@@ -371,6 +411,9 @@ async function runLoop(
|
||||
}
|
||||
|
||||
pendingMessages = (await config.getSteeringMessages?.()) || [];
|
||||
if (await stopIfAborted()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createServer, type Server } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveNpmRunner } from "../../../scripts/npm-runner.mjs";
|
||||
import { createPnpmRunnerSpawnSpec } from "../../../scripts/pnpm-runner.mjs";
|
||||
import { getWindowsSystem32ExePath } from "../../../src/infra/windows-install-roots.js";
|
||||
import { createNodeEvalArgs } from "../../../src/test-utils/node-process.js";
|
||||
@@ -162,6 +163,24 @@ function runPnpmCommand(
|
||||
});
|
||||
}
|
||||
|
||||
function runNpmCommand(
|
||||
args: string[],
|
||||
options: { cwd: string; timeoutMs?: number },
|
||||
): Promise<CommandResult> {
|
||||
const env = createCommandEnv();
|
||||
const runner = resolveNpmRunner({
|
||||
env,
|
||||
npmArgs: args,
|
||||
});
|
||||
return runCommand(runner.command, runner.args, {
|
||||
cwd: options.cwd,
|
||||
env: runner.env ?? env,
|
||||
shell: runner.shell,
|
||||
timeoutMs: options.timeoutMs,
|
||||
windowsVerbatimArguments: runner.windowsVerbatimArguments,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeWorkspaceDependencies(
|
||||
dependencies: Record<string, string> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
@@ -340,7 +359,7 @@ describe("OpenClaw SDK package e2e", () => {
|
||||
}
|
||||
for (const packageRoot of packageRoots) {
|
||||
const stagingRoot = await createPackStagingRoot(packageRoot, tempDir);
|
||||
await runCommand("npm", ["pack", "--ignore-scripts", "--pack-destination", tempDir], {
|
||||
await runNpmCommand(["pack", "--ignore-scripts", "--pack-destination", tempDir], {
|
||||
cwd: stagingRoot,
|
||||
});
|
||||
}
|
||||
@@ -363,13 +382,9 @@ describe("OpenClaw SDK package e2e", () => {
|
||||
);
|
||||
await fs.writeFile(path.join(tempDir, ".npmrc"), `@openclaw:registry=${registry.registryUrl}`);
|
||||
try {
|
||||
await runCommand(
|
||||
"npm",
|
||||
["install", "--ignore-scripts", "--no-audit", "--no-fund", sdkTarball],
|
||||
{
|
||||
cwd: tempDir,
|
||||
},
|
||||
);
|
||||
await runNpmCommand(["install", "--ignore-scripts", "--no-audit", "--no-fund", sdkTarball], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
} finally {
|
||||
await registry.close();
|
||||
}
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -1930,8 +1930,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../packages/normalization-core
|
||||
dompurify:
|
||||
specifier: 3.4.9
|
||||
version: 3.4.9
|
||||
specifier: 3.4.11
|
||||
version: 3.4.11
|
||||
highlight.js:
|
||||
specifier: 11.11.1
|
||||
version: 11.11.1
|
||||
@@ -5112,8 +5112,8 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.4.9:
|
||||
resolution: {integrity: sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==}
|
||||
dompurify@3.4.11:
|
||||
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
@@ -11078,7 +11078,7 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.4.9:
|
||||
dompurify@3.4.11:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Summarizes GitHub Actions run/job timings for CI analysis.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { parsePositiveInt } from "./lib/numeric-options.mjs";
|
||||
import { execPlainGh } from "./lib/plain-gh.mjs";
|
||||
|
||||
const DEFAULT_GITHUB_REPOSITORY = "openclaw/openclaw";
|
||||
const RUN_JOBS_PAGE_SIZE = 20;
|
||||
@@ -17,16 +18,21 @@ function parseJsonCommand(command, args, options = {}) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt <= GH_JSON_RETRY_DELAYS_MS.length; attempt += 1) {
|
||||
try {
|
||||
return JSON.parse(
|
||||
execFileSync(command, args, {
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
const stdout =
|
||||
command === "gh"
|
||||
? execPlainGh(args, {
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
})
|
||||
: execFileSync(command, args, {
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
});
|
||||
return JSON.parse(stdout);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const retryable = /HTTP 5\d\d|Server Error|ETIMEDOUT|ECONNRESET|EAI_AGAIN/u.test(message);
|
||||
const retryable = isRetryableGhJsonErrorMessage(message);
|
||||
if (!retryable || attempt === GH_JSON_RETRY_DELAYS_MS.length) {
|
||||
throw error;
|
||||
}
|
||||
@@ -36,6 +42,12 @@ function parseJsonCommand(command, args, options = {}) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export function isRetryableGhJsonErrorMessage(message) {
|
||||
return /HTTP 5\d\d|HTTP 429|Server Error|secondary rate limit|abuse detection|ETIMEDOUT|ECONNRESET|EAI_AGAIN/iu.test(
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRunJob(job) {
|
||||
return {
|
||||
completedAt: job.completedAt ?? job.completed_at ?? null,
|
||||
@@ -210,8 +222,7 @@ export function selectLatestMainPushCiRun(runs, headSha = null) {
|
||||
}
|
||||
|
||||
function getLatestCiRunId() {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
const raw = execPlainGh(
|
||||
["run", "list", "--branch", "main", "--workflow", "CI", "--limit", "1", "--json", "databaseId"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
@@ -234,8 +245,7 @@ function getRemoteMainSha() {
|
||||
|
||||
function getLatestMainPushCiRunId() {
|
||||
const headSha = getRemoteMainSha();
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
const raw = execPlainGh(
|
||||
[
|
||||
"run",
|
||||
"list",
|
||||
@@ -258,8 +268,7 @@ function getLatestMainPushCiRunId() {
|
||||
}
|
||||
|
||||
function listRecentSuccessfulCiRuns(limit) {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
const raw = execPlainGh(
|
||||
[
|
||||
"run",
|
||||
"list",
|
||||
|
||||
84
scripts/lib/plain-gh.mjs
Normal file
84
scripts/lib/plain-gh.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function isExecutable(filePath) {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pathEntries(env) {
|
||||
return String(env.PATH ?? "")
|
||||
.split(path.delimiter)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function plainGhEnv(env = process.env) {
|
||||
const next = { ...env };
|
||||
delete next.CLICOLOR;
|
||||
delete next.CLICOLOR_FORCE;
|
||||
delete next.COLORTERM;
|
||||
delete next.GH_FORCE_TTY;
|
||||
next.NO_COLOR = "1";
|
||||
next.FORCE_COLOR = "0";
|
||||
next.CLICOLOR = "0";
|
||||
next.CLICOLOR_FORCE = "0";
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resolvePlainGhBin(env = process.env) {
|
||||
if (env.OPENCLAW_GH_BIN) {
|
||||
if (isExecutable(env.OPENCLAW_GH_BIN)) {
|
||||
return env.OPENCLAW_GH_BIN;
|
||||
}
|
||||
throw new Error(`OPENCLAW_GH_BIN is not executable: ${env.OPENCLAW_GH_BIN}`);
|
||||
}
|
||||
|
||||
for (const candidate of ["/opt/homebrew/bin/gh", "/usr/local/bin/gh"]) {
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const homeBin = env.HOME ? path.join(env.HOME, "bin") : "";
|
||||
for (const entry of pathEntries(env)) {
|
||||
if (homeBin && entry === homeBin) {
|
||||
continue;
|
||||
}
|
||||
const candidate = path.join(entry, process.platform === "win32" ? "gh.exe" : "gh");
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of pathEntries(env)) {
|
||||
const candidate = path.join(entry, process.platform === "win32" ? "gh.exe" : "gh");
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("missing required command: gh");
|
||||
}
|
||||
|
||||
export function execPlainGh(args, options = {}) {
|
||||
const env = plainGhEnv(options.env ?? process.env);
|
||||
const ghBin = resolvePlainGhBin(env);
|
||||
return execFileSync(ghBin, args, {
|
||||
...options,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
export function spawnPlainGh(args, options = {}) {
|
||||
const env = plainGhEnv(options.env ?? process.env);
|
||||
const ghBin = resolvePlainGhBin(env);
|
||||
return spawnSync(ghBin, args, {
|
||||
...options,
|
||||
env,
|
||||
});
|
||||
}
|
||||
70
scripts/lib/plain-gh.sh
Normal file
70
scripts/lib/plain-gh.sh
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
plain_gh_env() {
|
||||
env \
|
||||
-u CLICOLOR \
|
||||
-u CLICOLOR_FORCE \
|
||||
-u COLORTERM \
|
||||
-u GH_FORCE_TTY \
|
||||
NO_COLOR=1 \
|
||||
FORCE_COLOR=0 \
|
||||
CLICOLOR=0 \
|
||||
CLICOLOR_FORCE=0 \
|
||||
"$@"
|
||||
}
|
||||
|
||||
resolve_plain_gh_bin() {
|
||||
if [ -n "${OPENCLAW_GH_BIN:-}" ]; then
|
||||
if [ -x "$OPENCLAW_GH_BIN" ]; then
|
||||
printf '%s\n' "$OPENCLAW_GH_BIN"
|
||||
return 0
|
||||
fi
|
||||
printf 'OPENCLAW_GH_BIN is not executable: %s\n' "$OPENCLAW_GH_BIN" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local candidate
|
||||
for candidate in /opt/homebrew/bin/gh /usr/local/bin/gh; do
|
||||
if [ -x "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
if candidate=$(PATH="$(plain_gh_search_path)" type -P gh 2>/dev/null); then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
|
||||
type -P gh 2>/dev/null
|
||||
}
|
||||
|
||||
plain_gh_search_path() {
|
||||
local path_value="${PATH:-}"
|
||||
local home_bin="${HOME:-}/bin"
|
||||
local item
|
||||
local output=""
|
||||
local first=true
|
||||
local path_parts=()
|
||||
|
||||
IFS=':' read -r -a path_parts <<<"$path_value"
|
||||
for item in "${path_parts[@]}"; do
|
||||
if [ -n "${HOME:-}" ] && [ "$item" = "$home_bin" ]; then
|
||||
continue
|
||||
fi
|
||||
if [ "$first" = "true" ]; then
|
||||
output="$item"
|
||||
first=false
|
||||
else
|
||||
output="${output}:$item"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '%s\n' "$output"
|
||||
}
|
||||
|
||||
gh_plain() {
|
||||
local gh_bin
|
||||
gh_bin=$(resolve_plain_gh_bin) || return 1
|
||||
plain_gh_env "$gh_bin" "$@"
|
||||
}
|
||||
@@ -146,7 +146,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
|
||||
"provider-auth-login": 3,
|
||||
"provider-model-shared": 29,
|
||||
"provider-stream-family": 40,
|
||||
"provider-stream-shared": 28,
|
||||
"provider-stream-shared": 29,
|
||||
"provider-stream": 40,
|
||||
"provider-web-search": 1,
|
||||
"provider-zai-endpoint": 3,
|
||||
@@ -163,8 +163,8 @@ let publicDeprecatedExportsByEntrypointBudget;
|
||||
try {
|
||||
budgets = {
|
||||
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 321),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10327),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5184),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10328),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5185),
|
||||
publicDeprecatedExports: readBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
|
||||
3247,
|
||||
|
||||
14
scripts/pr
14
scripts/pr
@@ -21,6 +21,9 @@ if common_git_dir=$(git -C "$script_parent_dir" rev-parse --path-format=absolute
|
||||
fi
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$script_parent_dir/lib/plain-gh.sh"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage:
|
||||
@@ -48,11 +51,16 @@ USAGE
|
||||
require_cmds() {
|
||||
local missing=()
|
||||
local cmd
|
||||
for cmd in git gh jq rg pnpm node; do
|
||||
for cmd in git jq rg pnpm node; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
missing+=("$cmd")
|
||||
fi
|
||||
done
|
||||
if ! OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)"; then
|
||||
missing+=("gh")
|
||||
else
|
||||
export OPENCLAW_GH_BIN
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "Missing required command(s): ${missing[*]}"
|
||||
@@ -60,6 +68,10 @@ require_cmds() {
|
||||
fi
|
||||
}
|
||||
|
||||
gh() {
|
||||
gh_plain "$@"
|
||||
}
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$script_parent_dir/pr-lib/worktree.sh"
|
||||
# shellcheck disable=SC1091
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
||||
import { execPlainGh } from "./lib/plain-gh.mjs";
|
||||
|
||||
export const SCHEDULED_HOSTED_WORKFLOWS = [
|
||||
"Blacksmith Testbox",
|
||||
@@ -209,8 +209,7 @@ export function collectHostedGateEvidence({ sha, workflowRuns, changelogOnly = f
|
||||
}
|
||||
|
||||
function loadWorkflowRuns(repo, sha) {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
const raw = execPlainGh(
|
||||
["api", `repos/${repo}/actions/runs?head_sha=${sha}&per_page=100`, "--paginate", "--slurp"],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
@@ -509,6 +509,52 @@ describe("startAcpSpawnParentStreamRelay", () => {
|
||||
relay.dispose();
|
||||
});
|
||||
|
||||
it("relays the latest replaceable assistant snapshot instead of superseded drafts", () => {
|
||||
const relay = startAcpSpawnParentStreamRelay({
|
||||
runId: "run-replaceable-assistant",
|
||||
parentSessionKey: "agent:main:main",
|
||||
childSessionKey: "agent:codex:acp:child-replaceable-assistant",
|
||||
agentId: "codex",
|
||||
streamFlushMs: 10,
|
||||
noOutputNoticeMs: 120_000,
|
||||
emitStartNotice: false,
|
||||
});
|
||||
|
||||
emitAgentEvent({
|
||||
runId: "run-replaceable-assistant",
|
||||
stream: "assistant",
|
||||
data: {
|
||||
text: "coordination draft",
|
||||
delta: "coordination draft",
|
||||
replaceable: true,
|
||||
},
|
||||
});
|
||||
emitAgentEvent({
|
||||
runId: "run-replaceable-assistant",
|
||||
stream: "assistant",
|
||||
data: {
|
||||
text: "final answer",
|
||||
delta: "",
|
||||
replace: true,
|
||||
replaceable: true,
|
||||
},
|
||||
});
|
||||
emitAgentEvent({
|
||||
runId: "run-replaceable-assistant",
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "end",
|
||||
startedAt: 1_000,
|
||||
endedAt: 2_000,
|
||||
},
|
||||
});
|
||||
|
||||
const texts = collectedTexts();
|
||||
expectNoTextWithFragment(texts, "coordination draft");
|
||||
expectTextWithFragment(texts, "codex: final answer");
|
||||
relay.dispose();
|
||||
});
|
||||
|
||||
it("relays commentary-phase assistant text in parent progress mode by default", () => {
|
||||
const relay = startAcpSpawnParentStreamRelay({
|
||||
runId: "run-commentary-default",
|
||||
|
||||
@@ -431,6 +431,7 @@ export function startAcpSpawnParentStreamRelay(params: {
|
||||
let disposed = false;
|
||||
let pendingText = "";
|
||||
let pendingProgressKind: string | undefined;
|
||||
let replaceableAssistantSnapshot: string | undefined;
|
||||
const itemProgressTextById = new Map<string, string>();
|
||||
let lastProgressAt = Date.now();
|
||||
let stallNotified = false;
|
||||
@@ -511,6 +512,15 @@ export function startAcpSpawnParentStreamRelay(params: {
|
||||
scheduleFlush();
|
||||
};
|
||||
|
||||
const flushReplaceableAssistantSnapshot = () => {
|
||||
const snapshot = replaceableAssistantSnapshot;
|
||||
replaceableAssistantSnapshot = undefined;
|
||||
if (!snapshot?.trim()) {
|
||||
return;
|
||||
}
|
||||
appendVisibleProgress(snapshot, "assistant:replaceable");
|
||||
};
|
||||
|
||||
const appendItemProgressSnapshot = (snapshot: { itemId: string; text: string }) => {
|
||||
const previous = itemProgressTextById.get(snapshot.itemId) ?? "";
|
||||
if (snapshot.text === previous) {
|
||||
@@ -605,10 +615,27 @@ export function startAcpSpawnParentStreamRelay(params: {
|
||||
const assistantPhase = normalizeAssistantPhase(
|
||||
(data as { phase?: unknown } | undefined)?.phase,
|
||||
);
|
||||
const deltaCandidate =
|
||||
(data as { delta?: unknown } | undefined)?.delta ??
|
||||
(data as { text?: unknown } | undefined)?.text;
|
||||
const delta = typeof deltaCandidate === "string" ? deltaCandidate : undefined;
|
||||
const textCandidate = (data as { text?: unknown } | undefined)?.text;
|
||||
const deltaCandidate = (data as { delta?: unknown } | undefined)?.delta;
|
||||
const snapshot =
|
||||
typeof textCandidate === "string"
|
||||
? textCandidate
|
||||
: typeof deltaCandidate === "string"
|
||||
? deltaCandidate
|
||||
: undefined;
|
||||
if ((data as { replaceable?: unknown } | undefined)?.replaceable === true) {
|
||||
if (snapshot?.trim()) {
|
||||
replaceableAssistantSnapshot = snapshot;
|
||||
lastProgressAt = Date.now();
|
||||
logEvent("assistant_replaceable_snapshot", {
|
||||
text: snapshot,
|
||||
...(assistantPhase ? { phase: assistantPhase } : {}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = typeof deltaCandidate === "string" ? deltaCandidate : snapshot;
|
||||
if (!delta || !delta.trim()) {
|
||||
return;
|
||||
}
|
||||
@@ -622,6 +649,7 @@ export function startAcpSpawnParentStreamRelay(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
replaceableAssistantSnapshot = undefined;
|
||||
appendVisibleProgress(delta, `assistant:${assistantPhase ?? "unknown"}`);
|
||||
return;
|
||||
}
|
||||
@@ -697,6 +725,7 @@ export function startAcpSpawnParentStreamRelay(params: {
|
||||
const phase = normalizeOptionalString((event.data as { phase?: unknown } | undefined)?.phase);
|
||||
logEvent("lifecycle", { phase: phase ?? "unknown", data: event.data });
|
||||
if (phase === "end") {
|
||||
flushReplaceableAssistantSnapshot();
|
||||
flushPending();
|
||||
const startedAt = asFiniteNumber(
|
||||
(event.data as { startedAt?: unknown } | undefined)?.startedAt,
|
||||
@@ -719,6 +748,7 @@ export function startAcpSpawnParentStreamRelay(params: {
|
||||
}
|
||||
|
||||
if (phase === "error") {
|
||||
flushReplaceableAssistantSnapshot();
|
||||
flushPending();
|
||||
const errorText = normalizeOptionalString(
|
||||
(event.data as { error?: unknown } | undefined)?.error,
|
||||
|
||||
@@ -29,6 +29,7 @@ const resolveEmbeddedAgentStreamFnMock = vi.fn();
|
||||
const prepareCliRunContextMock = vi.fn();
|
||||
const executePreparedCliRunMock = vi.fn();
|
||||
const diagDebugMock = vi.fn();
|
||||
const ensureSelectedAgentHarnessPluginMock = vi.fn();
|
||||
|
||||
vi.mock("../llm/stream.js", async () => {
|
||||
const original = await vi.importActual<typeof import("../llm/stream.js")>("../llm/stream.js");
|
||||
@@ -119,7 +120,7 @@ vi.mock("./model-runtime-aliases.js", () => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
return runtime || undefined;
|
||||
return runtime === "claude-cli" ? runtime : undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -131,6 +132,11 @@ vi.mock("./cli-runner/execute.runtime.js", () => ({
|
||||
executePreparedCliRun: (...args: unknown[]) => executePreparedCliRunMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./harness/runtime-plugin.js", () => ({
|
||||
ensureSelectedAgentHarnessPlugin: (...args: unknown[]) =>
|
||||
ensureSelectedAgentHarnessPluginMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./embedded-agent-runner/runs.js", () => ({
|
||||
getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args),
|
||||
}));
|
||||
@@ -455,6 +461,7 @@ describe("runBtwSideQuestion", () => {
|
||||
prepareCliRunContextMock.mockReset();
|
||||
executePreparedCliRunMock.mockReset();
|
||||
diagDebugMock.mockReset();
|
||||
ensureSelectedAgentHarnessPluginMock.mockReset();
|
||||
clearAgentHarnesses();
|
||||
|
||||
readFileMock.mockResolvedValue("mock transcript");
|
||||
@@ -835,6 +842,55 @@ describe("runBtwSideQuestion", () => {
|
||||
expect(streamSimpleMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("loads a cold Copilot harness before selecting the /btw provider fallback", async () => {
|
||||
let loaded = false;
|
||||
ensureSelectedAgentHarnessPluginMock.mockImplementation(async () => {
|
||||
if (loaded) {
|
||||
return;
|
||||
}
|
||||
loaded = true;
|
||||
registerAgentHarness({
|
||||
id: "copilot",
|
||||
label: "Copilot test harness",
|
||||
supports: () => ({ supported: true, priority: 100 }),
|
||||
runAttempt: vi.fn(),
|
||||
});
|
||||
});
|
||||
resolveModelWithRegistryMock.mockReturnValue({
|
||||
provider: "github-copilot",
|
||||
id: "gpt-4o",
|
||||
api: "openai-completions",
|
||||
});
|
||||
mockDoneAnswer("Copilot fallback answer.");
|
||||
|
||||
const result = await runSideQuestion({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"github-copilot/gpt-4o": { agentRuntime: { id: "copilot" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
provider: "github-copilot",
|
||||
model: "gpt-4o",
|
||||
sessionKey: DEFAULT_SESSION_KEY,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ text: "Copilot fallback answer." });
|
||||
expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledOnce();
|
||||
expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledWith({
|
||||
provider: "github-copilot",
|
||||
modelId: "gpt-4o",
|
||||
config: expect.any(Object),
|
||||
agentId: "main",
|
||||
sessionKey: DEFAULT_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
expect(streamSimpleMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("runs CLI-runtime alias BTW as an ephemeral CLI side question", async () => {
|
||||
const cleanup = vi.fn(async () => undefined);
|
||||
prepareCliRunContextMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -30,6 +30,7 @@ import { EmbeddedBlockChunker, type BlockReplyChunking } from "./embedded-agent-
|
||||
import { resolveModelWithRegistry } from "./embedded-agent-runner/model.js";
|
||||
import { getActiveEmbeddedRunSnapshot } from "./embedded-agent-runner/runs.js";
|
||||
import { resolveEmbeddedAgentStreamFn } from "./embedded-agent-runner/stream-resolution.js";
|
||||
import { ensureSelectedAgentHarnessPlugin } from "./harness/runtime-plugin.js";
|
||||
import {
|
||||
resolveAvailableAgentHarnessPolicy,
|
||||
resolvePluginHarnessPolicyToolsAllow,
|
||||
@@ -479,13 +480,32 @@ export async function runBtwSideQuestion(
|
||||
config: params.cfg,
|
||||
});
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, sessionAgentId);
|
||||
const harness = selectAgentHarness({
|
||||
provider: params.provider,
|
||||
modelId: params.model,
|
||||
config: params.cfg,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
const preparedHarnesses = new Map<string, AgentHarness>();
|
||||
const prepareHarness = async (provider: string, modelId: string): Promise<AgentHarness> => {
|
||||
const key = `${provider}/${modelId}`;
|
||||
const cached = preparedHarnesses.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
await ensureSelectedAgentHarnessPlugin({
|
||||
provider,
|
||||
modelId,
|
||||
config: params.cfg,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
workspaceDir,
|
||||
});
|
||||
const harness = selectAgentHarness({
|
||||
provider,
|
||||
modelId,
|
||||
config: params.cfg,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
preparedHarnesses.set(key, harness);
|
||||
return harness;
|
||||
};
|
||||
const harness = await prepareHarness(params.provider, params.model);
|
||||
let runtimeSelection: Awaited<ReturnType<typeof resolveRuntimeModel>> | undefined;
|
||||
const resolveRuntimeSelection = async () => {
|
||||
if (!runtimeSelection) {
|
||||
@@ -647,13 +667,12 @@ export async function runBtwSideQuestion(
|
||||
}
|
||||
|
||||
const runtimeSelectionForHarness = await resolveRuntimeSelection();
|
||||
const runtimeHarness = selectAgentHarness({
|
||||
provider: runtimeSelectionForHarness.model.provider,
|
||||
modelId: runtimeSelectionForHarness.model.id,
|
||||
config: params.cfg,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
// Model resolution can canonicalize a legacy provider alias, so reselect against the resolved
|
||||
// provider/model instead of reusing the raw route's selection.
|
||||
const runtimeHarness = await prepareHarness(
|
||||
runtimeSelectionForHarness.model.provider,
|
||||
runtimeSelectionForHarness.model.id,
|
||||
);
|
||||
if (runtimeHarness.runSideQuestion) {
|
||||
return runHarnessSideQuestion(runtimeHarness, runtimeSelectionForHarness);
|
||||
}
|
||||
|
||||
@@ -504,7 +504,7 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
: []
|
||||
).filter((text) => !shouldSuppressRawErrorText(text));
|
||||
|
||||
let hasUserFacingAssistantReply = hasSourceReplyPayload;
|
||||
let hasUserFacingAssistantReply = hasSourceReplyPayload || deliveredSourceReplyViaMessageTool;
|
||||
const hasUserFacingErrorReply = replyItems.some((item) => item.isError === true);
|
||||
let hasUserFacingFailureAcknowledgement = false;
|
||||
for (const text of answerTexts) {
|
||||
|
||||
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
ensurePluginRegistryLoaded: vi.fn(),
|
||||
resolveActivatableProviderOwnerPluginIds: vi.fn(),
|
||||
resolveBundledProviderCompatPluginIds: vi.fn(),
|
||||
resolveManifestActivationPlan: vi.fn(),
|
||||
resolveOwningPluginIdsForProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -20,6 +21,10 @@ vi.mock("../../plugins/providers.js", () => ({
|
||||
resolveOwningPluginIdsForProviderRef: mocks.resolveOwningPluginIdsForProvider,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/activation-planner.js", () => ({
|
||||
resolveManifestActivationPlan: mocks.resolveManifestActivationPlan,
|
||||
}));
|
||||
|
||||
describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
let ensureSelectedAgentHarnessPlugin: typeof import("./runtime-plugin.js").ensureSelectedAgentHarnessPlugin;
|
||||
|
||||
@@ -27,7 +32,30 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
mocks.ensurePluginRegistryLoaded.mockReset();
|
||||
mocks.resolveActivatableProviderOwnerPluginIds.mockReset();
|
||||
mocks.resolveBundledProviderCompatPluginIds.mockReset();
|
||||
mocks.resolveManifestActivationPlan.mockReset();
|
||||
mocks.resolveOwningPluginIdsForProvider.mockReset();
|
||||
mocks.resolveManifestActivationPlan.mockImplementation(
|
||||
({
|
||||
trigger,
|
||||
config,
|
||||
}: {
|
||||
trigger: { kind: "agentHarness"; runtime: string };
|
||||
config?: OpenClawConfig;
|
||||
}) => {
|
||||
const pluginId = trigger.runtime;
|
||||
const allow = config?.plugins?.allow ?? [];
|
||||
if (
|
||||
config?.plugins?.entries?.[pluginId]?.enabled === false ||
|
||||
(allow.length > 0 && !allow.includes(pluginId))
|
||||
) {
|
||||
return { entries: [] };
|
||||
}
|
||||
return {
|
||||
entries:
|
||||
pluginId === "codex" || pluginId === "copilot" ? [{ pluginId, origin: "bundled" }] : [],
|
||||
};
|
||||
},
|
||||
);
|
||||
mocks.resolveOwningPluginIdsForProvider.mockImplementation(
|
||||
({ provider }: { provider: string }) => (provider === "openai" ? ["openai"] : undefined),
|
||||
);
|
||||
@@ -132,6 +160,61 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("loads a manifest-owned custom harness runtime before selection", async () => {
|
||||
mocks.resolveManifestActivationPlan.mockReturnValueOnce({
|
||||
entries: [{ pluginId: "custom-harness-plugin", origin: "workspace" }],
|
||||
});
|
||||
|
||||
await ensureSelectedAgentHarnessPlugin({
|
||||
provider: "custom-provider",
|
||||
modelId: "custom-model",
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"custom-harness-plugin": { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
agentHarnessRuntimeOverride: "custom-harness",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(mocks.resolveManifestActivationPlan).toHaveBeenCalledWith({
|
||||
trigger: { kind: "agentHarness", runtime: "custom-harness" },
|
||||
config: expect.any(Object),
|
||||
workspaceDir: "/tmp/workspace",
|
||||
requireExplicitManifestOwnerTrust: true,
|
||||
});
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope: "all",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
onlyPluginIds: ["custom-harness-plugin", "memory-core"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not activate an untrusted workspace harness from manifest metadata alone", async () => {
|
||||
mocks.resolveManifestActivationPlan.mockReturnValueOnce({
|
||||
entries: [],
|
||||
});
|
||||
|
||||
await ensureSelectedAgentHarnessPlugin({
|
||||
provider: "custom-provider",
|
||||
modelId: "custom-model",
|
||||
agentHarnessRuntimeOverride: "custom-harness",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(mocks.resolveManifestActivationPlan).toHaveBeenCalledWith({
|
||||
trigger: { kind: "agentHarness", runtime: "custom-harness" },
|
||||
config: undefined,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
requireExplicitManifestOwnerTrust: true,
|
||||
});
|
||||
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not bypass a restrictive allowlist that omits a configured Copilot harness", async () => {
|
||||
// A configured harness can request loading, but explicit plugin allowlists
|
||||
// remain the operator's boundary and are not widened implicitly.
|
||||
@@ -158,21 +241,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope: "all",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
onlyPluginIds: ["copilot"],
|
||||
config: expect.objectContaining({
|
||||
plugins: expect.objectContaining({
|
||||
allow: ["telegram"],
|
||||
entries: expect.not.objectContaining({
|
||||
copilot: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("widens a scoped harness allowlist with the provider owner for openai models", async () => {
|
||||
@@ -337,22 +406,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveBundledProviderCompatPluginIds).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveActivatableProviderOwnerPluginIds).not.toHaveBeenCalled();
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope: "all",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
onlyPluginIds: ["codex"],
|
||||
config: expect.objectContaining({
|
||||
plugins: expect.objectContaining({
|
||||
allow: ["telegram"],
|
||||
entries: expect.not.objectContaining({
|
||||
codex: expect.anything(),
|
||||
openai: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps real bundled memory-core in a Codex scoped load when the provider has no owner plugin", async () => {
|
||||
@@ -417,5 +471,6 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
|
||||
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveManifestActivationPlan).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { withActivatedPluginIds } from "../../plugins/activation-context.js";
|
||||
import { resolveManifestActivationPlan } from "../../plugins/activation-planner.js";
|
||||
import { resolveEffectivePluginActivationState } from "../../plugins/config-state.js";
|
||||
import { isPluginEnabledByDefaultForPlatform } from "../../plugins/default-enablement.js";
|
||||
import {
|
||||
@@ -16,16 +17,9 @@ import {
|
||||
} from "../../plugins/providers.js";
|
||||
import { isDefaultAgentRuntimeId, OPENCLAW_AGENT_RUNTIME_ID } from "../agent-runtime-id.js";
|
||||
import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js";
|
||||
import { isCliRuntimeAliasForProvider } from "../model-runtime-aliases.js";
|
||||
import { resolveAgentHarnessPolicy } from "./policy.js";
|
||||
|
||||
/**
|
||||
* Lazy-loads plugin-backed harness runtimes before selection.
|
||||
*
|
||||
* Only cold-loadable runtimes live here; always-loaded core/openclaw runtimes should not trigger
|
||||
* plugin registry scans on every embedded-agent turn.
|
||||
*/
|
||||
const COLD_LOADABLE_HARNESS_PLUGIN_IDS = new Set(["codex", "copilot"]);
|
||||
|
||||
function dedupePluginIds(values: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
@@ -82,13 +76,26 @@ function resolveHarnessPluginIds(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir: string;
|
||||
}): string[] {
|
||||
const activationPlan = resolveManifestActivationPlan({
|
||||
trigger: { kind: "agentHarness", runtime: params.runtime },
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
requireExplicitManifestOwnerTrust: true,
|
||||
});
|
||||
const harnessPluginIds = activationPlan.entries.map((entry) => entry.pluginId);
|
||||
if (harnessPluginIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (params.runtime !== "codex") {
|
||||
return [params.runtime];
|
||||
return harnessPluginIds;
|
||||
}
|
||||
if (!harnessPluginIds.includes("codex")) {
|
||||
return harnessPluginIds;
|
||||
}
|
||||
if (restrictiveAllowlistOmitsPlugin(params.config, "codex")) {
|
||||
// Respect a restrictive allowlist even when Codex would normally pull in provider owner
|
||||
// plugins. Operators who set an allowlist expect no implicit plugin expansion.
|
||||
return ["codex"];
|
||||
return harnessPluginIds;
|
||||
}
|
||||
const providerOwnerPluginIds = dedupePluginIds(
|
||||
resolveOwningPluginIdsForProviderRef({
|
||||
@@ -98,7 +105,7 @@ function resolveHarnessPluginIds(params: {
|
||||
}) ?? [],
|
||||
);
|
||||
if (providerOwnerPluginIds.length === 0) {
|
||||
return ["codex"];
|
||||
return harnessPluginIds;
|
||||
}
|
||||
const safeProviderOwnerPluginIds = dedupePluginIds([
|
||||
...resolveBundledProviderCompatPluginIds({
|
||||
@@ -114,6 +121,7 @@ function resolveHarnessPluginIds(params: {
|
||||
]);
|
||||
return dedupePluginIds([
|
||||
"codex",
|
||||
...harnessPluginIds,
|
||||
...providerOwnerPluginIds.filter(
|
||||
(pluginId) => pluginId !== "codex" && safeProviderOwnerPluginIds.includes(pluginId),
|
||||
),
|
||||
@@ -164,7 +172,11 @@ export async function ensureSelectedAgentHarnessPlugin(params: {
|
||||
if (
|
||||
isDefaultAgentRuntimeId(runtime) ||
|
||||
runtime === OPENCLAW_AGENT_RUNTIME_ID ||
|
||||
!COLD_LOADABLE_HARNESS_PLUGIN_IDS.has(runtime)
|
||||
isCliRuntimeAliasForProvider({
|
||||
runtime,
|
||||
provider: params.provider,
|
||||
cfg: params.config,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -177,6 +189,9 @@ export async function ensureSelectedAgentHarnessPlugin(params: {
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
if (pluginIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const memoryPluginIds = resolveSelectedMemoryPluginIds({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
|
||||
@@ -71,6 +71,8 @@ export type SupplementalContextFacts = {
|
||||
};
|
||||
untrustedContext?: Array<{ label: string; source?: string; type?: string; payload: unknown }>;
|
||||
groupSystemPrompt?: string;
|
||||
/** Prompt-like group metadata from user-controlled sources; never enters the system prompt. */
|
||||
untrustedGroupSystemPrompt?: string;
|
||||
};
|
||||
|
||||
/** Raw inbound message context accepted from channels before finalization. */
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
InboundSourceModality,
|
||||
MentionSource,
|
||||
MsgContext,
|
||||
SupplementalContextFacts,
|
||||
} from "../../auto-reply/templating.js";
|
||||
import type { GroupKeyResolution } from "../../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
@@ -28,6 +29,7 @@ import type { InboundLastRouteUpdate, RecordInboundSession } from "../session.ty
|
||||
import type { ChannelBotLoopProtectionFacts } from "./bot-loop-protection.js";
|
||||
|
||||
export type { InboundEventKind } from "../inbound-event/kind.js";
|
||||
export type { SupplementalContextFacts } from "../../auto-reply/templating.js";
|
||||
|
||||
/** Admission decision for an inbound channel event before agent dispatch. */
|
||||
export type ChannelTurnAdmission =
|
||||
@@ -219,39 +221,6 @@ export type CommandFacts = {
|
||||
authorized?: boolean;
|
||||
};
|
||||
|
||||
/** Quoted, forwarded, thread, and untrusted context facts attached to an inbound turn. */
|
||||
export type SupplementalContextFacts = {
|
||||
quote?: {
|
||||
id?: string;
|
||||
fullId?: string;
|
||||
body?: string;
|
||||
sender?: string;
|
||||
senderAllowed?: boolean;
|
||||
isExternal?: boolean;
|
||||
isQuote?: boolean;
|
||||
};
|
||||
forwarded?: {
|
||||
from?: string;
|
||||
fromType?: string;
|
||||
fromId?: string;
|
||||
date?: number;
|
||||
senderAllowed?: boolean;
|
||||
};
|
||||
thread?: {
|
||||
id?: string;
|
||||
starterBody?: string;
|
||||
historyBody?: string;
|
||||
label?: string;
|
||||
parentSessionKey?: string;
|
||||
modelParentSessionKey?: string;
|
||||
senderAllowed?: boolean;
|
||||
};
|
||||
untrustedContext?: Array<{ label: string; source?: string; type?: string; payload: unknown }>;
|
||||
groupSystemPrompt?: string;
|
||||
/** Prompt-like group metadata from user-controlled sources; never enters the system prompt. */
|
||||
untrustedGroupSystemPrompt?: string;
|
||||
};
|
||||
|
||||
/** Inbound media facts supplied to the agent context. */
|
||||
export type InboundMediaFacts = {
|
||||
path?: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
|
||||
import { runConfigSet } from "./config-cli.js";
|
||||
|
||||
function createTestRuntime() {
|
||||
@@ -91,8 +91,8 @@ async function withExecDryRunConfigHarness(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.OPENCLAW_TEST_FAST = "1";
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
setTestEnvValue("OPENCLAW_TEST_FAST", "1");
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
|
||||
clearConfigCache();
|
||||
clearRuntimeConfigSnapshot();
|
||||
|
||||
@@ -117,8 +117,8 @@ describe("config cli integration", () => {
|
||||
const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]);
|
||||
try {
|
||||
fs.writeFileSync(configPath, `${JSON.stringify({ gateway: { port: 18789 } }, null, 2)}\n`);
|
||||
process.env.OPENCLAW_TEST_FAST = "1";
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
setTestEnvValue("OPENCLAW_TEST_FAST", "1");
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
|
||||
clearConfigCache();
|
||||
clearRuntimeConfigSnapshot();
|
||||
await runConfigSet({
|
||||
@@ -152,8 +152,8 @@ describe("config cli integration", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.OPENCLAW_TEST_FAST = "1";
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
setTestEnvValue("OPENCLAW_TEST_FAST", "1");
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
|
||||
clearConfigCache();
|
||||
clearRuntimeConfigSnapshot();
|
||||
|
||||
@@ -222,9 +222,9 @@ describe("config cli integration", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.OPENCLAW_TEST_FAST = "1";
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
process.env.DISCORD_BOT_TOKEN = "test-token";
|
||||
setTestEnvValue("OPENCLAW_TEST_FAST", "1");
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
|
||||
setTestEnvValue("DISCORD_BOT_TOKEN", "test-token");
|
||||
clearConfigCache();
|
||||
clearRuntimeConfigSnapshot();
|
||||
|
||||
@@ -293,9 +293,9 @@ describe("config cli integration", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.OPENCLAW_TEST_FAST = "1";
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
delete process.env.MISSING_TEST_SECRET;
|
||||
setTestEnvValue("OPENCLAW_TEST_FAST", "1");
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
|
||||
deleteTestEnvValue("MISSING_TEST_SECRET");
|
||||
clearConfigCache();
|
||||
clearRuntimeConfigSnapshot();
|
||||
|
||||
|
||||
@@ -23,12 +23,16 @@ vi.mock("../cli/command-secret-targets.js", () => ({
|
||||
getChannelsCommandSecretTargetIds: mocks.getChannelsCommandSecretTargetIds,
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
getRuntimeConfig: mocks.loadConfig,
|
||||
loadConfig: mocks.loadConfig,
|
||||
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
|
||||
replaceConfigFile: mocks.replaceConfigFile,
|
||||
}));
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
getRuntimeConfig: mocks.loadConfig,
|
||||
loadConfig: mocks.loadConfig,
|
||||
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
|
||||
replaceConfigFile: mocks.replaceConfigFile,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../cli/plugins-registry-refresh.js", () => ({
|
||||
refreshPluginRegistryAfterConfigMutation: mocks.refreshPluginRegistryAfterConfigMutation,
|
||||
|
||||
@@ -18,12 +18,16 @@ const mocks = vi.hoisted(() => ({
|
||||
listReadOnlyChannelPluginsForConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./shared.js", () => ({
|
||||
requireValidConfig: vi.fn(async () => ({ channels: {} })),
|
||||
formatChannelAccountLabel: vi.fn(
|
||||
({ channel, accountId }: { channel: string; accountId: string }) => `${channel}:${accountId}`,
|
||||
),
|
||||
}));
|
||||
vi.mock("./shared.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./shared.js")>("./shared.js");
|
||||
return {
|
||||
...actual,
|
||||
requireValidConfig: vi.fn(async () => ({ channels: {} })),
|
||||
formatChannelAccountLabel: vi.fn(
|
||||
({ channel, accountId }: { channel: string; accountId: string }) => `${channel}:${accountId}`,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: vi.fn(),
|
||||
@@ -242,9 +246,7 @@ describe("channelsCapabilitiesCommand", () => {
|
||||
|
||||
await channelsCapabilitiesCommand({ channel: "slack", timeout: "999999" }, runtime);
|
||||
|
||||
expect(probeAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeoutMs: 30_000 }),
|
||||
);
|
||||
expect(probeAccount).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 30_000 }));
|
||||
expect(buildCapabilitiesDiagnostics).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeoutMs: 30_000 }),
|
||||
);
|
||||
@@ -274,10 +276,7 @@ describe("channelsCapabilitiesCommand", () => {
|
||||
configChanged: false,
|
||||
});
|
||||
|
||||
await channelsCapabilitiesCommand(
|
||||
{ channel: "slack", json: true, timeout: "1" },
|
||||
runtime,
|
||||
);
|
||||
await channelsCapabilitiesCommand({ channel: "slack", json: true, timeout: "1" }, runtime);
|
||||
|
||||
const payload = JSON.parse(logs[0] ?? "{}") as {
|
||||
channels?: Array<{ probe?: unknown }>;
|
||||
@@ -343,10 +342,7 @@ describe("channelsCapabilitiesCommand", () => {
|
||||
configChanged: false,
|
||||
});
|
||||
|
||||
await channelsCapabilitiesCommand(
|
||||
{ channel: "slack", json: true, timeout: "1" },
|
||||
runtime,
|
||||
);
|
||||
await channelsCapabilitiesCommand({ channel: "slack", json: true, timeout: "1" }, runtime);
|
||||
|
||||
const payload = JSON.parse(logs[0] ?? "{}") as {
|
||||
channels?: Array<{ diagnostics?: unknown }>;
|
||||
|
||||
@@ -19,17 +19,13 @@ import type {
|
||||
import { formatCliCommand } from "../../cli/command-format.js";
|
||||
import { formatUnknownChannelMessage } from "../../cli/error-format.js";
|
||||
import { parseTimeoutMsWithFallback } from "../../cli/parse-timeout.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
|
||||
import {
|
||||
readConfigFileSnapshot,
|
||||
replaceConfigFile,
|
||||
type OpenClawConfig,
|
||||
} from "../../config/config.js";
|
||||
import { readConfigFileSnapshot } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
|
||||
import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js";
|
||||
import { persistResolvedChannelPluginConfig } from "./plugin-config-persistence.js";
|
||||
import { formatChannelAccountLabel, requireValidConfig } from "./shared.js";
|
||||
|
||||
export type ChannelsCapabilitiesOptions = {
|
||||
@@ -351,35 +347,11 @@ export async function channelsCapabilitiesCommand(
|
||||
allowInstall: true,
|
||||
});
|
||||
if (resolved.configChanged) {
|
||||
cfg = resolved.cfg;
|
||||
const shouldMovePluginInstalls = Boolean(
|
||||
cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0,
|
||||
);
|
||||
if (shouldMovePluginInstalls) {
|
||||
const committed = await commitConfigWithPendingPluginInstalls({
|
||||
nextConfig: cfg,
|
||||
baseHash: (await sourceSnapshotPromise)?.hash,
|
||||
});
|
||||
cfg = committed.config;
|
||||
await refreshPluginRegistryAfterConfigMutation({
|
||||
config: cfg,
|
||||
reason: "source-changed",
|
||||
installRecords: committed.installRecords,
|
||||
logger: { warn: (message) => runtime.log(message) },
|
||||
});
|
||||
} else {
|
||||
await replaceConfigFile({
|
||||
nextConfig: cfg,
|
||||
baseHash: (await sourceSnapshotPromise)?.hash,
|
||||
});
|
||||
if (resolved.pluginInstalled) {
|
||||
await refreshPluginRegistryAfterConfigMutation({
|
||||
config: cfg,
|
||||
reason: "source-changed",
|
||||
logger: { warn: (message) => runtime.log(message) },
|
||||
});
|
||||
}
|
||||
}
|
||||
cfg = await persistResolvedChannelPluginConfig({
|
||||
resolved,
|
||||
baseHash: (await sourceSnapshotPromise)?.hash,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
return resolved.plugin ? [resolved.plugin] : null;
|
||||
})();
|
||||
|
||||
50
src/commands/channels/plugin-config-persistence.ts
Normal file
50
src/commands/channels/plugin-config-persistence.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
|
||||
import { replaceConfigFile } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
|
||||
export async function persistResolvedChannelPluginConfig(params: {
|
||||
resolved: {
|
||||
cfg: OpenClawConfig;
|
||||
configChanged: boolean;
|
||||
pluginInstalled: boolean;
|
||||
};
|
||||
baseHash?: string;
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<OpenClawConfig> {
|
||||
if (!params.resolved.configChanged) {
|
||||
return params.resolved.cfg;
|
||||
}
|
||||
|
||||
const cfg = params.resolved.cfg;
|
||||
const shouldMovePluginInstalls = Boolean(
|
||||
cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0,
|
||||
);
|
||||
if (shouldMovePluginInstalls) {
|
||||
const committed = await commitConfigWithPendingPluginInstalls({
|
||||
nextConfig: cfg,
|
||||
baseHash: params.baseHash,
|
||||
});
|
||||
await refreshPluginRegistryAfterConfigMutation({
|
||||
config: committed.config,
|
||||
reason: "source-changed",
|
||||
installRecords: committed.installRecords,
|
||||
logger: { warn: (message) => params.runtime.log(message) },
|
||||
});
|
||||
return committed.config;
|
||||
}
|
||||
|
||||
await replaceConfigFile({
|
||||
nextConfig: cfg,
|
||||
baseHash: params.baseHash,
|
||||
});
|
||||
if (params.resolved.pluginInstalled) {
|
||||
await refreshPluginRegistryAfterConfigMutation({
|
||||
config: cfg,
|
||||
reason: "source-changed",
|
||||
logger: { warn: (message) => params.runtime.log(message) },
|
||||
});
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
@@ -13,17 +13,12 @@ import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolu
|
||||
import { formatCliCommand } from "../../cli/command-format.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
|
||||
import { formatUnsupportedChannelActionMessage } from "../../cli/error-format.js";
|
||||
import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
|
||||
import {
|
||||
getRuntimeConfig,
|
||||
readConfigFileSnapshot,
|
||||
replaceConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import { getRuntimeConfig, readConfigFileSnapshot } from "../../config/config.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
|
||||
import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js";
|
||||
import { persistResolvedChannelPluginConfig } from "./plugin-config-persistence.js";
|
||||
|
||||
export type ChannelsResolveOptions = {
|
||||
channel?: string;
|
||||
@@ -156,35 +151,11 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti
|
||||
);
|
||||
}
|
||||
if (resolvedExplicit?.configChanged) {
|
||||
cfg = resolvedExplicit.cfg;
|
||||
const shouldMovePluginInstalls = Boolean(
|
||||
cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0,
|
||||
);
|
||||
if (shouldMovePluginInstalls) {
|
||||
const committed = await commitConfigWithPendingPluginInstalls({
|
||||
nextConfig: cfg,
|
||||
baseHash: (await sourceSnapshotPromise)?.hash,
|
||||
});
|
||||
cfg = committed.config;
|
||||
await refreshPluginRegistryAfterConfigMutation({
|
||||
config: cfg,
|
||||
reason: "source-changed",
|
||||
installRecords: committed.installRecords,
|
||||
logger: { warn: (message) => runtime.log(message) },
|
||||
});
|
||||
} else {
|
||||
await replaceConfigFile({
|
||||
nextConfig: cfg,
|
||||
baseHash: (await sourceSnapshotPromise)?.hash,
|
||||
});
|
||||
if (resolvedExplicit.pluginInstalled) {
|
||||
await refreshPluginRegistryAfterConfigMutation({
|
||||
config: cfg,
|
||||
reason: "source-changed",
|
||||
logger: { warn: (message) => runtime.log(message) },
|
||||
});
|
||||
}
|
||||
}
|
||||
cfg = await persistResolvedChannelPluginConfig({
|
||||
resolved: resolvedExplicit,
|
||||
baseHash: (await sourceSnapshotPromise)?.hash,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
const selection = explicitChannel
|
||||
|
||||
@@ -245,16 +245,16 @@ describe("maybeRepairGatewayDaemon", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function runAutoRepair() {
|
||||
async function runAutoRepair(options: { repair?: boolean; yes?: boolean } = { repair: true }) {
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
await maybeRepairGatewayDaemon({
|
||||
cfg: { gateway: {} },
|
||||
runtime,
|
||||
prompter: createDoctorPrompter({
|
||||
runtime,
|
||||
options: { repair: true },
|
||||
options,
|
||||
}),
|
||||
options: { deep: false, repair: true },
|
||||
options: { deep: false, ...options },
|
||||
gatewayDetailsMessage: "details",
|
||||
healthOk: false,
|
||||
});
|
||||
@@ -542,6 +542,39 @@ describe("maybeRepairGatewayDaemon", () => {
|
||||
expect(service.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips running service restart during non-interactive repairs", async () => {
|
||||
setPlatform("linux");
|
||||
|
||||
await runNonInteractiveRepair();
|
||||
|
||||
expect(service.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts stopped service during non-interactive repairs", async () => {
|
||||
setPlatform("linux");
|
||||
service.readRuntime.mockResolvedValue({ status: "stopped" });
|
||||
|
||||
await runNonInteractiveRepair();
|
||||
|
||||
expect(service.restart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("restarts running service when repair is explicitly approved", async () => {
|
||||
setPlatform("linux");
|
||||
|
||||
await runAutoRepair();
|
||||
|
||||
expect(service.restart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("restarts running service when --yes explicitly approves repairs", async () => {
|
||||
setPlatform("linux");
|
||||
|
||||
await runAutoRepair({ yes: true });
|
||||
|
||||
expect(service.restart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips gateway service install when service repair policy is external", async () => {
|
||||
setPlatform("linux");
|
||||
service.isLoaded.mockResolvedValue(false);
|
||||
|
||||
@@ -459,6 +459,10 @@ export async function maybeRepairGatewayDaemon(params: {
|
||||
// Health probe failed — fall through to the restart prompt below.
|
||||
}
|
||||
}
|
||||
if (params.options.nonInteractive === true) {
|
||||
// --fix auto-approves runtime repairs; do not let a headless doctor kill its live gateway.
|
||||
return;
|
||||
}
|
||||
|
||||
const restart = await confirmDoctorServiceRepair(
|
||||
params.prompter,
|
||||
|
||||
@@ -600,6 +600,144 @@ describe("maybeRepairLegacyCronStore", () => {
|
||||
expectNoteContaining("1 job still uses legacy", "Cron");
|
||||
});
|
||||
|
||||
it("advises on isolated shell-prompt jobs without a non-actionable --fix repair note (#94655)", async () => {
|
||||
const storePath = await makeTempStorePath();
|
||||
const shellPromptJobs: Array<Record<string, unknown>> = [
|
||||
createCurrentCronJob({
|
||||
id: "shell-prompt-job-1",
|
||||
name: "Shell prompt job 1",
|
||||
schedule: { kind: "cron", expr: "*/30 * * * *", tz: "UTC" },
|
||||
sessionTarget: "isolated",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message:
|
||||
"Run python3 scripts/check_mail.py and send a compact summary if anything changed.",
|
||||
toolsAllow: ["*"],
|
||||
},
|
||||
delivery: { mode: "announce" },
|
||||
}),
|
||||
createCurrentCronJob({
|
||||
id: "shell-prompt-job-2",
|
||||
name: "Shell prompt job 2",
|
||||
schedule: { kind: "cron", expr: "15 * * * *", tz: "UTC" },
|
||||
sessionTarget: "isolated",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "Run node scripts/check_mail.js and summarize any new messages.",
|
||||
toolsAllow: ["bash"],
|
||||
},
|
||||
delivery: { mode: "announce" },
|
||||
}),
|
||||
createCurrentCronJob({
|
||||
id: "shell-prompt-job-3",
|
||||
name: "Shell prompt job 3",
|
||||
schedule: { kind: "cron", expr: "45 * * * *", tz: "UTC" },
|
||||
sessionTarget: "isolated",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "Execute ./scripts/check_mail.sh and report changed mailbox counts.",
|
||||
toolsAllow: ["shell"],
|
||||
},
|
||||
delivery: { mode: "announce" },
|
||||
}),
|
||||
];
|
||||
const shellPromptJob = requirePersistedJob(shellPromptJobs, 0);
|
||||
await writeCurrentCronStore(storePath, shellPromptJobs);
|
||||
|
||||
const prompter = makePrompter(true);
|
||||
await maybeRepairLegacyCronStore({
|
||||
cfg: createCronConfig(storePath),
|
||||
options: {},
|
||||
prompter,
|
||||
});
|
||||
|
||||
// The advisory is informational only: doctor --fix cannot rewrite a working
|
||||
// isolated agentTurn job, so the misleading repair note must stay absent.
|
||||
expectNoNoteContaining("Cron store issues detected", "Cron");
|
||||
expectNoteContaining(
|
||||
"3 isolated cron jobs drive shell/process tools from the agent prompt and keep running as-is: `Shell prompt job 1`, `Shell prompt job 2`, `Shell prompt job 3`.",
|
||||
"Cron",
|
||||
);
|
||||
expectNoteContaining("informational only", "Cron");
|
||||
expectNoteContaining("Shell prompt job 1", "Cron");
|
||||
expectNoteContaining("Shell prompt job 2", "Cron");
|
||||
expectNoteContaining("Shell prompt job 3", "Cron");
|
||||
expectNoNoteContaining("openclaw doctor --fix", "Cron");
|
||||
expectNoNoteContaining("jobs.json", "Cron");
|
||||
expect(prompter.confirm).not.toHaveBeenCalled();
|
||||
|
||||
// No churn: the advisory does not rewrite the still-working jobs.
|
||||
const persistedJobs = await readPersistedJobs(storePath);
|
||||
expect(persistedJobs).toEqual(shellPromptJobs);
|
||||
const job = requirePersistedJob(persistedJobs, 0);
|
||||
expect(job).toEqual(shellPromptJob);
|
||||
const reloaded = await loadCronJobsStoreWithConfigJobs(storePath);
|
||||
expect(reloaded.configJobIndexes).toEqual([0, 1, 2]);
|
||||
expect(reloaded.invalidConfigRows).toEqual([]);
|
||||
const configJob = requirePersistedJob(reloaded.configJobs, 0);
|
||||
expect(configJob).toEqual(
|
||||
Object.fromEntries(Object.entries(shellPromptJob).filter(([key]) => key !== "updatedAtMs")),
|
||||
);
|
||||
expect(reloaded.configJobRuntimeEntries[0]).toEqual({
|
||||
updatedAtMs: shellPromptJob.updatedAtMs,
|
||||
state: {},
|
||||
scheduleIdentity: JSON.stringify({
|
||||
version: 1,
|
||||
enabled: shellPromptJob.enabled,
|
||||
schedule: shellPromptJob.schedule,
|
||||
}),
|
||||
});
|
||||
const payload = requireRecord(job.payload, "cron payload");
|
||||
expect(payload.kind).toBe("agentTurn");
|
||||
expect(payload.message).toContain("python3 scripts/check_mail.py");
|
||||
});
|
||||
|
||||
it("keeps restricted command prompts actionable without a --fix repair note", async () => {
|
||||
const storePath = await makeTempStorePath();
|
||||
const commandPromptJob = createCurrentCronJob({
|
||||
id: "restricted-command-prompt",
|
||||
name: "Restricted command prompt",
|
||||
schedule: { kind: "cron", expr: "*/30 * * * *", tz: "UTC" },
|
||||
sessionTarget: "isolated",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: [
|
||||
"Command to run:",
|
||||
"- command: python3 scripts/check_mail.py",
|
||||
"- workdir: /home/openclaw/.razor/clawd",
|
||||
].join("\n"),
|
||||
toolsAllow: ["read", "message"],
|
||||
},
|
||||
delivery: { mode: "announce" },
|
||||
});
|
||||
await writeCurrentCronStore(storePath, [commandPromptJob]);
|
||||
|
||||
const prompter = makePrompter(true);
|
||||
await maybeRepairLegacyCronStore({
|
||||
cfg: createCronConfig(storePath),
|
||||
options: {},
|
||||
prompter,
|
||||
});
|
||||
|
||||
expectNoNoteContaining("Cron store issues detected", "Cron");
|
||||
expectNoteContaining(
|
||||
"1 isolated cron job describes a shell command in the agent prompt but lacks shell/process tool access: `Restricted command prompt`.",
|
||||
"Cron",
|
||||
);
|
||||
expectNoteContaining("not the supported shell-tool prompt shape", "Cron");
|
||||
expectNoteContaining("Recreate the job as a command cron job", "Cron");
|
||||
expectNoNoteContaining("informational only", "Cron");
|
||||
expectNoNoteContaining("keep running as-is", "Cron");
|
||||
expectNoNoteContaining("openclaw doctor --fix", "Cron");
|
||||
expect(prompter.confirm).not.toHaveBeenCalled();
|
||||
|
||||
const job = requirePersistedJob(await readPersistedJobs(storePath), 0);
|
||||
const payload = requireRecord(job.payload, "cron payload");
|
||||
expect(payload.kind).toBe("agentTurn");
|
||||
expect(payload.message).toContain("python3 scripts/check_mail.py");
|
||||
expect(payload.toolsAllow).toEqual(["read", "message"]);
|
||||
});
|
||||
|
||||
it("repairs malformed persisted cron ids before list rendering sees them", async () => {
|
||||
const storePath = await makeTempStorePath();
|
||||
await writeCronStore(storePath, [
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
} from "./legacy-store-migration.js";
|
||||
import {
|
||||
formatLegacyIssuePreview,
|
||||
formatUnresolvedCommandPromptAdvisory,
|
||||
formatUnresolvedShellPromptAdvisory,
|
||||
mergeLegacyCronJobs,
|
||||
mergeRuntimeEntryIntoConfigJob,
|
||||
needsSqliteProjectionBackfill,
|
||||
@@ -336,9 +338,21 @@ export async function maybeRepairLegacyCronStore(params: {
|
||||
const normalized = normalizeStoredCronJobs(rawJobs);
|
||||
const notifyCount = rawJobs.filter((job) => job.notify === true).length;
|
||||
const dreamingStaleCount = countStaleDreamingJobs(rawJobs);
|
||||
const previewLines = formatLegacyIssuePreview(normalized.issues, {
|
||||
unresolvedAgentTurnShellToolPrompt: normalized.unresolvedAgentTurnShellToolPromptJobs,
|
||||
});
|
||||
// Unresolved agentTurn command prompts are not auto-fixable; keep them out of the
|
||||
// --fix preview so the repair note does not promise a fix that never lands (#94655).
|
||||
const commandPromptAdvisory = formatUnresolvedCommandPromptAdvisory(
|
||||
normalized.unresolvedAgentTurnCommandPromptJobs,
|
||||
);
|
||||
if (commandPromptAdvisory) {
|
||||
note(commandPromptAdvisory, "Cron");
|
||||
}
|
||||
const shellPromptAdvisory = formatUnresolvedShellPromptAdvisory(
|
||||
normalized.unresolvedAgentTurnShellToolPromptJobs,
|
||||
);
|
||||
if (shellPromptAdvisory) {
|
||||
note(shellPromptAdvisory, "Cron");
|
||||
}
|
||||
const previewLines = formatLegacyIssuePreview(normalized.issues);
|
||||
if (legacyStoreDetected) {
|
||||
previewLines.unshift(
|
||||
legacyImportCount > 0
|
||||
|
||||
@@ -12,6 +12,10 @@ type LegacyAgentTurnCommandPayload = {
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
export type UnresolvedAgentTurnShellToolPromptKind =
|
||||
| "commandPromptWithoutShellAccess"
|
||||
| "shellToolPrompt";
|
||||
|
||||
const LEGACY_AGENT_TURN_COMMAND_MARKER_RE = /\bCommand to run\s*:/iu;
|
||||
const LEGACY_AGENT_TURN_COMMAND_FIELD_RE = /^\s*-\s*(command|workdir|timeout)\s*:\s*(.*?)\s*$/iu;
|
||||
const SHELL_TOOL_NAMES = new Set(["bash", "command", "exec", "process", "shell", "sh"]);
|
||||
@@ -217,17 +221,27 @@ export function migrateLegacyAgentTurnCommandPayload(payload: UnknownRecord): bo
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasUnresolvedAgentTurnShellToolPrompt(payload: UnknownRecord): boolean {
|
||||
export function classifyUnresolvedAgentTurnShellToolPrompt(
|
||||
payload: UnknownRecord,
|
||||
): UnresolvedAgentTurnShellToolPromptKind | null {
|
||||
if (payload.kind !== "agentTurn") {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
const message = readString(payload.message);
|
||||
if (typeof message !== "string") {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
const parsed = parseLegacyAgentTurnCommandMessage(message);
|
||||
return (
|
||||
Boolean(parsed) ||
|
||||
(hasShellToolAccess(payload.toolsAllow) && SHELL_COMMAND_MESSAGE_RE.test(message))
|
||||
);
|
||||
const shellToolAccess = hasShellToolAccess(payload.toolsAllow);
|
||||
if (parsed && !shellToolAccess) {
|
||||
return "commandPromptWithoutShellAccess";
|
||||
}
|
||||
if (shellToolAccess && SHELL_COMMAND_MESSAGE_RE.test(message)) {
|
||||
return "shellToolPrompt";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasUnresolvedAgentTurnShellToolPrompt(payload: UnknownRecord): boolean {
|
||||
return classifyUnresolvedAgentTurnShellToolPrompt(payload) !== null;
|
||||
}
|
||||
|
||||
@@ -5,28 +5,55 @@ import { normalizeCronJobInput } from "../../../cron/normalize.js";
|
||||
import type { CronJob } from "../../../cron/types.js";
|
||||
|
||||
type CronLegacyIssueCounts = Partial<Record<string, number>>;
|
||||
type CronLegacyIssueDetails = {
|
||||
unresolvedAgentTurnShellToolPrompt?: string[];
|
||||
};
|
||||
|
||||
function pluralize(count: number, noun: string) {
|
||||
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function formatJobNameList(names: string[] | undefined): string {
|
||||
if (!names || names.length === 0) {
|
||||
return "";
|
||||
}
|
||||
function formatJobNameList(names: string[]): string {
|
||||
const preview = names.slice(0, 5).map((name) => `\`${name}\``);
|
||||
const remaining = names.length - preview.length;
|
||||
return remaining > 0 ? `: ${preview.join(", ")} (+${remaining} more)` : `: ${preview.join(", ")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisory for isolated agentTurn cron jobs that describe a command but cannot access shell tools.
|
||||
* These need operator attention, but `doctor --fix` cannot safely infer whether to grant tool
|
||||
* access or recreate them as command cron jobs.
|
||||
*/
|
||||
export function formatUnresolvedCommandPromptAdvisory(names: string[]): string | null {
|
||||
if (names.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const describeVerb = names.length === 1 ? "describes" : "describe";
|
||||
const accessVerb = names.length === 1 ? "lacks" : "lack";
|
||||
return [
|
||||
`${pluralize(names.length, "isolated cron job")} ${describeVerb} a shell command in the agent prompt but ${accessVerb} shell/process tool access${formatJobNameList(names)}.`,
|
||||
"- This is not the supported shell-tool prompt shape, so doctor cannot prove the job will execute the requested command.",
|
||||
'- Recreate the job as a command cron job (`openclaw cron add ... --command "<shell>"`) or grant explicit shell/process tool access before relying on it.',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisory for isolated agentTurn cron jobs that drive shell/process tools from the prompt.
|
||||
* These keep running and are not a legacy store row, so `doctor --fix` cannot rewrite them;
|
||||
* routing this through the auto-repair preview made the finding persist after every --fix.
|
||||
*/
|
||||
export function formatUnresolvedShellPromptAdvisory(names: string[]): string | null {
|
||||
if (names.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const verb = names.length === 1 ? "drives" : "drive";
|
||||
const keepVerb = names.length === 1 ? "keeps" : "keep";
|
||||
return [
|
||||
`${pluralize(names.length, "isolated cron job")} ${verb} shell/process tools from the agent prompt and ${keepVerb} running as-is${formatJobNameList(names)}.`,
|
||||
"- This is a supported shape, not a legacy store row, so the doctor fix path cannot convert it and the finding is informational only.",
|
||||
'- For a deterministic run, recreate the job as a command cron job (`openclaw cron add ... --command "<shell>"`).',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Convert legacy cron issue counts into doctor preview lines. */
|
||||
export function formatLegacyIssuePreview(
|
||||
issues: CronLegacyIssueCounts,
|
||||
details: CronLegacyIssueDetails = {},
|
||||
): string[] {
|
||||
export function formatLegacyIssuePreview(issues: CronLegacyIssueCounts): string[] {
|
||||
const lines: string[] = [];
|
||||
if (issues.jobId) {
|
||||
lines.push(`- ${pluralize(issues.jobId, "job")} still uses legacy \`jobId\``);
|
||||
@@ -58,11 +85,6 @@ export function formatLegacyIssuePreview(
|
||||
`- ${pluralize(issues.legacyAgentTurnCommandPayload, "job")} uses an agent prompt to run a shell command`,
|
||||
);
|
||||
}
|
||||
if (issues.unresolvedAgentTurnShellToolPrompt) {
|
||||
lines.push(
|
||||
`- ${pluralize(issues.unresolvedAgentTurnShellToolPrompt, "job")} asks an isolated agent for shell/process tools and needs manual command conversion${formatJobNameList(details.unresolvedAgentTurnShellToolPrompt)}`,
|
||||
);
|
||||
}
|
||||
if (issues.legacyPayloadProvider) {
|
||||
lines.push(
|
||||
`- ${pluralize(issues.legacyPayloadProvider, "job")} still uses payload \`provider\` as a delivery alias`,
|
||||
|
||||
@@ -190,6 +190,8 @@ describe("normalizeStoredCronJobs", () => {
|
||||
|
||||
expect(result.issues.legacyAgentTurnCommandPayload).toBeUndefined();
|
||||
expect(result.issues.unresolvedAgentTurnShellToolPrompt).toBe(1);
|
||||
expect(result.unresolvedAgentTurnCommandPromptJobs).toEqual(["Legacy job"]);
|
||||
expect(result.unresolvedAgentTurnShellToolPromptJobs).toEqual([]);
|
||||
const payload = job.payload as Record<string, unknown>;
|
||||
expect(payload.kind).toBe("agentTurn");
|
||||
expect(payload.message).toContain(command);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { inferCronJobName } from "../../../cron/service/normalize.js";
|
||||
import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "../../../cron/stagger.js";
|
||||
import { normalizeLegacyDeliveryInput } from "./legacy-delivery.js";
|
||||
import {
|
||||
hasUnresolvedAgentTurnShellToolPrompt,
|
||||
classifyUnresolvedAgentTurnShellToolPrompt,
|
||||
hasLegacyOpenAICodexCronModelRef,
|
||||
migrateLegacyAgentTurnCommandPayload,
|
||||
migrateLegacyCronPayload,
|
||||
@@ -41,6 +41,7 @@ type CronStoreIssues = Partial<Record<CronStoreIssueKey, number>>;
|
||||
|
||||
type NormalizeCronStoreJobsResult = {
|
||||
issues: CronStoreIssues;
|
||||
unresolvedAgentTurnCommandPromptJobs: string[];
|
||||
unresolvedAgentTurnShellToolPromptJobs: string[];
|
||||
jobs: Array<Record<string, unknown>>;
|
||||
mutated: boolean;
|
||||
@@ -246,7 +247,12 @@ export function normalizeStoredCronJobs(
|
||||
jobs: Array<Record<string, unknown>>,
|
||||
): NormalizeCronStoreJobsResult {
|
||||
const issues: CronStoreIssues = {};
|
||||
const unresolvedAgentTurnCommandPromptJobs: string[] = [];
|
||||
const unresolvedAgentTurnShellToolPromptJobs: string[] = [];
|
||||
const unresolvedAgentTurnPromptJobsByKind = {
|
||||
commandPromptWithoutShellAccess: unresolvedAgentTurnCommandPromptJobs,
|
||||
shellToolPrompt: unresolvedAgentTurnShellToolPromptJobs,
|
||||
};
|
||||
let mutated = false;
|
||||
const keptJobs: Array<Record<string, unknown>> = [];
|
||||
const removedJobs: NormalizeCronStoreJobsResult["removedJobs"] = [];
|
||||
@@ -421,11 +427,14 @@ export function normalizeStoredCronJobs(
|
||||
if (migrateLegacyAgentTurnCommandPayload(payloadRecord)) {
|
||||
mutated = true;
|
||||
trackIssue("legacyAgentTurnCommandPayload");
|
||||
} else if (hasUnresolvedAgentTurnShellToolPrompt(payloadRecord)) {
|
||||
trackIssue("unresolvedAgentTurnShellToolPrompt");
|
||||
const name = normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id);
|
||||
if (name) {
|
||||
unresolvedAgentTurnShellToolPromptJobs.push(name);
|
||||
} else {
|
||||
const unresolvedPromptKind = classifyUnresolvedAgentTurnShellToolPrompt(payloadRecord);
|
||||
if (unresolvedPromptKind) {
|
||||
trackIssue("unresolvedAgentTurnShellToolPrompt");
|
||||
const name = normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id);
|
||||
if (name) {
|
||||
unresolvedAgentTurnPromptJobsByKind[unresolvedPromptKind].push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -626,5 +635,12 @@ export function normalizeStoredCronJobs(
|
||||
jobs.splice(0, jobs.length, ...keptJobs);
|
||||
}
|
||||
|
||||
return { issues, unresolvedAgentTurnShellToolPromptJobs, jobs, mutated, removedJobs };
|
||||
return {
|
||||
issues,
|
||||
unresolvedAgentTurnCommandPromptJobs,
|
||||
unresolvedAgentTurnShellToolPromptJobs,
|
||||
jobs,
|
||||
mutated,
|
||||
removedJobs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -983,6 +983,15 @@ describe("gateway-status command", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes the full caller timeout through to active configured remote probes", async () => {
|
||||
const { runtime } = createRuntimeCapture();
|
||||
probeGateway.mockClear();
|
||||
|
||||
await runGatewayStatus(runtime, { timeout: "15000", json: true });
|
||||
|
||||
expect(requireProbeCall("wss://remote.example:18789").timeoutMs).toBe(15_000);
|
||||
});
|
||||
|
||||
it("uses configured handshake timeout as the default local probe budget", async () => {
|
||||
const { runtime } = createRuntimeCapture();
|
||||
probeGateway.mockClear();
|
||||
|
||||
@@ -436,20 +436,30 @@ describe("resolveProbeBudgetMs", () => {
|
||||
).toBe(2_500);
|
||||
});
|
||||
|
||||
it("keeps non-local probe caps unchanged", () => {
|
||||
it("lets active remote probes use the full caller budget", () => {
|
||||
expect(
|
||||
resolveProbeBudgetMs(15_000, {
|
||||
kind: "configRemote",
|
||||
active: true,
|
||||
url: "wss://gateway.example/ws",
|
||||
}),
|
||||
).toBe(1500);
|
||||
).toBe(15_000);
|
||||
expect(
|
||||
resolveProbeBudgetMs(15_000, {
|
||||
kind: "explicit",
|
||||
active: true,
|
||||
url: "wss://gateway.example/ws",
|
||||
}),
|
||||
).toBe(15_000);
|
||||
});
|
||||
|
||||
it("keeps inactive remote and SSH tunnel probes on the short cap", () => {
|
||||
expect(
|
||||
resolveProbeBudgetMs(15_000, {
|
||||
kind: "configRemote",
|
||||
active: false,
|
||||
url: "wss://gateway.example/ws",
|
||||
}),
|
||||
).toBe(1500);
|
||||
expect(
|
||||
resolveProbeBudgetMs(15_000, {
|
||||
|
||||
@@ -150,15 +150,15 @@ export function resolveProbeBudgetMs(
|
||||
if (target.kind === "sshTunnel") {
|
||||
return Math.min(2000, overallMs);
|
||||
}
|
||||
if (target.active) {
|
||||
return overallMs;
|
||||
}
|
||||
if (target.kind === "localLoopback") {
|
||||
return Math.min(800, overallMs);
|
||||
}
|
||||
if (!isLoopbackProbeTarget(target)) {
|
||||
return Math.min(1500, overallMs);
|
||||
}
|
||||
if (target.kind === "localLoopback" && !target.active) {
|
||||
return Math.min(800, overallMs);
|
||||
}
|
||||
// Active/discovered loopback probes and explicit loopback URLs should honor
|
||||
// the caller budget because healthy local detail RPCs can legitimately take
|
||||
// longer than the legacy short caps.
|
||||
return overallMs;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
upsertApiKeyProfile,
|
||||
writeOAuthCredentials,
|
||||
} from "../plugins/provider-auth-helpers.js";
|
||||
import { setTestEnvValue } from "../test-utils/env.js";
|
||||
import {
|
||||
createAuthTestLifecycle,
|
||||
readAuthProfilesForAgent,
|
||||
@@ -162,7 +163,8 @@ describe("writeOAuthCredentials", () => {
|
||||
|
||||
it("writes OAuth credentials to all sibling agent dirs when syncSiblingAgents=true", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-sync-"));
|
||||
process.env.OPENCLAW_STATE_DIR = tempStateDir;
|
||||
lifecycle.setStateDir(tempStateDir);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir);
|
||||
|
||||
const mainAgentDir = path.join(tempStateDir, "agents", "main", "agent");
|
||||
const kidAgentDir = path.join(tempStateDir, "agents", "kid", "agent");
|
||||
@@ -171,7 +173,7 @@ describe("writeOAuthCredentials", () => {
|
||||
await fs.mkdir(kidAgentDir, { recursive: true });
|
||||
await fs.mkdir(workerAgentDir, { recursive: true });
|
||||
|
||||
process.env.OPENCLAW_AGENT_DIR = kidAgentDir;
|
||||
setTestEnvValue("OPENCLAW_AGENT_DIR", kidAgentDir);
|
||||
|
||||
const creds = {
|
||||
refresh: "refresh-sync",
|
||||
@@ -198,14 +200,15 @@ describe("writeOAuthCredentials", () => {
|
||||
|
||||
it("writes OAuth credentials only to target dir by default", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-nosync-"));
|
||||
process.env.OPENCLAW_STATE_DIR = tempStateDir;
|
||||
lifecycle.setStateDir(tempStateDir);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir);
|
||||
|
||||
const mainAgentDir = path.join(tempStateDir, "agents", "main", "agent");
|
||||
const kidAgentDir = path.join(tempStateDir, "agents", "kid", "agent");
|
||||
await fs.mkdir(mainAgentDir, { recursive: true });
|
||||
await fs.mkdir(kidAgentDir, { recursive: true });
|
||||
|
||||
process.env.OPENCLAW_AGENT_DIR = kidAgentDir;
|
||||
setTestEnvValue("OPENCLAW_AGENT_DIR", kidAgentDir);
|
||||
|
||||
const creds = {
|
||||
refresh: "refresh-kid",
|
||||
@@ -229,7 +232,8 @@ describe("writeOAuthCredentials", () => {
|
||||
|
||||
it("syncs siblings from explicit agentDir outside OPENCLAW_STATE_DIR", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-external-"));
|
||||
process.env.OPENCLAW_STATE_DIR = tempStateDir;
|
||||
lifecycle.setStateDir(tempStateDir);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir);
|
||||
|
||||
// Create standard-layout agents tree *outside* OPENCLAW_STATE_DIR
|
||||
const externalRoot = path.join(tempStateDir, "external", "agents");
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { makeTempWorkspace } from "../test-helpers/workspace.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
|
||||
import { createThrowingRuntime } from "./onboard-non-interactive.test-helpers.js";
|
||||
import type { installGatewayDaemonNonInteractive } from "./onboard-non-interactive/local/daemon-install.js";
|
||||
|
||||
@@ -336,8 +336,8 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
throw new Error("temp home not initialized");
|
||||
}
|
||||
const stateDir = await fs.mkdtemp(path.join(tempHome, prefix));
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
delete process.env.OPENCLAW_CONFIG_PATH;
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
|
||||
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
|
||||
return stateDir;
|
||||
};
|
||||
const withStateDir = async (
|
||||
@@ -364,16 +364,16 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
"OPENCLAW_GATEWAY_TOKEN",
|
||||
"OPENCLAW_GATEWAY_PASSWORD",
|
||||
]);
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "1";
|
||||
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.OPENCLAW_SKIP_CRON = "1";
|
||||
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
|
||||
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
setTestEnvValue("OPENCLAW_SKIP_CHANNELS", "1");
|
||||
setTestEnvValue("OPENCLAW_SKIP_GMAIL_WATCHER", "1");
|
||||
setTestEnvValue("OPENCLAW_SKIP_CRON", "1");
|
||||
setTestEnvValue("OPENCLAW_SKIP_CANVAS_HOST", "1");
|
||||
setTestEnvValue("OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", "1");
|
||||
deleteTestEnvValue("OPENCLAW_GATEWAY_TOKEN");
|
||||
deleteTestEnvValue("OPENCLAW_GATEWAY_PASSWORD");
|
||||
|
||||
tempHome = await makeTempWorkspace("openclaw-onboard-");
|
||||
process.env.HOME = tempHome;
|
||||
setTestEnvValue("HOME", tempHome);
|
||||
|
||||
await loadGatewayOnboardModules();
|
||||
});
|
||||
@@ -969,8 +969,8 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
return;
|
||||
}
|
||||
await withStateDir("state-lan-", async (stateDir) => {
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
process.env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json");
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json"));
|
||||
|
||||
const port = getPseudoPort(40_000);
|
||||
const workspace = path.join(stateDir, "openclaw");
|
||||
|
||||
@@ -67,9 +67,10 @@ export async function listConfiguredMcpServers(): Promise<ConfigMcpReadResult> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateConfiguredMcpServerTools(params: {
|
||||
async function updateConfiguredMcpServerConfig(params: {
|
||||
name: string;
|
||||
tools: McpServerToolSelection | null;
|
||||
update: (server: Record<string, unknown>) => Record<string, unknown>;
|
||||
errorLabel: string;
|
||||
}): Promise<ConfigMcpWriteResult> {
|
||||
const name = params.name.trim();
|
||||
if (!name) {
|
||||
@@ -92,22 +93,7 @@ export async function updateConfiguredMcpServerTools(params: {
|
||||
|
||||
const next = structuredClone(loaded.config);
|
||||
const servers = normalizeConfiguredMcpServers(next.mcp?.servers);
|
||||
const server = { ...servers[name] };
|
||||
if (params.tools === null) {
|
||||
delete server.toolFilter;
|
||||
} else {
|
||||
const include = normalizeToolSelectionList(params.tools.include);
|
||||
const exclude = normalizeToolSelectionList(params.tools.exclude);
|
||||
if (include || exclude) {
|
||||
server.toolFilter = {
|
||||
...(include ? { include } : {}),
|
||||
...(exclude ? { exclude } : {}),
|
||||
};
|
||||
} else {
|
||||
delete server.toolFilter;
|
||||
}
|
||||
}
|
||||
servers[name] = server;
|
||||
servers[name] = params.update({ ...servers[name] });
|
||||
next.mcp = {
|
||||
...next.mcp,
|
||||
servers,
|
||||
@@ -119,7 +105,7 @@ export async function updateConfiguredMcpServerTools(params: {
|
||||
return {
|
||||
ok: false,
|
||||
path: loaded.path,
|
||||
error: `Config invalid after MCP tool selection update (${issue.path}: ${issue.message}).`,
|
||||
error: `Config invalid after MCP ${params.errorLabel} (${issue.path}: ${issue.message}).`,
|
||||
};
|
||||
}
|
||||
await replaceConfigFile({
|
||||
@@ -135,57 +121,42 @@ export async function updateConfiguredMcpServerTools(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateConfiguredMcpServerTools(params: {
|
||||
name: string;
|
||||
tools: McpServerToolSelection | null;
|
||||
}): Promise<ConfigMcpWriteResult> {
|
||||
return updateConfiguredMcpServerConfig({
|
||||
name: params.name,
|
||||
errorLabel: "tool selection update",
|
||||
update: (server) => {
|
||||
if (params.tools === null) {
|
||||
delete server.toolFilter;
|
||||
} else {
|
||||
const include = normalizeToolSelectionList(params.tools.include);
|
||||
const exclude = normalizeToolSelectionList(params.tools.exclude);
|
||||
if (include || exclude) {
|
||||
server.toolFilter = {
|
||||
...(include ? { include } : {}),
|
||||
...(exclude ? { exclude } : {}),
|
||||
};
|
||||
} else {
|
||||
delete server.toolFilter;
|
||||
}
|
||||
}
|
||||
return server;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateConfiguredMcpServer(params: {
|
||||
name: string;
|
||||
update: (server: Record<string, unknown>) => Record<string, unknown>;
|
||||
}): Promise<ConfigMcpWriteResult> {
|
||||
const name = params.name.trim();
|
||||
if (!name) {
|
||||
return { ok: false, path: "", error: "MCP server name is required." };
|
||||
}
|
||||
|
||||
const loaded = await listConfiguredMcpServers();
|
||||
if (!loaded.ok) {
|
||||
return loaded;
|
||||
}
|
||||
if (!Object.hasOwn(loaded.mcpServers, name)) {
|
||||
return {
|
||||
ok: true,
|
||||
path: loaded.path,
|
||||
config: loaded.config,
|
||||
mcpServers: loaded.mcpServers,
|
||||
updated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const next = structuredClone(loaded.config);
|
||||
const servers = normalizeConfiguredMcpServers(next.mcp?.servers);
|
||||
servers[name] = canonicalizeConfiguredMcpServer(params.update({ ...servers[name] }));
|
||||
next.mcp = {
|
||||
...next.mcp,
|
||||
servers,
|
||||
};
|
||||
|
||||
const validated = validateConfigObjectWithPlugins(next);
|
||||
if (!validated.ok) {
|
||||
const issue = validated.issues[0];
|
||||
return {
|
||||
ok: false,
|
||||
path: loaded.path,
|
||||
error: `Config invalid after MCP configure (${issue.path}: ${issue.message}).`,
|
||||
};
|
||||
}
|
||||
await replaceConfigFile({
|
||||
nextConfig: validated.config,
|
||||
baseHash: loaded.baseHash,
|
||||
return updateConfiguredMcpServerConfig({
|
||||
name: params.name,
|
||||
errorLabel: "configure",
|
||||
update: (server) => canonicalizeConfiguredMcpServer(params.update(server)),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
path: loaded.path,
|
||||
config: validated.config,
|
||||
mcpServers: servers,
|
||||
updated: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setConfiguredMcpServer(params: {
|
||||
|
||||
@@ -28,6 +28,9 @@ vi.mock("../channels/plugins/configured-state.js", async (importOriginal) => {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}) => {
|
||||
if (params.channelId === "cache-channel") {
|
||||
return Boolean(params.env?.CACHE_CHANNEL_TOKEN?.trim());
|
||||
}
|
||||
if (params.channelId === "irc") {
|
||||
return Boolean(params.env?.IRC_HOST?.trim() && params.env?.IRC_NICK?.trim());
|
||||
}
|
||||
@@ -1198,10 +1201,11 @@ describe("applyPluginAutoEnable core", () => {
|
||||
it("does not reuse same-turn auto-enable results after discovery mutates in place", () => {
|
||||
const config: OpenClawConfig = {};
|
||||
const mutableDiscovery: PluginDiscoveryResult = { candidates: [], diagnostics: [] };
|
||||
const manifestRegistry = makeRegistry([{ id: "irc-plugin", channels: ["irc"] }]);
|
||||
const manifestRegistry = makeRegistry([
|
||||
{ id: "cache-channel-plugin", channels: ["cache-channel"] },
|
||||
]);
|
||||
const configuredEnv = makeIsolatedEnv({
|
||||
IRC_HOST: "irc.libera.chat",
|
||||
IRC_NICK: "openclaw-bot",
|
||||
CACHE_CHANNEL_TOKEN: "configured",
|
||||
});
|
||||
|
||||
const first = applyPluginAutoEnable({
|
||||
@@ -1211,7 +1215,10 @@ describe("applyPluginAutoEnable core", () => {
|
||||
manifestRegistry,
|
||||
});
|
||||
mutableDiscovery.candidates.push(
|
||||
makeBundledChannelCandidate({ pluginId: "irc-plugin", channelId: "irc" }),
|
||||
makeBundledChannelCandidate({
|
||||
pluginId: "cache-channel-plugin",
|
||||
channelId: "cache-channel",
|
||||
}),
|
||||
);
|
||||
const second = applyPluginAutoEnable({
|
||||
config,
|
||||
@@ -1220,8 +1227,8 @@ describe("applyPluginAutoEnable core", () => {
|
||||
manifestRegistry,
|
||||
});
|
||||
|
||||
expect(first.config.plugins?.entries?.["irc-plugin"]).toBeUndefined();
|
||||
expect(second.config.plugins?.entries?.["irc-plugin"]?.enabled).toBe(true);
|
||||
expect(first.config.plugins?.entries?.["cache-channel-plugin"]).toBeUndefined();
|
||||
expect(second.config.plugins?.entries?.["cache-channel-plugin"]?.enabled).toBe(true);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,3 +11,15 @@ export function resolveAssistantStreamDeltaText(evt: AgentEventPayload): string
|
||||
const text = evt.data.text;
|
||||
return typeof delta === "string" ? delta : typeof text === "string" ? text : "";
|
||||
}
|
||||
|
||||
export function isReplaceableAssistantStreamEvent(evt: AgentEventPayload): boolean {
|
||||
return evt.data.replaceable === true;
|
||||
}
|
||||
|
||||
export function resolveAssistantStreamSnapshotText(evt: AgentEventPayload): string {
|
||||
const text = evt.data.text;
|
||||
if (typeof text === "string") {
|
||||
return text;
|
||||
}
|
||||
return resolveAssistantStreamDeltaText(evt);
|
||||
}
|
||||
|
||||
@@ -2313,6 +2313,81 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("buffers replaceable assistant events for streaming chat completions", async () => {
|
||||
const port = enabledPort;
|
||||
agentCommand.mockClear();
|
||||
agentCommand.mockImplementationOnce((async (opts: unknown) => {
|
||||
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "assistant",
|
||||
data: { text: "coordination draft", delta: "coordination draft", replaceable: true },
|
||||
});
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "assistant",
|
||||
data: { text: "final answer", delta: "", replace: true, replaceable: true },
|
||||
});
|
||||
emitAgentEvent({ runId, stream: "assistant", data: { text: "final answer" } });
|
||||
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
|
||||
return { payloads: [{ text: "final answer" }] };
|
||||
}) as never);
|
||||
|
||||
const res = await postChatCompletions(port, {
|
||||
stream: true,
|
||||
model: "openclaw",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = parseSseDataLines(await res.text());
|
||||
const chunks = data
|
||||
.filter((d) => d !== "[DONE]")
|
||||
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
||||
const allContent = chunks
|
||||
.flatMap((chunk) => (chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
||||
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
|
||||
.filter((content): content is string => typeof content === "string")
|
||||
.join("");
|
||||
|
||||
expect(allContent).toBe("final answer");
|
||||
expect(allContent).not.toContain("coordination draft");
|
||||
});
|
||||
|
||||
it("prefers final result text over buffered replaceable chat drafts", async () => {
|
||||
const port = enabledPort;
|
||||
agentCommand.mockClear();
|
||||
agentCommand.mockImplementationOnce((async (opts: unknown) => {
|
||||
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "assistant",
|
||||
data: { text: "coordination draft", delta: "coordination draft", replaceable: true },
|
||||
});
|
||||
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
|
||||
return { payloads: [{ text: "final answer" }] };
|
||||
}) as never);
|
||||
|
||||
const res = await postChatCompletions(port, {
|
||||
stream: true,
|
||||
model: "openclaw",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = parseSseDataLines(await res.text());
|
||||
const chunks = data
|
||||
.filter((d) => d !== "[DONE]")
|
||||
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
||||
const allContent = chunks
|
||||
.flatMap((chunk) => (chunk.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
||||
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
|
||||
.filter((content): content is string => typeof content === "string")
|
||||
.join("");
|
||||
|
||||
expect(allContent).toBe("final answer");
|
||||
});
|
||||
|
||||
it("includes usage in final stream chunk when stream_options.include_usage=true", async () => {
|
||||
const port = enabledPort;
|
||||
agentCommand.mockClear();
|
||||
|
||||
@@ -35,7 +35,11 @@ import {
|
||||
type InputImageSource,
|
||||
} from "../media/input-files.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveAssistantStreamDeltaText } from "./agent-event-assistant-text.js";
|
||||
import {
|
||||
isReplaceableAssistantStreamEvent,
|
||||
resolveAssistantStreamDeltaText,
|
||||
resolveAssistantStreamSnapshotText,
|
||||
} from "./agent-event-assistant-text.js";
|
||||
import {
|
||||
buildAgentMessageFromConversationEntries,
|
||||
type ConversationEntry,
|
||||
@@ -1180,6 +1184,7 @@ export async function handleOpenAiHttpRequest(
|
||||
let wroteStopChunk = false;
|
||||
let sawAssistantDelta = false;
|
||||
let bufferedAssistantContent = "";
|
||||
let bufferedReplaceableAssistantContent = "";
|
||||
let finalUsage: OpenAiChatCompletionsUsage | undefined;
|
||||
let finalizeRequested = false;
|
||||
let finalizeFinishReason: "stop" | "tool_calls" = "stop";
|
||||
@@ -1226,6 +1231,20 @@ export async function handleOpenAiHttpRequest(
|
||||
}
|
||||
|
||||
if (evt.stream === "assistant") {
|
||||
const text = evt.data?.text;
|
||||
const replace = evt.data?.replace === true;
|
||||
if (replace && typeof text === "string") {
|
||||
bufferedReplaceableAssistantContent = text;
|
||||
}
|
||||
|
||||
if (isReplaceableAssistantStreamEvent(evt)) {
|
||||
const snapshot = resolveAssistantStreamSnapshotText(evt);
|
||||
if (snapshot) {
|
||||
bufferedReplaceableAssistantContent = snapshot;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const content = resolveAssistantStreamDeltaText(evt) ?? "";
|
||||
if (!content) {
|
||||
return;
|
||||
@@ -1313,7 +1332,10 @@ export async function handleOpenAiHttpRequest(
|
||||
writeAssistantRoleChunk(res, { runId, model });
|
||||
}
|
||||
if (!sawAssistantDelta) {
|
||||
const commentary = bufferedAssistantContent || resolveAgentResponseCommentary(result);
|
||||
const commentary =
|
||||
bufferedAssistantContent ||
|
||||
resolveAgentResponseCommentary(result) ||
|
||||
bufferedReplaceableAssistantContent;
|
||||
if (commentary) {
|
||||
sawAssistantDelta = true;
|
||||
writeAssistantContentChunk(res, {
|
||||
@@ -1339,7 +1361,11 @@ export async function handleOpenAiHttpRequest(
|
||||
writeAssistantRoleChunk(res, { runId, model });
|
||||
}
|
||||
|
||||
const content = resolveAgentResponseText(result);
|
||||
const content =
|
||||
resolveAgentResponseCommentary(result) ||
|
||||
bufferedReplaceableAssistantContent ||
|
||||
resolveAgentResponseText(result) ||
|
||||
"No response from OpenClaw.";
|
||||
|
||||
sawAssistantDelta = true;
|
||||
writeAssistantContentChunk(res, {
|
||||
|
||||
@@ -1372,6 +1372,83 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
expect(response?.output?.[1]?.name).toBe("get_weather");
|
||||
});
|
||||
|
||||
it("buffers replaceable assistant events for streaming responses", async () => {
|
||||
const port = enabledPort;
|
||||
agentCommand.mockClear();
|
||||
agentCommand.mockImplementationOnce((async (opts: unknown) => {
|
||||
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "assistant",
|
||||
data: { text: "coordination draft", delta: "coordination draft", replaceable: true },
|
||||
});
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "assistant",
|
||||
data: { text: "final answer", delta: "", replace: true, replaceable: true },
|
||||
});
|
||||
emitAgentEvent({ runId, stream: "assistant", data: { text: "final answer" } });
|
||||
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
|
||||
return { payloads: [{ text: "final answer" }] };
|
||||
}) as never);
|
||||
|
||||
const res = await postResponses(port, {
|
||||
stream: true,
|
||||
model: "openclaw",
|
||||
input: "hi",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const events = parseSseEvents(await res.text());
|
||||
const deltas = events
|
||||
.filter((event) => event.event === "response.output_text.delta")
|
||||
.map((event) => {
|
||||
const parsed = JSON.parse(event.data) as { delta?: string };
|
||||
return parsed.delta ?? "";
|
||||
})
|
||||
.join("");
|
||||
const completed = JSON.parse(findSseEvent(events, "response.completed").data) as {
|
||||
response?: { output?: Array<{ content?: Array<{ text?: string }> }> };
|
||||
};
|
||||
|
||||
expect(deltas).toBe("final answer");
|
||||
expect(completed.response?.output?.[0]?.content?.[0]?.text).toBe("final answer");
|
||||
expect(deltas).not.toContain("coordination draft");
|
||||
});
|
||||
|
||||
it("prefers final result text over buffered replaceable response drafts", async () => {
|
||||
const port = enabledPort;
|
||||
agentCommand.mockClear();
|
||||
agentCommand.mockImplementationOnce((async (opts: unknown) => {
|
||||
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "assistant",
|
||||
data: { text: "coordination draft", delta: "coordination draft", replaceable: true },
|
||||
});
|
||||
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
|
||||
return { payloads: [{ text: "final answer" }] };
|
||||
}) as never);
|
||||
|
||||
const res = await postResponses(port, {
|
||||
stream: true,
|
||||
model: "openclaw",
|
||||
input: "hi",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const events = parseSseEvents(await res.text());
|
||||
const deltas = events
|
||||
.filter((event) => event.event === "response.output_text.delta")
|
||||
.map((event) => {
|
||||
const parsed = JSON.parse(event.data) as { delta?: string };
|
||||
return parsed.delta ?? "";
|
||||
})
|
||||
.join("");
|
||||
|
||||
expect(deltas).toBe("final answer");
|
||||
});
|
||||
|
||||
it("falls back to payload text for streamed function_call responses", async () => {
|
||||
const port = enabledPort;
|
||||
agentCommand.mockClear();
|
||||
|
||||
@@ -33,7 +33,11 @@ import {
|
||||
type InputImageSource,
|
||||
} from "../media/input-files.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveAssistantStreamDeltaText } from "./agent-event-assistant-text.js";
|
||||
import {
|
||||
isReplaceableAssistantStreamEvent,
|
||||
resolveAssistantStreamDeltaText,
|
||||
resolveAssistantStreamSnapshotText,
|
||||
} from "./agent-event-assistant-text.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import {
|
||||
@@ -912,11 +916,13 @@ export async function handleOpenResponsesHttpRequest(
|
||||
setSseHeaders(res);
|
||||
|
||||
let accumulatedText = "";
|
||||
let bufferedReplaceableAssistantContent = "";
|
||||
let sawAssistantDelta = false;
|
||||
let closed = false;
|
||||
let unsubscribe = () => {};
|
||||
let stopWatchingDisconnect = () => {};
|
||||
let finalUsage: Usage | undefined;
|
||||
let finalizeStatus: ResponseResource["status"] | null = null;
|
||||
let finalizeRequested: { status: ResponseResource["status"]; text: string } | null = null;
|
||||
|
||||
const maybeFinalize = () => {
|
||||
@@ -982,6 +988,7 @@ export async function handleOpenResponsesHttpRequest(
|
||||
if (finalizeRequested) {
|
||||
return;
|
||||
}
|
||||
finalizeStatus = status;
|
||||
finalizeRequested = { status, text };
|
||||
maybeFinalize();
|
||||
};
|
||||
@@ -1028,13 +1035,39 @@ export async function handleOpenResponsesHttpRequest(
|
||||
}
|
||||
|
||||
if (evt.stream === "assistant") {
|
||||
if (isReplaceableAssistantStreamEvent(evt)) {
|
||||
const snapshot = resolveAssistantStreamSnapshotText(evt);
|
||||
if (snapshot) {
|
||||
bufferedReplaceableAssistantContent = snapshot;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const text = evt.data?.text;
|
||||
const replace = evt.data?.replace === true;
|
||||
const hadAssistantDelta = sawAssistantDelta;
|
||||
if (replace && typeof text === "string") {
|
||||
accumulatedText = text;
|
||||
}
|
||||
|
||||
const content = resolveAssistantStreamDeltaText(evt);
|
||||
if (!content) {
|
||||
if (
|
||||
replace &&
|
||||
typeof text === "string" &&
|
||||
text &&
|
||||
!toolChoiceConstraint &&
|
||||
!hadAssistantDelta
|
||||
) {
|
||||
sawAssistantDelta = true;
|
||||
writeSseEvent(res, {
|
||||
type: "response.output_text.delta",
|
||||
item_id: outputItemId,
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
delta: text,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1064,7 +1097,8 @@ export async function handleOpenResponsesHttpRequest(
|
||||
if (evt.stream === "lifecycle") {
|
||||
const phase = evt.data?.phase;
|
||||
if (phase === "end" || phase === "error") {
|
||||
const finalText = accumulatedText || "No response from OpenClaw.";
|
||||
const finalText =
|
||||
accumulatedText || bufferedReplaceableAssistantContent || "No response from OpenClaw.";
|
||||
const finalStatus = phase === "error" ? "failed" : "completed";
|
||||
requestFinalize(finalStatus, finalText);
|
||||
}
|
||||
@@ -1097,6 +1131,12 @@ export async function handleOpenResponsesHttpRequest(
|
||||
// Check for pending client tool calls BEFORE maybeFinalize() because the
|
||||
// lifecycle:end event may already have requested finalization.
|
||||
const resultAny = result as { payloads?: Array<{ text?: string }>; meta?: unknown };
|
||||
const resultPayloadText = Array.isArray(resultAny.payloads)
|
||||
? resultAny.payloads
|
||||
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: "";
|
||||
const meta = resultAny.meta;
|
||||
const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta);
|
||||
|
||||
@@ -1137,22 +1177,16 @@ export async function handleOpenResponsesHttpRequest(
|
||||
) {
|
||||
const usage = finalUsage ?? createEmptyUsage();
|
||||
const finalText =
|
||||
accumulatedText ||
|
||||
(Array.isArray(resultAny.payloads)
|
||||
? resultAny.payloads
|
||||
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: "");
|
||||
accumulatedText || resultPayloadText || bufferedReplaceableAssistantContent;
|
||||
|
||||
if (toolChoiceConstraint && accumulatedText) {
|
||||
if (toolChoiceConstraint && finalText && !sawAssistantDelta) {
|
||||
sawAssistantDelta = true;
|
||||
writeSseEvent(res, {
|
||||
type: "response.output_text.delta",
|
||||
item_id: outputItemId,
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
delta: accumulatedText,
|
||||
delta: finalText,
|
||||
});
|
||||
}
|
||||
writeSseEvent(res, {
|
||||
@@ -1237,25 +1271,16 @@ export async function handleOpenResponsesHttpRequest(
|
||||
return;
|
||||
}
|
||||
|
||||
maybeFinalize();
|
||||
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: if no streaming deltas were received, send the full response as text
|
||||
if (!sawAssistantDelta) {
|
||||
const payloads = resultAny.payloads;
|
||||
const content =
|
||||
Array.isArray(payloads) && payloads.length > 0
|
||||
? payloads
|
||||
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: "No response from OpenClaw.";
|
||||
resultPayloadText || bufferedReplaceableAssistantContent || "No response from OpenClaw.";
|
||||
|
||||
accumulatedText = content;
|
||||
sawAssistantDelta = true;
|
||||
if (finalizeStatus !== null) {
|
||||
finalizeRequested = { status: finalizeStatus, text: content };
|
||||
}
|
||||
|
||||
writeSseEvent(res, {
|
||||
type: "response.output_text.delta",
|
||||
@@ -1265,6 +1290,8 @@ export async function handleOpenResponsesHttpRequest(
|
||||
delta: content,
|
||||
});
|
||||
}
|
||||
|
||||
maybeFinalize();
|
||||
} catch (err) {
|
||||
if (closed || abortController.signal.aborted) {
|
||||
return;
|
||||
|
||||
@@ -60,6 +60,29 @@ describe("server chat stream text merge", () => {
|
||||
).toBe("Before tool call\nAfter tool call");
|
||||
});
|
||||
|
||||
it("replaces prior live text when Codex assistant item switches with empty delta", () => {
|
||||
const prior = "coordination draft";
|
||||
const replacement = resolveMergedAssistantText({
|
||||
previousText: prior,
|
||||
nextText: "final answer",
|
||||
nextDelta: "",
|
||||
});
|
||||
|
||||
expect(replacement).toBe("final answer");
|
||||
expect(replacement).not.toContain(prior);
|
||||
});
|
||||
|
||||
it("would concatenate superseded text if replacement incorrectly included delta", () => {
|
||||
const prior = "coordination draft";
|
||||
const buggy = resolveMergedAssistantText({
|
||||
previousText: prior,
|
||||
nextText: "final ",
|
||||
nextDelta: "final ",
|
||||
});
|
||||
|
||||
expect(buggy).toBe("coordination draftfinal ");
|
||||
});
|
||||
|
||||
it("caps merged live text while preserving the newest assistant output", () => {
|
||||
const result = resolveMergedAssistantText({
|
||||
previousText: "a".repeat(MAX_LIVE_CHAT_BUFFER_CHARS - 2),
|
||||
|
||||
@@ -149,7 +149,6 @@ import {
|
||||
import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js";
|
||||
import {
|
||||
canonicalizeSpawnedByForAgent,
|
||||
loadGatewaySessionRow,
|
||||
loadSessionEntry,
|
||||
migrateAndPruneGatewaySessionStoreKey,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
@@ -166,6 +165,7 @@ import {
|
||||
waitForTerminalGatewayDedupe,
|
||||
} from "./agent-wait-dedupe.js";
|
||||
import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js";
|
||||
import { emitSessionsChanged } from "./session-change-event.js";
|
||||
import type {
|
||||
GatewayRequestContext,
|
||||
GatewayRequestHandlerOptions,
|
||||
@@ -571,92 +571,6 @@ function requestGroupMatchesTrusted(params: {
|
||||
return Boolean(params.trustedGroupId && requestGroupId === params.trustedGroupId);
|
||||
}
|
||||
|
||||
function emitSessionsChanged(
|
||||
context: Pick<
|
||||
GatewayRequestHandlerOptions["context"],
|
||||
"broadcastToConnIds" | "getSessionEventSubscriberConnIds"
|
||||
>,
|
||||
payload: { sessionKey?: string; agentId?: string; reason: string },
|
||||
) {
|
||||
const connIds = context.getSessionEventSubscriberConnIds();
|
||||
if (connIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const sessionRow = payload.sessionKey
|
||||
? loadGatewaySessionRow(
|
||||
payload.sessionKey,
|
||||
payload.sessionKey === "global" && payload.agentId
|
||||
? { agentId: payload.agentId }
|
||||
: undefined,
|
||||
)
|
||||
: null;
|
||||
// Unscoped global updates must not leak one agent's goal into another agent's UI row.
|
||||
const omitUnscopedGlobalGoal = payload.sessionKey === "global" && !payload.agentId;
|
||||
context.broadcastToConnIds(
|
||||
"sessions.changed",
|
||||
{
|
||||
...payload,
|
||||
ts: Date.now(),
|
||||
...(sessionRow
|
||||
? {
|
||||
updatedAt: sessionRow.updatedAt ?? undefined,
|
||||
sessionId: sessionRow.sessionId,
|
||||
kind: sessionRow.kind,
|
||||
channel: sessionRow.channel,
|
||||
subject: sessionRow.subject,
|
||||
groupChannel: sessionRow.groupChannel,
|
||||
space: sessionRow.space,
|
||||
chatType: sessionRow.chatType,
|
||||
origin: sessionRow.origin,
|
||||
spawnedBy: sessionRow.spawnedBy,
|
||||
spawnedWorkspaceDir: sessionRow.spawnedWorkspaceDir,
|
||||
spawnedCwd: sessionRow.spawnedCwd,
|
||||
forkedFromParent: sessionRow.forkedFromParent,
|
||||
spawnDepth: sessionRow.spawnDepth,
|
||||
subagentRole: sessionRow.subagentRole,
|
||||
subagentControlScope: sessionRow.subagentControlScope,
|
||||
label: sessionRow.label,
|
||||
displayName: sessionRow.displayName,
|
||||
deliveryContext: sessionRow.deliveryContext,
|
||||
parentSessionKey: sessionRow.parentSessionKey,
|
||||
childSessions: sessionRow.childSessions,
|
||||
thinkingLevel: sessionRow.thinkingLevel,
|
||||
fastMode: sessionRow.fastMode,
|
||||
verboseLevel: sessionRow.verboseLevel,
|
||||
traceLevel: sessionRow.traceLevel,
|
||||
reasoningLevel: sessionRow.reasoningLevel,
|
||||
elevatedLevel: sessionRow.elevatedLevel,
|
||||
sendPolicy: sessionRow.sendPolicy,
|
||||
systemSent: sessionRow.systemSent,
|
||||
abortedLastRun: sessionRow.abortedLastRun,
|
||||
inputTokens: sessionRow.inputTokens,
|
||||
outputTokens: sessionRow.outputTokens,
|
||||
lastChannel: sessionRow.lastChannel,
|
||||
lastTo: sessionRow.lastTo,
|
||||
lastAccountId: sessionRow.lastAccountId,
|
||||
lastThreadId: sessionRow.lastThreadId,
|
||||
totalTokens: sessionRow.totalTokens,
|
||||
totalTokensFresh: sessionRow.totalTokensFresh,
|
||||
...(omitUnscopedGlobalGoal ? {} : { goal: sessionRow.goal ?? null }),
|
||||
contextTokens: sessionRow.contextTokens,
|
||||
estimatedCostUsd: sessionRow.estimatedCostUsd,
|
||||
responseUsage: sessionRow.responseUsage,
|
||||
modelProvider: sessionRow.modelProvider,
|
||||
model: sessionRow.model,
|
||||
status: sessionRow.status,
|
||||
startedAt: sessionRow.startedAt,
|
||||
endedAt: sessionRow.endedAt,
|
||||
runtimeMs: sessionRow.runtimeMs,
|
||||
compactionCheckpointCount: sessionRow.compactionCheckpointCount,
|
||||
latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
connIds,
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
}
|
||||
|
||||
type GatewayAgentTaskTerminalStatus = Extract<
|
||||
TaskStatus,
|
||||
"succeeded" | "failed" | "timed_out" | "cancelled"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Shared sessions.changed broadcaster for gateway RPC and chat-command mutations.
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { buildGatewaySessionEventFields } from "../session-event-payload.js";
|
||||
import { loadGatewaySessionRow } from "../session-utils.js";
|
||||
import { hasTrackedActiveSessionRun } from "./session-active-runs.js";
|
||||
import type { GatewayRequestContext } from "./types.js";
|
||||
@@ -33,7 +34,6 @@ export function emitSessionsChanged(
|
||||
: undefined,
|
||||
)
|
||||
: null;
|
||||
const omitUnscopedGlobalGoal = payload.sessionKey === "global" && !payload.agentId;
|
||||
const defaultAgentId = resolveDefaultAgentId(context.getRuntimeConfig());
|
||||
context.broadcastToConnIds(
|
||||
"sessions.changed",
|
||||
@@ -42,66 +42,21 @@ export function emitSessionsChanged(
|
||||
ts: Date.now(),
|
||||
...(sessionRow
|
||||
? {
|
||||
updatedAt: sessionRow.updatedAt ?? undefined,
|
||||
sessionId: sessionRow.sessionId,
|
||||
kind: sessionRow.kind,
|
||||
channel: sessionRow.channel,
|
||||
subject: sessionRow.subject,
|
||||
groupChannel: sessionRow.groupChannel,
|
||||
space: sessionRow.space,
|
||||
chatType: sessionRow.chatType,
|
||||
origin: sessionRow.origin,
|
||||
spawnedBy: sessionRow.spawnedBy,
|
||||
spawnedWorkspaceDir: sessionRow.spawnedWorkspaceDir,
|
||||
spawnedCwd: sessionRow.spawnedCwd,
|
||||
forkedFromParent: sessionRow.forkedFromParent,
|
||||
spawnDepth: sessionRow.spawnDepth,
|
||||
subagentRole: sessionRow.subagentRole,
|
||||
subagentControlScope: sessionRow.subagentControlScope,
|
||||
label: sessionRow.label,
|
||||
displayName: sessionRow.displayName,
|
||||
deliveryContext: sessionRow.deliveryContext,
|
||||
parentSessionKey: sessionRow.parentSessionKey,
|
||||
childSessions: sessionRow.childSessions,
|
||||
thinkingLevel: sessionRow.thinkingLevel,
|
||||
fastMode: sessionRow.fastMode,
|
||||
...buildGatewaySessionEventFields({
|
||||
sessionRow,
|
||||
agentId: payload.agentId,
|
||||
hasActiveRun: hasTrackedActiveSessionRun({
|
||||
context,
|
||||
requestedKey: payload.sessionKey ?? sessionRow.key,
|
||||
canonicalKey: sessionRow.key,
|
||||
agentId: sessionRow.key === "global" ? payload.agentId : undefined,
|
||||
defaultAgentId,
|
||||
}),
|
||||
}),
|
||||
effectiveFastMode: sessionRow.effectiveFastMode,
|
||||
effectiveFastModeSource: sessionRow.effectiveFastModeSource,
|
||||
fastAutoOnSeconds: sessionRow.fastAutoOnSeconds,
|
||||
verboseLevel: sessionRow.verboseLevel,
|
||||
traceLevel: sessionRow.traceLevel,
|
||||
reasoningLevel: sessionRow.reasoningLevel,
|
||||
elevatedLevel: sessionRow.elevatedLevel,
|
||||
sendPolicy: sessionRow.sendPolicy,
|
||||
systemSent: sessionRow.systemSent,
|
||||
abortedLastRun: sessionRow.abortedLastRun,
|
||||
inputTokens: sessionRow.inputTokens,
|
||||
outputTokens: sessionRow.outputTokens,
|
||||
lastChannel: sessionRow.lastChannel,
|
||||
lastTo: sessionRow.lastTo,
|
||||
lastAccountId: sessionRow.lastAccountId,
|
||||
lastThreadId: sessionRow.lastThreadId,
|
||||
totalTokens: sessionRow.totalTokens,
|
||||
totalTokensFresh: sessionRow.totalTokensFresh,
|
||||
...(omitUnscopedGlobalGoal ? {} : { goal: sessionRow.goal ?? null }),
|
||||
contextTokens: sessionRow.contextTokens,
|
||||
estimatedCostUsd: sessionRow.estimatedCostUsd,
|
||||
responseUsage: sessionRow.responseUsage,
|
||||
modelProvider: sessionRow.modelProvider,
|
||||
model: sessionRow.model,
|
||||
status: sessionRow.status,
|
||||
hasActiveRun: hasTrackedActiveSessionRun({
|
||||
context,
|
||||
requestedKey: payload.sessionKey ?? sessionRow.key,
|
||||
canonicalKey: sessionRow.key,
|
||||
agentId: sessionRow.key === "global" ? payload.agentId : undefined,
|
||||
defaultAgentId,
|
||||
}),
|
||||
startedAt: sessionRow.startedAt,
|
||||
endedAt: sessionRow.endedAt,
|
||||
runtimeMs: sessionRow.runtimeMs,
|
||||
compactionCheckpointCount: sessionRow.compactionCheckpointCount,
|
||||
latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint,
|
||||
pluginExtensions: sessionRow.pluginExtensions,
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
SessionMessageSubscriberRegistry,
|
||||
} from "./server-chat.js";
|
||||
import { hasTrackedActiveSessionRun } from "./server-methods/session-active-runs.js";
|
||||
import { buildGatewaySessionEventFields } from "./session-event-payload.js";
|
||||
import { resolveSessionKeyForTranscriptFile } from "./session-transcript-key.js";
|
||||
import {
|
||||
attachOpenClawTranscriptMeta,
|
||||
@@ -59,11 +60,10 @@ function buildGatewaySessionSnapshot(params: {
|
||||
if (!sessionRow) {
|
||||
return {};
|
||||
}
|
||||
const omitUnscopedGlobalGoal = sessionRow.key === "global" && !params.agentId;
|
||||
// The unscoped global row hides goal state to avoid presenting one agent's
|
||||
// scoped goal as the global/default session goal.
|
||||
const session = params.includeSession ? { ...sessionRow } : undefined;
|
||||
if (session && omitUnscopedGlobalGoal) {
|
||||
if (session && sessionRow.key === "global" && !params.agentId) {
|
||||
// The unscoped global row hides goal state to avoid presenting one agent's
|
||||
// scoped goal as the global/default session goal.
|
||||
delete session.goal;
|
||||
}
|
||||
if (session && params.hasActiveRun !== undefined) {
|
||||
@@ -71,58 +71,16 @@ function buildGatewaySessionSnapshot(params: {
|
||||
}
|
||||
return {
|
||||
...(session ? { session } : {}),
|
||||
updatedAt: sessionRow.updatedAt ?? undefined,
|
||||
sessionId: sessionRow.sessionId,
|
||||
kind: sessionRow.kind,
|
||||
channel: sessionRow.channel,
|
||||
subject: sessionRow.subject,
|
||||
groupChannel: sessionRow.groupChannel,
|
||||
space: sessionRow.space,
|
||||
chatType: sessionRow.chatType,
|
||||
origin: sessionRow.origin,
|
||||
spawnedBy: sessionRow.spawnedBy,
|
||||
spawnedWorkspaceDir: sessionRow.spawnedWorkspaceDir,
|
||||
spawnedCwd: sessionRow.spawnedCwd,
|
||||
forkedFromParent: sessionRow.forkedFromParent,
|
||||
spawnDepth: sessionRow.spawnDepth,
|
||||
subagentRole: sessionRow.subagentRole,
|
||||
subagentControlScope: sessionRow.subagentControlScope,
|
||||
label: params.label ?? sessionRow.label,
|
||||
displayName: params.displayName ?? sessionRow.displayName,
|
||||
deliveryContext: sessionRow.deliveryContext,
|
||||
parentSessionKey: params.parentSessionKey ?? sessionRow.parentSessionKey,
|
||||
childSessions: sessionRow.childSessions,
|
||||
thinkingLevel: sessionRow.thinkingLevel,
|
||||
fastMode: sessionRow.fastMode,
|
||||
verboseLevel: sessionRow.verboseLevel,
|
||||
reasoningLevel: sessionRow.reasoningLevel,
|
||||
elevatedLevel: sessionRow.elevatedLevel,
|
||||
sendPolicy: sessionRow.sendPolicy,
|
||||
systemSent: sessionRow.systemSent,
|
||||
abortedLastRun: sessionRow.abortedLastRun,
|
||||
inputTokens: sessionRow.inputTokens,
|
||||
outputTokens: sessionRow.outputTokens,
|
||||
lastChannel: sessionRow.lastChannel,
|
||||
lastTo: sessionRow.lastTo,
|
||||
lastAccountId: sessionRow.lastAccountId,
|
||||
lastThreadId: sessionRow.lastThreadId,
|
||||
totalTokens: sessionRow.totalTokens,
|
||||
totalTokensFresh: sessionRow.totalTokensFresh,
|
||||
...(omitUnscopedGlobalGoal ? {} : { goal: sessionRow.goal ?? null }),
|
||||
contextTokens: sessionRow.contextTokens,
|
||||
estimatedCostUsd: sessionRow.estimatedCostUsd,
|
||||
responseUsage: sessionRow.responseUsage,
|
||||
modelProvider: sessionRow.modelProvider,
|
||||
model: sessionRow.model,
|
||||
status: sessionRow.status,
|
||||
...(params.hasActiveRun === undefined ? {} : { hasActiveRun: params.hasActiveRun }),
|
||||
...buildGatewaySessionEventFields({
|
||||
sessionRow,
|
||||
agentId: params.agentId,
|
||||
label: params.label,
|
||||
displayName: params.displayName,
|
||||
parentSessionKey: params.parentSessionKey,
|
||||
hasActiveRun: params.hasActiveRun,
|
||||
}),
|
||||
subagentRunState: sessionRow.subagentRunState,
|
||||
hasActiveSubagentRun: sessionRow.hasActiveSubagentRun,
|
||||
startedAt: sessionRow.startedAt,
|
||||
endedAt: sessionRow.endedAt,
|
||||
runtimeMs: sessionRow.runtimeMs,
|
||||
compactionCheckpointCount: sessionRow.compactionCheckpointCount,
|
||||
latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
pinActivePluginSessionExtensionRegistry,
|
||||
} from "../plugins/runtime.js";
|
||||
import type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
import { getTotalQueueSize } from "../process/command-queue.js";
|
||||
import { getTotalQueueSize, isGatewayDraining } from "../process/command-queue.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import {
|
||||
clearSecretsRuntimeSnapshot,
|
||||
@@ -876,6 +876,7 @@ export async function startGatewayServer(
|
||||
startedAt: serverStartedAt,
|
||||
getStartupPending: isGatewayStartupPending,
|
||||
getStartupPendingReason: () => startupPendingReason,
|
||||
getGatewayDraining: isGatewayDraining,
|
||||
getEventLoopHealth: readinessEventLoopHealth.snapshot,
|
||||
shouldSkipChannelReadiness: () =>
|
||||
isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) ||
|
||||
|
||||
@@ -68,6 +68,7 @@ function createReadinessHarness(params: {
|
||||
accounts?: Record<string, Partial<ChannelAccountSnapshot>>;
|
||||
getStartupPending?: () => boolean;
|
||||
getStartupPendingReason?: Parameters<typeof createReadinessChecker>[0]["getStartupPendingReason"];
|
||||
getGatewayDraining?: Parameters<typeof createReadinessChecker>[0]["getGatewayDraining"];
|
||||
getEventLoopHealth?: Parameters<typeof createReadinessChecker>[0]["getEventLoopHealth"];
|
||||
shouldSkipChannelReadiness?: Parameters<
|
||||
typeof createReadinessChecker
|
||||
@@ -83,6 +84,7 @@ function createReadinessHarness(params: {
|
||||
startedAt,
|
||||
getStartupPending: params.getStartupPending,
|
||||
getStartupPendingReason: params.getStartupPendingReason,
|
||||
getGatewayDraining: params.getGatewayDraining,
|
||||
getEventLoopHealth: params.getEventLoopHealth,
|
||||
shouldSkipChannelReadiness: params.shouldSkipChannelReadiness,
|
||||
cacheTtlMs: params.cacheTtlMs,
|
||||
@@ -178,6 +180,35 @@ describe("createReadinessChecker", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports not ready while the gateway command queue is draining for restart", () => {
|
||||
withReadinessClock(() => {
|
||||
const { manager, readiness } = createReadinessHarness({
|
||||
getGatewayDraining: () => true,
|
||||
cacheTtlMs: 1_000,
|
||||
});
|
||||
|
||||
expect(readiness()).toEqual(failingSnapshot(["gateway-draining"]));
|
||||
expect(manager.getRuntimeSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not cache gateway-draining readiness", () => {
|
||||
withReadinessClock(() => {
|
||||
let gatewayDraining = true;
|
||||
const { manager, readiness } = createReadinessHarness({
|
||||
getGatewayDraining: () => gatewayDraining,
|
||||
cacheTtlMs: 1_000,
|
||||
});
|
||||
|
||||
expect(readiness()).toEqual(failingSnapshot(["gateway-draining"]));
|
||||
expect(manager.getRuntimeSnapshot).not.toHaveBeenCalled();
|
||||
|
||||
gatewayDraining = false;
|
||||
expect(readiness()).toEqual(readySnapshot());
|
||||
expect(manager.getRuntimeSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores disabled and unconfigured channels", () => {
|
||||
withReadinessClock(() => {
|
||||
const { readiness } = createReadinessHarness({
|
||||
|
||||
@@ -42,6 +42,7 @@ export function createReadinessChecker(deps: {
|
||||
startedAt: number;
|
||||
getStartupPending?: () => boolean;
|
||||
getStartupPendingReason?: () => string | undefined;
|
||||
getGatewayDraining?: () => boolean;
|
||||
getEventLoopHealth?: () => GatewayEventLoopHealth | undefined;
|
||||
shouldSkipChannelReadiness?: () => boolean;
|
||||
cacheTtlMs?: number;
|
||||
@@ -61,6 +62,12 @@ export function createReadinessChecker(deps: {
|
||||
deps.getEventLoopHealth,
|
||||
);
|
||||
}
|
||||
if (deps.getGatewayDraining?.()) {
|
||||
return withEventLoopHealth(
|
||||
{ ready: false, failing: ["gateway-draining"], uptimeMs },
|
||||
deps.getEventLoopHealth,
|
||||
);
|
||||
}
|
||||
if (deps.shouldSkipChannelReadiness?.()) {
|
||||
return withEventLoopHealth({ ready: true, failing: [], uptimeMs }, deps.getEventLoopHealth);
|
||||
}
|
||||
|
||||
65
src/gateway/session-event-payload.ts
Normal file
65
src/gateway/session-event-payload.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { GatewaySessionRow } from "./session-utils.js";
|
||||
|
||||
export function buildGatewaySessionEventFields(params: {
|
||||
sessionRow: GatewaySessionRow;
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
parentSessionKey?: string;
|
||||
hasActiveRun?: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const { sessionRow } = params;
|
||||
const omitUnscopedGlobalGoal = sessionRow.key === "global" && !params.agentId;
|
||||
return {
|
||||
updatedAt: sessionRow.updatedAt ?? undefined,
|
||||
sessionId: sessionRow.sessionId,
|
||||
kind: sessionRow.kind,
|
||||
channel: sessionRow.channel,
|
||||
subject: sessionRow.subject,
|
||||
groupChannel: sessionRow.groupChannel,
|
||||
space: sessionRow.space,
|
||||
chatType: sessionRow.chatType,
|
||||
origin: sessionRow.origin,
|
||||
spawnedBy: sessionRow.spawnedBy,
|
||||
spawnedWorkspaceDir: sessionRow.spawnedWorkspaceDir,
|
||||
spawnedCwd: sessionRow.spawnedCwd,
|
||||
forkedFromParent: sessionRow.forkedFromParent,
|
||||
spawnDepth: sessionRow.spawnDepth,
|
||||
subagentRole: sessionRow.subagentRole,
|
||||
subagentControlScope: sessionRow.subagentControlScope,
|
||||
label: params.label ?? sessionRow.label,
|
||||
displayName: params.displayName ?? sessionRow.displayName,
|
||||
deliveryContext: sessionRow.deliveryContext,
|
||||
parentSessionKey: params.parentSessionKey ?? sessionRow.parentSessionKey,
|
||||
childSessions: sessionRow.childSessions,
|
||||
thinkingLevel: sessionRow.thinkingLevel,
|
||||
fastMode: sessionRow.fastMode,
|
||||
verboseLevel: sessionRow.verboseLevel,
|
||||
reasoningLevel: sessionRow.reasoningLevel,
|
||||
elevatedLevel: sessionRow.elevatedLevel,
|
||||
sendPolicy: sessionRow.sendPolicy,
|
||||
systemSent: sessionRow.systemSent,
|
||||
abortedLastRun: sessionRow.abortedLastRun,
|
||||
inputTokens: sessionRow.inputTokens,
|
||||
outputTokens: sessionRow.outputTokens,
|
||||
lastChannel: sessionRow.lastChannel,
|
||||
lastTo: sessionRow.lastTo,
|
||||
lastAccountId: sessionRow.lastAccountId,
|
||||
lastThreadId: sessionRow.lastThreadId,
|
||||
totalTokens: sessionRow.totalTokens,
|
||||
totalTokensFresh: sessionRow.totalTokensFresh,
|
||||
...(omitUnscopedGlobalGoal ? {} : { goal: sessionRow.goal ?? null }),
|
||||
contextTokens: sessionRow.contextTokens,
|
||||
estimatedCostUsd: sessionRow.estimatedCostUsd,
|
||||
responseUsage: sessionRow.responseUsage,
|
||||
modelProvider: sessionRow.modelProvider,
|
||||
model: sessionRow.model,
|
||||
status: sessionRow.status,
|
||||
...(params.hasActiveRun === undefined ? {} : { hasActiveRun: params.hasActiveRun }),
|
||||
startedAt: sessionRow.startedAt,
|
||||
endedAt: sessionRow.endedAt,
|
||||
runtimeMs: sessionRow.runtimeMs,
|
||||
compactionCheckpointCount: sessionRow.compactionCheckpointCount,
|
||||
latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint,
|
||||
};
|
||||
}
|
||||
@@ -1691,6 +1691,45 @@ describe("gateway session utils", () => {
|
||||
});
|
||||
|
||||
test("listAgentsForGateway reports per-agent thinking defaults from the agent model", () => {
|
||||
const resolveDeepSeekThinkingProfile = vi.fn(() => ({
|
||||
levels: [
|
||||
{ id: "off" as const },
|
||||
{ id: "minimal" as const },
|
||||
{ id: "low" as const },
|
||||
{ id: "medium" as const },
|
||||
{ id: "high" as const },
|
||||
{ id: "xhigh" as const },
|
||||
],
|
||||
defaultLevel: "medium" as const,
|
||||
}));
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.providers.push(
|
||||
{
|
||||
pluginId: "test-minimax",
|
||||
source: "test",
|
||||
provider: {
|
||||
id: "minimax",
|
||||
label: "MiniMax",
|
||||
auth: [],
|
||||
resolveThinkingProfile: () => ({
|
||||
levels: [{ id: "off" }],
|
||||
defaultLevel: "off",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "test-deepseek",
|
||||
source: "test",
|
||||
provider: {
|
||||
id: "deepseek",
|
||||
label: "DeepSeek",
|
||||
auth: [],
|
||||
resolveThinkingProfile: resolveDeepSeekThinkingProfile,
|
||||
},
|
||||
},
|
||||
);
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: {
|
||||
@@ -1713,6 +1752,12 @@ describe("gateway session utils", () => {
|
||||
const agent = result.agents.find((row) => row.id === "investment-master");
|
||||
|
||||
expect(agent?.model).toEqual({ primary: "deepseek/deepseek-v4-flash" });
|
||||
expect(resolveDeepSeekThinkingProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-flash",
|
||||
}),
|
||||
);
|
||||
expect(agent?.thinkingDefault).toBe("xhigh");
|
||||
expect(agent?.thinkingLevels?.map((level) => level.id)).toEqual(
|
||||
expect.arrayContaining(["off", "minimal", "low", "medium", "high", "xhigh"]),
|
||||
|
||||
@@ -29,7 +29,7 @@ type GlobalRuntimeDotEnvOptions = {
|
||||
stateEnvPath?: string;
|
||||
};
|
||||
|
||||
function readGlobalRuntimeDotEnvFile(params: {
|
||||
export function readDotEnvFile(params: {
|
||||
entryFilter?: (key: string, value: string) => boolean;
|
||||
filePath: string;
|
||||
quiet?: boolean;
|
||||
@@ -137,12 +137,12 @@ export function loadGlobalRuntimeDotEnvFiles(opts?: GlobalRuntimeDotEnvOptions)
|
||||
process.env.OPENCLAW_STATE_DIR?.trim() !== undefined &&
|
||||
path.resolve(stateEnvPath) !== path.resolve(defaultStateEnvPath);
|
||||
const globalEnvs = globalEnvPaths.map((filePath) =>
|
||||
readGlobalRuntimeDotEnvFile({ entryFilter: opts?.entryFilter, filePath, quiet }),
|
||||
readDotEnvFile({ entryFilter: opts?.entryFilter, filePath, quiet }),
|
||||
);
|
||||
const parsedFiles = [...globalEnvs];
|
||||
let gatewayEnv: LoadedDotEnvFile | null = null;
|
||||
if (!hasExplicitNonDefaultStateDir) {
|
||||
gatewayEnv = readGlobalRuntimeDotEnvFile({
|
||||
gatewayEnv = readDotEnvFile({
|
||||
entryFilter: opts?.entryFilter,
|
||||
filePath: path.join(
|
||||
resolveRequiredHomeDir(process.env, os.homedir),
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
// Loads dotenv files while blocking unsafe workspace env keys.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import dotenv from "dotenv";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { listKnownProviderAuthEnvVarNames } from "../secrets/provider-env-vars.js";
|
||||
import { loadGlobalRuntimeDotEnvFiles } from "./dotenv-global.js";
|
||||
import { loadGlobalRuntimeDotEnvFiles, readDotEnvFile } from "./dotenv-global.js";
|
||||
import {
|
||||
isDangerousHostEnvOverrideVarName,
|
||||
isDangerousHostEnvVarName,
|
||||
normalizeEnvVarKey,
|
||||
} from "./host-env-security.js";
|
||||
|
||||
const logger = createSubsystemLogger("infra:dotenv");
|
||||
|
||||
const BLOCKED_PROVIDER_AUTH_WORKSPACE_DOTENV_KEYS = [
|
||||
"AI_GATEWAY_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
@@ -222,55 +217,6 @@ function shouldBlockWorkspaceDotEnvKey(
|
||||
);
|
||||
}
|
||||
|
||||
type DotEnvEntry = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type LoadedDotEnvFile = {
|
||||
filePath: string;
|
||||
entries: DotEnvEntry[];
|
||||
};
|
||||
|
||||
function readDotEnvFile(params: {
|
||||
filePath: string;
|
||||
shouldBlockKey: (key: string) => boolean;
|
||||
quiet?: boolean;
|
||||
}): LoadedDotEnvFile | null {
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(params.filePath, "utf8");
|
||||
} catch (error) {
|
||||
if (!params.quiet) {
|
||||
const code =
|
||||
error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
||||
if (code !== "ENOENT") {
|
||||
logger.warn(`Failed to read ${params.filePath}: ${String(error)}`, { error });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: Record<string, string>;
|
||||
try {
|
||||
parsed = dotenv.parse(content);
|
||||
} catch (error) {
|
||||
if (!params.quiet) {
|
||||
logger.warn(`Failed to parse ${params.filePath}: ${String(error)}`, { error });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const entries: DotEnvEntry[] = [];
|
||||
for (const [rawKey, value] of Object.entries(parsed)) {
|
||||
const key = normalizeEnvVarKey(rawKey, { portable: true });
|
||||
if (!key || params.shouldBlockKey(key)) {
|
||||
continue;
|
||||
}
|
||||
entries.push({ key, value });
|
||||
}
|
||||
return { filePath: params.filePath, entries };
|
||||
}
|
||||
|
||||
export function loadWorkspaceDotEnvFile(filePath: string, opts?: { quiet?: boolean }) {
|
||||
let providerAuthBlockedKeys: ReadonlySet<string> | undefined;
|
||||
const getProviderAuthBlockedKeys = () => {
|
||||
@@ -279,7 +225,7 @@ export function loadWorkspaceDotEnvFile(filePath: string, opts?: { quiet?: boole
|
||||
};
|
||||
const parsed = readDotEnvFile({
|
||||
filePath,
|
||||
shouldBlockKey: (key) => shouldBlockWorkspaceDotEnvKey(key, getProviderAuthBlockedKeys),
|
||||
entryFilter: (key) => !shouldBlockWorkspaceDotEnvKey(key, getProviderAuthBlockedKeys),
|
||||
quiet: opts?.quiet ?? true,
|
||||
});
|
||||
if (!parsed) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
createPlainTextToolCallCompatWrapper,
|
||||
defaultToolStreamExtraParams,
|
||||
isOpenAICompatibleThinkingEnabled,
|
||||
setQwenChatTemplateThinking,
|
||||
stripTrailingAnthropicAssistantPrefillWhenThinking,
|
||||
} from "./provider-stream-shared.js";
|
||||
|
||||
@@ -160,6 +161,38 @@ describe("isOpenAICompatibleThinkingEnabled", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setQwenChatTemplateThinking", () => {
|
||||
it("preserves existing chat-template kwargs and enables thinking", () => {
|
||||
const payload = {
|
||||
chat_template_kwargs: {
|
||||
custom_flag: "keep",
|
||||
preserve_thinking: false,
|
||||
},
|
||||
};
|
||||
|
||||
setQwenChatTemplateThinking(payload, true);
|
||||
|
||||
expect(payload.chat_template_kwargs).toEqual({
|
||||
custom_flag: "keep",
|
||||
preserve_thinking: false,
|
||||
enable_thinking: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates the required chat-template kwargs when absent", () => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
setQwenChatTemplateThinking(payload, false);
|
||||
|
||||
expect(payload).toEqual({
|
||||
chat_template_kwargs: {
|
||||
enable_thinking: false,
|
||||
preserve_thinking: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDeepSeekV4OpenAICompatibleThinkingWrapper", () => {
|
||||
it("backfills reasoning_content on every replayed assistant message when thinking is enabled", () => {
|
||||
const payload = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user