diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index da03d88d8..e40f0f9e2 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -109,6 +109,62 @@ type ModelFallbackRunResult = { attempts: FallbackAttempt[]; }; +function buildFallbackSuccess(params: { + result: T; + provider: string; + model: string; + attempts: FallbackAttempt[]; +}): ModelFallbackRunResult { + return { + result: params.result, + provider: params.provider, + model: params.model, + attempts: params.attempts, + }; +} + +async function runFallbackCandidate(params: { + run: (provider: string, model: string) => Promise; + provider: string; + model: string; +}): Promise<{ ok: true; result: T } | { ok: false; error: unknown }> { + try { + return { + ok: true, + result: await params.run(params.provider, params.model), + }; + } catch (err) { + if (shouldRethrowAbort(err)) { + throw err; + } + return { ok: false, error: err }; + } +} + +async function runFallbackAttempt(params: { + run: (provider: string, model: string) => Promise; + provider: string; + model: string; + attempts: FallbackAttempt[]; +}): Promise<{ success: ModelFallbackRunResult } | { error: unknown }> { + const runResult = await runFallbackCandidate({ + run: params.run, + provider: params.provider, + model: params.model, + }); + if (runResult.ok) { + return { + success: buildFallbackSuccess({ + result: runResult.result, + provider: params.provider, + model: params.model, + attempts: params.attempts, + }), + }; + } + return { error: runResult.error }; +} + function sameModelCandidate(a: ModelCandidate, b: ModelCandidate): boolean { return a.provider === b.provider && a.model === b.model; } @@ -444,18 +500,12 @@ export async function runWithModelFallback(params: { } } - try { - const result = await params.run(candidate.provider, candidate.model); - return { - result, - provider: candidate.provider, - model: candidate.model, - attempts, - }; - } catch (err) { - if (shouldRethrowAbort(err)) { - throw err; - } + const attemptRun = await runFallbackAttempt({ run: params.run, ...candidate, attempts }); + if ("success" in attemptRun) { + return attemptRun.success; + } + const err = attemptRun.error; + { // Context overflow errors should be handled by the inner runner's // compaction/retry logic, not by model fallback. If one escapes as a // throw, rethrow it immediately rather than trying a different model @@ -532,18 +582,12 @@ export async function runWithImageModelFallback(params: { for (let i = 0; i < candidates.length; i += 1) { const candidate = candidates[i]; - try { - const result = await params.run(candidate.provider, candidate.model); - return { - result, - provider: candidate.provider, - model: candidate.model, - attempts, - }; - } catch (err) { - if (shouldRethrowAbort(err)) { - throw err; - } + const attemptRun = await runFallbackAttempt({ run: params.run, ...candidate, attempts }); + if ("success" in attemptRun) { + return attemptRun.success; + } + { + const err = attemptRun.error; lastError = err; attempts.push({ provider: candidate.provider, diff --git a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts index 2ea2c25da..2fd417af6 100644 --- a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts +++ b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts @@ -13,40 +13,40 @@ import { ensureOpenClawModelsJson } from "./models-config.js"; installModelsConfigTestHooks({ restoreFetch: true }); +async function writeAuthProfiles(agentDir: string, profiles: Record) { + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + JSON.stringify({ version: 1, profiles }, null, 2), + ); +} + +function expectBearerAuthHeader(fetchMock: { mock: { calls: unknown[][] } }, token: string) { + const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record }]; + expect(opts?.headers?.Authorization).toBe(`Bearer ${token}`); +} + describe("models-config", () => { it("uses the first github-copilot profile when env tokens are missing", async () => { await withTempHome(async (home) => { await withUnsetCopilotTokenEnv(async () => { const fetchMock = mockCopilotTokenExchangeSuccess(); const agentDir = path.join(home, "agent-profiles"); - await fs.mkdir(agentDir, { recursive: true }); - await fs.writeFile( - path.join(agentDir, "auth-profiles.json"), - JSON.stringify( - { - version: 1, - profiles: { - "github-copilot:alpha": { - type: "token", - provider: "github-copilot", - token: "alpha-token", - }, - "github-copilot:beta": { - type: "token", - provider: "github-copilot", - token: "beta-token", - }, - }, - }, - null, - 2, - ), - ); + await writeAuthProfiles(agentDir, { + "github-copilot:alpha": { + type: "token", + provider: "github-copilot", + token: "alpha-token", + }, + "github-copilot:beta": { + type: "token", + provider: "github-copilot", + token: "beta-token", + }, + }); await ensureOpenClawModelsJson({ models: { providers: {} } }, agentDir); - - const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record }]; - expect(opts?.headers?.Authorization).toBe("Bearer alpha-token"); + expectBearerAuthHeader(fetchMock, "alpha-token"); }); }); }); @@ -82,31 +82,21 @@ describe("models-config", () => { await withUnsetCopilotTokenEnv(async () => { const fetchMock = mockCopilotTokenExchangeSuccess(); const agentDir = path.join(home, "agent-profiles"); - await fs.mkdir(agentDir, { recursive: true }); process.env.COPILOT_REF_TOKEN = "token-from-ref-env"; - await fs.writeFile( - path.join(agentDir, "auth-profiles.json"), - JSON.stringify( - { - version: 1, - profiles: { - "github-copilot:default": { - type: "token", - provider: "github-copilot", - tokenRef: { source: "env", provider: "default", id: "COPILOT_REF_TOKEN" }, - }, - }, + try { + await writeAuthProfiles(agentDir, { + "github-copilot:default": { + type: "token", + provider: "github-copilot", + tokenRef: { source: "env", provider: "default", id: "COPILOT_REF_TOKEN" }, }, - null, - 2, - ), - ); + }); - await ensureOpenClawModelsJson({ models: { providers: {} } }, agentDir); - - const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record }]; - expect(opts?.headers?.Authorization).toBe("Bearer token-from-ref-env"); - delete process.env.COPILOT_REF_TOKEN; + await ensureOpenClawModelsJson({ models: { providers: {} } }, agentDir); + expectBearerAuthHeader(fetchMock, "token-from-ref-env"); + } finally { + delete process.env.COPILOT_REF_TOKEN; + } }); }); }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts index 49698bc63..920479135 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts @@ -285,19 +285,8 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { list: [{ id: "main", subagents: { allowAgents: ["*"] } }, { id: "my-research_agent01" }], }, }); - callGatewayMock.mockImplementation(async () => ({ - runId: "run-1", - status: "accepted", - acceptedAt: 1000, - })); - const tool = await getSessionsSpawnTool({ - agentSessionKey: "main", - agentChannel: "whatsapp", - }); - const result = await tool.execute("call-valid", { - task: "do thing", - agentId: "my-research_agent01", - }); + mockAcceptedSpawn(1000); + const result = await executeSpawn("call-valid", "my-research_agent01"); const details = result.details as { status?: string }; expect(details.status).toBe("accepted"); }); @@ -312,19 +301,8 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { ], }, }); - callGatewayMock.mockImplementation(async () => ({ - runId: "run-1", - status: "accepted", - acceptedAt: 1000, - })); - const tool = await getSessionsSpawnTool({ - agentSessionKey: "main", - agentChannel: "whatsapp", - }); - const result = await tool.execute("call-unconfigured", { - task: "do thing", - agentId: "research", - }); + mockAcceptedSpawn(1000); + const result = await executeSpawn("call-unconfigured", "research"); const details = result.details as { status?: string }; // Must pass: "research" is in allowAgents even though not in agents.list expect(details.status).toBe("accepted"); diff --git a/src/agents/pi-embedded-runner/run/payloads.errors.test.ts b/src/agents/pi-embedded-runner/run/payloads.errors.test.ts index 7d60b544f..4268e177d 100644 --- a/src/agents/pi-embedded-runner/run/payloads.errors.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.errors.test.ts @@ -40,6 +40,19 @@ describe("buildEmbeddedRunPayloads", () => { expect(payloads[0]?.text).toBe(OVERLOADED_FALLBACK_TEXT); }; + function expectNoSyntheticCompletionForSession(sessionKey: string) { + const payloads = buildPayloads({ + sessionKey, + toolMetas: [{ toolName: "write", meta: "/tmp/out.md" }], + lastAssistant: makeAssistant({ + stopReason: "stop", + errorMessage: undefined, + content: [], + }), + }); + expect(payloads).toHaveLength(0); + } + it("suppresses raw API error JSON when the assistant errored", () => { const payloads = buildPayloads({ assistantTexts: [errorJson], @@ -140,31 +153,11 @@ describe("buildEmbeddedRunPayloads", () => { }); it("does not add synthetic completion text for channel sessions", () => { - const payloads = buildPayloads({ - sessionKey: "agent:main:discord:channel:c123", - toolMetas: [{ toolName: "write", meta: "/tmp/out.md" }], - lastAssistant: makeAssistant({ - stopReason: "stop", - errorMessage: undefined, - content: [], - }), - }); - - expect(payloads).toHaveLength(0); + expectNoSyntheticCompletionForSession("agent:main:discord:channel:c123"); }); it("does not add synthetic completion text for group sessions", () => { - const payloads = buildPayloads({ - sessionKey: "agent:main:telegram:group:g123", - toolMetas: [{ toolName: "write", meta: "/tmp/out.md" }], - lastAssistant: makeAssistant({ - stopReason: "stop", - errorMessage: undefined, - content: [], - }), - }); - - expect(payloads).toHaveLength(0); + expectNoSyntheticCompletionForSession("agent:main:telegram:group:g123"); }); it("does not add synthetic completion text when messaging tool already delivered output", () => { diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index 81e746fc7..00140f7b7 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -102,6 +102,23 @@ const createCompactionContext = (params: { }, }) as unknown as Partial; +async function runCompactionScenario(params: { + sessionManager: ExtensionContext["sessionManager"]; + event: unknown; + apiKey: string | null; +}) { + const compactionHandler = createCompactionHandler(); + const getApiKeyMock = vi.fn().mockResolvedValue(params.apiKey); + const mockContext = createCompactionContext({ + sessionManager: params.sessionManager, + getApiKeyMock, + }); + const result = (await compactionHandler(params.event, mockContext)) as { + cancel?: boolean; + }; + return { result, getApiKeyMock }; +} + describe("compaction-safeguard tool failures", () => { it("formats tool failures with meta and summary", () => { const messages: AgentMessage[] = [ @@ -377,23 +394,16 @@ describe("compaction-safeguard extension model fallback", () => { // Set up runtime with model (mimics buildEmbeddedExtensionPaths behavior) setCompactionSafeguardRuntime(sessionManager, { model }); - const compactionHandler = createCompactionHandler(); const mockEvent = createCompactionEvent({ messageText: "test message", tokensBefore: 1000, }); - - const getApiKeyMock = vi.fn().mockResolvedValue(null); - const mockContext = createCompactionContext({ + const { result, getApiKeyMock } = await runCompactionScenario({ sessionManager, - getApiKeyMock, + event: mockEvent, + apiKey: null, }); - // Call the handler and wait for result - const result = (await compactionHandler(mockEvent, mockContext)) as { - cancel?: boolean; - }; - expect(result).toEqual({ cancel: true }); // KEY ASSERTION: Prove the fallback path was exercised @@ -410,22 +420,16 @@ describe("compaction-safeguard extension model fallback", () => { // Do NOT set runtime.model (both ctx.model and runtime.model will be undefined) - const compactionHandler = createCompactionHandler(); const mockEvent = createCompactionEvent({ messageText: "test", tokensBefore: 500, }); - - const getApiKeyMock = vi.fn().mockResolvedValue(null); - const mockContext = createCompactionContext({ + const { result, getApiKeyMock } = await runCompactionScenario({ sessionManager, - getApiKeyMock, + event: mockEvent, + apiKey: null, }); - const result = (await compactionHandler(mockEvent, mockContext)) as { - cancel?: boolean; - }; - expect(result).toEqual({ cancel: true }); // Verify early return: getApiKey should NOT have been called when both models are missing @@ -439,7 +443,6 @@ describe("compaction-safeguard double-compaction guard", () => { const model = createAnthropicModelFixture(); setCompactionSafeguardRuntime(sessionManager, { model }); - const compactionHandler = createCompactionHandler(); const mockEvent = { preparation: { messagesToSummarize: [] as AgentMessage[], @@ -451,16 +454,11 @@ describe("compaction-safeguard double-compaction guard", () => { customInstructions: "", signal: new AbortController().signal, }; - - const getApiKeyMock = vi.fn().mockResolvedValue("sk-test"); - const mockContext = createCompactionContext({ + const { result, getApiKeyMock } = await runCompactionScenario({ sessionManager, - getApiKeyMock, + event: mockEvent, + apiKey: "sk-test", }); - - const result = (await compactionHandler(mockEvent, mockContext)) as { - cancel?: boolean; - }; expect(result).toEqual({ cancel: true }); expect(getApiKeyMock).not.toHaveBeenCalled(); }); @@ -470,20 +468,15 @@ describe("compaction-safeguard double-compaction guard", () => { const model = createAnthropicModelFixture(); setCompactionSafeguardRuntime(sessionManager, { model }); - const compactionHandler = createCompactionHandler(); const mockEvent = createCompactionEvent({ messageText: "real message", tokensBefore: 1500, }); - const getApiKeyMock = vi.fn().mockResolvedValue(null); - const mockContext = createCompactionContext({ + const { result, getApiKeyMock } = await runCompactionScenario({ sessionManager, - getApiKeyMock, + event: mockEvent, + apiKey: null, }); - - const result = (await compactionHandler(mockEvent, mockContext)) as { - cancel?: boolean; - }; expect(result).toEqual({ cancel: true }); expect(getApiKeyMock).toHaveBeenCalled(); }); diff --git a/src/agents/pi-model-discovery.auth.test.ts b/src/agents/pi-model-discovery.auth.test.ts index c4d4bae25..a85e01a8f 100644 --- a/src/agents/pi-model-discovery.auth.test.ts +++ b/src/agents/pi-model-discovery.auth.test.ts @@ -43,6 +43,20 @@ function writeRuntimeOpenRouterProfile(agentDir: string): void { ); } +async function writeLegacyAuthJson( + agentDir: string, + authEntries: Record, +): Promise { + await fs.writeFile(path.join(agentDir, "auth.json"), JSON.stringify(authEntries, null, 2)); +} + +async function readLegacyAuthJson(agentDir: string): Promise> { + return JSON.parse(await fs.readFile(path.join(agentDir, "auth.json"), "utf8")) as Record< + string, + unknown + >; +} + describe("discoverAuthStorage", () => { it("loads runtime credentials from auth-profiles without writing auth.json", async () => { await withAgentDir(async (agentDir) => { @@ -91,28 +105,19 @@ describe("discoverAuthStorage", () => { it("scrubs static api_key entries from legacy auth.json and keeps oauth entries", async () => { await withAgentDir(async (agentDir) => { writeRuntimeOpenRouterProfile(agentDir); - await fs.writeFile( - path.join(agentDir, "auth.json"), - JSON.stringify( - { - openrouter: { type: "api_key", key: "legacy-static-key" }, - "openai-codex": { - type: "oauth", - access: "oauth-access", - refresh: "oauth-refresh", - expires: Date.now() + 60_000, - }, - }, - null, - 2, - ), - ); + await writeLegacyAuthJson(agentDir, { + openrouter: { type: "api_key", key: "legacy-static-key" }, + "openai-codex": { + type: "oauth", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }); discoverAuthStorage(agentDir); - const parsed = JSON.parse(await fs.readFile(path.join(agentDir, "auth.json"), "utf8")) as { - [key: string]: unknown; - }; + const parsed = await readLegacyAuthJson(agentDir); expect(parsed.openrouter).toBeUndefined(); expect(parsed["openai-codex"]).toMatchObject({ type: "oauth", @@ -127,22 +132,13 @@ describe("discoverAuthStorage", () => { process.env.OPENCLAW_AUTH_STORE_READONLY = "1"; try { writeRuntimeOpenRouterProfile(agentDir); - await fs.writeFile( - path.join(agentDir, "auth.json"), - JSON.stringify( - { - openrouter: { type: "api_key", key: "legacy-static-key" }, - }, - null, - 2, - ), - ); + await writeLegacyAuthJson(agentDir, { + openrouter: { type: "api_key", key: "legacy-static-key" }, + }); discoverAuthStorage(agentDir); - const parsed = JSON.parse(await fs.readFile(path.join(agentDir, "auth.json"), "utf8")) as { - [key: string]: unknown; - }; + const parsed = await readLegacyAuthJson(agentDir); expect(parsed.openrouter).toMatchObject({ type: "api_key", key: "legacy-static-key" }); } finally { if (previous === undefined) { diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index e6679744e..0b44729e5 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -106,6 +106,36 @@ async function createHostEscapeFixture(stateDir: string) { return { workspaceDir, outsideFile }; } +async function expectMkdirpAllowsExistingDirectory(params?: { forceBoundaryIoFallback?: boolean }) { + await withTempDir("openclaw-fs-bridge-mkdirp-", async (stateDir) => { + const workspaceDir = path.join(stateDir, "workspace"); + const nestedDir = path.join(workspaceDir, "memory", "kemik"); + await fs.mkdir(nestedDir, { recursive: true }); + + if (params?.forceBoundaryIoFallback) { + mockedOpenBoundaryFile.mockImplementationOnce(async () => ({ + ok: false, + reason: "io", + error: Object.assign(new Error("EISDIR"), { code: "EISDIR" }), + })); + } + + const bridge = createSandboxFsBridge({ + sandbox: createSandbox({ + workspaceDir, + agentWorkspaceDir: workspaceDir, + }), + }); + + await expect(bridge.mkdirp({ filePath: "memory/kemik" })).resolves.toBeUndefined(); + + const mkdirCall = findCallByScriptFragment('mkdir -p -- "$1"'); + expect(mkdirCall).toBeDefined(); + const mkdirPath = mkdirCall ? getDockerPathArg(mkdirCall[0]) : ""; + expect(mkdirPath).toBe("/workspace/memory/kemik"); + }); +} + describe("sandbox fs bridge shell compatibility", () => { beforeEach(() => { mockedExecDockerRaw.mockClear(); @@ -235,53 +265,11 @@ describe("sandbox fs bridge shell compatibility", () => { }); it("allows mkdirp for existing in-boundary subdirectories", async () => { - await withTempDir("openclaw-fs-bridge-mkdirp-", async (stateDir) => { - const workspaceDir = path.join(stateDir, "workspace"); - const nestedDir = path.join(workspaceDir, "memory", "kemik"); - await fs.mkdir(nestedDir, { recursive: true }); - - const bridge = createSandboxFsBridge({ - sandbox: createSandbox({ - workspaceDir, - agentWorkspaceDir: workspaceDir, - }), - }); - - await expect(bridge.mkdirp({ filePath: "memory/kemik" })).resolves.toBeUndefined(); - - const mkdirCall = findCallByScriptFragment('mkdir -p -- "$1"'); - expect(mkdirCall).toBeDefined(); - const mkdirPath = mkdirCall ? getDockerPathArg(mkdirCall[0]) : ""; - expect(mkdirPath).toBe("/workspace/memory/kemik"); - }); + await expectMkdirpAllowsExistingDirectory(); }); it("allows mkdirp when boundary open reports io for an existing directory", async () => { - await withTempDir("openclaw-fs-bridge-mkdirp-io-", async (stateDir) => { - const workspaceDir = path.join(stateDir, "workspace"); - const nestedDir = path.join(workspaceDir, "memory", "kemik"); - await fs.mkdir(nestedDir, { recursive: true }); - - mockedOpenBoundaryFile.mockImplementationOnce(async () => ({ - ok: false, - reason: "io", - error: Object.assign(new Error("EISDIR"), { code: "EISDIR" }), - })); - - const bridge = createSandboxFsBridge({ - sandbox: createSandbox({ - workspaceDir, - agentWorkspaceDir: workspaceDir, - }), - }); - - await expect(bridge.mkdirp({ filePath: "memory/kemik" })).resolves.toBeUndefined(); - - const mkdirCall = findCallByScriptFragment('mkdir -p -- "$1"'); - expect(mkdirCall).toBeDefined(); - const mkdirPath = mkdirCall ? getDockerPathArg(mkdirCall[0]) : ""; - expect(mkdirPath).toBe("/workspace/memory/kemik"); - }); + await expectMkdirpAllowsExistingDirectory({ forceBoundaryIoFallback: true }); }); it("rejects mkdirp when target exists as a file", async () => { diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts index 665bdddc3..a8d09a0e1 100644 --- a/src/agents/session-write-lock.test.ts +++ b/src/agents/session-write-lock.test.ts @@ -47,6 +47,34 @@ async function expectCurrentPidOwnsLock(params: { await lock.release(); } +async function expectActiveInProcessLockIsNotReclaimed(params?: { + legacyStarttime?: unknown; +}): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-")); + try { + const sessionFile = path.join(root, "sessions.json"); + const lockPath = `${sessionFile}.lock`; + const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); + const lockPayload = { + pid: process.pid, + createdAt: new Date().toISOString(), + ...(params && "legacyStarttime" in params ? { starttime: params.legacyStarttime } : {}), + }; + await fs.writeFile(lockPath, JSON.stringify(lockPayload), "utf8"); + + await expect( + acquireSessionWriteLock({ + sessionFile, + timeoutMs: 50, + allowReentrant: false, + }), + ).rejects.toThrow(/session file locked/); + await lock.release(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + describe("acquireSessionWriteLock", () => { it("reuses locks across symlinked session paths", async () => { if (process.platform === "win32") { @@ -323,60 +351,11 @@ describe("acquireSessionWriteLock", () => { }); it("does not reclaim active in-process lock files without starttime", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-")); - try { - const sessionFile = path.join(root, "sessions.json"); - const lockPath = `${sessionFile}.lock`; - const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - await fs.writeFile( - lockPath, - JSON.stringify({ - pid: process.pid, - createdAt: new Date().toISOString(), - }), - "utf8", - ); - - await expect( - acquireSessionWriteLock({ - sessionFile, - timeoutMs: 50, - allowReentrant: false, - }), - ).rejects.toThrow(/session file locked/); - await lock.release(); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } + await expectActiveInProcessLockIsNotReclaimed(); }); it("does not reclaim active in-process lock files with malformed starttime", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-")); - try { - const sessionFile = path.join(root, "sessions.json"); - const lockPath = `${sessionFile}.lock`; - const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - await fs.writeFile( - lockPath, - JSON.stringify({ - pid: process.pid, - createdAt: new Date().toISOString(), - starttime: 123.5, - }), - "utf8", - ); - - await expect( - acquireSessionWriteLock({ - sessionFile, - timeoutMs: 50, - allowReentrant: false, - }), - ).rejects.toThrow(/session file locked/); - await lock.release(); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } + await expectActiveInProcessLockIsNotReclaimed({ legacyStarttime: 123.5 }); }); it("registers cleanup for SIGQUIT and SIGABRT", () => { diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 875d7e552..5e1e76313 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -410,56 +410,47 @@ export async function spawnSubagentDirect( } thinkingOverride = normalized; } - try { - await callGateway({ - method: "sessions.patch", - params: { key: childSessionKey, spawnDepth: childDepth }, - timeoutMs: 10_000, - }); - } catch (err) { - const messageText = - err instanceof Error ? err.message : typeof err === "string" ? err : "error"; + const patchChildSession = async (patch: Record): Promise => { + try { + await callGateway({ + method: "sessions.patch", + params: { key: childSessionKey, ...patch }, + timeoutMs: 10_000, + }); + return undefined; + } catch (err) { + return err instanceof Error ? err.message : typeof err === "string" ? err : "error"; + } + }; + + const spawnDepthPatchError = await patchChildSession({ spawnDepth: childDepth }); + if (spawnDepthPatchError) { return { status: "error", - error: messageText, + error: spawnDepthPatchError, childSessionKey, }; } if (resolvedModel) { - try { - await callGateway({ - method: "sessions.patch", - params: { key: childSessionKey, model: resolvedModel }, - timeoutMs: 10_000, - }); - modelApplied = true; - } catch (err) { - const messageText = - err instanceof Error ? err.message : typeof err === "string" ? err : "error"; + const modelPatchError = await patchChildSession({ model: resolvedModel }); + if (modelPatchError) { return { status: "error", - error: messageText, + error: modelPatchError, childSessionKey, }; } + modelApplied = true; } if (thinkingOverride !== undefined) { - try { - await callGateway({ - method: "sessions.patch", - params: { - key: childSessionKey, - thinkingLevel: thinkingOverride === "off" ? null : thinkingOverride, - }, - timeoutMs: 10_000, - }); - } catch (err) { - const messageText = - err instanceof Error ? err.message : typeof err === "string" ? err : "error"; + const thinkingPatchError = await patchChildSession({ + thinkingLevel: thinkingOverride === "off" ? null : thinkingOverride, + }); + if (thinkingPatchError) { return { status: "error", - error: messageText, + error: thinkingPatchError, childSessionKey, }; } diff --git a/src/agents/tools/discord-actions-guild.ts b/src/agents/tools/discord-actions-guild.ts index 93d81f572..5fb10c878 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/src/agents/tools/discord-actions-guild.ts @@ -327,36 +327,22 @@ export async function handleDiscordGuildAction( integer: true, }); const availableTags = parseAvailableTags(params.availableTags); + const editPayload = { + channelId, + name: name ?? undefined, + topic: topic ?? undefined, + position: position ?? undefined, + parentId, + nsfw, + rateLimitPerUser: rateLimitPerUser ?? undefined, + archived, + locked, + autoArchiveDuration: autoArchiveDuration ?? undefined, + availableTags, + }; const channel = accountId - ? await editChannelDiscord( - { - channelId, - name: name ?? undefined, - topic: topic ?? undefined, - position: position ?? undefined, - parentId, - nsfw, - rateLimitPerUser: rateLimitPerUser ?? undefined, - archived, - locked, - autoArchiveDuration: autoArchiveDuration ?? undefined, - availableTags, - }, - { accountId }, - ) - : await editChannelDiscord({ - channelId, - name: name ?? undefined, - topic: topic ?? undefined, - position: position ?? undefined, - parentId, - nsfw, - rateLimitPerUser: rateLimitPerUser ?? undefined, - archived, - locked, - autoArchiveDuration: autoArchiveDuration ?? undefined, - availableTags, - }); + ? await editChannelDiscord(editPayload, { accountId }) + : await editChannelDiscord(editPayload); return jsonResult({ ok: true, channel }); } case "channelDelete": { diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 5a4f56b8d..3046098ab 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -2,7 +2,7 @@ import { type Context, complete } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveUserPath } from "../../utils.js"; -import { getDefaultLocalRoots, loadWebMedia } from "../../web/media.js"; +import { loadWebMedia } from "../../web/media.js"; import { minimaxUnderstandImage } from "../minimax-vlm.js"; import { coerceImageAssistantText, @@ -15,6 +15,7 @@ import { applyImageModelConfigDefaults, buildTextToolResult, resolveModelFromRegistry, + resolveMediaToolLocalRoots, resolveModelRuntimeApiKey, resolvePromptAndModelOverride, } from "./media-tool-shared.js"; @@ -24,7 +25,6 @@ import { discoverAuthStorage, discoverModels, ensureOpenClawModelsJson, - normalizeWorkspaceDir, resolveSandboxedBridgeMediaPath, runWithImageModelFallback, type AnyAgentTool, @@ -298,17 +298,9 @@ export function createImageTool(options?: { ? "Analyze one or more images with a vision model. Use image for a single path/URL, or images for multiple (up to 20). Only use this tool when images were NOT already provided in the user's message. Images mentioned in the prompt are automatically visible to you." : "Analyze one or more images with the configured image model (agents.defaults.imageModel). Use image for a single path/URL, or images for multiple (up to 20). Provide a prompt describing what to analyze."; - const localRoots = (() => { - const workspaceDir = normalizeWorkspaceDir(options?.workspaceDir); - if (options?.fsPolicy?.workspaceOnly) { - return workspaceDir ? [workspaceDir] : []; - } - const roots = getDefaultLocalRoots(); - if (!workspaceDir) { - return roots; - } - return Array.from(new Set([...roots, workspaceDir])); - })(); + const localRoots = resolveMediaToolLocalRoots(options?.workspaceDir, { + workspaceOnly: options?.fsPolicy?.workspaceOnly === true, + }); return { label: "Image", diff --git a/src/agents/tools/media-tool-shared.ts b/src/agents/tools/media-tool-shared.ts index a869a9df9..177bf2962 100644 --- a/src/agents/tools/media-tool-shared.ts +++ b/src/agents/tools/media-tool-shared.ts @@ -36,9 +36,15 @@ export function applyImageModelConfigDefaults( }; } -export function resolveMediaToolLocalRoots(workspaceDirRaw: string | undefined): string[] { - const roots = getDefaultLocalRoots(); +export function resolveMediaToolLocalRoots( + workspaceDirRaw: string | undefined, + options?: { workspaceOnly?: boolean }, +): string[] { const workspaceDir = normalizeWorkspaceDir(workspaceDirRaw); + if (options?.workspaceOnly) { + return workspaceDir ? [workspaceDir] : []; + } + const roots = getDefaultLocalRoots(); if (!workspaceDir) { return [...roots]; } diff --git a/src/agents/tools/pdf-tool.ts b/src/agents/tools/pdf-tool.ts index 3755c6faa..c03dbe24f 100644 --- a/src/agents/tools/pdf-tool.ts +++ b/src/agents/tools/pdf-tool.ts @@ -3,7 +3,7 @@ import { Type } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; import { extractPdfContent, type PdfExtractedContent } from "../../media/pdf-extract.js"; import { resolveUserPath } from "../../utils.js"; -import { getDefaultLocalRoots, loadWebMediaRaw } from "../../web/media.js"; +import { loadWebMediaRaw } from "../../web/media.js"; import { coerceImageModelConfig, type ImageModelConfig, @@ -13,6 +13,7 @@ import { applyImageModelConfigDefaults, buildTextToolResult, resolveModelFromRegistry, + resolveMediaToolLocalRoots, resolveModelRuntimeApiKey, resolvePromptAndModelOverride, } from "./media-tool-shared.js"; @@ -30,7 +31,6 @@ import { discoverAuthStorage, discoverModels, ensureOpenClawModelsJson, - normalizeWorkspaceDir, resolveSandboxedBridgeMediaPath, runWithImageModelFallback, type AnyAgentTool, @@ -327,17 +327,9 @@ export function createPdfTool(options?: { ? Math.floor(maxPagesDefault) : DEFAULT_MAX_PAGES; - const localRoots = (() => { - const workspaceDir = normalizeWorkspaceDir(options?.workspaceDir); - if (options?.fsPolicy?.workspaceOnly) { - return workspaceDir ? [workspaceDir] : []; - } - const roots = getDefaultLocalRoots(); - if (!workspaceDir) { - return roots; - } - return Array.from(new Set([...roots, workspaceDir])); - })(); + const localRoots = resolveMediaToolLocalRoots(options?.workspaceDir, { + workspaceOnly: options?.fsPolicy?.workspaceOnly === true, + }); const description = "Analyze one or more PDF documents with a model. Supports native PDF analysis for Anthropic and Google models, with text/image extraction fallback for other providers. Use pdf for a single path/URL, or pdfs for multiple (up to 10). Provide a prompt describing what to analyze.";