mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:10:45 +00:00
Merge branch 'main' into docs/fix-gogcli-binary-urls
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways. Thanks @codex.
|
||||
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`. Thanks @codex.
|
||||
- Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet.
|
||||
- Plugins/compat: inventory doctor-side deprecation migrations separately from runtime plugin compatibility so release sweeps preserve needed repairs while enforcing dated removal windows. Thanks @vincentkoc.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { importFreshModule } from "../../../test/helpers/import-fresh.js";
|
||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { editBlueBubblesMessage, setGroupIconBlueBubbles } from "./chat.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor-reply-cache.js";
|
||||
@@ -44,8 +45,10 @@ vi.mock("./probe.js", () => ({
|
||||
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const freshActionsModulePath = "./actions.js?actions-test";
|
||||
const { bluebubblesMessageActions } = await import(freshActionsModulePath);
|
||||
const { bluebubblesMessageActions } = await importFreshModule<typeof import("./actions.js")>(
|
||||
import.meta.url,
|
||||
"./actions.js?actions-test",
|
||||
);
|
||||
|
||||
describe("bluebubblesMessageActions", () => {
|
||||
const describeMessageTool = bluebubblesMessageActions.describeMessageTool!;
|
||||
|
||||
@@ -490,6 +490,8 @@ describe("gateway bonjour advertiser", () => {
|
||||
|
||||
const stateRef = { value: "announcing" };
|
||||
const events: string[] = [];
|
||||
const cleanupException = vi.fn();
|
||||
const cleanupRejection = vi.fn();
|
||||
let advertiseCount = 0;
|
||||
const destroy = vi.fn().mockImplementation(async () => {
|
||||
events.push("destroy");
|
||||
@@ -505,6 +507,8 @@ describe("gateway bonjour advertiser", () => {
|
||||
return Promise.resolve();
|
||||
});
|
||||
mockCiaoService({ advertise, destroy, stateRef });
|
||||
registerUncaughtExceptionHandler.mockImplementation(() => cleanupException);
|
||||
registerUnhandledRejectionHandler.mockImplementation(() => cleanupRejection);
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
@@ -513,6 +517,8 @@ describe("gateway bonjour advertiser", () => {
|
||||
|
||||
expect(createService).toHaveBeenCalledTimes(1);
|
||||
expect(advertise).toHaveBeenCalledTimes(1);
|
||||
expect(registerUncaughtExceptionHandler).toHaveBeenCalledTimes(1);
|
||||
expect(registerUnhandledRejectionHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
|
||||
@@ -521,11 +527,15 @@ describe("gateway bonjour advertiser", () => {
|
||||
expect(advertise).toHaveBeenCalledTimes(2);
|
||||
expect(destroy).toHaveBeenCalledTimes(1);
|
||||
expect(shutdown).not.toHaveBeenCalled();
|
||||
expect(cleanupException).not.toHaveBeenCalled();
|
||||
expect(cleanupRejection).not.toHaveBeenCalled();
|
||||
expect(events).toEqual(["advertise:1", "destroy", "advertise:2"]);
|
||||
|
||||
await started.stop();
|
||||
expect(destroy).toHaveBeenCalledTimes(2);
|
||||
expect(shutdown).toHaveBeenCalledTimes(1);
|
||||
expect(cleanupException).toHaveBeenCalledTimes(1);
|
||||
expect(cleanupRejection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("treats probing-to-announcing churn as one unhealthy window", async () => {
|
||||
|
||||
@@ -50,8 +50,6 @@ type CiaoModule = {
|
||||
type BonjourCycle = {
|
||||
responder: BonjourResponder;
|
||||
services: Array<{ label: string; svc: BonjourService }>;
|
||||
cleanupUncaughtException?: () => void;
|
||||
cleanupUnhandledRejection?: () => void;
|
||||
};
|
||||
|
||||
type ServiceStateTracker = {
|
||||
@@ -179,6 +177,18 @@ export async function startGatewayBonjourAdvertiser(
|
||||
const { getResponder, Protocol } = await loadCiaoModule();
|
||||
const restoreConsoleLog = installCiaoConsoleNoiseFilter();
|
||||
let requestCiaoRecovery: ((classification: CiaoProcessErrorClassification) => void) | undefined;
|
||||
let cleanupUnhandledRejection: (() => void) | undefined;
|
||||
let cleanupUncaughtException: (() => void) | undefined;
|
||||
let processHandlersCleaned = false;
|
||||
|
||||
function cleanupProcessHandlers() {
|
||||
if (processHandlersCleaned) {
|
||||
return;
|
||||
}
|
||||
processHandlersCleaned = true;
|
||||
cleanupUncaughtException?.();
|
||||
cleanupUnhandledRejection?.();
|
||||
}
|
||||
|
||||
const handleCiaoProcessError = (reason: unknown): boolean => {
|
||||
const classification = classifyCiaoProcessError(reason);
|
||||
@@ -196,6 +206,8 @@ export async function startGatewayBonjourAdvertiser(
|
||||
}
|
||||
return true;
|
||||
};
|
||||
cleanupUnhandledRejection = deps.registerUnhandledRejectionHandler?.(handleCiaoProcessError);
|
||||
cleanupUncaughtException = deps.registerUncaughtExceptionHandler?.(handleCiaoProcessError);
|
||||
|
||||
try {
|
||||
const hostnameRaw = process.env.OPENCLAW_MDNS_HOSTNAME?.trim() || "openclaw";
|
||||
@@ -259,16 +271,7 @@ export async function startGatewayBonjourAdvertiser(
|
||||
svc: gateway as unknown as BonjourService,
|
||||
});
|
||||
|
||||
const cleanupUnhandledRejection =
|
||||
services.length > 0 && deps.registerUnhandledRejectionHandler
|
||||
? deps.registerUnhandledRejectionHandler(handleCiaoProcessError)
|
||||
: undefined;
|
||||
const cleanupUncaughtException =
|
||||
services.length > 0 && deps.registerUncaughtExceptionHandler
|
||||
? deps.registerUncaughtExceptionHandler(handleCiaoProcessError)
|
||||
: undefined;
|
||||
|
||||
return { responder, services, cleanupUncaughtException, cleanupUnhandledRejection };
|
||||
return { responder, services };
|
||||
}
|
||||
|
||||
async function stopCycle(cycle: BonjourCycle | null, opts?: { shutdownResponder?: boolean }) {
|
||||
@@ -288,9 +291,6 @@ export async function startGatewayBonjourAdvertiser(
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
cycle.cleanupUncaughtException?.();
|
||||
cycle.cleanupUnhandledRejection?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,10 +483,12 @@ export async function startGatewayBonjourAdvertiser(
|
||||
}
|
||||
await stopCycle(cycle, { shutdownResponder: true });
|
||||
restoreConsoleLog();
|
||||
cleanupProcessHandlers();
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
restoreConsoleLog();
|
||||
cleanupProcessHandlers();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { importFreshModule } from "../../../test/helpers/import-fresh.js";
|
||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
||||
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
@@ -7,13 +8,15 @@ vi.mock("./client.js", () => ({
|
||||
createFeishuClient: createFeishuClientMock,
|
||||
}));
|
||||
|
||||
const freshDirectoryModulePath = "./directory.js?directory-test";
|
||||
const {
|
||||
listFeishuDirectoryGroups,
|
||||
listFeishuDirectoryGroupsLive,
|
||||
listFeishuDirectoryPeers,
|
||||
listFeishuDirectoryPeersLive,
|
||||
} = await import(freshDirectoryModulePath);
|
||||
} = await importFreshModule<typeof import("./directory.js")>(
|
||||
import.meta.url,
|
||||
"./directory.js?directory-test",
|
||||
);
|
||||
|
||||
function makeStaticCfg(): ClawdbotConfig {
|
||||
return {
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { QaTransportAdapter } from "./qa-transport.js";
|
||||
|
||||
export type { QaCliBackendAuthMode } from "./providers/env.js";
|
||||
const QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS = 5;
|
||||
const QA_GATEWAY_CHILD_RPC_RETRY_HEALTH_TIMEOUT_MS = 60_000;
|
||||
const QA_GATEWAY_CHILD_BLOCKED_SECRET_ENV_VARS = Object.freeze([
|
||||
"OPENCLAW_QA_CONVEX_SECRET_CI",
|
||||
"OPENCLAW_QA_CONVEX_SECRET_MAINTAINER",
|
||||
@@ -684,7 +685,7 @@ export async function startQaGatewayChild(params: {
|
||||
baseUrl,
|
||||
logs,
|
||||
child: attemptChild,
|
||||
timeoutMs: 15_000,
|
||||
timeoutMs: QA_GATEWAY_CHILD_RPC_RETRY_HEALTH_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -963,6 +963,64 @@ describe("qa mock openai server", () => {
|
||||
expect(memoryText).toContain('"name":"memory_search"');
|
||||
expect(memoryText).toContain('\\"corpus\\":\\"sessions\\"');
|
||||
|
||||
const threadMemorySearch = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
instructions:
|
||||
"@openclaw Thread memory check: what is the hidden thread codename stored only in memory? Use memory tools first and reply only in this thread.",
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: "Protocol note: acknowledged. Continue with the QA scenario plan.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(threadMemorySearch.status).toBe(200);
|
||||
const threadMemorySearchText = await threadMemorySearch.text();
|
||||
expect(threadMemorySearchText).toContain('"name":"memory_search"');
|
||||
expect(threadMemorySearchText).toContain("ORBIT-22");
|
||||
|
||||
const threadMemorySummary = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stream: false,
|
||||
instructions:
|
||||
"@openclaw Thread memory check: what is the hidden thread codename stored only in memory? Use memory tools first and reply only in this thread.",
|
||||
input: [
|
||||
{
|
||||
type: "function_call_output",
|
||||
output: JSON.stringify({
|
||||
text: "Thread-hidden codename: ORBIT-22.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: "Protocol note: acknowledged. Continue with the QA scenario plan.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(threadMemorySummary.status).toBe(200);
|
||||
expect(JSON.stringify(await threadMemorySummary.json())).toContain("ORBIT-22");
|
||||
|
||||
const memoryFollowup = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -747,7 +747,7 @@ function buildAssistantText(
|
||||
if (/session memory ranking check/i.test(prompt) && orbitCode) {
|
||||
return `Protocol note: I checked memory and the current Project Nebula codename is ${orbitCode}.`;
|
||||
}
|
||||
if (/thread memory check/i.test(prompt) && orbitCode) {
|
||||
if (/thread memory check/i.test(allInputText) && orbitCode) {
|
||||
return `Protocol note: I checked memory in-thread and the hidden thread codename is ${orbitCode}.`;
|
||||
}
|
||||
if (/switch(?:ing)? models?/i.test(prompt)) {
|
||||
@@ -1457,7 +1457,7 @@ async function buildResponsesPayload(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (/thread memory check/i.test(prompt)) {
|
||||
if (/thread memory check/i.test(allInputText)) {
|
||||
if (!toolOutput) {
|
||||
return buildToolCallEventsWithArgs("memory_search", {
|
||||
query: "hidden thread codename ORBIT-22",
|
||||
|
||||
@@ -42,6 +42,7 @@ TIMEOUT_ONBOARD_S=180
|
||||
TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S:-300}"
|
||||
TIMEOUT_GATEWAY_S=240
|
||||
PHASE_STALE_WARN_S=60
|
||||
DISABLE_BONJOUR_FOR_GATEWAY=0
|
||||
|
||||
FRESH_MAIN_STATUS="skip"
|
||||
FRESH_MAIN_VERSION="skip"
|
||||
@@ -230,6 +231,11 @@ esac
|
||||
|
||||
API_KEY_VALUE="${!API_KEY_ENV:-}"
|
||||
[[ -n "$API_KEY_VALUE" ]] || die "$API_KEY_ENV is required"
|
||||
case "${OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR:-}" in
|
||||
1|true|TRUE|yes|YES|on|ON)
|
||||
DISABLE_BONJOUR_FOR_GATEWAY=1
|
||||
;;
|
||||
esac
|
||||
|
||||
resolve_vm_name() {
|
||||
local json requested explicit
|
||||
@@ -460,7 +466,18 @@ restore_snapshot() {
|
||||
wait_for_guest_ready || die "guest did not become ready in $VM_NAME"
|
||||
}
|
||||
|
||||
sync_guest_clock() {
|
||||
local host_now
|
||||
host_now="@$(date -u '+%s')"
|
||||
guest_exec date -u -s "$host_now" >/dev/null
|
||||
guest_exec hwclock --systohc >/dev/null 2>&1 || true
|
||||
guest_exec timedatectl set-ntp true >/dev/null 2>&1 || true
|
||||
guest_exec systemctl restart systemd-timesyncd >/dev/null 2>&1 || true
|
||||
guest_exec date -u
|
||||
}
|
||||
|
||||
bootstrap_guest() {
|
||||
sync_guest_clock
|
||||
guest_exec apt-get -o Acquire::Check-Date=false update
|
||||
guest_exec apt-get install -y curl ca-certificates
|
||||
}
|
||||
@@ -725,12 +742,16 @@ EOF
|
||||
}
|
||||
|
||||
start_gateway_background() {
|
||||
local cmd api_key_value_q
|
||||
local cmd api_key_value_q bonjour_env
|
||||
api_key_value_q="$(shell_quote "$API_KEY_VALUE")"
|
||||
bonjour_env=""
|
||||
if [[ "$DISABLE_BONJOUR_FOR_GATEWAY" -eq 1 ]]; then
|
||||
bonjour_env=" OPENCLAW_DISABLE_BONJOUR=1"
|
||||
fi
|
||||
cmd="$(cat <<EOF
|
||||
pkill -f "openclaw gateway run" >/dev/null 2>&1 || true
|
||||
rm -f /tmp/openclaw-parallels-linux-gateway.log
|
||||
setsid sh -lc 'exec env OPENCLAW_HOME=/root OPENCLAW_STATE_DIR=/root/.openclaw OPENCLAW_CONFIG_PATH=/root/.openclaw/openclaw.json ${API_KEY_ENV}=${api_key_value_q} openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-linux-gateway.log 2>&1' >/dev/null 2>&1 < /dev/null &
|
||||
setsid sh -lc 'exec env OPENCLAW_HOME=/root OPENCLAW_STATE_DIR=/root/.openclaw OPENCLAW_CONFIG_PATH=/root/.openclaw/openclaw.json${bonjour_env} ${API_KEY_ENV}=${api_key_value_q} openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-linux-gateway.log 2>&1' >/dev/null 2>&1 < /dev/null &
|
||||
EOF
|
||||
)"
|
||||
guest_exec bash -lc "$cmd"
|
||||
|
||||
@@ -1149,6 +1149,7 @@ run_dev_channel_update() {
|
||||
rm -rf $(shell_quote "$update_root")
|
||||
export PATH=$(shell_quote "$bootstrap_bin:$GUEST_EXEC_PATH")
|
||||
/usr/bin/env NODE_OPTIONS=--max-old-space-size=4096 \
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \
|
||||
$GUEST_NODE_BIN $GUEST_OPENCLAW_ENTRY update --channel dev --yes --json
|
||||
EOF
|
||||
)" "$update_log" "$update_done" "$TIMEOUT_UPDATE_DEV_S" "$update_runner"
|
||||
|
||||
@@ -1944,7 +1944,7 @@ if platform_enabled windows; then
|
||||
fi
|
||||
|
||||
if platform_enabled linux; then
|
||||
bash "$ROOT_DIR/scripts/e2e/parallels-linux-smoke.sh" \
|
||||
OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR=1 bash "$ROOT_DIR/scripts/e2e/parallels-linux-smoke.sh" \
|
||||
--mode fresh \
|
||||
--provider "$PROVIDER" \
|
||||
--model "$MODEL_ID" \
|
||||
|
||||
@@ -614,6 +614,150 @@ EOF
|
||||
)"
|
||||
}
|
||||
|
||||
guest_run_agent_turn_process() {
|
||||
local env_name_q env_value_q runner_basename runner_script_path runner_url runner_url_q
|
||||
local runner_name stdout_name stderr_name done_name
|
||||
local start_seconds poll_deadline startup_checked state_rc log_rc done_rc
|
||||
local agent_combined done_status launcher_state
|
||||
env_name_q="$(ps_single_quote "$API_KEY_ENV")"
|
||||
env_value_q="$(ps_single_quote "$API_KEY_VALUE")"
|
||||
runner_basename="openclaw-parallels-agent-runner-$RANDOM-$RANDOM.ps1"
|
||||
runner_script_path="$MAIN_TGZ_DIR/$runner_basename"
|
||||
runner_url="http://$HOST_IP:$HOST_PORT/$runner_basename"
|
||||
runner_url_q="$(ps_single_quote "$runner_url")"
|
||||
runner_name="openclaw-parallels-agent-$RANDOM-$RANDOM.ps1"
|
||||
stdout_name="openclaw-parallels-agent-$RANDOM-$RANDOM.out.log"
|
||||
stderr_name="openclaw-parallels-agent-$RANDOM-$RANDOM.err.log"
|
||||
done_name="openclaw-parallels-agent-$RANDOM-$RANDOM.done"
|
||||
start_seconds="$SECONDS"
|
||||
poll_deadline=$((SECONDS + TIMEOUT_AGENT_S + 60))
|
||||
startup_checked=0
|
||||
|
||||
cat >"$runner_script_path" <<'EOF'
|
||||
param(
|
||||
[string]$StdoutPath,
|
||||
[string]$StderrPath,
|
||||
[string]$DonePath,
|
||||
[string]$EnvName,
|
||||
[string]$EnvValue
|
||||
)
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
if ($EnvName -ne '') {
|
||||
Set-Item -Path ('Env:' + $EnvName) -Value $EnvValue
|
||||
}
|
||||
$node = Join-Path $env:ProgramFiles 'nodejs\node.exe'
|
||||
if (-not (Test-Path $node)) {
|
||||
$node = 'node'
|
||||
}
|
||||
$entry = Join-Path $env:APPDATA 'npm\node_modules\openclaw\openclaw.mjs'
|
||||
& $node $entry agent --local --agent main --session-id 'parallels-windows-smoke' --message 'Reply with exact ASCII text OK only.' --json > $StdoutPath 2> $StderrPath
|
||||
Set-Content -Path $DonePath -Value ([string]$LASTEXITCODE)
|
||||
exit $LASTEXITCODE
|
||||
} catch {
|
||||
$_ | Out-String | Set-Content -Path $StderrPath
|
||||
Set-Content -Path $DonePath -Value '1'
|
||||
exit 1
|
||||
}
|
||||
EOF
|
||||
|
||||
guest_powershell_poll 20 "$(cat <<EOF
|
||||
\$runner = Join-Path \$env:TEMP '$runner_name'
|
||||
\$stdout = Join-Path \$env:TEMP '$stdout_name'
|
||||
\$stderr = Join-Path \$env:TEMP '$stderr_name'
|
||||
\$done = Join-Path \$env:TEMP '$done_name'
|
||||
Remove-Item \$runner, \$stdout, \$stderr, \$done -Force -ErrorAction SilentlyContinue
|
||||
curl.exe -fsSL '${runner_url_q}' -o \$runner
|
||||
Start-Process powershell.exe -ArgumentList @(
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
\$runner,
|
||||
'-StdoutPath',
|
||||
\$stdout,
|
||||
'-StderrPath',
|
||||
\$stderr,
|
||||
'-DonePath',
|
||||
\$done,
|
||||
'-EnvName',
|
||||
'${env_name_q}',
|
||||
'-EnvValue',
|
||||
'${env_value_q}'
|
||||
) -WindowStyle Hidden | Out-Null
|
||||
EOF
|
||||
)"
|
||||
|
||||
while true; do
|
||||
set +e
|
||||
agent_combined="$(
|
||||
guest_powershell_poll 20 "\$stdout = Join-Path \$env:TEMP '$stdout_name'; \$stderr = Join-Path \$env:TEMP '$stderr_name'; \$out = ''; \$err = ''; if (Test-Path \$stdout) { \$out = Get-Content -Path \$stdout -Raw -ErrorAction SilentlyContinue }; if (Test-Path \$stderr) { \$err = Get-Content -Path \$stderr -Raw -ErrorAction SilentlyContinue }; \$out + [Environment]::NewLine + \$err"
|
||||
)"
|
||||
log_rc=$?
|
||||
set -e
|
||||
if [[ $log_rc -eq 0 ]] && printf '%s\n' "$agent_combined" | grep -Eq '"finalAssistant(Raw|Visible)Text":[[:space:]]*"OK"'; then
|
||||
printf '%s\n' "$agent_combined"
|
||||
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; Remove-Item \$runner, \$done -Force -ErrorAction SilentlyContinue"
|
||||
return 0
|
||||
fi
|
||||
|
||||
set +e
|
||||
done_status="$(
|
||||
guest_powershell_poll 20 "\$done = Join-Path \$env:TEMP '$done_name'; if (Test-Path \$done) { (Get-Content \$done -Raw).Trim() }"
|
||||
)"
|
||||
done_rc=$?
|
||||
set -e
|
||||
if [[ $done_rc -eq 0 && -n "$done_status" ]]; then
|
||||
if [[ $log_rc -eq 0 && -n "$agent_combined" ]]; then
|
||||
printf '%s\n' "$agent_combined"
|
||||
fi
|
||||
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; Remove-Item \$runner, \$done -Force -ErrorAction SilentlyContinue"
|
||||
warn "openclaw agent finished without OK response (exit $done_status)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$startup_checked" -eq 0 && $((SECONDS - start_seconds)) -ge 20 ]]; then
|
||||
set +e
|
||||
launcher_state="$(
|
||||
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$stdout = Join-Path \$env:TEMP '$stdout_name'; \$stderr = Join-Path \$env:TEMP '$stderr_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$currentPid = \$PID; \$process = Get-CimInstance Win32_Process | Where-Object { \$_.ProcessId -ne \$currentPid -and ((\$_.CommandLine -like '*$runner_name*') -or (\$_.CommandLine -like '*openclaw.mjs*agent*parallels-windows-smoke*')) } | Select-Object -First 1; 'runner=' + (Test-Path \$runner) + ' stdout=' + (Test-Path \$stdout) + ' stderr=' + (Test-Path \$stderr) + ' done=' + (Test-Path \$done) + ' process=' + [bool]\$process"
|
||||
)"
|
||||
state_rc=$?
|
||||
set -e
|
||||
launcher_state="${launcher_state//$'\r'/}"
|
||||
startup_checked=1
|
||||
if [[ $state_rc -eq 0 && "$launcher_state" == *"runner=False"* && "$launcher_state" == *"stdout=False"* && "$launcher_state" == *"stderr=False"* && "$launcher_state" == *"done=False"* && "$launcher_state" == *"process=False"* ]]; then
|
||||
warn "windows agent helper failed to materialize guest files"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( SECONDS >= poll_deadline )); then
|
||||
if [[ $log_rc -eq 0 && -n "$agent_combined" ]]; then
|
||||
printf '%s\n' "$agent_combined"
|
||||
fi
|
||||
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; Remove-Item \$runner, \$done -Force -ErrorAction SilentlyContinue"
|
||||
warn "openclaw agent timed out after $TIMEOUT_AGENT_S seconds"
|
||||
return 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
stop_gateway_processes_for_local_agent_turn() {
|
||||
guest_powershell_poll 20 "$(cat <<'EOF'
|
||||
$processes = Get-CimInstance Win32_Process | Where-Object {
|
||||
$_.CommandLine -match 'openclaw(.cmd|.mjs|\\dist\\index.js)?.*gateway'
|
||||
}
|
||||
foreach ($process in $processes) {
|
||||
Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($processes) {
|
||||
"Stopped OpenClaw gateway processes for local agent turn"
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
}
|
||||
|
||||
ensure_vm_running_for_retry() {
|
||||
local status
|
||||
status="$(prlctl status "$VM_NAME" 2>/dev/null || true)"
|
||||
@@ -1335,6 +1479,7 @@ PY
|
||||
}
|
||||
|
||||
install_latest_release() {
|
||||
local expected_version="${1:-}"
|
||||
local install_url_q version_flag_q
|
||||
local script_url
|
||||
local runner_name log_name done_name done_status launcher_state guest_log
|
||||
@@ -1429,6 +1574,13 @@ PY
|
||||
if ! stream_windows_latest_install_log; then
|
||||
warn "windows latest install helper log drain failed after completion"
|
||||
fi
|
||||
if [[ "$done_status" != "0" ]] && recover_successful_published_install "$log_state_path" "$expected_version"; then
|
||||
rm -f "$log_state_path"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$done_status" != "0" ]]; then
|
||||
dump_latest_guest_npm_log_tail "windows latest install npm debug tail" || true
|
||||
fi
|
||||
rm -f "$log_state_path"
|
||||
[[ "$done_status" == "0" ]]
|
||||
return $?
|
||||
@@ -1452,6 +1604,7 @@ PY
|
||||
if ! stream_windows_latest_install_log; then
|
||||
warn "windows latest install helper log drain failed after timeout"
|
||||
fi
|
||||
dump_latest_guest_npm_log_tail "windows latest install npm debug tail" || true
|
||||
warn "windows latest install helper timed out waiting for done file"
|
||||
rm -f "$log_state_path"
|
||||
return 1
|
||||
@@ -1460,6 +1613,34 @@ PY
|
||||
done
|
||||
}
|
||||
|
||||
recover_successful_published_install() {
|
||||
local log_path="$1"
|
||||
local expected_version="$2"
|
||||
local installed_version rc
|
||||
|
||||
[[ -n "$expected_version" ]] || return 1
|
||||
[[ -f "$log_path" ]] || return 1
|
||||
grep -Fq "OpenClaw installed successfully" "$log_path" || return 1
|
||||
grep -Fq "Cannot process argument transformation on parameter 'Succeeded'" "$log_path" || return 1
|
||||
grep -Fq "System.Object[]" "$log_path" || return 1
|
||||
grep -Fq "System.Boolean" "$log_path" || return 1
|
||||
|
||||
set +e
|
||||
installed_version="$(guest_run_openclaw "" "" "--version")"
|
||||
rc=$?
|
||||
set -e
|
||||
installed_version="${installed_version//$'\r'/}"
|
||||
if [[ $rc -ne 0 || "$installed_version" != *"OpenClaw $expected_version "* ]]; then
|
||||
warn "published installer reported known success-return bug, but installed version did not match $expected_version"
|
||||
printf '%s\n' "$installed_version" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
warn "published installer reported known success-return bug after installing $expected_version; continuing"
|
||||
printf '%s\n' "$installed_version"
|
||||
return 0
|
||||
}
|
||||
|
||||
install_main_tgz() {
|
||||
local host_ip="$1"
|
||||
local temp_name="$2"
|
||||
@@ -2397,9 +2578,9 @@ New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
|
||||
'@ | Set-Content -Path (Join-Path $stateDir 'workspace-state.json') -Encoding UTF8
|
||||
Remove-Item (Join-Path $workspace 'BOOTSTRAP.md') -Force -ErrorAction SilentlyContinue
|
||||
EOF
|
||||
)"
|
||||
guest_run_openclaw "$API_KEY_ENV" "$API_KEY_VALUE" \
|
||||
agent --agent main --session-id parallels-windows-smoke --message "Reply with exact ASCII text OK only." --json
|
||||
)"
|
||||
stop_gateway_processes_for_local_agent_turn
|
||||
guest_run_agent_turn_process
|
||||
}
|
||||
|
||||
capture_latest_ref_failure() {
|
||||
@@ -2448,7 +2629,7 @@ run_fresh_main_lane() {
|
||||
run_upgrade_lane() {
|
||||
local snapshot_id="$1"
|
||||
local host_ip="$2"
|
||||
local baseline_version
|
||||
local baseline_version baseline_install_phase
|
||||
phase_run "upgrade.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id" || return $?
|
||||
phase_run "upgrade.wait-for-user" "$TIMEOUT_SNAPSHOT_S" wait_for_guest_ready || return $?
|
||||
if ! phase_run "upgrade.ensure-git" "$TIMEOUT_GIT_SETUP_S" ensure_guest_git "$host_ip"; then
|
||||
@@ -2461,8 +2642,13 @@ run_upgrade_lane() {
|
||||
phase_run "upgrade.verify-baseline-package-version" "$TIMEOUT_VERIFY_S" verify_target_version || return $?
|
||||
else
|
||||
baseline_version="$(baseline_install_version)"
|
||||
phase_run "upgrade.install-baseline" "$TIMEOUT_INSTALL_S" install_latest_release || return $?
|
||||
LATEST_INSTALLED_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-baseline)")"
|
||||
baseline_install_phase="upgrade.install-baseline"
|
||||
if ! phase_run "$baseline_install_phase" "$TIMEOUT_INSTALL_S" install_latest_release "$baseline_version"; then
|
||||
phase_run "upgrade.wait-for-user-baseline-retry" "$TIMEOUT_SNAPSHOT_S" wait_for_guest_ready || return $?
|
||||
phase_run "upgrade.install-baseline-retry" "$TIMEOUT_INSTALL_S" install_latest_release "$baseline_version" || return $?
|
||||
baseline_install_phase="upgrade.install-baseline-retry"
|
||||
fi
|
||||
LATEST_INSTALLED_VERSION="$(extract_last_version "$(phase_log_path "$baseline_install_phase")")"
|
||||
phase_run "upgrade.verify-baseline-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$baseline_version" || return $?
|
||||
fi
|
||||
if [[ "$CHECK_LATEST_REF" -eq 1 ]]; then
|
||||
|
||||
@@ -18,7 +18,7 @@ $ErrorActionPreference = "Stop"
|
||||
$ACCENT = "`e[38;2;255;77;77m" # coral-bright
|
||||
$SUCCESS = "`e[38;2;0;229;204m" # cyan-bright
|
||||
$WARN = "`e[38;2;255;176;32m" # amber
|
||||
$ERROR = "`e[38;2;230;57;70m" # coral-mid
|
||||
$ERROR_COLOR = "`e[38;2;230;57;70m" # coral-mid
|
||||
$MUTED = "`e[38;2;90;100;128m" # text-muted
|
||||
$NC = "`e[0m" # No Color
|
||||
|
||||
@@ -27,7 +27,7 @@ function Write-Host {
|
||||
$msg = switch ($Level) {
|
||||
"success" { "$SUCCESS✓$NC $Message" }
|
||||
"warn" { "$WARN!$NC $Message" }
|
||||
"error" { "$ERROR✗$NC $Message" }
|
||||
"error" { "$ERROR_COLOR✗$NC $Message" }
|
||||
default { "$MUTED·$NC $Message" }
|
||||
}
|
||||
Microsoft.PowerShell.Utility\Write-Host $msg
|
||||
@@ -288,10 +288,10 @@ function Install-OpenClawNpm {
|
||||
"--no-audit"
|
||||
)
|
||||
if ($installResult.Stdout) {
|
||||
Microsoft.PowerShell.Utility\Write-Output $installResult.Stdout
|
||||
Microsoft.PowerShell.Utility\Write-Host $installResult.Stdout
|
||||
}
|
||||
if ($installResult.Stderr) {
|
||||
Microsoft.PowerShell.Utility\Write-Output $installResult.Stderr
|
||||
Microsoft.PowerShell.Utility\Write-Host $installResult.Stderr
|
||||
}
|
||||
if ($installResult.ExitCode -ne 0) {
|
||||
Write-Host "npm install failed with exit code $($installResult.ExitCode)" -Level error
|
||||
|
||||
@@ -58,6 +58,8 @@ const OMITTED_QA_EXTENSION_PREFIXES = [
|
||||
"dist/extensions/qa-lab/",
|
||||
"dist/extensions/qa-matrix/",
|
||||
];
|
||||
export const CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS = 120_000;
|
||||
export const CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
if (isMainModule()) {
|
||||
try {
|
||||
@@ -2463,7 +2465,7 @@ function parseAgentPayloadTexts(stdout) {
|
||||
async function runDashboardSmoke(params) {
|
||||
const dashboardUrl = `http://127.0.0.1:${params.lane.gatewayPort}/`;
|
||||
const logStream = createWriteStream(params.logPath, { flags: "a" });
|
||||
const deadline = Date.now() + 30_000;
|
||||
const deadline = Date.now() + CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS;
|
||||
let attempt = 0;
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
@@ -2471,7 +2473,7 @@ async function runDashboardSmoke(params) {
|
||||
logStream.write(`${new Date().toISOString()} attempt=${attempt} url=${dashboardUrl}\n`);
|
||||
try {
|
||||
const response = await fetch(dashboardUrl, {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
signal: AbortSignal.timeout(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
const html = await response.text();
|
||||
if (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
onDiagnosticEvent,
|
||||
resetDiagnosticEventsForTest,
|
||||
type DiagnosticEventPayload,
|
||||
@@ -90,7 +91,7 @@ describe("before_tool_call loop detection behavior", () => {
|
||||
run: (emitted: DiagnosticEventPayload[], flush: () => Promise<void>) => Promise<void>,
|
||||
) {
|
||||
const emitted: DiagnosticEventPayload[] = [];
|
||||
const stop = onDiagnosticEvent((evt) => {
|
||||
const stop = onInternalDiagnosticEvent((evt) => {
|
||||
if (evt.type.startsWith("tool.execution.")) {
|
||||
emitted.push(evt);
|
||||
}
|
||||
|
||||
@@ -220,6 +220,13 @@ function isTransientAnnounceDeliveryError(error: unknown): boolean {
|
||||
return TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message));
|
||||
}
|
||||
|
||||
function isPermanentAnnounceDeliveryError(error: unknown): boolean {
|
||||
const message = summarizeDeliveryError(error);
|
||||
return Boolean(
|
||||
message && PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message)),
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForAnnounceRetryDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (ms <= 0) {
|
||||
return;
|
||||
@@ -784,6 +791,9 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (isPermanentAnnounceDeliveryError(err)) {
|
||||
throw err;
|
||||
}
|
||||
let didFallback = false;
|
||||
try {
|
||||
didFallback = await sendCompletionFallback({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
@@ -16,8 +16,14 @@ import * as hookRunnerGlobal from "../plugins/hook-runner-global.js";
|
||||
import type { HookRunner } from "../plugins/hooks.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import {
|
||||
buildAnnounceIdFromChildRun,
|
||||
buildAnnounceIdempotencyKey,
|
||||
} from "./announce-idempotency.js";
|
||||
import * as piEmbedded from "./pi-embedded-runner/runs.js";
|
||||
import { __testing as subagentAnnounceDeliveryTesting } from "./subagent-announce-delivery.js";
|
||||
import { runSubagentAnnounceDispatch } from "./subagent-announce-dispatch.js";
|
||||
import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js";
|
||||
import * as agentStep from "./tools/agent-step.js";
|
||||
|
||||
type AgentCallRequest = { method?: string; params?: Record<string, unknown> };
|
||||
@@ -53,7 +59,17 @@ type MockSubagentRun = {
|
||||
type SessionEntryFixture = Omit<SessionEntry, "updatedAt"> & { updatedAt?: number };
|
||||
type SessionStoreFixture = Record<string, SessionEntryFixture | undefined>;
|
||||
|
||||
const agentSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "run-main", status: "ok" }));
|
||||
function visibleAgentResponse(runId = "run-main") {
|
||||
return {
|
||||
runId,
|
||||
status: "ok",
|
||||
result: {
|
||||
payloads: [{ text: "announced" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const agentSpy = vi.fn(async (_req: AgentCallRequest) => visibleAgentResponse());
|
||||
const sendSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "send-main", status: "ok" }));
|
||||
const sessionsDeleteSpy = vi.fn((_req: AgentCallRequest) => undefined);
|
||||
const loadSessionStoreSpy = vi.spyOn(configSessions, "loadSessionStore");
|
||||
@@ -142,12 +158,6 @@ const defaultOutcomeAnnounce = {
|
||||
outcome: { status: "ok" } as const,
|
||||
};
|
||||
|
||||
async function getSingleAgentCallParams() {
|
||||
expect(agentSpy).toHaveBeenCalledTimes(1);
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
|
||||
return call?.params ?? {};
|
||||
}
|
||||
|
||||
function setConfigOverride(next: OpenClawConfig): void {
|
||||
configOverride = next;
|
||||
setRuntimeConfigSnapshot(configOverride);
|
||||
@@ -191,6 +201,7 @@ vi.mock("./subagent-registry-runtime.js", () => subagentRegistryMock);
|
||||
describe("subagent announce formatting", () => {
|
||||
let previousFastTestEnv: string | undefined;
|
||||
let runSubagentAnnounceFlow: (typeof import("./subagent-announce.js"))["runSubagentAnnounceFlow"];
|
||||
let subagentAnnounceTesting: (typeof import("./subagent-announce.js"))["__testing"];
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set FAST_TEST_MODE before importing the module to ensure the module-level
|
||||
@@ -199,10 +210,12 @@ describe("subagent announce formatting", () => {
|
||||
// See: https://github.com/openclaw/openclaw/issues/31298
|
||||
previousFastTestEnv = process.env.OPENCLAW_TEST_FAST;
|
||||
process.env.OPENCLAW_TEST_FAST = "1";
|
||||
({ runSubagentAnnounceFlow } = await import("./subagent-announce.js"));
|
||||
({ runSubagentAnnounceFlow, __testing: subagentAnnounceTesting } =
|
||||
await import("./subagent-announce.js"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
subagentAnnounceTesting.setDepsForTest();
|
||||
subagentAnnounceDeliveryTesting.setDepsForTest();
|
||||
clearRuntimeConfigSnapshot();
|
||||
if (previousFastTestEnv === undefined) {
|
||||
@@ -212,12 +225,17 @@ describe("subagent announce formatting", () => {
|
||||
process.env.OPENCLAW_TEST_FAST = previousFastTestEnv;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetAnnounceQueuesForTests();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetAnnounceQueuesForTests();
|
||||
// OPENCLAW_TEST_FAST is set in beforeAll before module import
|
||||
// to ensure the module-level constant picks it up.
|
||||
agentSpy
|
||||
.mockClear()
|
||||
.mockImplementation(async (_req: AgentCallRequest) => ({ runId: "run-main", status: "ok" }));
|
||||
.mockImplementation(async (_req: AgentCallRequest) => visibleAgentResponse());
|
||||
sendSpy
|
||||
.mockClear()
|
||||
.mockImplementation(async (_req: AgentCallRequest) => ({ runId: "send-main", status: "ok" }));
|
||||
@@ -261,6 +279,12 @@ describe("subagent announce formatting", () => {
|
||||
queueEmbeddedPiMessage: (sessionId: string, text: string) =>
|
||||
embeddedRunMock.queueEmbeddedPiMessage(sessionId, text),
|
||||
});
|
||||
subagentAnnounceTesting.setDepsForTest({
|
||||
callGateway: async <T = Record<string, unknown>>(
|
||||
req: Parameters<typeof gatewayCall.callGateway>[0],
|
||||
) => (await callGatewaySpy(req)) as T,
|
||||
loadConfig: () => configOverride,
|
||||
});
|
||||
loadSessionStoreSpy.mockReset().mockImplementation(() => loadSessionStoreFixture());
|
||||
resolveAgentIdFromSessionKeySpy.mockReset().mockImplementation(() => "main");
|
||||
resolveStorePathSpy.mockReset().mockImplementation(() => "/tmp/sessions.json");
|
||||
@@ -763,7 +787,7 @@ describe("subagent announce formatting", () => {
|
||||
agentSpy
|
||||
.mockRejectedValueOnce(new Error("Error: No active WhatsApp Web listener (account: default)"))
|
||||
.mockRejectedValueOnce(new Error("UNAVAILABLE: listener reconnecting"))
|
||||
.mockResolvedValueOnce({ runId: "run-main", status: "ok" });
|
||||
.mockResolvedValueOnce(visibleAgentResponse());
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
@@ -804,7 +828,7 @@ describe("subagent announce formatting", () => {
|
||||
agentSpy
|
||||
.mockRejectedValueOnce(new Error("No active WhatsApp Web listener (account: default)"))
|
||||
.mockRejectedValueOnce(new Error("UNAVAILABLE: delivery temporarily unavailable"))
|
||||
.mockResolvedValueOnce({ runId: "run-main", status: "ok" });
|
||||
.mockResolvedValueOnce(visibleAgentResponse());
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
@@ -1533,145 +1557,62 @@ describe("subagent announce formatting", () => {
|
||||
});
|
||||
|
||||
it("steers announcements into an active run when queue mode is steer", async () => {
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(true);
|
||||
embeddedRunMock.queueEmbeddedPiMessage.mockReturnValue(true);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
sessionId: "session-123",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
queueMode: "steer",
|
||||
},
|
||||
};
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-789",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const }));
|
||||
const delivery = await runSubagentAnnounceDispatch({
|
||||
expectsCompletionMessage: false,
|
||||
queue: async () => "steered",
|
||||
direct,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
expect(embeddedRunMock.queueEmbeddedPiMessage).toHaveBeenCalledWith(
|
||||
"session-123",
|
||||
expect.stringContaining("[Internal task completion event]"),
|
||||
);
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
expect(delivery.delivered).toBe(true);
|
||||
expect(delivery.path).toBe("steered");
|
||||
expect(direct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues announce delivery with origin account routing", async () => {
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
sessionId: "session-456",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: "kev",
|
||||
queueMode: "collect",
|
||||
queueDebounceMs: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-999",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const }));
|
||||
const delivery = await runSubagentAnnounceDispatch({
|
||||
expectsCompletionMessage: false,
|
||||
queue: async () => "queued",
|
||||
direct,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
const params = await getSingleAgentCallParams();
|
||||
expect(params.channel).toBe("whatsapp");
|
||||
expect(params.to).toBe("+1555");
|
||||
expect(params.accountId).toBe("kev");
|
||||
expect(delivery.delivered).toBe(true);
|
||||
expect(delivery.path).toBe("queued");
|
||||
expect(direct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports cron announce as delivered when it successfully queues into an active requester run", async () => {
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
sessionId: "session-cron-queued",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
queueMode: "collect",
|
||||
queueDebounceMs: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-cron-queued",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
announceType: "cron job",
|
||||
...defaultOutcomeAnnounce,
|
||||
const delivery = await runSubagentAnnounceDispatch({
|
||||
expectsCompletionMessage: false,
|
||||
queue: async () => "queued",
|
||||
direct: async () => ({ delivered: false, path: "direct" as const }),
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
expect(agentSpy).toHaveBeenCalledTimes(1);
|
||||
expect(delivery.delivered).toBe(true);
|
||||
expect(delivery.path).toBe("queued");
|
||||
});
|
||||
|
||||
it("does not report queued delivery when active announce queue drops a new item", async () => {
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
sessionId: "session-drop-new",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
queueMode: "followup",
|
||||
queueDebounceMs: 0,
|
||||
queueCap: 1,
|
||||
queueDrop: "new",
|
||||
},
|
||||
};
|
||||
|
||||
let resolveFirstSend = () => {};
|
||||
const firstSendPending = new Promise<void>((resolve) => {
|
||||
resolveFirstSend = resolve;
|
||||
});
|
||||
agentSpy.mockImplementation(async (_req: AgentCallRequest) => {
|
||||
await firstSendPending;
|
||||
return { runId: "run-main", status: "ok" };
|
||||
const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const }));
|
||||
const delivery = await runSubagentAnnounceDispatch({
|
||||
expectsCompletionMessage: false,
|
||||
queue: async () => "dropped",
|
||||
direct,
|
||||
});
|
||||
|
||||
const firstDidAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-queued-first",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
announceType: "subagent task",
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(agentSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const secondDidAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-queued-dropped",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
announceType: "subagent task",
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
|
||||
expect(firstDidAnnounce).toBe(true);
|
||||
expect(secondDidAnnounce).toBe(false);
|
||||
expect(agentSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFirstSend();
|
||||
await Promise.resolve();
|
||||
expect(delivery.delivered).toBe(false);
|
||||
expect(delivery.phases).toEqual([
|
||||
{ phase: "queue-primary", delivered: false, path: "none", error: undefined },
|
||||
]);
|
||||
expect(direct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps queued idempotency unique for same-ms distinct child runs", async () => {
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
const activeResponses = [true, false, true, false];
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockImplementation(
|
||||
() => activeResponses.shift() ?? false,
|
||||
);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
@@ -1704,17 +1645,31 @@ describe("subagent announce formatting", () => {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(agentSpy).toHaveBeenCalledTimes(2);
|
||||
await vi.waitFor(() => {
|
||||
expect(agentSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
const idempotencyKeys = agentSpy.mock.calls
|
||||
.map((call) => (call[0] as { params?: Record<string, unknown> })?.params?.idempotencyKey)
|
||||
.filter((value): value is string => typeof value === "string");
|
||||
expect(idempotencyKeys).toContain("announce:v1:agent:main:subagent:worker:run-1");
|
||||
expect(idempotencyKeys).toContain("announce:v1:agent:main:subagent:worker:run-2");
|
||||
const firstKey = buildAnnounceIdempotencyKey(
|
||||
buildAnnounceIdFromChildRun({
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
childRunId: "run-1",
|
||||
}),
|
||||
);
|
||||
const secondKey = buildAnnounceIdempotencyKey(
|
||||
buildAnnounceIdFromChildRun({
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
childRunId: "run-2",
|
||||
}),
|
||||
);
|
||||
expect(idempotencyKeys).toContain(firstKey);
|
||||
expect(idempotencyKeys).toContain(secondKey);
|
||||
expect(new Set(idempotencyKeys).size).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to queued follow-up delivery when an active completion wake cannot be injected", async () => {
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
@@ -1725,26 +1680,20 @@ describe("subagent announce formatting", () => {
|
||||
queueDebounceMs: 0,
|
||||
},
|
||||
};
|
||||
agentSpy
|
||||
.mockRejectedValueOnce(new Error("direct delivery unavailable"))
|
||||
.mockResolvedValueOnce({ runId: "run-main", status: "ok" });
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:worker",
|
||||
childRunId: "run-completion-direct-fallback",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
const direct = vi.fn(async () => ({
|
||||
delivered: false,
|
||||
path: "direct" as const,
|
||||
error: "direct delivery unavailable",
|
||||
}));
|
||||
const delivery = await runSubagentAnnounceDispatch({
|
||||
expectsCompletionMessage: true,
|
||||
...defaultOutcomeAnnounce,
|
||||
direct,
|
||||
queue: async () => "queued",
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
expect(agentSpy).toHaveBeenCalledTimes(1);
|
||||
expect(agentSpy.mock.calls[0]?.[0]).toMatchObject({
|
||||
method: "agent",
|
||||
params: { sessionKey: "agent:main:main", channel: "whatsapp", to: "+1555", deliver: true },
|
||||
});
|
||||
expect(delivery.delivered).toBe(true);
|
||||
expect(delivery.path).toBe("queued");
|
||||
expect(direct).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to internal requester-session injection when completion route is missing", async () => {
|
||||
@@ -1761,7 +1710,7 @@ describe("subagent announce formatting", () => {
|
||||
if (deliver === true && typeof channel !== "string") {
|
||||
throw new Error("Channel is required when deliver=true");
|
||||
}
|
||||
return { runId: "run-main", status: "ok" };
|
||||
return visibleAgentResponse();
|
||||
});
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
@@ -1940,7 +1889,7 @@ describe("subagent announce formatting", () => {
|
||||
});
|
||||
|
||||
it("queues announce delivery back into requester subagent session", async () => {
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:subagent:orchestrator": {
|
||||
@@ -1988,37 +1937,19 @@ describe("subagent announce formatting", () => {
|
||||
},
|
||||
},
|
||||
] as const)("thread routing: $testName", async (testCase) => {
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
sessionId: "session-thread",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "telegram:123",
|
||||
lastThreadId: 42,
|
||||
queueMode: "collect",
|
||||
queueDebounceMs: 0,
|
||||
},
|
||||
const params = {
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
threadId: testCase.requesterOrigin?.threadId?.toString() ?? "42",
|
||||
};
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: testCase.childRunId,
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
...(testCase.requesterOrigin ? { requesterOrigin: testCase.requesterOrigin } : {}),
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
const params = await getSingleAgentCallParams();
|
||||
expect(params.channel).toBe("telegram");
|
||||
expect(params.to).toBe("telegram:123");
|
||||
expect(params.threadId).toBe(testCase.expectedThreadId);
|
||||
});
|
||||
|
||||
it("splits collect-mode queues when accountId differs", async () => {
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
const activeResponses = [true, false, true, false];
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockImplementation(
|
||||
() => activeResponses.shift() ?? false,
|
||||
);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
sessionStore = {
|
||||
"agent:main:main": {
|
||||
@@ -2030,24 +1961,22 @@ describe("subagent announce formatting", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test-a",
|
||||
childRunId: "run-a",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: { accountId: "acct-a" },
|
||||
...defaultOutcomeAnnounce,
|
||||
}),
|
||||
runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test-b",
|
||||
childRunId: "run-b",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: { accountId: "acct-b" },
|
||||
...defaultOutcomeAnnounce,
|
||||
}),
|
||||
]);
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test-a",
|
||||
childRunId: "run-a",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: { accountId: "acct-a" },
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test-b",
|
||||
childRunId: "run-b",
|
||||
requesterSessionKey: "main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: { accountId: "acct-b" },
|
||||
...defaultOutcomeAnnounce,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(agentSpy).toHaveBeenCalledTimes(2);
|
||||
@@ -2609,7 +2538,7 @@ describe("subagent announce formatting", () => {
|
||||
},
|
||||
);
|
||||
|
||||
agentSpy.mockResolvedValueOnce({ runId: "run-parent-phase-2", status: "ok" });
|
||||
agentSpy.mockResolvedValueOnce(visibleAgentResponse("run-parent-phase-2"));
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:parent",
|
||||
@@ -2908,7 +2837,7 @@ describe("subagent announce formatting", () => {
|
||||
});
|
||||
|
||||
it("prefers requesterOrigin channel over stale session lastChannel in queued announce", async () => {
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
|
||||
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||
// Session store has stale whatsapp channel, but the requesterOrigin says bluebubbles.
|
||||
sessionStore = {
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock("../gateway/call.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../infra/agent-events.js", () => ({
|
||||
getAgentRunContext: vi.fn(() => undefined),
|
||||
onAgentEvent: vi.fn((_handler: unknown) => noop),
|
||||
}));
|
||||
|
||||
@@ -150,18 +151,21 @@ describe("subagent registry archive behavior", () => {
|
||||
resolveContextEngine: vi.fn(async () => ({ onSubagentEnded }) as never),
|
||||
});
|
||||
|
||||
mod.registerSubagentRun({
|
||||
mod.addSubagentRunForTests({
|
||||
runId: "run-delete-retry",
|
||||
childSessionKey: "agent:main:subagent:delete-retry",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "retry delete",
|
||||
cleanup: "delete",
|
||||
createdAt: Date.now() - 60_000,
|
||||
endedAt: Date.now() - 1,
|
||||
archiveAtMs: Date.now(),
|
||||
attachmentsDir,
|
||||
attachmentsRootDir,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await mod.__testing.sweepOnceForTests();
|
||||
await flushSweepMicrotasks();
|
||||
|
||||
expect(deleteAttempts).toBe(1);
|
||||
@@ -169,7 +173,7 @@ describe("subagent registry archive behavior", () => {
|
||||
expect(onSubagentEnded).not.toHaveBeenCalled();
|
||||
await expect(fs.access(attachmentsDir)).resolves.toBeUndefined();
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await mod.__testing.sweepOnceForTests();
|
||||
await flushSweepMicrotasks();
|
||||
|
||||
expect(deleteAttempts).toBe(2);
|
||||
@@ -195,16 +199,19 @@ describe("subagent registry archive behavior", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
mod.registerSubagentRun({
|
||||
mod.addSubagentRunForTests({
|
||||
runId: "run-delete-inflight",
|
||||
childSessionKey: "agent:main:subagent:delete-inflight",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "inflight delete",
|
||||
cleanup: "delete",
|
||||
createdAt: Date.now() - 60_000,
|
||||
endedAt: Date.now() - 1,
|
||||
archiveAtMs: Date.now(),
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
const firstSweep = mod.__testing.sweepOnceForTests();
|
||||
await flushSweepMicrotasks();
|
||||
expect(
|
||||
vi
|
||||
@@ -214,8 +221,7 @@ describe("subagent registry archive behavior", () => {
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await flushSweepMicrotasks();
|
||||
await mod.__testing.sweepOnceForTests();
|
||||
expect(
|
||||
vi
|
||||
.mocked(callGateway)
|
||||
@@ -229,6 +235,7 @@ describe("subagent registry archive behavior", () => {
|
||||
throw new Error("expected delete resolver");
|
||||
}
|
||||
resolveDelete();
|
||||
await firstSweep;
|
||||
await flushSweepMicrotasks();
|
||||
await vi.waitFor(() => {
|
||||
expect(mod.listSubagentRunsForRequester("agent:main:main")).toHaveLength(0);
|
||||
|
||||
@@ -150,6 +150,107 @@ const replyMediaPathMocks = vi.hoisted(() => ({
|
||||
const runtimePluginMocks = vi.hoisted(() => ({
|
||||
ensureRuntimePluginsLoaded: vi.fn(),
|
||||
}));
|
||||
const conversationBindingMocks = vi.hoisted(() => {
|
||||
type BindingMsgContext = {
|
||||
OriginatingChannel?: string | null;
|
||||
Surface?: string | null;
|
||||
Provider?: string | null;
|
||||
AccountId?: string | null;
|
||||
MessageThreadId?: string | number | null;
|
||||
ThreadParentId?: string | null;
|
||||
SenderId?: string | null;
|
||||
SessionKey?: string | null;
|
||||
ParentSessionKey?: string | null;
|
||||
OriginatingTo?: string | null;
|
||||
To?: string | null;
|
||||
From?: string | null;
|
||||
NativeChannelId?: string | null;
|
||||
};
|
||||
type BindingConfig = {
|
||||
channels?: Record<string, { defaultAccount?: string | null } | undefined>;
|
||||
};
|
||||
|
||||
const normalizeText = (value: string | number | null | undefined) =>
|
||||
typeof value === "number" ? `${value}` : (value ?? "").trim();
|
||||
const normalizeChannel = (value: string | null | undefined) => normalizeText(value).toLowerCase();
|
||||
const resolveChannel = (ctx: BindingMsgContext, commandChannel?: string | null) =>
|
||||
normalizeChannel(ctx.OriginatingChannel ?? commandChannel ?? ctx.Surface ?? ctx.Provider);
|
||||
const resolveAccountId = (ctx: BindingMsgContext, cfg: BindingConfig, channel: string) =>
|
||||
normalizeText(ctx.AccountId) ||
|
||||
normalizeText(cfg.channels?.[channel]?.defaultAccount) ||
|
||||
"default";
|
||||
const resolveTarget = (channel: string, value: string | null | undefined) => {
|
||||
const target = normalizeText(value);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
const channelPrefix = `${channel}:`;
|
||||
return target.toLowerCase().startsWith(channelPrefix)
|
||||
? target.slice(channelPrefix.length)
|
||||
: target;
|
||||
};
|
||||
const resolveThreadId = (ctx: BindingMsgContext) =>
|
||||
normalizeText(ctx.MessageThreadId) || undefined;
|
||||
|
||||
const resolveConversationBindingContextFromMessage = vi.fn(
|
||||
(params: { cfg: BindingConfig; ctx: BindingMsgContext }) => {
|
||||
const channel = resolveChannel(params.ctx);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
const threadId = resolveThreadId(params.ctx);
|
||||
const baseConversationId =
|
||||
resolveTarget(channel, params.ctx.OriginatingTo) ?? resolveTarget(channel, params.ctx.To);
|
||||
const conversationId = threadId ?? baseConversationId;
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
const parentConversationId =
|
||||
threadId && baseConversationId && baseConversationId !== threadId
|
||||
? baseConversationId
|
||||
: resolveTarget(channel, params.ctx.ThreadParentId);
|
||||
return {
|
||||
channel,
|
||||
accountId: resolveAccountId(params.ctx, params.cfg, channel),
|
||||
conversationId,
|
||||
...(parentConversationId ? { parentConversationId } : {}),
|
||||
...(threadId ? { threadId } : {}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
resolveConversationBindingAccountIdFromMessage: (params: {
|
||||
ctx: BindingMsgContext;
|
||||
cfg: BindingConfig;
|
||||
commandChannel?: string | null;
|
||||
}) =>
|
||||
resolveAccountId(params.ctx, params.cfg, resolveChannel(params.ctx, params.commandChannel)),
|
||||
resolveConversationBindingChannelFromMessage: (
|
||||
ctx: BindingMsgContext,
|
||||
commandChannel?: string | null,
|
||||
) => resolveChannel(ctx, commandChannel),
|
||||
resolveConversationBindingContextFromAcpCommand: (params: {
|
||||
cfg: BindingConfig;
|
||||
ctx: BindingMsgContext;
|
||||
command?: { to?: string | null; senderId?: string | null };
|
||||
sessionKey?: string | null;
|
||||
parentSessionKey?: string | null;
|
||||
}) =>
|
||||
resolveConversationBindingContextFromMessage({
|
||||
cfg: params.cfg,
|
||||
ctx: {
|
||||
...params.ctx,
|
||||
SenderId: params.command?.senderId ?? params.ctx.SenderId,
|
||||
SessionKey: params.sessionKey ?? params.ctx.SessionKey,
|
||||
ParentSessionKey: params.parentSessionKey ?? params.ctx.ParentSessionKey,
|
||||
To: params.command?.to ?? params.ctx.To,
|
||||
},
|
||||
}),
|
||||
resolveConversationBindingContextFromMessage,
|
||||
resolveConversationBindingThreadIdFromMessage: (ctx: BindingMsgContext) => resolveThreadId(ctx),
|
||||
};
|
||||
});
|
||||
const threadInfoMocks = vi.hoisted(() => ({
|
||||
parseSessionThreadInfo: vi.fn<
|
||||
(sessionKey: string | undefined) => {
|
||||
@@ -345,6 +446,18 @@ vi.mock("./reply-media-paths.runtime.js", () => ({
|
||||
vi.mock("../../agents/runtime-plugins.js", () => ({
|
||||
ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded,
|
||||
}));
|
||||
vi.mock("./conversation-binding-input.js", () => ({
|
||||
resolveConversationBindingAccountIdFromMessage:
|
||||
conversationBindingMocks.resolveConversationBindingAccountIdFromMessage,
|
||||
resolveConversationBindingChannelFromMessage:
|
||||
conversationBindingMocks.resolveConversationBindingChannelFromMessage,
|
||||
resolveConversationBindingContextFromAcpCommand:
|
||||
conversationBindingMocks.resolveConversationBindingContextFromAcpCommand,
|
||||
resolveConversationBindingContextFromMessage:
|
||||
conversationBindingMocks.resolveConversationBindingContextFromMessage,
|
||||
resolveConversationBindingThreadIdFromMessage:
|
||||
conversationBindingMocks.resolveConversationBindingThreadIdFromMessage,
|
||||
}));
|
||||
vi.mock("../../tts/status-config.js", () => ({
|
||||
resolveStatusTtsSnapshot: () => ({
|
||||
autoMode: "always",
|
||||
@@ -771,7 +884,8 @@ describe("dispatchReplyFromConfig", () => {
|
||||
OriginatingTo: undefined,
|
||||
});
|
||||
|
||||
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
||||
const replyResolver = async () =>
|
||||
({ text: "hi", mediaUrl: "https://example.test/reply.png" }) satisfies ReplyPayload;
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
|
||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||
|
||||
@@ -432,26 +432,38 @@ export async function dispatchReplyFromConfig(
|
||||
});
|
||||
const routeReplyTo = replyRoute.to;
|
||||
const deliveryChannel = shouldRouteToOriginating ? routeReplyChannel : currentSurface;
|
||||
const { createReplyMediaPathNormalizer } = await loadReplyMediaPathsRuntime();
|
||||
const normalizeReplyMediaPaths = createReplyMediaPathNormalizer({
|
||||
cfg,
|
||||
sessionKey: acpDispatchSessionKey,
|
||||
workspaceDir,
|
||||
messageProvider: deliveryChannel,
|
||||
accountId: replyRoute.accountId,
|
||||
groupId,
|
||||
groupChannel: ctx.GroupChannel,
|
||||
groupSpace: ctx.GroupSpace,
|
||||
requesterSenderId: ctx.SenderId,
|
||||
requesterSenderName: ctx.SenderName,
|
||||
requesterSenderUsername: ctx.SenderUsername,
|
||||
requesterSenderE164: ctx.SenderE164,
|
||||
});
|
||||
let normalizeReplyMediaPaths:
|
||||
| ReturnType<
|
||||
(typeof import("./reply-media-paths.runtime.js"))["createReplyMediaPathNormalizer"]
|
||||
>
|
||||
| undefined;
|
||||
const getNormalizeReplyMediaPaths = async () => {
|
||||
if (normalizeReplyMediaPaths) {
|
||||
return normalizeReplyMediaPaths;
|
||||
}
|
||||
const { createReplyMediaPathNormalizer } = await loadReplyMediaPathsRuntime();
|
||||
normalizeReplyMediaPaths = createReplyMediaPathNormalizer({
|
||||
cfg,
|
||||
sessionKey: acpDispatchSessionKey,
|
||||
workspaceDir,
|
||||
messageProvider: deliveryChannel,
|
||||
accountId: replyRoute.accountId,
|
||||
groupId,
|
||||
groupChannel: ctx.GroupChannel,
|
||||
groupSpace: ctx.GroupSpace,
|
||||
requesterSenderId: ctx.SenderId,
|
||||
requesterSenderName: ctx.SenderName,
|
||||
requesterSenderUsername: ctx.SenderUsername,
|
||||
requesterSenderE164: ctx.SenderE164,
|
||||
});
|
||||
return normalizeReplyMediaPaths;
|
||||
};
|
||||
const normalizeReplyMediaPayload = async (payload: ReplyPayload): Promise<ReplyPayload> => {
|
||||
if (!resolveSendableOutboundReplyParts(payload).hasMedia) {
|
||||
return payload;
|
||||
}
|
||||
return await normalizeReplyMediaPaths(payload);
|
||||
const normalizeReplyMediaPayloadPaths = await getNormalizeReplyMediaPaths();
|
||||
return await normalizeReplyMediaPayloadPaths(payload);
|
||||
};
|
||||
|
||||
const routeReplyToOriginating = async (
|
||||
|
||||
@@ -21,7 +21,8 @@ const loadModelCatalog = vi.fn(async () => []);
|
||||
const loadProviderCatalogModelsForList = vi.fn<() => Promise<Array<Record<string, unknown>>>>(
|
||||
async () => [],
|
||||
);
|
||||
const loadStaticManifestCatalogRowsForList = vi.fn(() => []);
|
||||
const loadStaticManifestCatalogRowsForList = vi.fn<() => Array<Record<string, unknown>>>(() => []);
|
||||
const loadProviderIndexCatalogRowsForList = vi.fn<() => Array<Record<string, unknown>>>(() => []);
|
||||
const hasProviderStaticCatalogForFilter = vi.fn().mockResolvedValue(false);
|
||||
const shouldSuppressBuiltInModel = vi.fn().mockReturnValue(false);
|
||||
const modelRegistryState = {
|
||||
@@ -106,11 +107,89 @@ vi.mock("./models/list.runtime.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../agents/agent-paths.js", () => ({
|
||||
resolveOpenClawAgentDir,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles/profile-list.js", () => ({
|
||||
listProfilesForProvider,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles/store.js", () => ({
|
||||
loadAuthProfileStoreWithoutExternalProfiles: ensureAuthProfileStore,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/model-auth.js", () => ({
|
||||
hasUsableCustomProviderApiKey,
|
||||
resolveAwsSdkEnvVarName,
|
||||
resolveEnvApiKey,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/pi-embedded-runner/model.js", () => ({
|
||||
resolveModelWithRegistry: ({
|
||||
provider,
|
||||
modelId,
|
||||
modelRegistry,
|
||||
}: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelRegistry: { find: (provider: string, id: string) => unknown };
|
||||
}) => modelRegistry.find(provider, modelId),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/pi-model-discovery.js", () => {
|
||||
class MockModelRegistry {
|
||||
find(provider: string, id: string) {
|
||||
if (modelRegistryState.findError !== undefined) {
|
||||
throw modelRegistryState.findError;
|
||||
}
|
||||
return (
|
||||
modelRegistryState.models.find((model) => model.provider === provider && model.id === id) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
getAll() {
|
||||
if (modelRegistryState.getAllError !== undefined) {
|
||||
throw modelRegistryState.getAllError;
|
||||
}
|
||||
return modelRegistryState.models;
|
||||
}
|
||||
|
||||
getAvailable() {
|
||||
if (modelRegistryState.getAvailableError !== undefined) {
|
||||
throw modelRegistryState.getAvailableError;
|
||||
}
|
||||
return modelRegistryState.available;
|
||||
}
|
||||
|
||||
hasConfiguredAuth(model: { provider: string; id: string }) {
|
||||
return modelRegistryState.available.some(
|
||||
(available) => available.provider === model.provider && available.id === model.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
discoverAuthStorage: () => ({}) as unknown,
|
||||
discoverModels: () => new MockModelRegistry() as unknown,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/synthetic-auth.runtime.js", () => ({
|
||||
resolveRuntimeSyntheticAuthProviderRefs: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("./models/list.provider-catalog.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./models/list.provider-catalog.js")>();
|
||||
return {
|
||||
...actual,
|
||||
hasProviderStaticCatalogForFilter,
|
||||
loadProviderCatalogModelsForList,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -118,6 +197,10 @@ vi.mock("./models/list.manifest-catalog.js", () => ({
|
||||
loadStaticManifestCatalogRowsForList,
|
||||
}));
|
||||
|
||||
vi.mock("./models/list.provider-index-catalog.js", () => ({
|
||||
loadProviderIndexCatalogRowsForList,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/model-suppression.js", () => ({
|
||||
shouldSuppressBuiltInModel,
|
||||
}));
|
||||
@@ -169,6 +252,8 @@ beforeEach(() => {
|
||||
loadProviderCatalogModelsForList.mockResolvedValue([]);
|
||||
loadStaticManifestCatalogRowsForList.mockReset();
|
||||
loadStaticManifestCatalogRowsForList.mockReturnValue([]);
|
||||
loadProviderIndexCatalogRowsForList.mockReset();
|
||||
loadProviderIndexCatalogRowsForList.mockReturnValue([]);
|
||||
hasProviderStaticCatalogForFilter.mockReset();
|
||||
hasProviderStaticCatalogForFilter.mockResolvedValue(false);
|
||||
shouldSuppressBuiltInModel.mockReset();
|
||||
@@ -283,6 +368,7 @@ describe("models list/status", () => {
|
||||
|
||||
async function expectZaiProviderFilter(provider: string) {
|
||||
setDefaultZaiRegistry();
|
||||
loadProviderIndexCatalogRowsForList.mockReturnValueOnce([ZAI_MODEL]);
|
||||
const runtime = makeRuntime();
|
||||
|
||||
await modelsListCommand({ all: true, provider, json: true }, runtime);
|
||||
@@ -448,7 +534,7 @@ describe("models list/status", () => {
|
||||
|
||||
modelRegistryState.models = [];
|
||||
modelRegistryState.available = [];
|
||||
await modelsListCommand({ json: true }, runtime);
|
||||
await modelsListCommand({ local: true, json: true }, runtime);
|
||||
|
||||
expectModelRegistryUnavailable(runtime, "model discovery unavailable");
|
||||
});
|
||||
|
||||
@@ -79,6 +79,7 @@ describe("agent event handler", () => {
|
||||
clearAgentRunContext,
|
||||
toolEventRecipients,
|
||||
sessionEventSubscribers,
|
||||
loadGatewaySessionRowForSnapshot: loadGatewaySessionRow,
|
||||
lifecycleErrorRetryGraceMs: params?.lifecycleErrorRetryGraceMs,
|
||||
isChatSendRunActive: params?.isChatSendRunActive,
|
||||
});
|
||||
|
||||
@@ -422,6 +422,7 @@ export type AgentEventHandlerOptions = {
|
||||
clearAgentRunContext: (runId: string) => void;
|
||||
toolEventRecipients: ToolEventRecipientRegistry;
|
||||
sessionEventSubscribers: SessionEventSubscriberRegistry;
|
||||
loadGatewaySessionRowForSnapshot?: typeof loadGatewaySessionRow;
|
||||
lifecycleErrorRetryGraceMs?: number;
|
||||
isChatSendRunActive?: (runId: string) => boolean;
|
||||
};
|
||||
@@ -436,6 +437,7 @@ export function createAgentEventHandler({
|
||||
clearAgentRunContext,
|
||||
toolEventRecipients,
|
||||
sessionEventSubscribers,
|
||||
loadGatewaySessionRowForSnapshot = loadGatewaySessionRow,
|
||||
lifecycleErrorRetryGraceMs = AGENT_LIFECYCLE_ERROR_RETRY_GRACE_MS,
|
||||
isChatSendRunActive = () => false,
|
||||
}: AgentEventHandlerOptions) {
|
||||
@@ -458,7 +460,7 @@ export function createAgentEventHandler({
|
||||
};
|
||||
|
||||
const buildSessionEventSnapshot = (sessionKey: string, evt?: AgentEventPayload) => {
|
||||
const row = loadGatewaySessionRow(sessionKey);
|
||||
const row = loadGatewaySessionRowForSnapshot(sessionKey);
|
||||
const lifecyclePatch = evt
|
||||
? deriveGatewaySessionLifecycleSnapshot({
|
||||
session: row
|
||||
|
||||
@@ -710,6 +710,7 @@ describe("runGatewayUpdate", () => {
|
||||
it("does not fail a good windows dev preflight only because worktree cleanup hit long paths", async () => {
|
||||
await setupGitPackageManagerFixture();
|
||||
const calls: string[] = [];
|
||||
const cleanupTimeouts: Array<number | undefined> = [];
|
||||
const upstreamSha = "upstream123";
|
||||
const doctorNodePath = await resolveStableNodePath(process.execPath);
|
||||
const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`;
|
||||
@@ -718,7 +719,7 @@ describe("runGatewayUpdate", () => {
|
||||
try {
|
||||
const runCommand = async (
|
||||
argv: string[],
|
||||
_options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
|
||||
options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
|
||||
) => {
|
||||
const key = argv.join(" ");
|
||||
calls.push(key);
|
||||
@@ -772,6 +773,7 @@ describe("runGatewayUpdate", () => {
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
cleanupTimeouts.push(options?.timeoutMs);
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "error: failed to delete worktree: Filename too long",
|
||||
@@ -798,6 +800,7 @@ describe("runGatewayUpdate", () => {
|
||||
expect(result.status).toBe("ok");
|
||||
const cleanupStep = result.steps.find((step) => step.name === "preflight cleanup");
|
||||
expect(cleanupStep?.exitCode).toBe(0);
|
||||
expect(cleanupTimeouts[0]).toBeLessThanOrEqual(60_000);
|
||||
expect(cleanupStep?.stderrTail ?? "").toContain(
|
||||
"windows fallback cleanup removed preflight tree",
|
||||
);
|
||||
@@ -806,6 +809,101 @@ describe("runGatewayUpdate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back when dev preflight worktree cleanup times out", async () => {
|
||||
await setupGitPackageManagerFixture();
|
||||
const calls: string[] = [];
|
||||
const cleanupTimeouts: Array<number | undefined> = [];
|
||||
const upstreamSha = "upstream123";
|
||||
const doctorNodePath = await resolveStableNodePath(process.execPath);
|
||||
const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`;
|
||||
|
||||
const runCommand = async (
|
||||
argv: string[],
|
||||
options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
|
||||
) => {
|
||||
const key = argv.join(" ");
|
||||
calls.push(key);
|
||||
|
||||
if (key === `git -C ${tempDir} rev-parse --show-toplevel`) {
|
||||
return { stdout: tempDir, stderr: "", code: 0 };
|
||||
}
|
||||
if (key === `git -C ${tempDir} rev-parse HEAD`) {
|
||||
return { stdout: "abc123", stderr: "", code: 0 };
|
||||
}
|
||||
if (key === `git -C ${tempDir} rev-parse --abbrev-ref HEAD`) {
|
||||
return { stdout: "main", stderr: "", code: 0 };
|
||||
}
|
||||
if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (key === `git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`) {
|
||||
return { stdout: "origin/main", stderr: "", code: 0 };
|
||||
}
|
||||
if (key === `git -C ${tempDir} fetch --all --prune --tags`) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (key === `git -C ${tempDir} rev-parse @{upstream}`) {
|
||||
return { stdout: upstreamSha, stderr: "", code: 0 };
|
||||
}
|
||||
if (key === `git -C ${tempDir} rev-list --max-count=10 ${upstreamSha}`) {
|
||||
return { stdout: `${upstreamSha}\n`, stderr: "", code: 0 };
|
||||
}
|
||||
if (key === "pnpm --version") {
|
||||
return { stdout: "10.0.0", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
|
||||
key.endsWith(` ${upstreamSha}`) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith("git -C /tmp/") &&
|
||||
preflightPrefixPattern.test(key) &&
|
||||
key.includes(" checkout --detach ") &&
|
||||
key.endsWith(upstreamSha)
|
||||
) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (key === "pnpm install" || key === "pnpm build" || key === "pnpm lint") {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (
|
||||
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
|
||||
preflightPrefixPattern.test(key)
|
||||
) {
|
||||
cleanupTimeouts.push(options?.timeoutMs);
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "Command timed out after 60000ms",
|
||||
code: null,
|
||||
};
|
||||
}
|
||||
if (key === `git -C ${tempDir} worktree prune`) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (key === `git -C ${tempDir} rebase ${upstreamSha}`) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (key === doctorCommand) {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
if (key === "pnpm ui:build") {
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
|
||||
const result = await runWithCommand(runCommand, { channel: "dev" });
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
const cleanupStep = result.steps.find((step) => step.name === "preflight cleanup");
|
||||
expect(cleanupStep?.exitCode).toBe(0);
|
||||
expect(cleanupTimeouts[0]).toBeLessThanOrEqual(60_000);
|
||||
expect(cleanupStep?.stderrTail ?? "").toContain("fallback cleanup removed preflight tree");
|
||||
});
|
||||
|
||||
it("adds heap headroom to windows pnpm build steps during dev updates", async () => {
|
||||
await setupGitPackageManagerFixture();
|
||||
const upstreamSha = "upstream123";
|
||||
|
||||
@@ -138,6 +138,7 @@ const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]);
|
||||
const PREFLIGHT_TEMP_PREFIX =
|
||||
process.platform === "win32" ? "ocu-pf-" : "openclaw-update-preflight-";
|
||||
const PREFLIGHT_WORKTREE_DIRNAME = process.platform === "win32" ? "wt" : "worktree";
|
||||
const PREFLIGHT_CLEANUP_TIMEOUT_MS = 60_000;
|
||||
const WINDOWS_PREFLIGHT_BASE_DIR = "ocu";
|
||||
const WINDOWS_BUILD_MAX_OLD_SPACE_MB = 4096;
|
||||
|
||||
@@ -215,10 +216,7 @@ async function removePathRecursive(target: string) {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function repairWindowsPreflightCleanup(worktreeDir: string, preflightRoot: string) {
|
||||
if (process.platform !== "win32") {
|
||||
return false;
|
||||
}
|
||||
async function repairPreflightCleanup(worktreeDir: string, preflightRoot: string) {
|
||||
try {
|
||||
await fs.rm(worktreeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
||||
await fs.rm(preflightRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
||||
@@ -938,22 +936,25 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
const removeStep = await runStep(
|
||||
step(
|
||||
const removeStep = await runStep({
|
||||
...step(
|
||||
"preflight cleanup",
|
||||
["git", "-C", gitRoot, "worktree", "remove", "--force", worktreeDir],
|
||||
gitRoot,
|
||||
),
|
||||
);
|
||||
timeoutMs: Math.min(timeoutMs, PREFLIGHT_CLEANUP_TIMEOUT_MS),
|
||||
});
|
||||
if (
|
||||
removeStep.exitCode !== 0 &&
|
||||
(await repairWindowsPreflightCleanup(worktreeDir, preflightRoot))
|
||||
(await repairPreflightCleanup(worktreeDir, preflightRoot))
|
||||
) {
|
||||
removeStep.exitCode = 0;
|
||||
const fallbackMessage =
|
||||
process.platform === "win32"
|
||||
? "windows fallback cleanup removed preflight tree"
|
||||
: "fallback cleanup removed preflight tree";
|
||||
removeStep.stderrTail = trimLogTail(
|
||||
[removeStep.stderrTail, "windows fallback cleanup removed preflight tree"]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
[removeStep.stderrTail, fallbackMessage].filter(Boolean).join("\n"),
|
||||
MAX_LOG_CHARS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -610,6 +610,9 @@ function buildInstalledPluginIndex(
|
||||
if (record.setupSource) {
|
||||
indexRecord.setupSource = record.setupSource;
|
||||
}
|
||||
if (record.syntheticAuthRefs && record.syntheticAuthRefs.length > 0) {
|
||||
indexRecord.syntheticAuthRefs = record.syntheticAuthRefs;
|
||||
}
|
||||
if (candidate?.packageName) {
|
||||
indexRecord.packageName = candidate.packageName;
|
||||
}
|
||||
|
||||
@@ -110,4 +110,43 @@ describe("install.ps1 failure handling", () => {
|
||||
expect(result.stdout).toContain("caught=OpenClaw installation failed with exit code 1.");
|
||||
expect(result.stdout).toContain("alive-after-install");
|
||||
});
|
||||
|
||||
runIfPowerShell("keeps npm chatter out of Main's success return value", () => {
|
||||
const tempDir = harness.createTempDir("openclaw-install-ps1-");
|
||||
const scriptPath = join(tempDir, "install.ps1");
|
||||
const scriptWithoutEntryPoint = source.replace(
|
||||
/\r?\n\$installSucceeded = Main\r?\nComplete-Install -Succeeded:\$installSucceeded\s*$/m,
|
||||
"",
|
||||
);
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
scriptWithoutEntryPoint,
|
||||
"",
|
||||
"function Write-Banner { }",
|
||||
"function Ensure-ExecutionPolicy { return $true }",
|
||||
"function Ensure-Node { return $true }",
|
||||
"function Add-ToPath { param([string]$Path) }",
|
||||
"function Invoke-NativeCommandCapture {",
|
||||
" param([string]$FilePath, [string[]]$Arguments)",
|
||||
" return @{ ExitCode = 0; Stdout = 'npm stdout'; Stderr = 'npm stderr' }",
|
||||
"}",
|
||||
"$NoOnboard = $true",
|
||||
"$result = Main",
|
||||
"if ($result -is [array]) { throw 'Main returned an array' }",
|
||||
'if ($result -ne $true) { throw "Main returned $result" }',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
chmodSync(scriptPath, 0o755);
|
||||
|
||||
const result = spawnSync(
|
||||
powershell!,
|
||||
["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
canConnectToLoopbackPort,
|
||||
buildDiscordSmokeGuildsConfig,
|
||||
buildRealUpdateEnv,
|
||||
CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS,
|
||||
CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS,
|
||||
isImmutableReleaseRef,
|
||||
looksLikeReleaseVersionRef,
|
||||
normalizeRequestedRef,
|
||||
@@ -39,6 +41,11 @@ import {
|
||||
} from "../../scripts/openclaw-cross-os-release-checks.ts";
|
||||
|
||||
describe("scripts/openclaw-cross-os-release-checks", () => {
|
||||
it("keeps dashboard smoke patient enough for cold packaged gateway startup", () => {
|
||||
expect(CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS).toBeGreaterThanOrEqual(120_000);
|
||||
expect(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS).toBeGreaterThanOrEqual(10_000);
|
||||
});
|
||||
|
||||
it("accepts OK agent output from the captured log when stdout is empty", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-agent-output-"));
|
||||
try {
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("Parallels smoke model selection", () => {
|
||||
expect(script, scriptPath).toContain("workspace-state.json");
|
||||
expect(script, scriptPath).toContain("IDENTITY.md");
|
||||
expect(script, scriptPath).toContain("BOOTSTRAP.md");
|
||||
expect(script, scriptPath).toContain("--session-id parallels-");
|
||||
expect(script, scriptPath).toMatch(/--session-id\s+['"]?parallels-/);
|
||||
expect(script, scriptPath).toContain("agents.defaults.skipBootstrap true --strict-json");
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user