diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 7078780e31f4..a52e0ef61111 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -598,6 +598,10 @@ function createWatcherMock(effectiveUsePolling?: boolean) { return watcher; } +function makeGatewayPortConfig(port: number): OpenClawConfig { + return { gateway: { reload: {}, port } }; +} + function makeSnapshot(partial: Partial = {}): ConfigFileSnapshot { const config = partial.config ?? {}; const sourceConfig = (partial.sourceConfig ?? @@ -827,6 +831,11 @@ function createReloaderHarness( type ReloaderHarness = ReturnType; +async function flushWatcherChange(harness: ReloaderHarness) { + harness.watcher.emit("change"); + await vi.runAllTimersAsync(); +} + function getOnlyRestartCall(harness: ReloaderHarness): [GatewayReloadPlan, OpenClawConfig] { expect(harness.onRestart).toHaveBeenCalledTimes(1); const call = harness.onRestart.mock.calls[0]; @@ -976,12 +985,8 @@ describe("startGatewayConfigReloader", () => { }); it("watches resolved includes and reconciles them after an accepted reload", async () => { - const initialConfig: OpenClawConfig = { - gateway: { reload: {}, port: 18789 }, - }; - const nextConfig: OpenClawConfig = { - gateway: { reload: {}, port: 18790 }, - }; + const initialConfig = makeGatewayPortConfig(18789); + const nextConfig = makeGatewayPortConfig(18790); const initialIncludePath = "/tmp/initial.json5"; const retainedIncludePath = "/tmp/retained.json5"; const addedIncludePath = "/tmp/added.json5"; @@ -1005,8 +1010,7 @@ describe("startGatewayConfigReloader", () => { expect.objectContaining({ ignoreInitial: true }), ); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); // Candidate discovery adds the new include before acceptance; acceptance // then retires the old include in a second readiness-reconciled watcher. @@ -1044,16 +1048,14 @@ describe("startGatewayConfigReloader", () => { initialIncludedPaths: [acceptedIncludePath], }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.watcher.close).toHaveBeenCalledOnce(); expect(chokidar.watch).toHaveBeenLastCalledWith( ["/tmp/openclaw.json", acceptedIncludePath, firstCandidatePath], expect.objectContaining({ ignoreInitial: true }), ); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.watcher.close).toHaveBeenCalledTimes(2); expect(chokidar.watch).toHaveBeenLastCalledWith( ["/tmp/openclaw.json", acceptedIncludePath, secondCandidatePath], @@ -1092,19 +1094,14 @@ describe("startGatewayConfigReloader", () => { }); it("journals valid external watcher edits and advances the snapshot slot", async () => { - const initialConfig: OpenClawConfig = { - gateway: { reload: {}, port: 18789 }, - }; - const nextConfig: OpenClawConfig = { - gateway: { reload: {}, port: 18790 }, - }; + const initialConfig = makeGatewayPortConfig(18789); + const nextConfig = makeGatewayPortConfig(18790); const readSnapshot = vi.fn(async () => makeSnapshot({ config: nextConfig, parsed: nextConfig, hash: "next-raw-hash" }), ); const harness = createReloaderHarness(readSnapshot, { initialConfig }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(configAuditMocks.append).toHaveBeenCalledOnce(); expect(configAuditMocks.append.mock.calls[0]?.[0]?.record).toMatchObject({ @@ -1127,12 +1124,8 @@ describe("startGatewayConfigReloader", () => { }); it("does not duplicate another OpenClaw process's journaled write", async () => { - const initialConfig: OpenClawConfig = { - gateway: { reload: {}, port: 18789 }, - }; - const nextConfig: OpenClawConfig = { - gateway: { reload: {}, port: 18790 }, - }; + const initialConfig = makeGatewayPortConfig(18789); + const nextConfig = makeGatewayPortConfig(18790); const harness = createReloaderHarness( vi.fn(async () => makeSnapshot({ config: nextConfig, parsed: nextConfig, hash: "other-write" }), @@ -1146,8 +1139,7 @@ describe("startGatewayConfigReloader", () => { }); configAuditMocks.append.mockClear(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(configAuditMocks.append).not.toHaveBeenCalled(); expect(configAuditMocks.upsertSnapshot).toHaveBeenLastCalledWith( @@ -1173,8 +1165,7 @@ describe("startGatewayConfigReloader", () => { ); configAuditMocks.upsertSnapshot.mockClear(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(configAuditMocks.append.mock.calls[0]?.[0]?.record).toMatchObject({ event: "config.external", @@ -1203,15 +1194,12 @@ describe("startGatewayConfigReloader", () => { const harness = createReloaderHarness(vi.fn(async () => activeSnapshot)); configAuditMocks.append.mockClear(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); + await flushWatcherChange(harness); expect(configAuditMocks.append).toHaveBeenCalledOnce(); activeSnapshot = secondInvalid; - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(configAuditMocks.append).toHaveBeenCalledTimes(2); expect(configAuditMocks.append.mock.calls[1]?.[0]?.record).toMatchObject({ @@ -1224,17 +1212,13 @@ describe("startGatewayConfigReloader", () => { }); it("uses the last observed hash when a valid edit follows an invalid one", async () => { - const initialConfig: OpenClawConfig = { - gateway: { reload: {}, port: 18789 }, - }; + const initialConfig = makeGatewayPortConfig(18789); const invalid = makeSnapshot({ valid: false, hash: "invalid-raw-hash", issues: [{ path: "gateway.port", message: "expected number" }], }); - const nextConfig: OpenClawConfig = { - gateway: { reload: {}, port: 18790 }, - }; + const nextConfig = makeGatewayPortConfig(18790); let activeSnapshot = invalid; const harness = createReloaderHarness( vi.fn(async () => activeSnapshot), @@ -1242,15 +1226,13 @@ describe("startGatewayConfigReloader", () => { ); configAuditMocks.append.mockClear(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); activeSnapshot = makeSnapshot({ config: nextConfig, parsed: nextConfig, hash: "valid-raw-hash", }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(configAuditMocks.append.mock.calls[1]?.[0]?.record).toMatchObject({ detectedBy: "watch", @@ -1263,9 +1245,7 @@ describe("startGatewayConfigReloader", () => { }); it("journals a return to the accepted bytes after an invalid edit", async () => { - const initialConfig: OpenClawConfig = { - gateway: { reload: {}, port: 18789 }, - }; + const initialConfig = makeGatewayPortConfig(18789); const invalid = makeSnapshot({ valid: false, hash: "invalid-raw-hash", @@ -1278,15 +1258,13 @@ describe("startGatewayConfigReloader", () => { ); configAuditMocks.append.mockClear(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); activeSnapshot = makeSnapshot({ config: initialConfig, parsed: initialConfig, hash: "initial-raw-hash", }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(configAuditMocks.append.mock.calls[1]?.[0]?.record).toMatchObject({ detectedBy: "watch", @@ -1323,8 +1301,7 @@ describe("startGatewayConfigReloader", () => { ); configAuditMocks.append.mockClear(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(configAuditMocks.append.mock.calls[0]?.[0]?.record).toMatchObject({ detectedBy: "watch", @@ -1520,8 +1497,7 @@ describe("startGatewayConfigReloader", () => { ); configAuditMocks.append.mockClear(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(configAuditMocks.append).not.toHaveBeenCalled(); await harness.reloader.stop(); @@ -1545,8 +1521,7 @@ describe("startGatewayConfigReloader", () => { ); configAuditMocks.append.mockClear(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(configAuditMocks.append.mock.calls[0]?.[0]?.record).toMatchObject({ event: "config.external", @@ -1603,8 +1578,7 @@ describe("startGatewayConfigReloader", () => { ); const harness = createReloaderHarness(readSnapshot, { initialConfig }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onConfigCandidateCommitted).toHaveBeenCalledOnce(); expect(harness.onConfigCandidateCommitted).toHaveBeenCalledWith({ @@ -1615,8 +1589,7 @@ describe("startGatewayConfigReloader", () => { // A same-content echo must not re-notify: nothing changed. harness.onConfigCandidateCommitted.mockClear(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onConfigCandidateCommitted).not.toHaveBeenCalled(); await harness.reloader.stop(); }); @@ -1634,8 +1607,7 @@ describe("startGatewayConfigReloader", () => { ); const harness = createReloaderHarness(readSnapshot, { initialConfig }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onHotReload).not.toHaveBeenCalled(); expect(harness.onRestart).not.toHaveBeenCalled(); @@ -1644,16 +1616,13 @@ describe("startGatewayConfigReloader", () => { }); it("notifies lifecycle owners when a persisted edit reverts to the current baseline", async () => { - const initialConfig: OpenClawConfig = { - gateway: { reload: {}, port: 18789 }, - }; + const initialConfig = makeGatewayPortConfig(18789); const readSnapshot = vi.fn(async () => makeSnapshot({ config: initialConfig, hash: "reverted-restart-edit" }), ); const harness = createReloaderHarness(readSnapshot, { initialConfig }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onConfigAccepted).toHaveBeenCalledOnce(); expect(harness.onConfigApplied).not.toHaveBeenCalled(); @@ -1679,8 +1648,7 @@ describe("startGatewayConfigReloader", () => { onConfigCandidateObserved, }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(onConfigCandidateObserved).toHaveBeenCalledOnce(); expect(harness.onConfigAccepted).toHaveBeenCalledOnce(); @@ -1743,8 +1711,7 @@ describe("startGatewayConfigReloader", () => { await vi.runAllTimersAsync(); harness.onConfigAccepted.mockClear(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(onRestart).toHaveBeenCalledOnce(); expect(onRestart.mock.calls[0]?.[3]).toEqual(effectiveConfig); @@ -1962,8 +1929,7 @@ describe("startGatewayConfigReloader", () => { initialInternalWriteHash: "accepted-write", }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onConfigAccepted).not.toHaveBeenCalled(); expect(harness.onRestart).not.toHaveBeenCalled(); @@ -2212,8 +2178,7 @@ describe("startGatewayConfigReloader", () => { sourceFingerprint: "source-queued", writtenAtMs: Date.now(), }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(readSnapshot).toHaveBeenCalledTimes(1); expect(harness.onNoopConfigCommit).not.toHaveBeenCalled(); @@ -2500,8 +2465,7 @@ describe("startGatewayConfigReloader", () => { const readSnapshot = vi.fn(async () => makeSnapshot({ config: nextConfig, hash: "sandbox" })); const harness = createReloaderHarness(readSnapshot, { initialConfig }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onConfigChange).toHaveBeenCalledTimes(1); expect(harness.onConfigChange.mock.calls[0]?.[0].noopPaths).toContain( @@ -2528,8 +2492,7 @@ describe("startGatewayConfigReloader", () => { ); const harness = createReloaderHarness(readSnapshot, { initialConfig }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onNoopConfigCommit).toHaveBeenCalledTimes(1); expect(harness.onNoopConfigCommit.mock.calls[0]?.[0].noopPaths).toContain( @@ -2582,8 +2545,7 @@ describe("startGatewayConfigReloader", () => { pinActivePluginChannelRegistry(channelRegistry); try { - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); const [plan] = getOnlyHotReloadCall(harness); expect(plan.restartChannelAccounts).toEqual(new Map([["mattermost", new Set(["alpha"])]])); @@ -2677,8 +2639,7 @@ describe("startGatewayConfigReloader", () => { const readSnapshot = vi.fn(async () => makeSnapshot({ config: nextConfig, hash: "hot" })); const harness = createReloaderHarness(readSnapshot, { initialConfig }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onConfigChange.mock.invocationCallOrder[0]).toBeLessThan( harness.onHotReload.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, @@ -2702,8 +2663,7 @@ describe("startGatewayConfigReloader", () => { const readSnapshot = vi.fn(async () => makeSnapshot({ config: nextConfig, hash: "terminal" })); const harness = createReloaderHarness(readSnapshot, { initialConfig }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); await Promise.resolve(); expect(harness.onConfigChange).toHaveBeenCalledTimes(1); @@ -2763,8 +2723,7 @@ describe("startGatewayConfigReloader", () => { const readSnapshot = vi.fn(async () => makeSnapshot({ config: nextConfig, hash: "off" })); const harness = createReloaderHarness(readSnapshot, { initialConfig }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onConfigChange).not.toHaveBeenCalled(); expect(harness.onHotReload).not.toHaveBeenCalled(); @@ -2782,8 +2741,7 @@ describe("startGatewayConfigReloader", () => { const readSnapshot = vi.fn(async () => makeSnapshot({ config: nextConfig, hash: "hot" })); const harness = createReloaderHarness(readSnapshot, { initialConfig }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onConfigChange).toHaveBeenCalledOnce(); expect(harness.onHotReload).not.toHaveBeenCalled(); @@ -3083,8 +3041,7 @@ describe("startGatewayConfigReloader", () => { harness.emitWrite(makeZeroDebounceHookWrite("internal-retry-1")); await vi.runAllTimersAsync(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onHotReload).toHaveBeenCalledTimes(2); expect(readSnapshot).toHaveBeenCalledTimes(1); @@ -3398,8 +3355,7 @@ describe("startGatewayConfigReloader", () => { }), }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(targetEnv[envKey]).toBe("c"); harness.emitWrite({ @@ -3573,8 +3529,7 @@ describe("startGatewayConfigReloader", () => { ...makeZeroDebounceHookWrite("same-root-hash"), afterWrite: { mode: "none", reason: "stale resolved intent" }, }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); const [, hotConfig] = getOnlyHotReloadCall(harness); expect(hotConfig).toEqual(freshConfig); @@ -3630,8 +3585,7 @@ describe("startGatewayConfigReloader", () => { writtenAtMs: Date.now(), afterWrite: { mode: "none", reason: "secret-aware writer intent" }, }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onConfigAccepted).toHaveBeenCalledWith( runtimeConfig, @@ -3814,8 +3768,7 @@ describe("startGatewayConfigReloader", () => { }, }); await vi.runAllTimersAsync(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onConfigAccepted).toHaveBeenCalledTimes(2); const replayOwnership = harness.onConfigAccepted.mock.calls[1]?.[1]; @@ -3845,8 +3798,7 @@ describe("startGatewayConfigReloader", () => { await vi.runAllTimersAsync(); const originalOwnership = harness.onConfigAccepted.mock.calls[0]?.[1]; - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onConfigAccepted.mock.calls.map((call) => call[3])).toEqual([ { runtimeApplied: false }, @@ -3979,8 +3931,7 @@ describe("startGatewayConfigReloader", () => { ...makeZeroDebounceHookWrite("same-invalid-root-hash"), afterWrite: { mode: "restart", reason: "must not replay invalid config" }, }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onConfigAccepted).not.toHaveBeenCalled(); expect(harness.onRestart).not.toHaveBeenCalled(); @@ -4066,11 +4017,9 @@ describe("startGatewayConfigReloader", () => { ...makeZeroDebounceHookWrite("replay-retry"), afterWrite: { mode: "restart", reason: "retry original intent" }, }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(readSnapshot).toHaveBeenCalledTimes(2); expect(harness.onRestart).toHaveBeenCalledTimes(2); @@ -4091,8 +4040,7 @@ describe("startGatewayConfigReloader", () => { }); await vi.runAllTimersAsync(); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(readSnapshot).toHaveBeenCalledOnce(); expect(harness.onRestart).toHaveBeenCalledTimes(2); @@ -4522,8 +4470,7 @@ describe("startGatewayConfigReloader", () => { ...makeZeroDebounceHookWrite("startup-internal-1"), afterWrite: { mode: "restart", reason: "live writer owns startup hash" }, }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); + await flushWatcherChange(harness); expect(harness.onRestart).toHaveBeenCalledOnce(); expect(harness.onRestart.mock.calls[0]?.[0].restartReasons).toContain( diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index b570d33483f5..bdb2bec38cb1 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -692,6 +692,15 @@ function lastNodeSendCall(context: ChatContext) { | undefined; } +function findAssistantTranscriptUpdates() { + return mockState.emittedTranscriptUpdates.filter( + (update) => + typeof update.message === "object" && + update.message !== null && + (update.message as { role?: unknown }).role === "assistant", + ); +} + function findAssistantUpdateWithBlock(predicate: (block: Record) => boolean) { return mockState.emittedTranscriptUpdates.find((update) => { const message = update.message as { role?: unknown; content?: unknown } | undefined; @@ -844,8 +853,44 @@ function createChatContext(): Pick< type ChatContext = ReturnType; +function useChatTestModel(model: "vision-model" | "text-only") { + mockState.sessionEntry = { modelProvider: "test-provider", model }; + mockState.modelCatalog = [ + { + provider: "test-provider", + id: model, + name: model === "vision-model" ? "Vision model" : "Text only", + input: model === "vision-model" ? ["text", "image"] : ["text"], + }, + ]; +} + +async function createReadyChatTranscript(prefix: string) { + await createTranscriptFixture(prefix); + mockState.finalText = "ok"; +} + function createChatRequestFixture() { - return { context: createChatContext(), respond: vi.fn() }; + const context = createChatContext(); + const respond = vi.fn(); + return { + context, + respond, + send: (params: Omit[0], "context" | "respond">) => + runNonStreamingChatSend({ context, respond, ...params }), + inject: (params: Parameters>[0]["params"]) => + expectDefined( + chatHandlers["chat.inject"], + 'chatHandlers["chat.inject"] test invariant', + )({ + params, + respond, + req: {} as never, + client: null as never, + isWebchatConnect: () => false, + context: context as GatewayRequestContext, + }), + }; } type NonStreamingChatSendWaitFor = "broadcast" | "dedupe" | "none"; @@ -1011,11 +1056,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => parentId: null, }); const before = loadTranscriptEventsSync(transcriptScope()); - const { context, respond } = createChatRequestFixture(); + const { context, respond, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: `idem-${name}-leaf`, requestParams: { expectedLeafEntryId: expectedLeaf }, waitFor: "none", @@ -1033,11 +1076,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("allows an expected empty leaf when the transcript is still empty", async () => { await createGatewayUserTurnSqliteFixture("openclaw-chat-send-matching-empty-leaf-"); - const { context, respond } = createChatRequestFixture(); + const { context, respond, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-matching-empty-leaf", requestParams: { expectedLeafEntryId: null }, }); @@ -1062,11 +1103,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => now: 1, parentId: null, }); - const { context, respond } = createChatRequestFixture(); + const { context, respond, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: `idem-${_name}-leaf`, requestParams, }); @@ -1094,11 +1133,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => reason: "command-metadata", }, ]; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-command-session-metadata", message: "/goal pause waiting", waitFor: "none", @@ -1133,11 +1170,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, ]; mockState.dispatchErrorAfterDelivery = new Error("delivery failed after metadata"); - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-command-session-metadata-error", message: "/goal pause waiting", expectBroadcast: false, @@ -1157,11 +1192,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("persists non-agent delivery mirrors with the chat send idempotency key", async () => { await createTranscriptFixture("openclaw-chat-send-final-idem-"); mockState.finalText = "mirror text"; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-final-mirror", expectBroadcast: false, }); @@ -1180,11 +1213,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("persists non-agent delivery mirrors to SQLite without creating active JSONL", async () => { await withSqliteTranscriptFixtureState("openclaw-chat-send-final-sqlite-", async () => { mockState.finalText = "sqlite mirror text"; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-final-sqlite", expectBroadcast: false, }); @@ -1210,11 +1241,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ); - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-plugin-binding-history", expectBroadcast: false, }); @@ -1245,11 +1274,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ); - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-plugin-binding-rotation", expectBroadcast: false, }); @@ -1276,11 +1303,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ); - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-plugin-binding-blocked", expectBroadcast: false, }); @@ -1310,11 +1335,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, { kind: "final", payload: { text: "derived reply without owner" } }, ]; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-plugin-binding-partial", expectBroadcast: false, }); @@ -1341,11 +1364,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ); - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-source-mirror-legacy", expectBroadcast: false, }); @@ -1363,11 +1384,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("registers tool-event recipients for clients advertising tool-events capability", async () => { - await createTranscriptFixture("openclaw-chat-send-tool-events-"); - mockState.finalText = "ok"; + await createReadyChatTranscript("openclaw-chat-send-tool-events-"); mockState.triggerAgentRunStart = true; mockState.agentRunId = "run-current"; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); context.chatAbortControllers.set("run-same-session", { controller: new AbortController(), sessionId: "sess-prev", @@ -1383,9 +1403,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expiresAtMs: Date.now() + 10_000, }); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-tool-events-on", client: { connId: "conn-1", @@ -1409,7 +1427,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.finalText = "ok"; mockState.triggerAgentRunStart = true; mockState.agentRunId = "run-current-global"; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); context.chatAbortControllers.set("run-default-global", { controller: new AbortController(), sessionId: "sess-default-global", @@ -1426,9 +1444,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expiresAtMs: Date.now() + 10_000, }); - await runNonStreamingChatSend({ - context, - respond, + await send({ sessionKey: "global", idempotencyKey: "idem-global-tool-events", client: { @@ -1454,7 +1470,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.finalText = "ok"; mockState.triggerAgentRunStart = true; mockState.agentRunId = "run-current-work-global"; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); context.chatAbortControllers.set("run-default-global", { controller: new AbortController(), sessionId: "sess-default-global", @@ -1471,9 +1487,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expiresAtMs: Date.now() + 10_000, }); - await runNonStreamingChatSend({ - context, - respond, + await send({ sessionKey: "agent:work:main", idempotencyKey: "idem-global-alias-tool-events", client: { @@ -1496,11 +1510,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => session: { scope: "global" }, }; mockState.sessionEntry = { canonicalKey: "global" }; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ sessionKey: "agent:work:main", idempotencyKey: "idem-global-alias-load", expectBroadcast: false, @@ -1519,11 +1531,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => session: { scope: "global" }, }; mockState.sessionEntry = { canonicalKey: "global" }; - const { context, respond } = createChatRequestFixture(); + const { respond, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ sessionKey: "main", requestParams: { agentId: "work" }, idempotencyKey: "idem-global-main-alias-load", @@ -1548,11 +1558,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => agents: { list: [{ id: "main", default: true }] }, session: { scope: "per-sender" }, }; - const { context, respond } = createChatRequestFixture(); + const { respond, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ sessionKey: "global", requestParams: { agentId: "main" }, idempotencyKey: "idem-per-sender-global-alias", @@ -1595,11 +1603,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.dispatchWait = new Promise((resolve) => { releaseDispatch = resolve; }); - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - const pending = runNonStreamingChatSend({ - context, - respond, + const pending = send({ sessionKey: "agent:work:main", idempotencyKey: "idem-global-alias-abort-key", waitFor: "none", @@ -1671,15 +1677,12 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("does not register tool-event recipients without tool-events capability", async () => { - await createTranscriptFixture("openclaw-chat-send-tool-events-off-"); - mockState.finalText = "ok"; + await createReadyChatTranscript("openclaw-chat-send-tool-events-off-"); mockState.triggerAgentRunStart = true; mockState.agentRunId = "run-no-cap"; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-tool-events-off", client: { connId: "conn-2", @@ -1715,11 +1718,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-agent-audio", expectBroadcast: false, waitFor: "none", @@ -1781,11 +1782,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ), }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-owned-media", expectBroadcast: false, waitFor: "dedupe", @@ -1833,22 +1832,15 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-agent-tts", expectBroadcast: false, waitFor: "dedupe", }); - const assistantUpdates = mockState.emittedTranscriptUpdates.filter( - (update) => - typeof update.message === "object" && - update.message !== null && - (update.message as { role?: unknown }).role === "assistant", - ); + const assistantUpdates = findAssistantTranscriptUpdates(); expect(assistantUpdates).toHaveLength(1); const message = assistantUpdates[0]?.message as Record | undefined; const content = Array.isArray(message?.content) @@ -1894,22 +1886,15 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-stale-agent-media", expectBroadcast: false, waitFor: "dedupe", }); - const assistantUpdates = mockState.emittedTranscriptUpdates.filter( - (update) => - typeof update.message === "object" && - update.message !== null && - (update.message as { role?: unknown }).role === "assistant", - ); + const assistantUpdates = findAssistantTranscriptUpdates(); // Agent-run delivery is a live projection; message_end owns persisted // assistant transcript entries, including stale media/text final payloads. expect(assistantUpdates).toStrictEqual([]); @@ -1940,22 +1925,15 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-agent-text-only", expectBroadcast: false, waitFor: "dedupe", }); - const assistantUpdates = mockState.emittedTranscriptUpdates.filter( - (update) => - typeof update.message === "object" && - update.message !== null && - (update.message as { role?: unknown }).role === "assistant", - ); + const assistantUpdates = findAssistantTranscriptUpdates(); // Normal agent-run final text must not be mirrored into JSONL by WebChat; // The agent runtime persists the model-visible assistant turn from message_end. expect(assistantUpdates).toStrictEqual([]); @@ -1994,11 +1972,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => payload: sourceReply, }, ]; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-source-reply", message: "hello from codex", }); @@ -2013,12 +1989,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(nodeSend?.[0]).toBe("main"); expect(nodeSend?.[1]).toBe("chat"); expect(extractFirstTextBlock(nodeSend?.[2])).toBe("Codex source reply"); - const assistantUpdates = mockState.emittedTranscriptUpdates.filter( - (update) => - typeof update.message === "object" && - update.message !== null && - (update.message as { role?: unknown }).role === "assistant", - ); + const assistantUpdates = findAssistantTranscriptUpdates(); expect(assistantUpdates).toStrictEqual([]); const assistantEntries = await readActiveAssistantTranscriptMessages(); expect(assistantEntries.map((entry) => entry.idempotencyKey)).toStrictEqual([ @@ -2038,11 +2009,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-status-notice", message: "/compact", }); @@ -2098,14 +2067,12 @@ describe("chat directive tag stripping for non-streaming final payloads", () => payload: sourceReply, }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(rewrittenAt); try { - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-source-reply-media", message: "hello from codex", }); @@ -2119,12 +2086,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const broadcastContent = getMessageContent(broadcast); expect(String(broadcastContent[1]?.url)).toContain("/api/chat/media/outgoing/"); expect(String(broadcastContent[1]?.openUrl)).toContain("/api/chat/media/outgoing/"); - const assistantUpdates = mockState.emittedTranscriptUpdates.filter( - (update) => - typeof update.message === "object" && - update.message !== null && - (update.message as { role?: unknown }).role === "assistant", - ); + const assistantUpdates = findAssistantTranscriptUpdates(); expect(assistantUpdates).toStrictEqual([]); const assistantEntries = await readActiveAssistantTranscriptMessages(); expect(assistantEntries).toHaveLength(1); @@ -2175,11 +2137,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ), }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-source-reply-sqlite", message: "hello from codex", }); @@ -2224,11 +2184,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ), }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-source-reply-deduped", message: "hello from codex", }); @@ -2306,11 +2264,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ), }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-source-reply-multi", message: "hello from codex", }); @@ -2388,11 +2344,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ), }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-source-reply-partial", message: "hello from codex", }); @@ -2464,11 +2418,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ), }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-source-reply-text-tail", message: "hello from codex", }); @@ -2521,11 +2473,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ), }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-source-reply-collision", message: "hello from codex", }); @@ -2570,11 +2520,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ), }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-source-reply-media-only", message: "hello from codex", }); @@ -2637,11 +2585,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ), }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-source-reply-media-only-sibling", message: "hello from codex", }); @@ -2698,11 +2644,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ), }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-source-reply-later", message: "hello from codex", }); @@ -2753,11 +2697,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-source-reply-error", message: "hello from codex", }); @@ -2851,11 +2793,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-status-notice-error", message: "/compact", }); @@ -2886,11 +2826,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - const broadcast = await runNonStreamingChatSend({ - context, - respond, + const broadcast = await send({ idempotencyKey: "idem-agent-returned-error", message: "please keep working", }); @@ -2911,12 +2849,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); const userUpdate = findUserUpdate(); expectUserUpdateIdentity(userUpdate); - const assistantUpdates = mockState.emittedTranscriptUpdates.filter( - (update) => - typeof update.message === "object" && - update.message !== null && - (update.message as { role?: unknown }).role === "assistant", - ); + const assistantUpdates = findAssistantTranscriptUpdates(); expect(assistantUpdates).toStrictEqual([]); }); @@ -2939,11 +2872,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => trustedLocalMedia: true, audioAsVoice: true, }; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-command-tts", }); @@ -2960,12 +2891,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => isVoiceNote: true, }, }); - const assistantUpdates = mockState.emittedTranscriptUpdates.filter( - (update) => - typeof update.message === "object" && - update.message !== null && - (update.message as { role?: unknown }).role === "assistant", - ); + const assistantUpdates = findAssistantTranscriptUpdates(); expect(assistantUpdates).toHaveLength(1); expect(JSON.stringify(assistantUpdates[0]?.message)).toContain("Command result with TTS."); }); @@ -2983,11 +2909,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-command-block", message: "/export-trajectory bundle", }); @@ -3040,11 +2964,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-command-block-media", message: "/export-trajectory bundle", }); @@ -3086,11 +3008,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-command-pair-qr", message: "/pair qr", }); @@ -3129,11 +3049,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => payload: { text: "Approve once to create the bundle." }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-command-block-text", message: "/export-trajectory bundle", }); @@ -3160,11 +3078,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-command-block-duplicate-text", message: "/export-trajectory bundle", }); @@ -3195,11 +3111,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => payload: { text: "[[reply_to_current]]" }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-command-block-reply-directive", message: "/export-trajectory bundle", }); @@ -3494,11 +3408,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => text: "Scan this QR code with the OpenClaw iOS app:", mediaUrl: "data:image/png;base64,cG5n", }; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-agent-image", }); @@ -3524,11 +3436,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => payload: { text: "final answer" }, }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-reasoning-hidden", }); @@ -3538,19 +3448,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("chat.inject keeps message defined when directive tag is the only content", async () => { await createTranscriptFixture("openclaw-chat-inject-directive-only-"); - const { context, respond } = createChatRequestFixture(); + const { context, respond, inject } = createChatRequestFixture(); - await expectDefined( - chatHandlers["chat.inject"], - 'chatHandlers["chat.inject"] test invariant', - )({ - params: { sessionKey: "main", message: "[[reply_to_current]]" }, - respond, - req: {} as never, - client: null as never, - isWebchatConnect: () => false, - context: context as GatewayRequestContext, - }); + await inject({ sessionKey: "main", message: "[[reply_to_current]]" }); expect(respond).toHaveBeenCalled(); const [ok, payload] = lastRespondCall(respond) ?? []; @@ -3567,19 +3467,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("chat.inject rejects archived sessions without appending", async () => { await createTranscriptFixture("openclaw-chat-inject-archived-"); mockState.sessionEntry = { archivedAt: Date.now() }; - const { context, respond } = createChatRequestFixture(); + const { context, respond, inject } = createChatRequestFixture(); - await expectDefined( - chatHandlers["chat.inject"], - 'chatHandlers["chat.inject"] test invariant', - )({ - params: { sessionKey: "main", message: "must stay read-only" }, - respond, - req: {} as never, - client: null as never, - isWebchatConnect: () => false, - context: context as GatewayRequestContext, - }); + await inject({ sessionKey: "main", message: "must stay read-only" }); const response = lastRespondCall(respond); expect(response?.[0]).toBe(false); @@ -3635,19 +3525,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("chat.inject persists to SQLite without creating active JSONL", async () => { await withSqliteTranscriptFixtureState("openclaw-chat-inject-sqlite-", async () => { - const { context, respond } = createChatRequestFixture(); + const { respond, inject } = createChatRequestFixture(); - await expectDefined( - chatHandlers["chat.inject"], - 'chatHandlers["chat.inject"] test invariant', - )({ - params: { sessionKey: "main", message: "hello sqlite inject" }, - respond, - req: {} as never, - client: null as never, - isWebchatConnect: () => false, - context: context as GatewayRequestContext, - }); + await inject({ sessionKey: "main", message: "hello sqlite inject" }); const [ok, payload] = lastRespondCall(respond) ?? []; expect(ok).toBe(true); @@ -3662,11 +3542,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("chat.send non-streaming final keeps message defined for directive-only assistant text", async () => { await createTranscriptFixture("openclaw-chat-send-directive-only-"); mockState.finalText = "[[reply_to_current]]"; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-directive-only", }); @@ -3681,11 +3559,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("preserves inline reply directives in transcript text while stripping them from display", async () => { await createTranscriptFixture("openclaw-chat-send-inline-reply-transcript-"); mockState.finalText = "see[[reply_to_current]]now with spacing"; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-inline-reply-transcript", }); @@ -3760,21 +3636,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("chat.inject strips external untrusted wrapper metadata from final payload text", async () => { await createTranscriptFixture("openclaw-chat-inject-untrusted-meta-"); - const { context, respond } = createChatRequestFixture(); + const { context, respond, inject } = createChatRequestFixture(); - await expectDefined( - chatHandlers["chat.inject"], - 'chatHandlers["chat.inject"] test invariant', - )({ - params: { - sessionKey: "main", - message: `hello\n\n${UNTRUSTED_CONTEXT_SUFFIX}`, - }, - respond, - req: {} as never, - client: null as never, - isWebchatConnect: () => false, - context: context as GatewayRequestContext, + await inject({ + sessionKey: "main", + message: `hello\n\n${UNTRUSTED_CONTEXT_SUFFIX}`, }); expect(respond).toHaveBeenCalled(); @@ -3788,21 +3654,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.sessionEntry = { canonicalKey: "agent:main:canon", }; - const { context, respond } = createChatRequestFixture(); + const { context, respond, inject } = createChatRequestFixture(); - await expectDefined( - chatHandlers["chat.inject"], - 'chatHandlers["chat.inject"] test invariant', - )({ - params: { - sessionKey: "legacy-key", - message: "hello", - }, - respond, - req: {} as never, - client: null as never, - isWebchatConnect: () => false, - context: context as GatewayRequestContext, + await inject({ + sessionKey: "legacy-key", + message: "hello", }); const response = lastRespondCall(respond); @@ -3824,23 +3680,13 @@ describe("chat directive tag stripping for non-streaming final payloads", () => updatedAt, status: "done", }); - const { context, respond } = createChatRequestFixture(); + const { respond, inject } = createChatRequestFixture(); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(appendedAt); try { - await expectDefined( - chatHandlers["chat.inject"], - 'chatHandlers["chat.inject"] test invariant', - )({ - params: { - sessionKey: "main", - message: "hello with registry marker", - }, - respond, - req: {} as never, - client: null as never, - isWebchatConnect: () => false, - context: context as GatewayRequestContext, + await inject({ + sessionKey: "main", + message: "hello with registry marker", }); const response = lastRespondCall(respond); @@ -3861,22 +3707,12 @@ describe("chat directive tag stripping for non-streaming final payloads", () => session: { scope: "global" }, }; mockState.sessionEntry = { canonicalKey: "global" }; - const { context, respond } = createChatRequestFixture(); + const { context, respond, inject } = createChatRequestFixture(); - await expectDefined( - chatHandlers["chat.inject"], - 'chatHandlers["chat.inject"] test invariant', - )({ - params: { - sessionKey: "main", - agentId: "work", - message: "hello selected global", - }, - respond, - req: {} as never, - client: null as never, - isWebchatConnect: () => false, - context: context as GatewayRequestContext, + await inject({ + sessionKey: "main", + agentId: "work", + message: "hello selected global", }); const response = lastRespondCall(respond); @@ -3899,11 +3735,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("chat.send non-streaming final strips external untrusted wrapper metadata from final payload text", async () => { await createTranscriptFixture("openclaw-chat-send-untrusted-meta-"); mockState.finalText = `hello\n\n${UNTRUSTED_CONTEXT_SUFFIX}`; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-untrusted-context", }); expect(extractFirstTextBlock(payload)?.trim()).toBe("hello"); @@ -3915,11 +3749,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => canonicalKey: "agent:main:canon", }; mockState.finalText = "hello"; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-canonical-key", sessionKey: "legacy-key", }); @@ -3934,12 +3766,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("chat.send broadcasts final replies for telegram-shaped session keys", async () => { await createTranscriptFixture("openclaw-chat-send-telegram-final-"); mockState.finalText = "telegram ok"; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); const sessionKey = "agent:main:telegram:direct:123456"; - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-telegram-final", sessionKey, }); @@ -3959,13 +3789,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("chat.send marks user slash commands as text command sources", async () => { - await createTranscriptFixture("openclaw-chat-send-text-command-source-"); - mockState.finalText = "ok"; - const { context, respond } = createChatRequestFixture(); + await createReadyChatTranscript("openclaw-chat-send-text-command-source-"); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-text-command-source", message: "/codex status", expectBroadcast: false, @@ -3978,13 +3805,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("chat.send keeps thinking metadata out of command text for normal messages", async () => { - await createTranscriptFixture("openclaw-chat-send-thinking-normal-message-"); - mockState.finalText = "ok"; - const { context, respond } = createChatRequestFixture(); + await createReadyChatTranscript("openclaw-chat-send-thinking-normal-message-"); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-thinking-normal-message", message: "hello from phone", requestParams: { @@ -4192,13 +4016,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ); it("chat.send accepts admin-scoped synthetic originating routes without external delivery", async () => { - await createTranscriptFixture("openclaw-chat-send-synthetic-origin-admin-"); - mockState.finalText = "ok"; - const { context, respond } = createChatRequestFixture(); + await createReadyChatTranscript("openclaw-chat-send-synthetic-origin-admin-"); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-synthetic-origin-admin", client: createScopedCliClient(["operator.admin"]), requestParams: { @@ -4221,13 +4042,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("rejects synthetic originating routes when the caller lacks admin scope", async () => { - await createTranscriptFixture("openclaw-chat-send-synthetic-origin-reject-"); - mockState.finalText = "ok"; - const { context, respond } = createChatRequestFixture(); + await createReadyChatTranscript("openclaw-chat-send-synthetic-origin-reject-"); + const { respond, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-synthetic-origin-reject", client: createScopedCliClient(["operator.write"]), requestParams: { @@ -4245,13 +4063,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("rejects reserved system provenance fields for non-ACP clients", async () => { - await createTranscriptFixture("openclaw-chat-send-system-provenance-reject-"); - mockState.finalText = "ok"; - const { context, respond } = createChatRequestFixture(); + await createReadyChatTranscript("openclaw-chat-send-system-provenance-reject-"); + const { respond, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-system-provenance-reject", requestParams: { systemInputProvenance: { kind: "external_user", sourceChannel: "acp" }, @@ -4268,13 +4083,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("rejects forged ACP metadata when the caller lacks admin scope", async () => { - await createTranscriptFixture("openclaw-chat-send-system-provenance-spoof-reject-"); - mockState.finalText = "ok"; - const { context, respond } = createChatRequestFixture(); + await createReadyChatTranscript("openclaw-chat-send-system-provenance-spoof-reject-"); + const { respond, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-system-provenance-spoof-reject", client: createScopedCliClient(["operator.write"], { id: "cli", @@ -4302,13 +4114,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("allows admin-scoped clients to inject system provenance without ACP metadata", async () => { - await createTranscriptFixture("openclaw-chat-send-system-provenance-admin-"); - mockState.finalText = "ok"; - const { context, respond } = createChatRequestFixture(); + await createReadyChatTranscript("openclaw-chat-send-system-provenance-admin-"); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-system-provenance-admin", message: "ops update", client: createScopedCliClient(["operator.admin"], { @@ -4341,13 +4150,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("forwards gateway caller scopes into the dispatch context", async () => { - await createTranscriptFixture("openclaw-chat-send-gateway-client-scopes-"); - mockState.finalText = "ok"; - const { context, respond } = createChatRequestFixture(); + await createReadyChatTranscript("openclaw-chat-send-gateway-client-scopes-"); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-gateway-client-scopes", message: "/scopecheck", client: createScopedCliClient(["operator.write", "operator.pairing"]), @@ -4362,13 +4168,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("forwards gateway client capabilities into the dispatch context", async () => { - await createTranscriptFixture("openclaw-chat-send-gateway-client-caps-"); - mockState.finalText = "ok"; - const { context, respond } = createChatRequestFixture(); + await createReadyChatTranscript("openclaw-chat-send-gateway-client-caps-"); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-gateway-client-caps", message: "show a widget", client: createScopedCliClient([], {}, [GATEWAY_CLIENT_CAPS.INLINE_WIDGETS]), @@ -4381,13 +4184,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("normalizes missing gateway caller scopes to an empty array before dispatch", async () => { - await createTranscriptFixture("openclaw-chat-send-missing-gateway-client-scopes-"); - mockState.finalText = "ok"; - const { context, respond } = createChatRequestFixture(); + await createReadyChatTranscript("openclaw-chat-send-missing-gateway-client-scopes-"); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-gateway-client-scopes-missing", message: "/scopecheck", client: createScopedCliClient(), @@ -4400,9 +4200,8 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("injects ACP system provenance into the agent-visible body", async () => { - await createTranscriptFixture("openclaw-chat-send-system-provenance-acp-"); - mockState.finalText = "ok"; - const { context, respond } = createChatRequestFixture(); + await createReadyChatTranscript("openclaw-chat-send-system-provenance-acp-"); + const { send } = createChatRequestFixture(); const provenance = { kind: "external_user" as const, originSessionId: "acp-session-1", @@ -4410,9 +4209,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => sourceTool: "openclaw_acp", }; - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-system-provenance-acp", message: "bench update", client: createScopedCliClient(["operator.admin"], { @@ -4448,14 +4245,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("prepares clean text-only chat.send user turns for Pi persistence", async () => { - await createTranscriptFixture("openclaw-chat-send-user-transcript-agent-run-"); - mockState.finalText = "ok"; + await createReadyChatTranscript("openclaw-chat-send-user-transcript-agent-run-"); mockState.triggerAgentRunStart = true; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-agent-run", message: "hello from dashboard", expectBroadcast: false, @@ -4475,8 +4269,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("does not emit pre-gate user transcript content when before_agent_run hooks are registered", async () => { - await createTranscriptFixture("openclaw-chat-send-user-transcript-before-run-gate-"); - mockState.finalText = "ok"; + await createReadyChatTranscript("openclaw-chat-send-user-transcript-before-run-gate-"); mockState.triggerAgentRunStart = true; mockState.hasBeforeAgentRunHooks = true; let userUpdateCountAtAgentStart = 0; @@ -4488,11 +4281,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => (update.message as { role?: unknown }).role === "user", ).length; }; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-before-run-gate", message: "secret prompt that may be blocked", expectBroadcast: false, @@ -4523,11 +4314,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ), }, ]; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-blocked-delivery-error", message: "secret prompt blocked before persistence then delivery failed", expectBroadcast: false, @@ -4547,11 +4336,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.triggerAgentRunStart = true; mockState.hasBeforeAgentRunHooks = true; mockState.dispatchErrorAfterAgentRunStart = new Error("model unavailable"); - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-gate-pass-error", message: "prompt allowed before model error", expectBroadcast: false, @@ -4568,18 +4355,15 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("prepares persisted media paths for Pi user-turn persistence", async () => { - await createTranscriptFixture("openclaw-chat-send-user-transcript-images-"); - mockState.finalText = "ok"; + await createReadyChatTranscript("openclaw-chat-send-user-transcript-images-"); mockState.triggerAgentRunStart = true; mockState.savedMediaResults = [ { path: "/tmp/chat-send-image-a.png", contentType: "image/png" }, { path: "/tmp/chat-send-image-b.jpg", contentType: "image/jpeg" }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-images", message: "edit these", requestParams: { @@ -4640,17 +4424,14 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("prepares non-image chat.send attachments as media refs without dispatch images", async () => { - await createTranscriptFixture("openclaw-chat-send-user-transcript-file-"); - mockState.finalText = "ok"; + await createReadyChatTranscript("openclaw-chat-send-user-transcript-file-"); mockState.triggerAgentRunStart = true; mockState.savedMediaResults = [ { path: "/tmp/chat-send-brief.pdf", contentType: "application/pdf" }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-file", message: "summarize this", requestParams: { @@ -4694,8 +4475,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("preserves offloaded attachment media paths in transcript order", async () => { - await createTranscriptFixture("openclaw-chat-send-user-transcript-offloaded-"); - mockState.finalText = "ok"; + await createReadyChatTranscript("openclaw-chat-send-user-transcript-offloaded-"); mockState.triggerAgentRunStart = true; mockState.sessionEntry = { modelProvider: "test-provider", @@ -4715,13 +4495,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () => { path: "/tmp/offloaded-big.png", contentType: "image/png" }, { path: "/tmp/chat-send-inline.png", contentType: "image/png" }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); const bigPng = Buffer.alloc(2_100_000); bigPng.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-offloaded", message: "edit both", requestParams: { @@ -4759,17 +4537,14 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("leaves ACP bridge user persistence to the agent runtime", async () => { - await createTranscriptFixture("openclaw-chat-send-user-transcript-acp-images-"); - mockState.finalText = "ok"; + await createReadyChatTranscript("openclaw-chat-send-user-transcript-acp-images-"); mockState.triggerAgentRunStart = true; mockState.savedMediaResults = [ { path: "/tmp/should-not-be-used.png", contentType: "image/png" }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-acp-images", message: "bridge image", client: { @@ -4816,11 +4591,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.saveMediaWait = new Promise((resolve) => { releaseSave = resolve; }); - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-no-agent-images-order", message: "quick command", requestParams: { @@ -4852,11 +4625,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("preserves media-only final replies in the final broadcast message", async () => { await createTranscriptFixture("openclaw-chat-send-media-only-final-"); mockState.finalPayload = { mediaUrl: "data:image/png;base64,cG5n" }; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-media-only-final", }); @@ -4872,11 +4643,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => text: "NO_REPLY", mediaUrl: "data:image/png;base64,cG5n", }; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-media-only-silent-final", }); @@ -4892,11 +4661,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => replyToCurrent: true, mediaUrl: "data:image/png;base64,cG5n", }; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-media-reply-tags", }); @@ -4931,11 +4698,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mediaUrl: "data:image/png;base64,cG5n", sensitiveMedia: true, }; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-sensitive-media-final", }); @@ -4969,11 +4734,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => text: "hello", replyToId: "abc]]\n[[audio_as_voice]]", }; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-sanitized-reply-id", }); @@ -4994,11 +4757,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => text: "hello[[reply_to:inline-id]]", replyToId: "]]\n[[", }; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - const payload = await runNonStreamingChatSend({ - context, - respond, + const payload = await send({ idempotencyKey: "idem-inline-reply-id-fallback", }); @@ -5013,25 +4774,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("routes text-only image offloads into media-understanding fields", async () => { - await createTranscriptFixture("openclaw-chat-send-text-only-attachments-"); - mockState.finalText = "ok"; - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "text-only", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "text-only", - name: "Text only", - input: ["text"], - }, - ]; - const { context, respond } = createChatRequestFixture(); + await createReadyChatTranscript("openclaw-chat-send-text-only-attachments-"); + useChatTestModel("text-only"); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-text-only-attachments", message: "describe image", requestParams: { @@ -5071,8 +4818,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("keeps image attachments inline for configured custom vision models", async () => { - await createTranscriptFixture("openclaw-chat-send-configured-custom-vision-"); - mockState.finalText = "ok"; + await createReadyChatTranscript("openclaw-chat-send-configured-custom-vision-"); mockState.sessionEntry = { modelProvider: "modelscope", model: "Qwen/Qwen3.5-35B-A3B", @@ -5087,11 +4833,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => input: ["text", "image"], }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-configured-custom-vision", message: "describe image", requestParams: { @@ -5120,28 +4864,14 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("keeps image attachments for text-only sessions bound to ACP", async () => { - await createTranscriptFixture("openclaw-chat-send-text-only-acp-bound-attachments-"); - mockState.finalText = "ok"; - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "text-only", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "text-only", - name: "Text only", - input: ["text"], - }, - ]; + await createReadyChatTranscript("openclaw-chat-send-text-only-acp-bound-attachments-"); + useChatTestModel("text-only"); bindingMocks.resolveByConversation.mockReturnValue({ targetSessionKey: "agent:claude:acp:spawned", }); - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-text-only-acp-bound-attachments", message: "describe image", client: createScopedCliClient(["operator.admin"]), @@ -5170,8 +4900,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("resolves attachment image support from the session agent model", async () => { - await createTranscriptFixture("openclaw-chat-send-agent-scoped-text-only-attachments-"); - mockState.finalText = "ok"; + await createReadyChatTranscript("openclaw-chat-send-agent-scoped-text-only-attachments-"); mockState.config = { agents: { list: [ @@ -5201,11 +4930,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => input: ["text"], }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ sessionKey: "agent:writer:main", idempotencyKey: "idem-agent-scoped-text-only-attachments", message: "describe image", @@ -5246,29 +4973,15 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("routes non-image offloaded refs into media facts for chat.send", async () => { - await createTranscriptFixture("openclaw-chat-send-non-image-ctx-media-paths-"); - mockState.finalText = "ok"; - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "vision-model", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "vision-model", - name: "Vision model", - input: ["text", "image"], - }, - ]; + await createReadyChatTranscript("openclaw-chat-send-non-image-ctx-media-paths-"); + useChatTestModel("vision-model"); mockState.savedMediaResults = [ { path: "/home/user/.openclaw/media/inbound/report.pdf", contentType: "application/pdf" }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); const pdf = Buffer.from("%PDF-1.4\n%µ¶\n1 0 obj\n<<>>\nendobj\n").toString("base64"); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-non-image-ctx-media", message: "read this", requestParams: { @@ -5304,29 +5017,15 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("routes image-named generic container bytes as non-image media paths for chat.send", async () => { - await createTranscriptFixture("openclaw-chat-send-spoofed-image-container-"); - mockState.finalText = "ok"; - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "vision-model", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "vision-model", - name: "Vision model", - input: ["text", "image"], - }, - ]; + await createReadyChatTranscript("openclaw-chat-send-spoofed-image-container-"); + useChatTestModel("vision-model"); mockState.savedMediaResults = [ { path: "/home/user/.openclaw/media/inbound/fake.zip", contentType: "application/zip" }, ]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); const zip = Buffer.from("PK\u0003\u0004zip-archive-bytes").toString("base64"); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-spoofed-image-container", message: "inspect this", requestParams: { @@ -5363,31 +5062,17 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("preserves sandbox-relative fact paths and workspace context for media-understanding", async () => { - await createTranscriptFixture("openclaw-chat-send-non-image-absolutize-"); - mockState.finalText = "ok"; - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "vision-model", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "vision-model", - name: "Vision model", - input: ["text", "image"], - }, - ]; + await createReadyChatTranscript("openclaw-chat-send-non-image-absolutize-"); + useChatTestModel("vision-model"); mockState.savedMediaResults = [ { path: "/home/user/.openclaw/media/inbound/report.pdf", contentType: "application/pdf" }, ]; mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" }; mockState.stagedRelativePaths = ["media/inbound/report.pdf"]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); const pdf = Buffer.from("%PDF-1.4\n%µ¶\n1 0 obj\n<<>>\nendobj\n").toString("base64"); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-non-image-absolutize", message: "read this", requestParams: { @@ -5413,20 +5098,8 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("preserves staged non-image paths when plugin-bound sessions also carry inline images", async () => { - await createTranscriptFixture("openclaw-chat-send-plugin-bound-mixed-media-staging-"); - mockState.finalText = "ok"; - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "vision-model", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "vision-model", - name: "Vision model", - input: ["text", "image"], - }, - ]; + await createReadyChatTranscript("openclaw-chat-send-plugin-bound-mixed-media-staging-"); + useChatTestModel("vision-model"); bindingMocks.resolveByConversation.mockReturnValue({ metadata: { pluginBindingOwner: "plugin", @@ -5440,12 +5113,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ]; mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" }; mockState.stagedRelativePaths = ["media/inbound/report.pdf"]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); const pdf = Buffer.from("%PDF-1.4\n").toString("base64"); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-plugin-bound-mixed-media-staging", message: "inspect these", client: createScopedCliClient(["operator.admin"]), @@ -5491,20 +5162,8 @@ describe("chat directive tag stripping for non-streaming final payloads", () => // A non-PDF managed offload cannot fall back to a managed path, so an infra // staging error stays a retryable 5xx. (Managed PDFs fall back instead — see // the staging-throw fallback test below.) #90097 - await createTranscriptFixture("openclaw-chat-send-stage-unavailable-"); - mockState.finalText = "ok"; - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "vision-model", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "vision-model", - name: "Vision model", - input: ["text", "image"], - }, - ]; + await createReadyChatTranscript("openclaw-chat-send-stage-unavailable-"); + useChatTestModel("vision-model"); mockState.savedMediaResults = [ { path: "/home/user/.openclaw/media/inbound/report.bin", @@ -5518,12 +5177,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => stageError.stack = "Error: ENOSPC: no space left on device\n at stageSandboxMedia (stage-sandbox-media.ts:1:1)"; mockState.stageSandboxMediaError = stageError; - const { context, respond } = createChatRequestFixture(); + const { context, respond, send } = createChatRequestFixture(); const binPayload = Buffer.from("OPENCLAW-BINARY\n").toString("base64"); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-stage-unavailable", message: "read this", requestParams: { @@ -5567,11 +5224,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("logs chat.send attachment parse failures with stack details", async () => { await createTranscriptFixture("openclaw-chat-send-attachment-parse-stack-"); - const { context, respond } = createChatRequestFixture(); + const { context, respond, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-chat-send-attachment-parse-stack", message: "inspect this", requestParams: { @@ -5617,20 +5272,8 @@ describe("chat directive tag stripping for non-streaming final payloads", () => // the returned `staged` map against the input refs. Non-PDF refs cannot fall // back to a managed path, so an incomplete stage stays a 5xx. (Managed PDFs // fall back instead — see the staging-skip fallback test below.) #90097 - await createTranscriptFixture("openclaw-chat-send-partial-stage-"); - mockState.finalText = "ok"; - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "vision-model", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "vision-model", - name: "Vision model", - input: ["text", "image"], - }, - ]; + await createReadyChatTranscript("openclaw-chat-send-partial-stage-"); + useChatTestModel("vision-model"); mockState.savedMediaResults = [ { path: "/home/user/.openclaw/media/inbound/report.bin", @@ -5644,12 +5287,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" }; mockState.stagedRelativePaths = ["media/inbound/report.bin", "media/inbound/data.bin"]; mockState.unstagedSources = ["/home/user/.openclaw/media/inbound/data.bin"]; - const { context, respond } = createChatRequestFixture(); + const { respond, send } = createChatRequestFixture(); const binPayload = Buffer.from("OPENCLAW-BINARY\n").toString("base64"); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-partial-stage", message: "read these", requestParams: { @@ -5690,32 +5331,18 @@ describe("chat directive tag stripping for non-streaming final payloads", () => // #90097: a managed inbound PDF above the sandbox staging cap is read // host-side (media-understanding) rather than copied into the sandbox, so // it must reach dispatch with its managed media path instead of a 4xx. - await createTranscriptFixture("openclaw-chat-send-managed-pdf-pass-through-"); - mockState.finalText = "ok"; - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "vision-model", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "vision-model", - name: "Vision model", - input: ["text", "image"], - }, - ]; + await createReadyChatTranscript("openclaw-chat-send-managed-pdf-pass-through-"); + useChatTestModel("vision-model"); mockState.savedMediaResults = [ { path: "/home/user/.openclaw/media/inbound/huge.pdf", contentType: "application/pdf" }, ]; mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" }; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); // 6MB PDF — above STAGED_MEDIA_MAX_BYTES (5MB) but below the 20MB parse cap. const oversized = Buffer.alloc(6 * 1024 * 1024); oversized.set(Buffer.from("%PDF-1.4\n"), 0); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-managed-pdf-pass-through", message: "read this", requestParams: { @@ -5749,20 +5376,8 @@ describe("chat directive tag stripping for non-streaming final payloads", () => // ENOSPC) the PDF must still reach the agent via its managed media path // instead of failing the send — host-side media-understanding reads it from // the media-store root. - await createTranscriptFixture("openclaw-chat-send-managed-pdf-stage-throw-"); - mockState.finalText = "ok"; - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "vision-model", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "vision-model", - name: "Vision model", - input: ["text", "image"], - }, - ]; + await createReadyChatTranscript("openclaw-chat-send-managed-pdf-stage-throw-"); + useChatTestModel("vision-model"); mockState.savedMediaResults = [ { path: "/home/user/.openclaw/media/inbound/report.pdf", contentType: "application/pdf" }, ]; @@ -5770,14 +5385,12 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.stageSandboxMediaError = Object.assign(new Error("ENOSPC: no space left on device"), { code: "ENOSPC", }); - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); // Small PDF (below the 5MB staging cap) so it takes the staging path, not the // oversized pass-through path. const pdf = Buffer.from("%PDF-1.4\n%µ¶\nendobj\n").toString("base64"); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-managed-pdf-stage-throw", message: "read this", requestParams: { @@ -5805,32 +5418,18 @@ describe("chat directive tag stripping for non-streaming final payloads", () => // path) and return it absent from the staged map. An already-managed PDF in // that state falls back to its managed media path rather than failing the // send; the staged workspace dir is still carried for any files that landed. - await createTranscriptFixture("openclaw-chat-send-managed-pdf-stage-skip-"); - mockState.finalText = "ok"; - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "vision-model", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "vision-model", - name: "Vision model", - input: ["text", "image"], - }, - ]; + await createReadyChatTranscript("openclaw-chat-send-managed-pdf-stage-skip-"); + useChatTestModel("vision-model"); mockState.savedMediaResults = [ { path: "/home/user/.openclaw/media/inbound/report.pdf", contentType: "application/pdf" }, ]; mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" }; // No stagedRelativePaths → staged map is empty and the fact keeps the // absolute path, mirroring stageSandboxMedia silently skipping the file. - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); const pdf = Buffer.from("%PDF-1.4\n%µ¶\nendobj\n").toString("base64"); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-managed-pdf-stage-skip", message: "read this", requestParams: { @@ -5855,20 +5454,8 @@ describe("chat directive tag stripping for non-streaming final payloads", () => // #90097: the PDF fallback is per-ref. A managed PDF that stages does not // rescue a sibling non-PDF that silently fell out of staging; that batch must // still surface a retryable 5xx and clean up every offloaded entry. - await createTranscriptFixture("openclaw-chat-send-mixed-stage-skip-"); - mockState.finalText = "ok"; - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "vision-model", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "vision-model", - name: "Vision model", - input: ["text", "image"], - }, - ]; + await createReadyChatTranscript("openclaw-chat-send-mixed-stage-skip-"); + useChatTestModel("vision-model"); mockState.savedMediaResults = [ { path: "/home/user/.openclaw/media/inbound/report.pdf", contentType: "application/pdf" }, { @@ -5879,13 +5466,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" }; mockState.stagedRelativePaths = ["media/inbound/report.pdf", "media/inbound/data.bin"]; mockState.unstagedSources = ["/home/user/.openclaw/media/inbound/data.bin"]; - const { context, respond } = createChatRequestFixture(); + const { respond, send } = createChatRequestFixture(); const pdf = Buffer.from("%PDF-1.4\n").toString("base64"); const bin = Buffer.from("OPENCLAW-BINARY\n").toString("base64"); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-mixed-stage-skip", message: "read these", requestParams: { @@ -5926,20 +5511,8 @@ describe("chat directive tag stripping for non-streaming final payloads", () => // retryable 5xx UNAVAILABLE, misleading clients into retrying a // deterministically broken request. Managed PDFs pass through (see above); // other oversized non-image files must still be rejected. - await createTranscriptFixture("openclaw-chat-send-sandbox-oversize-"); - mockState.finalText = "ok"; - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "vision-model", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "vision-model", - name: "Vision model", - input: ["text", "image"], - }, - ]; + await createReadyChatTranscript("openclaw-chat-send-sandbox-oversize-"); + useChatTestModel("vision-model"); mockState.savedMediaResults = [ { path: "/home/user/.openclaw/media/inbound/huge.bin", @@ -5947,15 +5520,13 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, ]; mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" }; - const { context, respond } = createChatRequestFixture(); + const { respond, send } = createChatRequestFixture(); // 6MB buffer — above STAGED_MEDIA_MAX_BYTES (5MB) but below the 20MB parse cap. const oversized = Buffer.alloc(6 * 1024 * 1024); oversized.set(Buffer.from("OPENCLAW-BINARY\n"), 0); const oversizedPayload = oversized.toString("base64"); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-sandbox-oversize", message: "read this", requestParams: { @@ -5986,8 +5557,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("passes imageOrder for mixed inline and offloaded chat.send attachments", async () => { - await createTranscriptFixture("openclaw-chat-send-image-order-"); - mockState.finalText = "ok"; + await createReadyChatTranscript("openclaw-chat-send-image-order-"); mockState.sessionEntry = { modelProvider: "test-provider", model: "vision-model", @@ -6003,13 +5573,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }, ]; mockState.savedMediaResults = [{ path: "/tmp/offloaded-big.png", contentType: "image/png" }]; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); const bigPng = Buffer.alloc(2_100_000); bigPng.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-image-order", message: "describe both", requestParams: { @@ -6034,26 +5602,13 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("maps media offload failures to UNAVAILABLE in chat.send", async () => { await createTranscriptFixture("openclaw-chat-send-media-offload-error-"); - mockState.sessionEntry = { - modelProvider: "test-provider", - model: "vision-model", - }; - mockState.modelCatalog = [ - { - provider: "test-provider", - id: "vision-model", - name: "Vision model", - input: ["text", "image"], - }, - ]; + useChatTestModel("vision-model"); mockState.saveMediaError = new Error("disk full"); - const { context, respond } = createChatRequestFixture(); + const { respond, send } = createChatRequestFixture(); const bigPng = Buffer.alloc(2_100_000); bigPng.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-media-offload-error", message: "describe image", requestParams: { @@ -6074,8 +5629,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); it("persists chat.send attachments one at a time", async () => { - await createTranscriptFixture("openclaw-chat-send-image-serial-save-"); - mockState.finalText = "ok"; + await createReadyChatTranscript("openclaw-chat-send-image-serial-save-"); mockState.savedMediaResults = [ { path: "/tmp/chat-send-image-a.png", contentType: "image/png" }, { path: "/tmp/chat-send-image-b.jpg", contentType: "image/jpeg" }, @@ -6084,11 +5638,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.saveMediaWait = new Promise((resolve) => { releaseSave = resolve; }); - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-image-serial-save", message: "serial please", requestParams: { @@ -6124,7 +5676,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("does not parse or offload attachments for stop commands", async () => { await createTranscriptFixture("openclaw-chat-send-stop-command-attachments-"); mockState.savedMediaResults = [{ path: "/tmp/should-not-exist.png", contentType: "image/png" }]; - const { context, respond } = createChatRequestFixture(); + const { context, respond, send } = createChatRequestFixture(); context.chatAbortControllers.set("run-same-session", { controller: new AbortController(), sessionId: "sess-prev", @@ -6133,9 +5685,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expiresAtMs: Date.now() + 10_000, }); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-stop-command-attachments", message: "/stop", requestParams: { @@ -6163,11 +5713,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("emits a user transcript update when chat.send completes without an agent run", async () => { await createGatewayUserTurnSqliteFixture("openclaw-chat-send-user-transcript-no-run-"); mockState.finalText = "ok"; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-no-run", message: "quick command", expectBroadcast: false, @@ -6186,11 +5734,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => it("emits a user transcript update when chat.send fails before an agent run starts", async () => { await createGatewayUserTurnSqliteFixture("openclaw-chat-send-user-transcript-error-no-run-"); mockState.dispatchError = new Error("upstream unavailable"); - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-error-no-run", message: "hello from failed dispatch", expectBroadcast: false, @@ -6214,11 +5760,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => "openclaw-chat-send-user-transcript-slash-error-no-run-", ); mockState.dispatchError = new Error("slash command continued into unavailable runtime"); - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-slash-error-no-run", message: "/unknown keep this user turn", expectBroadcast: false, @@ -6276,11 +5820,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ); mockState.hasBeforeAgentRunHooks = true; mockState.dispatchError = new Error("resolver unavailable"); - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-error-hook-pre-start", message: "hello before hooked startup failure", expectBroadcast: false, @@ -6302,11 +5844,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ); mockState.triggerAgentRunStart = true; mockState.dispatchErrorAfterAgentRunStart = new Error("cli backend unavailable"); - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-error-before-runtime-persist", message: "hello before cli startup failure", expectBroadcast: false, @@ -6333,11 +5873,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.triggerAgentRunStart = true; mockState.dispatchErrorAfterAgentRunStart = new Error("cli backend unavailable"); mockState.beforeMessageWriteContent = "[redacted by hook]"; - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-error-before-write-redact", message: "raw sensitive prompt", expectBroadcast: false, @@ -6362,11 +5900,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.triggerAgentRunStart = true; mockState.dispatchErrorAfterAgentRunStart = new Error("cli backend unavailable"); mockState.beforeMessageWriteBlock = true; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-error-before-write-block", message: "blocked sensitive prompt", expectBroadcast: false, @@ -6388,11 +5924,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ); mockState.triggerAgentRunStart = true; mockState.finalPayload = { text: "agent failed before prompt append", isError: true }; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-agent-error-no-runtime-persist", message: "hello before agent error payload", expectBroadcast: false, @@ -6419,11 +5953,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => setTimeout(() => reject(new Error("runtime prompt mirror failed")), 0); }); mockState.finalPayload = { text: "agent still answered" }; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-success-runtime-persist-failed", message: "hello before successful fallback", expectBroadcast: false, @@ -6451,11 +5983,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.triggerAgentRunStart = true; mockState.hasBeforeAgentRunHooks = true; mockState.finalPayload = { text: "agent failed before prompt append", isError: true }; - const { context, respond } = createChatRequestFixture(); + const { context, send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-user-transcript-agent-error-hook-pass", message: "hello before hooked agent error payload", expectBroadcast: false, @@ -6475,11 +6005,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => describe("chat.send operator UI client sender context", () => { it("does not inject sender identity fields for Control UI clients", async () => { await createGatewayUserTurnSqliteFixture("openclaw-chat-send-control-ui-sender-"); - const { context, respond } = createChatRequestFixture(); + const { send } = createChatRequestFixture(); - await runNonStreamingChatSend({ - context, - respond, + await send({ idempotencyKey: "idem-control-ui-sender", message: "hello from control ui", client: { @@ -6503,107 +6031,65 @@ describe("chat.send operator UI client sender context", () => { expect(mockState.lastTaskSuggestionDeliveryMode).toBe("gateway"); }); - it("enables task suggestions for TUI clients", async () => { - const { context, respond } = createChatRequestFixture(); - - await runNonStreamingChatSend({ - context, - respond, - idempotencyKey: "idem-tui-task-suggestions", + it.each([ + { + name: "enables task suggestions for TUI clients", + id: "tui", message: "hello from tui", - client: { - connect: { - client: { - id: GATEWAY_CLIENT_NAMES.TUI, - mode: GATEWAY_CLIENT_MODES.UI, - version: "dev", - platform: "terminal", - }, - caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], - scopes: ["operator.admin"], - }, - }, - expectBroadcast: false, - }); - - expect(mockState.lastTaskSuggestionDeliveryMode).toBe("gateway"); - }); - - it("withholds task suggestions from operator UI clients that cannot accept them", async () => { - const { context, respond } = createChatRequestFixture(); - - await runNonStreamingChatSend({ - context, - respond, - idempotencyKey: "idem-write-only-tui-task-suggestions", + clientId: GATEWAY_CLIENT_NAMES.TUI, + mode: GATEWAY_CLIENT_MODES.UI, + platform: "terminal", + scopes: ["operator.admin"], + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], + expected: "gateway", + }, + { + name: "withholds task suggestions from operator UI clients that cannot accept them", + id: "write-only-tui", message: "hello from a write-only tui", - client: { - connect: { - client: { - id: GATEWAY_CLIENT_NAMES.TUI, - mode: GATEWAY_CLIENT_MODES.UI, - version: "dev", - platform: "terminal", - }, - caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], - scopes: ["operator.write"], - }, - }, - expectBroadcast: false, - }); - - expect(mockState.lastTaskSuggestionDeliveryMode).toBeUndefined(); - }); - - it("withholds task suggestions from non-operator gateway clients", async () => { - const { context, respond } = createChatRequestFixture(); - - await runNonStreamingChatSend({ - context, - respond, - idempotencyKey: "idem-channel-task-suggestions", + clientId: GATEWAY_CLIENT_NAMES.TUI, + mode: GATEWAY_CLIENT_MODES.UI, + platform: "terminal", + scopes: ["operator.write"], + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], + expected: undefined, + }, + { + name: "withholds task suggestions from non-operator gateway clients", + id: "channel", message: "hello from a channel bridge", - client: { - connect: { - client: { - id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, - mode: GATEWAY_CLIENT_MODES.BACKEND, - version: "dev", - platform: "server", - }, - caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], - scopes: ["operator.write"], - }, - }, - expectBroadcast: false, - }); - - expect(mockState.lastTaskSuggestionDeliveryMode).toBeUndefined(); - }); - - it("withholds task suggestions from operator UI clients without action support", async () => { - const { context, respond } = createChatRequestFixture(); - - await runNonStreamingChatSend({ - context, - respond, - idempotencyKey: "idem-old-tui-task-suggestions", + clientId: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, + platform: "server", + scopes: ["operator.write"], + caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS], + expected: undefined, + }, + { + name: "withholds task suggestions from operator UI clients without action support", + id: "old-tui", message: "hello from an older tui", + clientId: GATEWAY_CLIENT_NAMES.TUI, + mode: GATEWAY_CLIENT_MODES.UI, + platform: "terminal", + scopes: ["operator.write"], + expected: undefined, + }, + ])("$name", async ({ id, message, clientId, mode, platform, scopes, caps, expected }) => { + const { send } = createChatRequestFixture(); + await send({ + idempotencyKey: `idem-${id}-task-suggestions`, + message, client: { connect: { - client: { - id: GATEWAY_CLIENT_NAMES.TUI, - mode: GATEWAY_CLIENT_MODES.UI, - version: "old", - platform: "terminal", - }, - scopes: ["operator.write"], + client: { id: clientId, mode, version: id === "old-tui" ? "old" : "dev", platform }, + ...(caps ? { caps } : {}), + scopes, }, }, expectBroadcast: false, }); - - expect(mockState.lastTaskSuggestionDeliveryMode).toBeUndefined(); + expect(mockState.lastTaskSuggestionDeliveryMode).toBe(expected); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index 0adcf79b21ba..10c86c12e7b6 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -213,6 +213,31 @@ function createTalkConfig(apiKey: unknown): OpenClawConfig { } as OpenClawConfig; } +type TalkHandlerCallOptions = { + params: Record; + respond: ReturnType; + context: unknown; + client?: unknown; + id?: string; +}; + +async function callTalkHandler( + method: keyof typeof talkHandlers, + { params, respond, context, client = { connId: "conn-1" }, id = "1" }: TalkHandlerCallOptions, +) { + await expectDefined( + talkHandlers[method], + `talkHandlers["${method}"] test invariant`, + )({ + req: { type: "req", id, method }, + params: params as never, + client: client as never, + isWebchatConnect: () => false, + respond: respond as never, + context: context as never, + }); +} + function expectRecordFields(record: unknown, expected: Record) { if (!record || typeof record !== "object") { throw new Error("Expected record"); @@ -341,15 +366,10 @@ describe("talk.catalog handler", () => { } as never); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.catalog"], - 'talkHandlers["talk.catalog"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.catalog" }, + await callTalkHandler("talk.catalog", { params: {}, - client: { connect: { scopes: ["operator.read"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, + client: { connect: { scopes: ["operator.read"] } }, + respond, context: { getRuntimeConfig: () => ({ @@ -377,7 +397,7 @@ describe("talk.catalog handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expect(respond).toHaveBeenCalledWith( @@ -484,15 +504,11 @@ describe("talk.catalog handler", () => { } as never); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.catalog"], - 'talkHandlers["talk.catalog"] test invariant', - )({ - req: { type: "req", id: "relay-catalog", method: "talk.catalog" }, + await callTalkHandler("talk.catalog", { params: {}, - client: { connect: { scopes: ["operator.read"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, + id: "relay-catalog", + client: { connect: { scopes: ["operator.read"] } }, + respond, context: { getRuntimeConfig: () => ({ @@ -503,7 +519,7 @@ describe("talk.catalog handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expect(mocks.resolveConfiguredRealtimeVoiceProvider).toHaveBeenCalledWith( @@ -559,15 +575,10 @@ describe("talk.catalog handler", () => { } as never); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.catalog"], - 'talkHandlers["talk.catalog"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.catalog" }, + await callTalkHandler("talk.catalog", { params: {}, - client: { connect: { scopes: ["operator.read"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, + client: { connect: { scopes: ["operator.read"] } }, + respond, context: { getRuntimeConfig: () => ({ @@ -599,7 +610,7 @@ describe("talk.catalog handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expect(mockCallArg(respond, 0, 1)).toMatchObject({ @@ -633,15 +644,10 @@ describe("talk.catalog handler", () => { ); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.catalog"], - 'talkHandlers["talk.catalog"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.catalog" }, + await callTalkHandler("talk.catalog", { params: {}, - client: { connect: { scopes: ["operator.read"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, + client: { connect: { scopes: ["operator.read"] } }, + respond, context: { getRuntimeConfig: () => ({ @@ -657,7 +663,7 @@ describe("talk.catalog handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expect(mockCallArg(respond, 0, 1)).toMatchObject({ @@ -710,15 +716,10 @@ describe("talk.catalog handler", () => { } as never); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.catalog"], - 'talkHandlers["talk.catalog"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.catalog" }, + await callTalkHandler("talk.catalog", { params: {}, - client: { connect: { scopes: ["operator.read"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, + client: { connect: { scopes: ["operator.read"] } }, + respond, context: { getRuntimeConfig: () => ({ @@ -741,7 +742,7 @@ describe("talk.catalog handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expect(mockCallArg(respond, 0, 1)).toMatchObject({ @@ -771,16 +772,11 @@ describe("talk.catalog handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.catalog"], - 'talkHandlers["talk.catalog"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.catalog" }, + await callTalkHandler("talk.catalog", { params: {}, - client: { connect: { scopes: ["operator.read"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + client: { connect: { scopes: ["operator.read"] } }, + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); const catalog = mockCallArg(respond, 0, 1) as Record>; @@ -811,15 +807,10 @@ describe("talk.catalog handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.catalog"], - 'talkHandlers["talk.catalog"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.catalog" }, + await callTalkHandler("talk.catalog", { params: {}, - client: { connect: { scopes: ["operator.read"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, + client: { connect: { scopes: ["operator.read"] } }, + respond, context: { getRuntimeConfig: () => ({ @@ -830,7 +821,7 @@ describe("talk.catalog handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expect(mockCallArg(respond, 0, 1)).toMatchObject({ @@ -923,16 +914,11 @@ describe("talk.speak handler", () => { ); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.speak"], - 'talkHandlers["talk.speak"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.speak" }, + await callTalkHandler("talk.speak", { params: { text: "Hello from talk mode." }, client: null, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => runtimeConfig } as never, + respond, + context: { getRuntimeConfig: () => runtimeConfig }, }); expect(mocks.getRuntimeConfig).not.toHaveBeenCalled(); @@ -965,15 +951,10 @@ describe("talk.config handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.config"], - 'talkHandlers["talk.config"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.config" }, + await callTalkHandler("talk.config", { params: {}, - client: { connect: { scopes: ["operator.read"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, + client: { connect: { scopes: ["operator.read"] } }, + respond, context: { getRuntimeConfig: () => ({ @@ -981,7 +962,7 @@ describe("talk.config handler", () => { realtime: { transport: "provider-websocket" }, }, }) as OpenClawConfig, - } as never, + }, }); const response = expectRespondOk(respond) as { config?: { talk?: Record } }; @@ -1072,16 +1053,11 @@ describe("talk.config handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.config"], - 'talkHandlers["talk.config"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.config" }, + await callTalkHandler("talk.config", { params: {}, - client: { connect: { scopes: ["operator.read"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => runtimeConfig } as never, + client: { connect: { scopes: ["operator.read"] } }, + respond, + context: { getRuntimeConfig: () => runtimeConfig }, }); const response = expectRespondOk(respond) as { config?: { talk?: Record } }; @@ -1183,16 +1159,11 @@ describe("talk.config handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.config"], - 'talkHandlers["talk.config"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.config" }, + await callTalkHandler("talk.config", { params: {}, - client: { connect: { scopes: ["operator.read"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => runtimeConfig } as never, + client: { connect: { scopes: ["operator.read"] } }, + respond, + context: { getRuntimeConfig: () => runtimeConfig }, }); const response = expectRespondOk(respond) as { config?: { talk?: Record } }; @@ -1220,16 +1191,11 @@ describe("talk.config handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.config"], - 'talkHandlers["talk.config"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.config" }, + await callTalkHandler("talk.config", { params: { includeSecrets: true }, - client: { connect: { scopes: ["operator.talk.secrets"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => runtimeConfig } as never, + client: { connect: { scopes: ["operator.talk.secrets"] } }, + respond, + context: { getRuntimeConfig: () => runtimeConfig }, }); const response = expectRespondOk(respond) as { config?: { talk?: Record } }; @@ -1306,16 +1272,11 @@ describe("talk.config handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.config"], - 'talkHandlers["talk.config"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.config" }, + await callTalkHandler("talk.config", { params: { includeSecrets: true }, - client: { connect: { scopes: ["operator.talk.secrets"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => runtimeConfig } as never, + client: { connect: { scopes: ["operator.talk.secrets"] } }, + respond, + context: { getRuntimeConfig: () => runtimeConfig }, }); const response = expectRespondOk(respond) as { config?: { talk?: Record } }; @@ -1378,16 +1339,11 @@ describe("talk.config handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.config"], - 'talkHandlers["talk.config"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.config" }, + await callTalkHandler("talk.config", { params: { includeSecrets: true }, - client: { connect: { scopes: ["operator.talk.secrets"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => runtimeConfig } as never, + client: { connect: { scopes: ["operator.talk.secrets"] } }, + respond, + context: { getRuntimeConfig: () => runtimeConfig }, }); const response = expectRespondOk(respond) as { config?: { talk?: Record } }; @@ -1464,16 +1420,11 @@ describe("talk.config handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.config"], - 'talkHandlers["talk.config"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.config" }, + await callTalkHandler("talk.config", { params: { includeSecrets: true }, - client: { connect: { scopes: ["operator.talk.secrets"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => runtimeConfig } as never, + client: { connect: { scopes: ["operator.talk.secrets"] } }, + respond, + context: { getRuntimeConfig: () => runtimeConfig }, }); const response = expectRespondOk(respond) as { config?: { talk?: Record } }; @@ -1512,16 +1463,11 @@ describe("talk.config handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.config"], - 'talkHandlers["talk.config"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.config" }, + await callTalkHandler("talk.config", { params: {}, - client: { connect: { scopes: ["operator.read"] } } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => runtimeConfig } as never, + client: { connect: { scopes: ["operator.read"] } }, + respond, + context: { getRuntimeConfig: () => runtimeConfig }, }); const response = expectRespondOk(respond) as { config?: { talk?: Record } }; @@ -1605,11 +1551,7 @@ describe("talk.session unified handlers", () => { }); const createRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.create"], - 'talkHandlers["talk.session.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.session.create" }, + await callTalkHandler("talk.session.create", { params: { sessionKey: "agent:main:main", mode: "realtime", @@ -1620,9 +1562,7 @@ describe("talk.session unified handlers", () => { voice: "alloy", language: "de", }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: createRespond as never, + respond: createRespond, context: { getRuntimeConfig: () => ({ @@ -1640,7 +1580,7 @@ describe("talk.session unified handlers", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expectRecordFields(mockCallArg(mocks.resolveConfiguredRealtimeVoiceProvider), { @@ -1678,16 +1618,11 @@ describe("talk.session unified handlers", () => { }); const inputRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.appendAudio"], - 'talkHandlers["talk.session.appendAudio"] test invariant', - )({ - req: { type: "req", id: "2", method: "talk.session.appendAudio" }, + await callTalkHandler("talk.session.appendAudio", { params: { sessionId: "relay-unified-1", audioBase64: "aGVsbG8=", timestamp: 42 }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: inputRespond as never, - context: {} as never, + id: "2", + respond: inputRespond, + context: {}, }); expect(mocks.sendTalkRealtimeRelayAudio).toHaveBeenCalledWith({ relaySessionId: "relay-unified-1", @@ -1697,16 +1632,11 @@ describe("talk.session unified handlers", () => { }); const cancelRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.cancelOutput"], - 'talkHandlers["talk.session.cancelOutput"] test invariant', - )({ - req: { type: "req", id: "3", method: "talk.session.cancelOutput" }, + await callTalkHandler("talk.session.cancelOutput", { params: { sessionId: "relay-unified-1", reason: "barge-in" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: cancelRespond as never, - context: {} as never, + id: "3", + respond: cancelRespond, + context: {}, }); expect(mocks.cancelTalkRealtimeRelayTurn).toHaveBeenCalledWith({ relaySessionId: "relay-unified-1", @@ -1715,16 +1645,11 @@ describe("talk.session unified handlers", () => { }); const markRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.acknowledgeMark"], - 'talkHandlers["talk.session.acknowledgeMark"] test invariant', - )({ - req: { type: "req", id: "3-mark", method: "talk.session.acknowledgeMark" }, + await callTalkHandler("talk.session.acknowledgeMark", { params: { sessionId: "relay-unified-1", markName: "audio-mark-1" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: markRespond as never, - context: {} as never, + id: "3-mark", + respond: markRespond, + context: {}, }); expect(mocks.acknowledgeTalkRealtimeRelayMark).toHaveBeenCalledWith({ relaySessionId: "relay-unified-1", @@ -1772,20 +1697,15 @@ describe("talk.session unified handlers", () => { new Error("provider rejected tool result"), ); const rejectedToolRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.submitToolResult"], - 'talkHandlers["talk.session.submitToolResult"] test invariant', - )({ - req: { type: "req", id: "4-rejected", method: "talk.session.submitToolResult" }, + await callTalkHandler("talk.session.submitToolResult", { params: { sessionId: "relay-unified-1", callId: "call-rejected", result: { ok: true }, }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: rejectedToolRespond as never, - context: {} as never, + id: "4-rejected", + respond: rejectedToolRespond, + context: {}, }); expectRespondError(rejectedToolRespond, { code: ErrorCodes.UNAVAILABLE, @@ -1793,21 +1713,16 @@ describe("talk.session unified handlers", () => { }); const steerRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.steer"], - 'talkHandlers["talk.session.steer"] test invariant', - )({ - req: { type: "req", id: "5", method: "talk.session.steer" }, + await callTalkHandler("talk.session.steer", { params: { sessionId: "relay-unified-1", sessionKey: "agent:main:main", text: "use the safer plan", mode: "steer", }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: steerRespond as never, - context: {} as never, + id: "5", + respond: steerRespond, + context: {}, }); expect(mocks.steerTalkRealtimeRelayAgentRun).toHaveBeenCalledWith({ relaySessionId: "relay-unified-1", @@ -1823,16 +1738,11 @@ describe("talk.session unified handlers", () => { }); const closeRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.close"], - 'talkHandlers["talk.session.close"] test invariant', - )({ - req: { type: "req", id: "6", method: "talk.session.close" }, + await callTalkHandler("talk.session.close", { params: { sessionId: "relay-unified-1" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: closeRespond as never, - context: {} as never, + id: "6", + respond: closeRespond, + context: {}, }); expect(mocks.stopTalkRealtimeRelaySession).toHaveBeenCalledWith({ relaySessionId: "relay-unified-1", @@ -1857,11 +1767,7 @@ describe("talk.session unified handlers", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.create"], - 'talkHandlers["talk.session.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.session.create" }, + await callTalkHandler("talk.session.create", { params: { mode: "realtime", transport: "gateway-relay", @@ -1869,9 +1775,7 @@ describe("talk.session unified handlers", () => { provider: "openai", model: "gpt-realtime-2", }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({ @@ -1882,7 +1786,7 @@ describe("talk.session unified handlers", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); const error = expectRespondError(respond, { @@ -1919,15 +1823,9 @@ describe("talk.session unified handlers", () => { }); const createRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.create"], - 'talkHandlers["talk.session.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.session.create" }, + await callTalkHandler("talk.session.create", { params: { mode: "transcription", provider: "openai-realtime" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: createRespond as never, + respond: createRespond, context: { getRuntimeConfig: () => ({ @@ -1949,7 +1847,7 @@ describe("talk.session unified handlers", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expectRespondOk(createRespond, { @@ -1968,16 +1866,11 @@ describe("talk.session unified handlers", () => { model: "gpt-4o-mini-transcribe", }); const inputRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.appendAudio"], - 'talkHandlers["talk.session.appendAudio"] test invariant', - )({ - req: { type: "req", id: "2", method: "talk.session.appendAudio" }, + await callTalkHandler("talk.session.appendAudio", { params: { sessionId: "stt-unified-1", audioBase64: "aGVsbG8=" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: inputRespond as never, - context: {} as never, + id: "2", + respond: inputRespond, + context: {}, }); expect(mocks.sendTalkTranscriptionRelayAudio).toHaveBeenCalledWith({ transcriptionSessionId: "stt-unified-1", @@ -1986,16 +1879,11 @@ describe("talk.session unified handlers", () => { }); const closeRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.close"], - 'talkHandlers["talk.session.close"] test invariant', - )({ - req: { type: "req", id: "3", method: "talk.session.close" }, + await callTalkHandler("talk.session.close", { params: { sessionId: "stt-unified-1" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: closeRespond as never, - context: {} as never, + id: "3", + respond: closeRespond, + context: {}, }); expect(mocks.stopTalkTranscriptionRelaySession).toHaveBeenCalledWith({ transcriptionSessionId: "stt-unified-1", @@ -2028,15 +1916,9 @@ describe("talk.session unified handlers", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.create"], - 'talkHandlers["talk.session.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.session.create" }, + await callTalkHandler("talk.session.create", { params: { mode: "transcription", transport: "gateway-relay", brain: "none" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({ @@ -2053,7 +1935,7 @@ describe("talk.session unified handlers", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expectRespondOk(respond, { @@ -2069,23 +1951,18 @@ describe("talk.session unified handlers", () => { it("creates and controls managed-room sessions through the unified API", async () => { const broadcastToConnIds = vi.fn(); const createRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.create"], - 'talkHandlers["talk.session.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.session.create" }, + await callTalkHandler("talk.session.create", { params: { mode: "stt-tts", transport: "managed-room", sessionKey: "session:main", ttlMs: 5000, }, - client: { connId: "conn-1", connect: { scopes: ["operator.admin"] } } as never, - isWebchatConnect: () => false, - respond: createRespond as never, + client: { connId: "conn-1", connect: { scopes: ["operator.admin"] } }, + respond: createRespond, context: { getRuntimeConfig: () => ({}) as OpenClawConfig, - } as never, + }, }); const session = mockCallArg(createRespond, 0, 1) as { sessionId: string; token: string }; @@ -2107,18 +1984,13 @@ describe("talk.session unified handlers", () => { }); const joinRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.join"], - 'talkHandlers["talk.session.join"] test invariant', - )({ - req: { type: "req", id: "2", method: "talk.session.join" }, + await callTalkHandler("talk.session.join", { params: { sessionId: session.sessionId, token: session.token }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: joinRespond as never, + id: "2", + respond: joinRespond, context: { broadcastToConnIds, - } as never, + }, }); const joinResult = expectRespondOk(joinRespond, { id: session.sessionId }) as { room?: Record; @@ -2133,19 +2005,14 @@ describe("talk.session unified handlers", () => { expect(mockCallArg(broadcastToConnIds, 0, 3)).toEqual({ dropIfSlow: true }); const startRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.startTurn"], - 'talkHandlers["talk.session.startTurn"] test invariant', - )({ - req: { type: "req", id: "3", method: "talk.session.startTurn" }, + await callTalkHandler("talk.session.startTurn", { params: { sessionId: session.sessionId, turnId: "turn-1" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: startRespond as never, + id: "3", + respond: startRespond, context: { getRuntimeConfig: () => ({}) as OpenClawConfig, broadcastToConnIds, - } as never, + }, }); const startResult = expectRespondOk(startRespond, { ok: true, turnId: "turn-1" }) as { @@ -2165,23 +2032,18 @@ describe("talk.session unified handlers", () => { expect(mockCallArg(broadcastToConnIds, 1, 3)).toEqual({ dropIfSlow: true }); const mismatchedSteerRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.steer"], - 'talkHandlers["talk.session.steer"] test invariant', - )({ - req: { type: "req", id: "4", method: "talk.session.steer" }, + await callTalkHandler("talk.session.steer", { params: { sessionId: session.sessionId, sessionKey: "session:other", text: "use the safer plan", mode: "steer", }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: mismatchedSteerRespond as never, + id: "4", + respond: mismatchedSteerRespond, context: { broadcastToConnIds, - } as never, + }, }); expectRespondError(mismatchedSteerRespond, { code: ErrorCodes.INVALID_REQUEST, @@ -2190,22 +2052,17 @@ describe("talk.session unified handlers", () => { expect(mocks.controlRealtimeVoiceAgentRun).not.toHaveBeenCalled(); const steerRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.steer"], - 'talkHandlers["talk.session.steer"] test invariant', - )({ - req: { type: "req", id: "5", method: "talk.session.steer" }, + await callTalkHandler("talk.session.steer", { params: { sessionId: session.sessionId, text: "use the safer plan", mode: "steer", }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: steerRespond as never, + id: "5", + respond: steerRespond, context: { broadcastToConnIds, - } as never, + }, }); expect(mocks.controlRealtimeVoiceAgentRun).toHaveBeenCalledWith({ sessionKey: "session:main", @@ -2220,18 +2077,13 @@ describe("talk.session unified handlers", () => { }); const closeRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.close"], - 'talkHandlers["talk.session.close"] test invariant', - )({ - req: { type: "req", id: "6", method: "talk.session.close" }, + await callTalkHandler("talk.session.close", { params: { sessionId: session.sessionId }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: closeRespond as never, + id: "6", + respond: closeRespond, context: { broadcastToConnIds, - } as never, + }, }); expect(closeRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); expect(mockCallArg(broadcastToConnIds, 2)).toBe("talk.event"); @@ -2245,23 +2097,18 @@ describe("talk.session unified handlers", () => { it("passes managed-room spawnedBy visibility scope to session resolution", async () => { const createRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.create"], - 'talkHandlers["talk.session.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.session.create" }, + await callTalkHandler("talk.session.create", { params: { mode: "stt-tts", transport: "managed-room", sessionKey: "agent:worker:subagent:child", spawnedBy: "agent:main:parent", }, - client: { connId: "conn-1", connect: { scopes: ["operator.write"] } } as never, - isWebchatConnect: () => false, - respond: createRespond as never, + client: { connId: "conn-1", connect: { scopes: ["operator.write"] } }, + respond: createRespond, context: { getRuntimeConfig: () => ({}) as OpenClawConfig, - } as never, + }, }); expectRespondOk(createRespond, { @@ -2281,22 +2128,17 @@ describe("talk.session unified handlers", () => { it("rejects unscoped managed-room session keys without admin scope", async () => { const createRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.create"], - 'talkHandlers["talk.session.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.session.create" }, + await callTalkHandler("talk.session.create", { params: { mode: "stt-tts", transport: "managed-room", sessionKey: "agent:worker:main", }, - client: { connId: "conn-1", connect: { scopes: ["operator.write"] } } as never, - isWebchatConnect: () => false, - respond: createRespond as never, + client: { connId: "conn-1", connect: { scopes: ["operator.write"] } }, + respond: createRespond, context: { getRuntimeConfig: () => ({}) as OpenClawConfig, - } as never, + }, }); expectRespondError(createRespond, { @@ -2310,94 +2152,67 @@ describe("talk.session unified handlers", () => { it("requires managed-room ownership before turn control", async () => { const broadcastToConnIds = vi.fn(); const createRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.create"], - 'talkHandlers["talk.session.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.session.create" }, + await callTalkHandler("talk.session.create", { params: { mode: "stt-tts", transport: "managed-room", sessionKey: "session:main", }, - client: { connId: "creator", connect: { scopes: ["operator.admin"] } } as never, - isWebchatConnect: () => false, - respond: createRespond as never, + client: { connId: "creator", connect: { scopes: ["operator.admin"] } }, + respond: createRespond, context: { getRuntimeConfig: () => ({}) as OpenClawConfig, - } as never, + }, }); const session = mockCallArg(createRespond, 0, 1) as { sessionId: string; token: string }; const unjoinedStartRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.startTurn"], - 'talkHandlers["talk.session.startTurn"] test invariant', - )({ - req: { type: "req", id: "2", method: "talk.session.startTurn" }, + await callTalkHandler("talk.session.startTurn", { params: { sessionId: session.sessionId, turnId: "turn-1" }, - client: { connId: "creator" } as never, - isWebchatConnect: () => false, - respond: unjoinedStartRespond as never, - context: { broadcastToConnIds } as never, + id: "2", + client: { connId: "creator" }, + respond: unjoinedStartRespond, + context: { broadcastToConnIds }, }); expectRespondError(unjoinedStartRespond, { code: ErrorCodes.INVALID_REQUEST, message: "talk.session.startTurn requires the active managed-room connection", }); - await expectDefined( - talkHandlers["talk.session.join"], - 'talkHandlers["talk.session.join"] test invariant', - )({ - req: { type: "req", id: "3", method: "talk.session.join" }, + await callTalkHandler("talk.session.join", { params: { sessionId: session.sessionId, token: session.token }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: vi.fn() as never, - context: { broadcastToConnIds } as never, + id: "3", + respond: vi.fn(), + context: { broadcastToConnIds }, }); const staleStartRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.startTurn"], - 'talkHandlers["talk.session.startTurn"] test invariant', - )({ - req: { type: "req", id: "4", method: "talk.session.startTurn" }, + await callTalkHandler("talk.session.startTurn", { params: { sessionId: session.sessionId, turnId: "turn-1" }, - client: { connId: "conn-2" } as never, - isWebchatConnect: () => false, - respond: staleStartRespond as never, - context: { broadcastToConnIds } as never, + id: "4", + client: { connId: "conn-2" }, + respond: staleStartRespond, + context: { broadcastToConnIds }, }); expectRespondError(staleStartRespond, { code: ErrorCodes.INVALID_REQUEST, message: "talk.session.startTurn requires the active managed-room connection", }); - await expectDefined( - talkHandlers["talk.session.startTurn"], - 'talkHandlers["talk.session.startTurn"] test invariant', - )({ - req: { type: "req", id: "5", method: "talk.session.startTurn" }, + await callTalkHandler("talk.session.startTurn", { params: { sessionId: session.sessionId, turnId: "turn-1" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: vi.fn() as never, - context: { broadcastToConnIds } as never, + id: "5", + respond: vi.fn(), + context: { broadcastToConnIds }, }); const staleEndRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.endTurn"], - 'talkHandlers["talk.session.endTurn"] test invariant', - )({ - req: { type: "req", id: "6", method: "talk.session.endTurn" }, + await callTalkHandler("talk.session.endTurn", { params: { sessionId: session.sessionId, turnId: "turn-1" }, - client: { connId: "conn-2" } as never, - isWebchatConnect: () => false, - respond: staleEndRespond as never, - context: { broadcastToConnIds } as never, + id: "6", + client: { connId: "conn-2" }, + respond: staleEndRespond, + context: { broadcastToConnIds }, }); expectRespondError(staleEndRespond, { code: ErrorCodes.INVALID_REQUEST, @@ -2405,16 +2220,12 @@ describe("talk.session unified handlers", () => { }); const staleCancelRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.cancelTurn"], - 'talkHandlers["talk.session.cancelTurn"] test invariant', - )({ - req: { type: "req", id: "7", method: "talk.session.cancelTurn" }, + await callTalkHandler("talk.session.cancelTurn", { params: { sessionId: session.sessionId, turnId: "turn-1" }, - client: { connId: "conn-2" } as never, - isWebchatConnect: () => false, - respond: staleCancelRespond as never, - context: { broadcastToConnIds } as never, + id: "7", + client: { connId: "conn-2" }, + respond: staleCancelRespond, + context: { broadcastToConnIds }, }); expectRespondError(staleCancelRespond, { code: ErrorCodes.INVALID_REQUEST, @@ -2422,54 +2233,40 @@ describe("talk.session unified handlers", () => { }); const staleCloseRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.close"], - 'talkHandlers["talk.session.close"] test invariant', - )({ - req: { type: "req", id: "8", method: "talk.session.close" }, + await callTalkHandler("talk.session.close", { params: { sessionId: session.sessionId }, - client: { connId: "conn-2" } as never, - isWebchatConnect: () => false, - respond: staleCloseRespond as never, - context: { broadcastToConnIds } as never, + id: "8", + client: { connId: "conn-2" }, + respond: staleCloseRespond, + context: { broadcastToConnIds }, }); expectRespondError(staleCloseRespond, { code: ErrorCodes.INVALID_REQUEST, message: "talk.session.close requires the active managed-room connection", }); - await expectDefined( - talkHandlers["talk.session.close"], - 'talkHandlers["talk.session.close"] test invariant', - )({ - req: { type: "req", id: "9", method: "talk.session.close" }, + await callTalkHandler("talk.session.close", { params: { sessionId: session.sessionId }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: vi.fn() as never, - context: { broadcastToConnIds } as never, + id: "9", + respond: vi.fn(), + context: { broadcastToConnIds }, }); }); it("keeps direct-tools managed-room sessions behind admin scope", async () => { const rejectedRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.create"], - 'talkHandlers["talk.session.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.session.create" }, + await callTalkHandler("talk.session.create", { params: { mode: "stt-tts", transport: "managed-room", brain: "direct-tools", sessionKey: "session:main", }, - client: { connId: "conn-1", connect: { scopes: ["operator.write"] } } as never, - isWebchatConnect: () => false, - respond: rejectedRespond as never, + client: { connId: "conn-1", connect: { scopes: ["operator.write"] } }, + respond: rejectedRespond, context: { getRuntimeConfig: () => ({}) as OpenClawConfig, - } as never, + }, }); expectRespondError(rejectedRespond, { @@ -2479,23 +2276,19 @@ describe("talk.session unified handlers", () => { expect(mocks.resolveSessionKeyFromResolveParams).not.toHaveBeenCalled(); const createRespond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.create"], - 'talkHandlers["talk.session.create"] test invariant', - )({ - req: { type: "req", id: "2", method: "talk.session.create" }, + await callTalkHandler("talk.session.create", { params: { mode: "stt-tts", transport: "managed-room", brain: "direct-tools", sessionKey: "session:main", }, - client: { connId: "conn-1", connect: { scopes: ["operator.admin"] } } as never, - isWebchatConnect: () => false, - respond: createRespond as never, + id: "2", + client: { connId: "conn-1", connect: { scopes: ["operator.admin"] } }, + respond: createRespond, context: { getRuntimeConfig: () => ({}) as OpenClawConfig, - } as never, + }, }); const session = mockCallArg(createRespond, 0, 1) as { sessionId: string }; @@ -2505,31 +2298,21 @@ describe("talk.session unified handlers", () => { }) as Record; expect(createResult.sessionId).toBeTypeOf("string"); - await expectDefined( - talkHandlers["talk.session.close"], - 'talkHandlers["talk.session.close"] test invariant', - )({ - req: { type: "req", id: "3", method: "talk.session.close" }, + await callTalkHandler("talk.session.close", { params: { sessionId: session.sessionId }, - client: { connId: "conn-1", connect: { scopes: ["operator.admin"] } } as never, - isWebchatConnect: () => false, - respond: vi.fn() as never, - context: {} as never, + id: "3", + client: { connId: "conn-1", connect: { scopes: ["operator.admin"] } }, + respond: vi.fn(), + context: {}, }); }); it("keeps browser-owned transports on the client session endpoint", async () => { const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.session.create"], - 'talkHandlers["talk.session.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.session.create" }, + await callTalkHandler("talk.session.create", { params: { mode: "realtime", transport: "webrtc" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); const error = expectRespondError(respond, { code: ErrorCodes.INVALID_REQUEST }); @@ -2554,21 +2337,15 @@ describe("talk.client.toolCall handler", () => { it("implicitly creates a voice session for consults without a binding", async () => { const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.toolCall"], - 'talkHandlers["talk.client.toolCall"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.toolCall" }, + await callTalkHandler("talk.client.toolCall", { params: { sessionKey: "main", callId: "call-unbound", name: "openclaw_agent_consult", args: { question: "Do something" }, }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); expect(mocks.createOrResumeClientVoiceSession).toHaveBeenCalledWith({ @@ -2587,21 +2364,17 @@ describe("talk.client.toolCall handler", () => { mocks.resolveOpenClientVoiceSessionId.mockReturnValueOnce("voice-test"); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.toolCall"], - 'talkHandlers["talk.client.toolCall"] test invariant', - )({ - req: { type: "req", id: "legacy", method: "talk.client.toolCall" }, + await callTalkHandler("talk.client.toolCall", { params: { sessionKey: "main", callId: "call-legacy", name: "openclaw_agent_consult", args: { question: "Continue the call" }, }, - client: { connId: "conn-legacy" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + id: "legacy", + client: { connId: "conn-legacy" }, + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); expect(mocks.assertClientVoiceSessionOpen).toHaveBeenCalledWith({ @@ -2616,11 +2389,7 @@ describe("talk.client.toolCall handler", () => { mocks.assertClientVoiceSessionOpen.mockReturnValueOnce("relay"); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.toolCall"], - 'talkHandlers["talk.client.toolCall"] test invariant', - )({ - req: { type: "req", id: "relay-owner", method: "talk.client.toolCall" }, + await callTalkHandler("talk.client.toolCall", { params: { sessionKey: "main", voiceSessionId: "relay-secret", @@ -2628,10 +2397,10 @@ describe("talk.client.toolCall handler", () => { name: "openclaw_agent_consult", args: { question: "Continue" }, }, - client: { connId: "other-conn" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + id: "relay-owner", + client: { connId: "other-conn" }, + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); expect(mocks.chatSend).not.toHaveBeenCalled(); @@ -2644,11 +2413,7 @@ describe("talk.client.toolCall handler", () => { it("starts agent consult through gateway policy instead of exposing chat.send to browser clients", async () => { const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.toolCall"], - 'talkHandlers["talk.client.toolCall"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.toolCall" }, + await callTalkHandler("talk.client.toolCall", { params: { sessionKey: "main", voiceSessionId: "voice-test", @@ -2656,12 +2421,10 @@ describe("talk.client.toolCall handler", () => { name: "openclaw_agent_consult", args: { question: "What is in this repo?", responseStyle: "one sentence" }, }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({}) as OpenClawConfig, - } as never, + }, }); const chatInput = mockCallArg(mocks.chatSend) as { @@ -2687,11 +2450,7 @@ describe("talk.client.toolCall handler", () => { ); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.toolCall"], - 'talkHandlers["talk.client.toolCall"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.toolCall" }, + await callTalkHandler("talk.client.toolCall", { params: { sessionKey: "main", voiceSessionId: "voice-test", @@ -2699,13 +2458,11 @@ describe("talk.client.toolCall handler", () => { name: "openclaw_agent_consult", args: { question: "What is running?" }, }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({}) as OpenClawConfig, logGateway: { warn: vi.fn() }, - } as never, + }, }); expectRespondOk(respond, { runId: "run-active" }); @@ -2715,11 +2472,7 @@ describe("talk.client.toolCall handler", () => { it("passes configured consult thinking and fast-mode overrides to chat.send", async () => { const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.toolCall"], - 'talkHandlers["talk.client.toolCall"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.toolCall" }, + await callTalkHandler("talk.client.toolCall", { params: { sessionKey: "main", voiceSessionId: "voice-test", @@ -2727,9 +2480,7 @@ describe("talk.client.toolCall handler", () => { name: "openclaw_agent_consult", args: { question: "Are the basement lights off?" }, }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({ @@ -2738,7 +2489,7 @@ describe("talk.client.toolCall handler", () => { consultFastMode: true, }, }) as OpenClawConfig, - } as never, + }, }); const chatInput = mockCallArg(mocks.chatSend) as { params?: Record }; @@ -2752,11 +2503,7 @@ describe("talk.client.toolCall handler", () => { it("links relay-owned agent consult runs so relay cancellation can abort them", async () => { const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.toolCall"], - 'talkHandlers["talk.client.toolCall"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.toolCall" }, + await callTalkHandler("talk.client.toolCall", { params: { sessionKey: "main", voiceSessionId: "relay-1", @@ -2765,12 +2512,10 @@ describe("talk.client.toolCall handler", () => { name: "openclaw_agent_consult", args: { question: "What now?" }, }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({}) as OpenClawConfig, - } as never, + }, }); expect(mocks.registerTalkRealtimeRelayAgentRun).toHaveBeenCalledWith({ @@ -2801,11 +2546,7 @@ describe("talk.client.toolCall handler", () => { ); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.toolCall"], - 'talkHandlers["talk.client.toolCall"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.toolCall" }, + await callTalkHandler("talk.client.toolCall", { params: { sessionKey: "main", voiceSessionId: "relay-1", @@ -2814,12 +2555,10 @@ describe("talk.client.toolCall handler", () => { name: "openclaw_agent_consult", args: { question: "What now?" }, }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({}) as OpenClawConfig, - } as never, + }, }); expect(mocks.registerTalkRealtimeRelayAgentRun).not.toHaveBeenCalled(); @@ -2833,22 +2572,16 @@ describe("talk.client.toolCall handler", () => { it("rejects client tool calls that are not the agent consult tool", async () => { const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.toolCall"], - 'talkHandlers["talk.client.toolCall"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.toolCall" }, + await callTalkHandler("talk.client.toolCall", { params: { sessionKey: "main", callId: "call-1", name: "unknown_tool", }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({}) as OpenClawConfig, - } as never, + }, }); expect(mocks.chatSend).not.toHaveBeenCalled(); @@ -2897,19 +2630,13 @@ describe("talk.client.steer handler", () => { it("routes browser-owned voice steering through the shared agent control helper", async () => { const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.steer"], - 'talkHandlers["talk.client.steer"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.steer" }, + await callTalkHandler("talk.client.steer", { params: { sessionKey: "agent:main:main", text: "use the safer plan", mode: "steer", }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: createSteerContext(), }); @@ -2928,19 +2655,13 @@ describe("talk.client.steer handler", () => { it("rejects steering for a session key owned by another connection", async () => { const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.steer"], - 'talkHandlers["talk.client.steer"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.steer" }, + await callTalkHandler("talk.client.steer", { params: { sessionKey: "agent:main:main", text: "use the safer plan", mode: "steer", }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: createSteerContext("conn-2"), }); @@ -2954,19 +2675,13 @@ describe("talk.client.steer handler", () => { it("rejects malformed client steering params", async () => { const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.steer"], - 'talkHandlers["talk.client.steer"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.steer" }, + await callTalkHandler("talk.client.steer", { params: { sessionKey: "agent:main:main", text: "", }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: {} as never, + respond, + context: {}, }); expect(mocks.controlRealtimeVoiceAgentRun).not.toHaveBeenCalled(); @@ -3032,11 +2747,7 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main", vadThreshold: 0.45, @@ -3044,9 +2755,7 @@ describe("talk.client.create handler", () => { prefixPaddingMs: 250, reasoningEffort: "low", }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({ @@ -3062,7 +2771,7 @@ describe("talk.client.create handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expectRecordFields(mockCallArg(mocks.resolveConfiguredRealtimeVoiceProvider), { @@ -3153,15 +2862,10 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "codex", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main", transport: "webrtc" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + id: "codex", + respond, context: { getRuntimeConfig: () => ({ @@ -3173,7 +2877,7 @@ describe("talk.client.create handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); const createInput = mockCallArg(createBrowserSession) as Record; @@ -3201,16 +2905,11 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "startup-failure", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main", transport: "webrtc" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + id: "startup-failure", + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); expect(createBrowserSession).toHaveBeenCalledWith( @@ -3242,16 +2941,11 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "persist-failure", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main", transport: "webrtc" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + id: "persist-failure", + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); expect(mocks.cancelInternalRealtimeVoiceBrowserSession).toHaveBeenCalledWith({ @@ -3283,16 +2977,11 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "expired-startup", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main", transport: "webrtc" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + id: "expired-startup", + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); expect(mocks.cancelInternalRealtimeVoiceBrowserSession).toHaveBeenCalledWith({ @@ -3326,16 +3015,11 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "transport-mismatch", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main", transport: "provider-websocket" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + id: "transport-mismatch", + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); expect(mocks.cancelInternalRealtimeVoiceBrowserSession).toHaveBeenCalledWith({ @@ -3371,20 +3055,14 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main", transport: "webrtc", capabilities: ["camera-frame"], }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); const createInput = mockCallArg(createBrowserSession) as Record; @@ -3395,16 +3073,11 @@ describe("talk.client.create handler", () => { createBrowserSession.mockClear(); respond.mockClear(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "audio", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main", transport: "webrtc" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + id: "audio", + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); expect((mockCallArg(createBrowserSession) as Record).tools).not.toContainEqual( expect.objectContaining({ name: REALTIME_VOICE_DESCRIBE_VIEW_TOOL_NAME }), @@ -3413,20 +3086,15 @@ describe("talk.client.create handler", () => { provider.id = "google"; createBrowserSession.mockClear(); respond.mockClear(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "2", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main", transport: "webrtc", capabilities: ["camera-frame"], }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + id: "2", + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); expect((mockCallArg(createBrowserSession) as Record).tools).toContainEqual( expect.objectContaining({ name: REALTIME_VOICE_DESCRIBE_VIEW_TOOL_NAME }), @@ -3435,20 +3103,15 @@ describe("talk.client.create handler", () => { provider.capabilities.supportsVideoFrames = false; createBrowserSession.mockClear(); respond.mockClear(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "3", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main", transport: "webrtc", capabilities: ["camera-frame"], }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + id: "3", + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); expect(createBrowserSession).not.toHaveBeenCalled(); expect(respond).toHaveBeenCalledWith( @@ -3480,15 +3143,9 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: {}, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({ @@ -3506,7 +3163,7 @@ describe("talk.client.create handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expectRecordFields(mockCallArg(mocks.resolveConfiguredRealtimeVoiceProvider), { @@ -3543,15 +3200,9 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: {}, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({ @@ -3568,7 +3219,7 @@ describe("talk.client.create handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expectRecordFields(mockCallArg(mocks.resolveConfiguredRealtimeVoiceProvider), { @@ -3613,15 +3264,9 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: {}, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({ @@ -3639,7 +3284,7 @@ describe("talk.client.create handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expectRecordFields(mockCallArg(mocks.resolveConfiguredRealtimeVoiceProvider), { @@ -3674,15 +3319,9 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: {}, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({ @@ -3694,7 +3333,7 @@ describe("talk.client.create handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expectRecordFields(mockCallArg(mocks.resolveConfiguredRealtimeVoiceProvider), { @@ -3724,15 +3363,9 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: {}, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({ @@ -3756,7 +3389,7 @@ describe("talk.client.create handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expectRecordFields(mockCallArg(mocks.resolveConfiguredRealtimeVoiceProvider), { @@ -3786,15 +3419,9 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: {}, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({ @@ -3811,7 +3438,7 @@ describe("talk.client.create handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expectRecordFields(mockCallArg(mocks.resolveConfiguredRealtimeVoiceProvider), { @@ -3824,16 +3451,10 @@ describe("talk.client.create handler", () => { it("rejects Gateway-owned transports on the client endpoint", async () => { const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main", mode: "realtime", transport: "gateway-relay" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); expectRespondError(respond, { @@ -3842,21 +3463,16 @@ describe("talk.client.create handler", () => { expect(mocks.resolveConfiguredRealtimeVoiceProvider).not.toHaveBeenCalled(); respond.mockClear(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "2", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main", mode: "realtime", transport: "gateway-relay", capabilities: ["camera-frame"], }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + id: "2", + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); expectRespondError(respond, { @@ -3896,16 +3512,10 @@ describe("talk.client.create handler", () => { }); const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main", mode: "realtime", capabilities: ["camera-frame"] }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, - context: { getRuntimeConfig: () => ({}) as OpenClawConfig } as never, + respond, + context: { getRuntimeConfig: () => ({}) as OpenClawConfig }, }); expect(createBrowserSession).toHaveBeenCalledOnce(); @@ -3917,15 +3527,9 @@ describe("talk.client.create handler", () => { it("rejects realtime brains the client endpoint cannot run", async () => { const respond = vi.fn(); - await expectDefined( - talkHandlers["talk.client.create"], - 'talkHandlers["talk.client.create"] test invariant', - )({ - req: { type: "req", id: "1", method: "talk.client.create" }, + await callTalkHandler("talk.client.create", { params: { sessionKey: "main" }, - client: { connId: "conn-1" } as never, - isWebchatConnect: () => false, - respond: respond as never, + respond, context: { getRuntimeConfig: () => ({ @@ -3935,7 +3539,7 @@ describe("talk.client.create handler", () => { }, }, }) as OpenClawConfig, - } as never, + }, }); expect(mocks.resolveConfiguredRealtimeVoiceProvider).not.toHaveBeenCalled(); diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index a85d50fb7e06..4a93f9d7a329 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -379,6 +379,17 @@ vi.mock("./server-cron.js", async () => { }; }); +function createRecordedChannelHandlers(events: string[]) { + return { + stop: vi.fn(async (channel: ChannelKind, accountId?: string) => { + events.push(`stop:${channel}:${accountId}`); + }), + start: vi.fn(async (channel: ChannelKind, accountId?: string) => { + events.push(`start:${channel}:${accountId}`); + }), + }; +} + function createTestCronReconciliation() { const complete = vi.fn<() => Promise>(async () => {}); return { @@ -389,21 +400,11 @@ function createTestCronReconciliation() { } function createCronRestartPlan(): GatewayReloadPlan { - return { + return createHotTailPlan({ changedPaths: ["cron"], - restartGateway: false, - restartReasons: [], hotReasons: ["cron"], - reloadHooks: false, - restartGmailWatcher: false, restartCron: true, - restartHeartbeat: false, - restartHealthMonitor: false, - reloadPlugins: false, - restartChannels: new Set(), - disposeMcpRuntimes: false, - noopPaths: [], - }; + }); } function createHotTailPlan(overrides: Partial = {}): GatewayReloadPlan { @@ -1989,21 +1990,12 @@ describe("gateway restart deferral preflight", () => { ...configA, gateway: { port: 19_001 }, } as OpenClawConfig; - const plan = { + const plan = createHotTailPlan({ changedPaths: ["channels.discord.token", "logging.level"], - restartGateway: false, - restartReasons: [], hotReasons: ["channels.discord.token"], - reloadHooks: false, - restartGmailWatcher: false, - restartCron: false, - restartHeartbeat: false, - restartHealthMonitor: false, - reloadPlugins: false, restartChannels: new Set(["discord"]), - disposeMcpRuntimes: false, noopPaths: ["logging.level"], - } satisfies GatewayReloadPlan; + }) satisfies GatewayReloadPlan; const configRestartPlan = { ...createHotTailPlan(), changedPaths: ["gateway.port"], @@ -2717,14 +2709,7 @@ describe("gateway channel hot reload handlers", () => { it("promotes unlisted accounts to a wholesale restart", async () => { const events: string[] = []; - const channels = { - stop: vi.fn(async (channel: ChannelKind, accountId?: string) => { - events.push(`stop:${channel}:${accountId}`); - }), - start: vi.fn(async (channel: ChannelKind, accountId?: string) => { - events.push(`start:${channel}:${accountId}`); - }), - }; + const channels = createRecordedChannelHandlers(events); const { applyHotReload } = createReloadHandlersForTest(undefined, channels); await withChannelReloadsEnabled(async () => { @@ -2738,14 +2723,7 @@ describe("gateway channel hot reload handlers", () => { it("promotes unresolvable accounts to a wholesale restart before stopping any account", async () => { const events: string[] = []; - const channels = { - stop: vi.fn(async (channel: ChannelKind, accountId?: string) => { - events.push(`stop:${channel}:${accountId}`); - }), - start: vi.fn(async (channel: ChannelKind, accountId?: string) => { - events.push(`start:${channel}:${accountId}`); - }), - }; + const channels = createRecordedChannelHandlers(events); const { applyHotReload, logChannels } = createReloadHandlersForTest(undefined, channels); await withChannelReloadsEnabled(async () => { @@ -2801,14 +2779,7 @@ describe("gateway channel hot reload handlers", () => { it("skips per-account restarts for channels already queued for wholesale restart", async () => { const events: string[] = []; - const channels = { - stop: vi.fn(async (channel: ChannelKind, accountId?: string) => { - events.push(`stop:${channel}:${accountId}`); - }), - start: vi.fn(async (channel: ChannelKind, accountId?: string) => { - events.push(`start:${channel}:${accountId}`); - }), - }; + const channels = createRecordedChannelHandlers(events); const { applyHotReload } = createReloadHandlersForTest(undefined, channels); await withChannelReloadsEnabled(async () => { @@ -2866,14 +2837,7 @@ describe("gateway channel hot reload handlers", () => { it("stops account targets without restarting them while autostart is suppressed", async () => { const events: string[] = []; - const channels = { - stop: vi.fn(async (channel: ChannelKind, accountId?: string) => { - events.push(`stop:${channel}:${accountId}`); - }), - start: vi.fn(async (channel: ChannelKind, accountId?: string) => { - events.push(`start:${channel}:${accountId}`); - }), - }; + const channels = createRecordedChannelHandlers(events); const { applyHotReload } = createReloadHandlersForTest( undefined, channels, @@ -2899,14 +2863,7 @@ describe("gateway channel hot reload handlers", () => { it("rechecks agent work admitted after plugin reload leaves the channel running", async () => { const events: string[] = []; - const channels = { - stop: vi.fn(async (channel: ChannelKind, accountId?: string) => { - events.push(`stop:${channel}:${accountId}`); - }), - start: vi.fn(async (channel: ChannelKind, accountId?: string) => { - events.push(`start:${channel}:${accountId}`); - }), - }; + const channels = createRecordedChannelHandlers(events); const reloadPlugins = vi.fn(async (params): Promise => { await params.beforeReplace(new Set()); hoisted.activeEmbeddedRunCount.value = 1; @@ -4600,21 +4557,11 @@ describe("gateway plugin hot reload handlers", () => { try { await handlers.applyHotReload( - { + createHotTailPlan({ changedPaths: [`env.vars.${envKey}`, "channels.discord.token"], - restartGateway: false, - restartReasons: [], hotReasons: [`env.vars.${envKey}`, "channels.discord.token"], - reloadHooks: false, - restartGmailWatcher: false, - restartCron: false, - restartHeartbeat: false, - restartHealthMonitor: false, - reloadPlugins: false, restartChannels: new Set(["discord"]), - disposeMcpRuntimes: false, - noopPaths: [], - }, + }), {}, { runtimeEnv: runtimeEnv.env, @@ -4667,21 +4614,11 @@ describe("gateway plugin hot reload handlers", () => { try { await handlers.applyHotReload( - { + createHotTailPlan({ changedPaths: [`env.vars.${envKey}`, "channels.discord.token"], - restartGateway: false, - restartReasons: [], hotReasons: [`env.vars.${envKey}`, "channels.discord.token"], - reloadHooks: false, - restartGmailWatcher: false, - restartCron: false, - restartHeartbeat: false, - restartHealthMonitor: false, - reloadPlugins: false, restartChannels: new Set(["discord"]), - disposeMcpRuntimes: false, - noopPaths: [], - }, + }), nextConfig, { runtimeEnv: runtimeEnv.env, @@ -4833,21 +4770,12 @@ describe("gateway plugin hot reload handlers", () => { vi.useFakeTimers(); const reload = handlers.applyHotReload( - { + createHotTailPlan({ changedPaths: ["hooks.path", "plugins.enabled"], - restartGateway: false, - restartReasons: [], hotReasons: ["hooks.path", "plugins.enabled"], reloadHooks: true, - restartGmailWatcher: false, - restartCron: false, - restartHeartbeat: false, - restartHealthMonitor: false, reloadPlugins: true, - restartChannels: new Set(), - disposeMcpRuntimes: false, - noopPaths: [], - }, + }), { hooks: { enabled: true, token: "token", path: "/next" } }, { isCurrent: () => true, @@ -5366,21 +5294,11 @@ describe("gateway plugin hot reload handlers", () => { }); describe("deferred channel reload abort generation", () => { - const abortChannelReloadPlan: GatewayReloadPlan = { + const abortChannelReloadPlan: GatewayReloadPlan = createHotTailPlan({ changedPaths: ["channels.whatsapp.enabled"], - restartGateway: false, - restartReasons: [], hotReasons: ["channels"], - reloadHooks: false, - restartGmailWatcher: false, - restartCron: false, - restartHeartbeat: false, - restartHealthMonitor: false, - reloadPlugins: false, restartChannels: new Set(["whatsapp"]), - disposeMcpRuntimes: false, - noopPaths: [], - }; + }); afterEach(() => { hoisted.activeTaskCount.value = 0;