diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index b5966ab79b..014926b763 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -65,8 +65,9 @@ const processSchema = Type.Object({ offset: Type.Optional(Type.Number({ description: "Log offset" })), limit: Type.Optional(Type.Number({ description: "Log length" })), timeout: Type.Optional( - Type.Union([Type.Number(), Type.String()], { + Type.Number({ description: "For poll: wait up to this many milliseconds before returning", + minimum: 0, }), ), }); @@ -138,7 +139,7 @@ export function createProcessTool( eof?: boolean; offset?: number; limit?: number; - timeout?: number | string; + timeout?: unknown; }; if (params.action === "list") { diff --git a/src/agents/openclaw-tools.sessions.e2e.test.ts b/src/agents/openclaw-tools.sessions.e2e.test.ts index df8e1bb718..b9a9c56dd2 100644 --- a/src/agents/openclaw-tools.sessions.e2e.test.ts +++ b/src/agents/openclaw-tools.sessions.e2e.test.ts @@ -783,7 +783,7 @@ describe("sessions tools", () => { text?: string; }; expect(details.status).toBe("ok"); - expect(details.text).toContain("tokens 1k (in 12 / out 1k)"); + expect(details.text).toMatch(/tokens 1(\.0)?k \(in 12 \/ out 1(\.0)?k\)/); expect(details.text).toContain("prompt/cache 197k"); expect(details.text).not.toContain("1.0k io"); } finally { diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts index 937d6f2826..4e155a7158 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts @@ -79,7 +79,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { it("sessions_spawn allows cross-agent spawning when configured", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setConfigOverride({ + setSessionsSpawnConfigOverride({ session: { mainKey: "main", scope: "per-sender", @@ -133,7 +133,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { it("sessions_spawn allows any agent when allowlist is *", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setConfigOverride({ + setSessionsSpawnConfigOverride({ session: { mainKey: "main", scope: "per-sender", @@ -187,7 +187,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { it("sessions_spawn normalizes allowlisted agent ids", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setConfigOverride({ + setSessionsSpawnConfigOverride({ session: { mainKey: "main", scope: "per-sender", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts index faac951d7b..6dbe106079 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { emitAgentEvent } from "../infra/agent-events.js"; import "./test-helpers/fast-core-tools.js"; +import { sleep } from "../utils.js"; import { createOpenClawTools } from "./openclaw-tools.js"; import { getCallGatewayMock, @@ -112,6 +113,16 @@ function setupSessionsSpawnGatewayMock(opts: { }; } +const waitFor = async (predicate: () => boolean, timeoutMs = 2000) => { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`timed out waiting for condition (timeoutMs=${timeoutMs})`); + } + await sleep(10); + } +}; + describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -120,16 +131,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { it("sessions_spawn runs cleanup flow after subagent completion", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - let patchParams: { key?: string; label?: string } = {}; + const patchCalls: Array<{ key?: string; label?: string }> = []; const ctx = setupSessionsSpawnGatewayMock({ includeSessionsList: true, includeChatHistory: true, onSessionsPatch: (params) => { const rec = params as { key?: string; label?: string } | undefined; - if (typeof rec?.label === "string" && rec.label.trim()) { - patchParams = { key: rec.key, label: rec.label }; - } + patchCalls.push({ key: rec?.key, label: rec?.label }); }, }); @@ -165,18 +174,16 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { }, }); - vi.useFakeTimers(); - try { - await vi.advanceTimersByTimeAsync(500); - } finally { - vi.useRealTimers(); - } + await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); + await waitFor(() => patchCalls.some((call) => call.label === "my-task")); + await waitFor(() => ctx.calls.filter((c) => c.method === "agent").length >= 2); const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); // Cleanup should patch the label - expect(patchParams.key).toBe(child.sessionKey); - expect(patchParams.label).toBe("my-task"); + const labelPatch = patchCalls.find((call) => call.label === "my-task"); + expect(labelPatch?.key).toBe(child.sessionKey); + expect(labelPatch?.label).toBe("my-task"); // Two agent calls: subagent spawn + main agent trigger const agentCalls = ctx.calls.filter((c) => c.method === "agent"); @@ -325,14 +332,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - vi.useFakeTimers(); - try { - await vi.advanceTimersByTimeAsync(500); - } finally { - vi.useRealTimers(); - } - const child = ctx.getChild(); + if (!child.runId) { + throw new Error("missing child runId"); + } + await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); + await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2); + await waitFor(() => Boolean(deletedKey)); + const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); @@ -415,12 +422,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - vi.useFakeTimers(); - try { - await vi.advanceTimersByTimeAsync(500); - } finally { - vi.useRealTimers(); - } + await waitFor(() => calls.filter((call) => call.method === "agent").length >= 2); const mainAgentCall = calls .filter((call) => call.method === "agent") diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts index 91aa41c494..1bbc6d70e5 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts @@ -289,8 +289,8 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { const request = opts as { method?: string; params?: unknown }; calls.push(request); if (request.method === "sessions.patch") { - const params = request.params as { model?: unknown } | undefined; - if (typeof params?.model === "string" && params.model.trim()) { + const model = (request.params as { model?: unknown } | undefined)?.model; + if (model === "bad-model") { throw new Error("invalid model: bad-model"); } return { ok: true }; diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 1e28861a9d..867aa85c9d 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -28,6 +28,7 @@ const SessionsSpawnToolSchema = Type.Object({ model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), + // Back-compat: older callers used timeoutSeconds for this tool. timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), cleanup: optionalStringEnum(["delete", "keep"] as const), }); @@ -98,14 +99,16 @@ export function createSessionsSpawnTool(opts?: { }); // Default to 0 (no timeout) when omitted. Sub-agent runs are long-lived // by default and should not inherit the main agent 600s timeout. - const legacyTimeoutSeconds = - typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds) - ? Math.max(0, Math.floor(params.timeoutSeconds)) - : undefined; + const timeoutSecondsCandidate = + typeof params.runTimeoutSeconds === "number" + ? params.runTimeoutSeconds + : typeof params.timeoutSeconds === "number" + ? params.timeoutSeconds + : undefined; const runTimeoutSeconds = - typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) - ? Math.max(0, Math.floor(params.runTimeoutSeconds)) - : (legacyTimeoutSeconds ?? 0); + typeof timeoutSecondsCandidate === "number" && Number.isFinite(timeoutSecondsCandidate) + ? Math.max(0, Math.floor(timeoutSecondsCandidate)) + : 0; let modelWarning: string | undefined; let modelApplied = false; diff --git a/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts b/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts index 5ac5281acb..6322d7c9a8 100644 --- a/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts +++ b/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts @@ -127,7 +127,10 @@ describe("group intro prompts", () => { vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "discord"'); expect(extraSystemPrompt).toContain( - `You are replying inside a Discord group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, + `You are in the Discord group chat "Release Squad". Participants: Alice, Bob.`, + ); + expect(extraSystemPrompt).toContain( + `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, ); }); }); @@ -158,8 +161,12 @@ describe("group intro prompts", () => { const extraSystemPrompt = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "whatsapp"'); + expect(extraSystemPrompt).toContain(`You are in the WhatsApp group chat "Ops".`); expect(extraSystemPrompt).toContain( - `You are replying inside a WhatsApp group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). WhatsApp IDs: SenderId is the participant JID (group participant id). ${groupParticipationNote} Address the specific sender noted in the message context.`, + `WhatsApp IDs: SenderId is the participant JID (group participant id).`, + ); + expect(extraSystemPrompt).toContain( + `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). WhatsApp IDs: SenderId is the participant JID (group participant id). ${groupParticipationNote} Address the specific sender noted in the message context.`, ); }); }); @@ -190,8 +197,9 @@ describe("group intro prompts", () => { const extraSystemPrompt = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "telegram"'); + expect(extraSystemPrompt).toContain(`You are in the Telegram group chat "Dev Chat".`); expect(extraSystemPrompt).toContain( - `You are replying inside a Telegram group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, + `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, ); }); }); diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index 524c6b3a88..ab0d6b497b 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -43,6 +43,13 @@ export function installBaseProgramMocks() { ], configureCommand, configureCommandWithSections, + configureCommandFromSectionsArg: (sections: unknown, runtime: unknown) => { + const resolved = Array.isArray(sections) ? sections : []; + if (resolved.length > 0) { + return configureCommandWithSections(resolved, runtime); + } + return configureCommand({}, runtime); + }, })); vi.mock("../commands/setup.js", () => ({ setupCommand })); vi.mock("../commands/onboard.js", () => ({ onboardCommand })); diff --git a/src/commands/status.e2e.test.ts b/src/commands/status.e2e.test.ts index d5a8dcb094..f3957a41c0 100644 --- a/src/commands/status.e2e.test.ts +++ b/src/commands/status.e2e.test.ts @@ -249,6 +249,7 @@ vi.mock("../infra/update-check.js", () => ({ }, registry: { latestVersion: "0.0.0" }, }), + formatGitInstallLabel: vi.fn(() => "main ยท @ deadbeef"), compareSemverStrings: vi.fn(() => 0), })); vi.mock("../config/config.js", async (importOriginal) => {