diff --git a/docs/modules/client/src.mdx b/docs/modules/client/src.mdx index 351cae192..51e0d7f8d 100644 --- a/docs/modules/client/src.mdx +++ b/docs/modules/client/src.mdx @@ -63,7 +63,7 @@ export interface AgentClientOptions { Configures agent client. -### [`ContextOptions`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/service.ts#L126) +### [`ContextOptions`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/service.ts#L118) _Interface_ @@ -242,7 +242,7 @@ export interface MoltzapdChildOptions { Inputs for starting the packaged daemon against caller-scoped test config. -### [`MoltZapService`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/service.ts#L233) +### [`MoltZapService`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/service.ts#L225) _Class_ @@ -263,9 +263,6 @@ export class MoltZapService { private serviceScope: Scope.CloseableScope | null = null; private readonly presentationState = new PresentationState(); - private readonly agentConversationCacheRef: Ref.Ref< - HashMap.HashMap - > = Effect.runSync(Ref.make(HashMap.empty())); private readonly lastReadRef: Ref.Ref< HashMap.HashMap>> > = Effect.runSync( @@ -367,6 +364,9 @@ export class MoltZapService { // service-owned scope. The Stream is materialized BEFORE `connect()` so // subscriptions are registered with the registry pre-handshake (a // pre-connect-legal operation). + // + // Stream errors of type `NotConnectedError` are surfaced on the + // fiber's failure channel only when the client transitions to ``` Stateful MoltZap client that manages connection, conversation tracking, @@ -389,7 +389,7 @@ export interface RpcCallOptions { Configures rpc call. -### [`ServiceRpcError`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/service.ts#L114) +### [`ServiceRpcError`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/service.ts#L112) _TypeAlias_ @@ -400,9 +400,9 @@ export type ServiceRpcError = Errors that can surface from the Effect-based service API: any tagged error an agent-callable method declares (recovered from the group's per-method -error unions) plus the transport errors. Methods that fan multiple calls -(e.g. `sendToAgent`) surface this broad union; a single-method call narrows -to that method's errors at the `call` site. +error unions) plus the transport errors. A method that fans several calls +surfaces this broad union; a single-method call narrows to that method's +errors at the `call` site. ## Files diff --git a/docs/modules/openclaw-channel/src.mdx b/docs/modules/openclaw-channel/src.mdx index b460aa30e..fc26db484 100644 --- a/docs/modules/openclaw-channel/src.mdx +++ b/docs/modules/openclaw-channel/src.mdx @@ -15,7 +15,7 @@ runtime entries from `index.*` at the extension root only, so the built ## Public surface -### [`createMoltzapChannelPlugin`](https://github.com/chughtapan/moltzap/blob/main/packages/openclaw-channel/src/openclaw-entry.ts#L1318) +### [`createMoltzapChannelPlugin`](https://github.com/chughtapan/moltzap/blob/main/packages/openclaw-channel/src/openclaw-entry.ts#L929) _Function_ @@ -38,27 +38,20 @@ and `resolveTarget` for openclaw's targeting layer. sequenceDiagram participant OC as openclaw runtime participant Plugin as moltzap plugin - participant Harness as caller-owned HarnessClient - participant Core as MoltZapChannelCore - participant Server as MoltZap server + participant Harness as HarnessClient + participant Daemon as moltzapd OC->>Plugin: startAccount(ctx) - alt HarnessClient is injected - Plugin->>Harness: drain turns sequentially - Harness-->>Plugin: originating HarnessTurn - else legacy profile client - Plugin->>Core: new MoltZapAgentClient → MoltZapChannelCore - Plugin->>Core: core.connect() — WS auth - Plugin->>Core: core.onInbound(handler) — register dispatch - Core->>Plugin: enriched message arrives - Plugin->>Plugin: bind HarnessTurn reply authority - end + Plugin->>Harness: harnessClientForProfile(accountId) + Harness->>Daemon: start the slot child and connect over loopback MCP + Plugin->>Harness: drain turns sequentially + Harness-->>Plugin: HarnessTurn carrying its bound reply Plugin->>OC: dispatchReplyWithBufferedBlockDispatcher note over OC: agent pipeline → LLM OC->>Plugin: deliver(payload, opts) — createHarnessReplyDeliver Plugin->>Plugin: turn.reply(text) - Plugin->>Server: core ingress bridge sends reply + Harness->>Daemon: reply routed to its originating conversation OC->>Plugin: stopAccount(ctx) - Plugin->>Plugin: stop owned drain or disconnect owned core + Plugin->>Plugin: signal the drain to stop ``` `deliver` returns `PromiseLike<boolean>` per openclaw contract; @@ -70,7 +63,7 @@ to `agent:<name>`. Other colon-prefixed shapes are rejected. **Returns:** The created moltzap channel plugin. -### [`default`](https://github.com/chughtapan/moltzap/blob/main/packages/openclaw-channel/src/openclaw-entry.ts#L1349) +### [`default`](https://github.com/chughtapan/moltzap/blob/main/packages/openclaw-channel/src/openclaw-entry.ts#L958) _Variable_ @@ -78,7 +71,7 @@ _Variable_ const plugin = ``` -### [`moltzapChannelPlugin`](https://github.com/chughtapan/moltzap/blob/main/packages/openclaw-channel/src/openclaw-entry.ts#L1346) +### [`moltzapChannelPlugin`](https://github.com/chughtapan/moltzap/blob/main/packages/openclaw-channel/src/openclaw-entry.ts#L955) _Variable_ @@ -91,7 +84,7 @@ Shared singleton so a single registration reuses the same `activeClients` closure across `startAccount` and `sendText`. Tests import this directly to assert against that shared state. -### [`MoltzapChannelPlugin`](https://github.com/chughtapan/moltzap/blob/main/packages/openclaw-channel/src/openclaw-entry.ts#L1337) +### [`MoltzapChannelPlugin`](https://github.com/chughtapan/moltzap/blob/main/packages/openclaw-channel/src/openclaw-entry.ts#L946) _TypeAlias_ @@ -103,6 +96,74 @@ export type MoltzapChannelPlugin = ReturnType< Represents moltzap channel plugin values. +### [`OpenClawConfig`](https://github.com/chughtapan/moltzap/blob/main/packages/openclaw-channel/src/openclaw-entry.ts#L182) + +_Interface_ + +```ts +export interface OpenClawConfig { + readonly [key: string]: unknown; + readonly channels?: { + readonly moltzap?: { + readonly accounts?: readonly MoltZapAccount[]; + }; + }; +} +``` + +OpenClaw's config object; the plugin reads only its `channels.moltzap` section. + +### [`OpenClawResolveTargetParams`](https://github.com/chughtapan/moltzap/blob/main/packages/openclaw-channel/src/openclaw-entry.ts#L255) + +_Interface_ + +```ts +export interface OpenClawResolveTargetParams { + readonly cfg: OpenClawConfig; + readonly accountId?: string | null; + readonly input: string; + readonly normalized: string; + readonly preferredKind?: "user" | "group" | "channel"; +} +``` + +One target-resolution request from OpenClaw's targeting layer. + +### [`OpenClawStartAccountContext`](https://github.com/chughtapan/moltzap/blob/main/packages/openclaw-channel/src/openclaw-entry.ts#L210) + +_Interface_ + +```ts +export interface OpenClawStartAccountContext { + cfg: OpenClawConfig; + accountId: string; + account: MoltZapAccount; + abortSignal: AbortSignal; + log?: OpenClawLogger; + setStatus: (next: Record) => void; + channelRuntime?: { + reply?: { + dispatchReplyWithBufferedBlockDispatcher?: OpenClawReplyDispatcher; + }; + }; +} +``` + +What OpenClaw hands the plugin when it starts one configured account. + +### [`OpenClawStopAccountContext`](https://github.com/chughtapan/moltzap/blob/main/packages/openclaw-channel/src/openclaw-entry.ts#L225) + +_Interface_ + +```ts +export interface OpenClawStopAccountContext { + accountId: string; + log?: Pick; +} +``` + +What OpenClaw hands the plugin when it stops one configured account. + ## Files - `openclaw-entry.ts` diff --git a/knip.json b/knip.json index 66bf0e123..8170ed560 100644 --- a/knip.json +++ b/knip.json @@ -27,7 +27,6 @@ "packages/openclaw-channel": { "entry": [ "src/**/*.test.ts", - "src/**/*.integration.test.ts", "src/**/__tests__/**/*.ts", "vitest*.config.mjs" ], diff --git a/packages/client/src/MODULE.md b/packages/client/src/MODULE.md index c7adb632b..fbc709ddf 100644 --- a/packages/client/src/MODULE.md +++ b/packages/client/src/MODULE.md @@ -58,7 +58,7 @@ export interface AgentClientOptions { Configures agent client. -### [`ContextOptions`](./service.ts#L126) +### [`ContextOptions`](./service.ts#L118) _Interface_ @@ -237,7 +237,7 @@ export interface MoltzapdChildOptions { Inputs for starting the packaged daemon against caller-scoped test config. -### [`MoltZapService`](./service.ts#L233) +### [`MoltZapService`](./service.ts#L225) _Class_ @@ -258,9 +258,6 @@ export class MoltZapService { private serviceScope: Scope.CloseableScope | null = null; private readonly presentationState = new PresentationState(); - private readonly agentConversationCacheRef: Ref.Ref< - HashMap.HashMap - > = Effect.runSync(Ref.make(HashMap.empty())); private readonly lastReadRef: Ref.Ref< HashMap.HashMap>> > = Effect.runSync( @@ -362,6 +359,9 @@ export class MoltZapService { // service-owned scope. The Stream is materialized BEFORE `connect()` so // subscriptions are registered with the registry pre-handshake (a // pre-connect-legal operation). + // + // Stream errors of type `NotConnectedError` are surfaced on the + // fiber's failure channel only when the client transitions to ``` Stateful MoltZap client that manages connection, conversation tracking, @@ -384,7 +384,7 @@ export interface RpcCallOptions { Configures rpc call. -### [`ServiceRpcError`](./service.ts#L114) +### [`ServiceRpcError`](./service.ts#L112) _TypeAlias_ @@ -395,9 +395,9 @@ export type ServiceRpcError = Errors that can surface from the Effect-based service API: any tagged error an agent-callable method declares (recovered from the group's per-method -error unions) plus the transport errors. Methods that fan multiple calls -(e.g. `sendToAgent`) surface this broad union; a single-method call narrows -to that method's errors at the `call` site. +error unions) plus the transport errors. A method that fans several calls +surfaces this broad union; a single-method call narrows to that method's +errors at the `call` site. ## Files diff --git a/packages/client/src/service.test.ts b/packages/client/src/service.test.ts index 69f269ba6..2a2334b92 100644 --- a/packages/client/src/service.test.ts +++ b/packages/client/src/service.test.ts @@ -1,6 +1,6 @@ import { it as effectIt } from "@effect/vitest"; import { describe, expect, it } from "vitest"; -import { Deferred, Effect, Exit, Fiber, Option, Schema } from "effect"; +import { Deferred, Effect, Fiber, Option } from "effect"; import { type Message, messageReceivedNotificationDefinition, @@ -15,20 +15,14 @@ import { testMessageId, } from "./test-utils/index.js"; -import { agentName, agentsList } from "@moltzap/protocol/identity"; -import { agentConversationCreate } from "@moltzap/protocol/conversation"; - const effectTest = effectIt.effect; -const AGENT_ALICE_ID = testAgentId("agent-alice-id"); const AGENT_SELF_ID = testAgentId("agent-self"); -const AGENT_BOB_ID = testAgentId("agent-bob-id"); const AGENT_BOB = testAgentId("agent-bob"); const AGENT_ALICE = testAgentId("agent-alice"); const AGENT_ATTACKER = testAgentId("agent-attacker"); const AGENT_SENDER = testAgentId("agent-sender"); const CONVERSATION_ALICE_ID = testConversationId("conv-alice"); -const CONVERSATION_BOB_ID = testConversationId("conv-bob"); const CONVERSATION_OTHER_ID = testConversationId("conv-other"); const CONVERSATION_SELF_ID = testConversationId("conv-self"); const CONVERSATION_SELF_A_ID = testConversationId("conv-self-a"); @@ -40,33 +34,13 @@ const VIEWER_TWO_ID = testConversationId("viewer-2"); const MESSAGE_ONE_ID = testMessageId("m-1"); const MESSAGE_TWO_ID = testMessageId("m-2"); const MESSAGE_THREE_ID = testMessageId("m-3"); -const decodeAgentName = Schema.decodeSync(agentName); -const SEND_TO_AGENT_NAME = decodeAgentName("alice"); -const BOB_AGENT_NAME = decodeAgentName("bob"); const ALICE_DISPLAY_NAME = "Alice"; const BOB_DISPLAY_NAME = "Bob"; const HELLO_TEXT = "hello"; const HI_TEXT = "hi"; const FIRST_TEXT = "first"; const SECOND_TEXT = "second"; -const HELLO_ALICE_TEXT = "hello alice"; -const HELLO_BOB_TEXT = "hello bob"; -const ALICE_AGAIN_TEXT = "alice again"; -const BOB_AGAIN_TEXT = "bob again"; const PLACEHOLDER_TEXT = "placeholder"; -const AGENT_NOT_FOUND_TAG = "AgentNotFound"; -const NOBODY_AGENT_NAME = "nobody"; -const missingCannedResponseFor = (method: string): RegExp => - new RegExp(`no canned response for ${method}`); -const LOOKUP_MISSING_RESPONSE_MESSAGE = missingCannedResponseFor( - agentsList.name, -); -const CREATE_MISSING_RESPONSE_MESSAGE = missingCannedResponseFor( - agentConversationCreate.name, -); -const SEND_MISSING_RESPONSE_MESSAGE = missingCannedResponseFor( - messagesSend.name, -); const PLAIN_NAME = "Alice"; const PLAIN_TEXT = "hello world"; const EMPTY_TEXT = ""; @@ -101,17 +75,6 @@ const FULL_HISTORY_MESSAGE_SPACING_MS = 1_000; const FULL_HISTORY_EXPECTED_MESSAGES = 50; const STORED_MESSAGE_COUNT = 30; -const conversationCreateResponse = ( - conversationId = CONVERSATION_ALICE_ID, -) => ({ - conversation: { - id: conversationId, - createdBy: AGENT_SELF_ID, - createdAt: DEFAULT_TEST_DATE, - updatedAt: DEFAULT_TEST_DATE, - }, -}); - const contextHeader = (conversationId: string): string => `Recent updates (you are in conv:${conversationId}):`; @@ -228,204 +191,6 @@ describe("MoltZapService.send", () => { ); }); -function seedAgentLookup( - service: FakeMoltZapService, - id = AGENT_ALICE_ID, - name = SEND_TO_AGENT_NAME, -): void { - service.setResponse(agentsList, { - agents: [{ id, name, status: "active" }], - }); -} - -function makeSendToAgentService(): FakeMoltZapService { - const service = new FakeMoltZapService(); - seedAgentLookup(service); - service.setResponse(agentConversationCreate, conversationCreateResponse()); - seedMessageSendResponse(service); - return service; -} - -function sendToAgentCreatesConversation() { - return Effect.gen(function* () { - const service = makeSendToAgentService(); - - yield* service.sendToAgent(SEND_TO_AGENT_NAME, HELLO_TEXT); - - expect(service.calls).toEqual([ - { - method: agentsList.name, - params: { limit: 100 }, - }, - { - method: agentConversationCreate.name, - params: { - participants: [AGENT_ALICE_ID], - }, - }, - { - method: messagesSend.name, - params: { - conversationId: CONVERSATION_ALICE_ID, - parts: [{ type: "text", text: HELLO_TEXT }], - }, - }, - ]); - }); -} - -function sendToAgentCachesConversation() { - return Effect.gen(function* () { - const service = makeSendToAgentService(); - yield* service.sendToAgent(SEND_TO_AGENT_NAME, FIRST_TEXT); - service.calls = []; - - yield* service.sendToAgent(SEND_TO_AGENT_NAME, SECOND_TEXT); - - expect(service.calls).toEqual([ - { - method: messagesSend.name, - params: { - conversationId: CONVERSATION_ALICE_ID, - parts: [{ type: "text", text: SECOND_TEXT }], - }, - }, - ]); - }); -} - -function sendToAgentCachesPerAgentName() { - return Effect.gen(function* () { - const service = makeSendToAgentService(); - yield* service.sendToAgent(SEND_TO_AGENT_NAME, HELLO_ALICE_TEXT); - - seedAgentLookup(service, AGENT_BOB_ID, BOB_AGENT_NAME); - service.setResponse( - agentConversationCreate, - conversationCreateResponse(CONVERSATION_BOB_ID), - ); - yield* service.sendToAgent(BOB_AGENT_NAME, HELLO_BOB_TEXT); - - service.calls = []; - yield* service.sendToAgent(SEND_TO_AGENT_NAME, ALICE_AGAIN_TEXT); - yield* service.sendToAgent(BOB_AGENT_NAME, BOB_AGAIN_TEXT); - - const sendCalls = service.calls.filter( - (call) => call.method === messagesSend.name, - ); - expect(sendCalls).toHaveLength(2); - const [firstSend, secondSend] = - /* Safe because the test fixture establishes this asserted shape. */ sendCalls as [ - (typeof sendCalls)[number], - (typeof sendCalls)[number], - ]; - expect( - /* Safe because the test fixture establishes this asserted shape. */ - (firstSend.params as { conversationId: string }).conversationId, - ).toBe(CONVERSATION_ALICE_ID); - expect( - /* Safe because the test fixture establishes this asserted shape. */ - (secondSend.params as { conversationId: string }).conversationId, - ).toBe(CONVERSATION_BOB_ID); - }); -} - -function sendToAgentMissingAgentFails() { - return Effect.gen(function* () { - const service = makeSendToAgentService(); - service.setResponse(agentsList, { agents: [] }); - - const exit = yield* Effect.exit( - service.sendToAgent(NOBODY_AGENT_NAME, HI_TEXT), - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(String(exit)).toContain(AGENT_NOT_FOUND_TAG); - expect(String(exit)).toContain(NOBODY_AGENT_NAME); - }); -} - -function sendToAgentLookupFailurePropagates() { - return Effect.gen(function* () { - const service = makeSendToAgentService(); - service.deleteResponse(agentsList); - - const exit = yield* Effect.exit( - service.sendToAgent(SEND_TO_AGENT_NAME, HI_TEXT), - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(String(exit)).toMatch(LOOKUP_MISSING_RESPONSE_MESSAGE); - }); -} - -function sendToAgentCreateFailurePropagates() { - return Effect.gen(function* () { - const service = makeSendToAgentService(); - service.deleteResponse(agentConversationCreate); - - const exit = yield* Effect.exit( - service.sendToAgent(SEND_TO_AGENT_NAME, HI_TEXT), - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(String(exit)).toMatch(CREATE_MISSING_RESPONSE_MESSAGE); - }); -} - -function sendToAgentSendFailurePropagates() { - return Effect.gen(function* () { - const service = makeSendToAgentService(); - service.deleteResponse(messagesSend); - - const exit = yield* Effect.exit( - service.sendToAgent(SEND_TO_AGENT_NAME, HI_TEXT), - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(String(exit)).toMatch(SEND_MISSING_RESPONSE_MESSAGE); - }); -} - -describe("MoltZapService.sendToAgent core flow", () => { - effectTest( - "resolves agent name, creates a DM, and sends the message on first call", - sendToAgentCreatesConversation, - ); - - effectTest( - "caches the conversation id and skips lookup on subsequent calls", - sendToAgentCachesConversation, - ); -}); - -describe("MoltZapService.sendToAgent cache partitioning", () => { - effectTest( - "maintains separate cache entries per agent name", - sendToAgentCachesPerAgentName, - ); -}); - -describe("MoltZapService.sendToAgent lookup failures", () => { - effectTest( - "throws a clear error when no agent is found for the given name", - sendToAgentMissingAgentFails, - ); - - effectTest( - "propagates errors from agent/identity/agents/list", - sendToAgentLookupFailurePropagates, - ); -}); - -describe("MoltZapService.sendToAgent send failures", () => { - effectTest( - "propagates errors from agent/conversation/create", - sendToAgentCreateFailurePropagates, - ); - - effectTest( - "propagates errors from agent/message/send", - sendToAgentSendFailurePropagates, - ); -}); - function plainTextPassesThrough() { expect(sanitizeForSystemReminder(PLAIN_NAME)).toBe(PLAIN_NAME); expect(sanitizeForSystemReminder(PLAIN_TEXT)).toBe(PLAIN_TEXT); diff --git a/packages/client/src/service.ts b/packages/client/src/service.ts index ee44f200f..4855333ed 100644 --- a/packages/client/src/service.ts +++ b/packages/client/src/service.ts @@ -1,6 +1,5 @@ import { agentId as AgentIdSchema, - AgentNotFoundError, agentsList, type AgentCard, type AgentId, @@ -16,7 +15,6 @@ import type { ClientDefinitionSuccess, } from "@moltzap/protocol/socket"; import { - agentConversationCreate, type ConversationCreatedNotification, conversationCreatedNotificationDefinition, conversationList, @@ -107,21 +105,15 @@ type AgentCallableTag = AgentCallableRpcs["_tag"]; /** * Errors that can surface from the Effect-based service API: any tagged error * an agent-callable method declares (recovered from the group's per-method - * error unions) plus the transport errors. Methods that fan multiple calls - * (e.g. `sendToAgent`) surface this broad union; a single-method call narrows - * to that method's errors at the `call` site. + * error unions) plus the transport errors. A method that fans several calls + * surfaces this broad union; a single-method call narrows to that method's + * errors at the `call` site. */ export type ServiceRpcError = | Rpc.Error | RpcTimeoutError | NotConnectedError; -const agentNotFound = (agentName: string): AgentNotFoundError => - new AgentNotFoundError({ - message: `Agent not found: ${agentName}`, - data: { agentName }, - }); - /** Configures context. */ export interface ContextOptions { type: "cross-conversation"; @@ -246,9 +238,6 @@ export class MoltZapService { private serviceScope: Scope.CloseableScope | null = null; private readonly presentationState = new PresentationState(); - private readonly agentConversationCacheRef: Ref.Ref< - HashMap.HashMap - > = Effect.runSync(Ref.make(HashMap.empty())); private readonly lastReadRef: Ref.Ref< HashMap.HashMap>> > = Effect.runSync( @@ -414,7 +403,6 @@ export class MoltZapService { Effect.all( [ this.presentationState.reset(), - Ref.set(this.agentConversationCacheRef, HashMap.empty()), Ref.set(this.lastReadRef, HashMap.empty()), ], { discard: true }, @@ -748,43 +736,6 @@ export class MoltZapService { ); } - /** - * Send to a named agent, minting the DM conversation on first use and - * reusing it afterwards. The per-name cache is what makes the DM stable: - * `agent/conversation/create` mints a fresh conversation on every call. - * @param agentName Name of the agent to reach. - * @param text Text to process. - * @returns The send result. - */ - sendToAgent( - agentName: string, - text: string, - ): Effect.Effect { - return Effect.gen( - function* (this: MoltZapService) { - const cache = yield* Ref.get(this.agentConversationCacheRef); - let conversationId = Option.getOrUndefined( - HashMap.get(cache, agentName), - ); - if (conversationId === undefined) { - const agent = yield* this.findVisibleAgentByName(agentName); - if (!agent) { - return yield* agentNotFound(agentName); - } - const created = yield* this.call(agentConversationCreate.name, { - participants: [agent.id], - }); - conversationId = created.conversation.id; - const cached = conversationId; - yield* Ref.update(this.agentConversationCacheRef, (m) => - HashMap.set(m, agentName, cached), - ); - } - yield* this.send(conversationId, text); - }.bind(this), - ); - } - private cacheAgentNames(agents: readonly AgentCard[]): Effect.Effect { return this.presentationState.cacheAgentNames(agents); } diff --git a/packages/openclaw-channel/AGENTS.md b/packages/openclaw-channel/AGENTS.md index c4509586b..457e0ab92 100644 --- a/packages/openclaw-channel/AGENTS.md +++ b/packages/openclaw-channel/AGENTS.md @@ -7,11 +7,11 @@ surface. ## Structure -- `src/openclaw-entry.ts` — the plugin: gateway `startAccount`, - notification routing, wraps `MoltZapChannelCore` - (`@moltzap/client/channel-base`) for inbound enrichment and - turn ordering, binds that ingress to `HarnessTurn`, and projects it into - OpenClaw's `DispatchContext`, deliver callback. +- `src/openclaw-entry.ts` — the plugin: gateway `startAccount` acquires the + account's `HarnessClient` from its profile slot, drains that client's turns, + and projects each one into OpenClaw's `DispatchContext` and deliver callback. + The plugin holds no network client of its own; `moltzapd` speaks the + protocols behind its loopback MCP boundary. - `src/context-log.ts` — `writeOpenClawContextLog`. - `src/openclaw-target.ts` — target validation and normalization. - `src/harness-turn-delivery.ts` — bound Harness reply delivery. @@ -61,13 +61,13 @@ surface. non-message notifications update channel state. Sender identity (`agent/identity/agents/list`) and conversation metadata (`ConversationList`) resolve through in-memory caches. -- Account startup connects once. A nonterminal disconnect updates channel - status, but the plugin does not yet drive reconnect or +- Account startup acquires one client and drains it. Termination of the turn + stream is the disconnect signal; the plugin drives no reconnect and no `agent/message/list` catch-up. Do not claim delivery across a disconnected window until both behaviors have a full-agent fault test. -- Single agent per service: each `MoltZapService` maps to exactly - one agent; the daemon binds `~/.moltzap/service-.sock` - and symlinks `~/.moltzap/service.sock` to it for CLI discovery. +- Single agent per slot: the OpenClaw account id names the profile slot, the + slot carries the loopback port its daemon binds, and one slot is exactly one + AgentId. - Never use `unknown` types — use explicit typed interfaces. ## Tests diff --git a/packages/openclaw-channel/package.json b/packages/openclaw-channel/package.json index 62de1c8b4..bcb46f2ef 100644 --- a/packages/openclaw-channel/package.json +++ b/packages/openclaw-channel/package.json @@ -25,7 +25,6 @@ "build": "nx run @moltzap/openclaw-channel:build", "lint": "nx run @moltzap/openclaw-channel:lint", "test": "vitest run", - "test:integration": "vitest run --config vitest.integration.config.mjs", "test:conformance": "vitest run -c vitest.conformance.config.mjs", "typecheck:tests": "tsc -p tsconfig.test.json" }, @@ -63,7 +62,6 @@ }, "devDependencies": { "@effect/vitest": "^0.30.0", - "@testcontainers/postgresql": "^10.18.0", "@types/node": "^25.5.0", "@types/pg": "^8.11.0", "@typescript/native": "npm:typescript@^7.0.2", diff --git a/packages/openclaw-channel/src/MODULE.md b/packages/openclaw-channel/src/MODULE.md index d339c2923..b00e4e7f4 100644 --- a/packages/openclaw-channel/src/MODULE.md +++ b/packages/openclaw-channel/src/MODULE.md @@ -10,7 +10,7 @@ runtime entries from `index.*` at the extension root only, so the built ## Public surface -### [`createMoltzapChannelPlugin`](./openclaw-entry.ts#L1318) +### [`createMoltzapChannelPlugin`](./openclaw-entry.ts#L929) _Function_ @@ -33,27 +33,20 @@ and `resolveTarget` for openclaw's targeting layer. sequenceDiagram participant OC as openclaw runtime participant Plugin as moltzap plugin - participant Harness as caller-owned HarnessClient - participant Core as MoltZapChannelCore - participant Server as MoltZap server + participant Harness as HarnessClient + participant Daemon as moltzapd OC->>Plugin: startAccount(ctx) - alt HarnessClient is injected - Plugin->>Harness: drain turns sequentially - Harness-->>Plugin: originating HarnessTurn - else legacy profile client - Plugin->>Core: new MoltZapAgentClient → MoltZapChannelCore - Plugin->>Core: core.connect() — WS auth - Plugin->>Core: core.onInbound(handler) — register dispatch - Core->>Plugin: enriched message arrives - Plugin->>Plugin: bind HarnessTurn reply authority - end + Plugin->>Harness: harnessClientForProfile(accountId) + Harness->>Daemon: start the slot child and connect over loopback MCP + Plugin->>Harness: drain turns sequentially + Harness-->>Plugin: HarnessTurn carrying its bound reply Plugin->>OC: dispatchReplyWithBufferedBlockDispatcher note over OC: agent pipeline → LLM OC->>Plugin: deliver(payload, opts) — createHarnessReplyDeliver Plugin->>Plugin: turn.reply(text) - Plugin->>Server: core ingress bridge sends reply + Harness->>Daemon: reply routed to its originating conversation OC->>Plugin: stopAccount(ctx) - Plugin->>Plugin: stop owned drain or disconnect owned core + Plugin->>Plugin: signal the drain to stop ``` `deliver` returns `PromiseLike<boolean>` per openclaw contract; @@ -65,7 +58,7 @@ to `agent:<name>`. Other colon-prefixed shapes are rejected. **Returns:** The created moltzap channel plugin. -### [`default`](./openclaw-entry.ts#L1349) +### [`default`](./openclaw-entry.ts#L958) _Variable_ @@ -73,7 +66,7 @@ _Variable_ const plugin = ``` -### [`moltzapChannelPlugin`](./openclaw-entry.ts#L1346) +### [`moltzapChannelPlugin`](./openclaw-entry.ts#L955) _Variable_ @@ -86,7 +79,7 @@ Shared singleton so a single registration reuses the same `activeClients` closure across `startAccount` and `sendText`. Tests import this directly to assert against that shared state. -### [`MoltzapChannelPlugin`](./openclaw-entry.ts#L1337) +### [`MoltzapChannelPlugin`](./openclaw-entry.ts#L946) _TypeAlias_ @@ -98,6 +91,74 @@ export type MoltzapChannelPlugin = ReturnType< Represents moltzap channel plugin values. +### [`OpenClawConfig`](./openclaw-entry.ts#L182) + +_Interface_ + +```ts +export interface OpenClawConfig { + readonly [key: string]: unknown; + readonly channels?: { + readonly moltzap?: { + readonly accounts?: readonly MoltZapAccount[]; + }; + }; +} +``` + +OpenClaw's config object; the plugin reads only its `channels.moltzap` section. + +### [`OpenClawResolveTargetParams`](./openclaw-entry.ts#L255) + +_Interface_ + +```ts +export interface OpenClawResolveTargetParams { + readonly cfg: OpenClawConfig; + readonly accountId?: string | null; + readonly input: string; + readonly normalized: string; + readonly preferredKind?: "user" | "group" | "channel"; +} +``` + +One target-resolution request from OpenClaw's targeting layer. + +### [`OpenClawStartAccountContext`](./openclaw-entry.ts#L210) + +_Interface_ + +```ts +export interface OpenClawStartAccountContext { + cfg: OpenClawConfig; + accountId: string; + account: MoltZapAccount; + abortSignal: AbortSignal; + log?: OpenClawLogger; + setStatus: (next: Record) => void; + channelRuntime?: { + reply?: { + dispatchReplyWithBufferedBlockDispatcher?: OpenClawReplyDispatcher; + }; + }; +} +``` + +What OpenClaw hands the plugin when it starts one configured account. + +### [`OpenClawStopAccountContext`](./openclaw-entry.ts#L225) + +_Interface_ + +```ts +export interface OpenClawStopAccountContext { + accountId: string; + log?: Pick; +} +``` + +What OpenClaw hands the plugin when it stops one configured account. + ## Files - `openclaw-entry.ts` diff --git a/packages/openclaw-channel/src/__tests__/openclaw-container.ts b/packages/openclaw-channel/src/__tests__/openclaw-container.ts deleted file mode 100644 index 7928737fe..000000000 --- a/packages/openclaw-channel/src/__tests__/openclaw-container.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Shared model configs for integration tests. - */ - -import type { ContainerModelConfig } from "../test-utils/container-core.js"; -import { Redacted } from "effect"; - -/** - * Echo model config — no API key required. - * @param echoPort Value supplied to the operation. - * @returns The echo model config result. - */ -export function echoModelConfig(echoPort: number): ContainerModelConfig { - return { - modelString: "echo/echo-1", - providerConfig: { - provider: "echo", - modelId: "echo-1", - baseUrl: `http://host.docker.internal:${echoPort}`, - api: "openai-completions", - apiKey: Redacted.make("test"), - }, - }; -} diff --git a/packages/openclaw-channel/src/__tests__/openclaw-routing.integration.test.ts b/packages/openclaw-channel/src/__tests__/openclaw-routing.integration.test.ts deleted file mode 100644 index 00b175f09..000000000 --- a/packages/openclaw-channel/src/__tests__/openclaw-routing.integration.test.ts +++ /dev/null @@ -1,522 +0,0 @@ -/** - * Tier 2: real OpenClaw gateway + real MoltZap server integration tests. - * - * Every test uses shared OpenClaw containers from globalSetup with an echo - * model provider, so no LLM API keys are required. - */ - -import { beforeAll, describe, expect, inject } from "vitest"; -import { live as it } from "@effect/vitest"; -import * as fc from "fast-check"; -import { Data, Duration, Effect, Fiber, Option, Stream } from "effect"; -import { MoltZapAgentClient } from "@moltzap/client"; -import { stripWsPath } from "@moltzap/client/test-utils"; -import { getLogs } from "../test-utils/container-core.js"; -import { agentId, redactedAgentKey } from "@moltzap/protocol/testing"; -import { - registerTestAgent, - extractMessage, - extractConversationBinding, - extractText, - type ConversationBinding, -} from "./test-helpers.js"; - -import { - agentsList, - type AgentId, - type AgentKey, -} from "@moltzap/protocol/identity"; -import { agentConversationCreate } from "@moltzap/protocol/conversation"; -import { - messageReceivedNotificationDefinition, - messagesSend, - type Message, -} from "@moltzap/protocol/message"; -import type { ListCursor, ResultOf } from "@moltzap/protocol/rpc"; - -interface GatewayHarness { - readonly containerAId: string; - readonly containerAAgentId: AgentId; - readonly containerBAgentId: AgentId; -} - -let wsUrl: string; - -const NOTIFICATION_WAIT_TIMEOUT_MS = 60_000; -const STANDARD_SCENARIO_TIMEOUT_MS = 90_000; -const LONG_SCENARIO_TIMEOUT_MS = 120_000; -const CROSS_CONTAINER_SCENARIO_TIMEOUT_MS = 180_000; -const CONVERSATION_EVENT_SETTLE_MS = 500; -const LARGE_MESSAGE_CHARS = 5_000; -const MIN_LARGE_REPLY_CHARS = 4_096; -const CONNECTION_SETTLE_MS = 1_000; -const RAPID_MESSAGE_COUNT = 3; -const TWO_CONTAINER_COUNT = 2; -const AGENT_LIST_PAGE_SIZE = 100; -const AGENT_LIST_MAX_PAGES = 20; - -const GATEWAY_LOG_PATTERN = "[gateway]"; -const MOLTZAP_LOG_PATTERN = "[moltzap]"; -const ECHO_PREFIX = "ECHO:"; -const TEXT_PART_TYPE = "text"; -const DM_HELLO_TEXT = "hello from alice"; -const GROUP_HELLO_TEXT = "hello group"; -const CONTAINER_A_TEXT = "hello container-a"; -const CONTAINER_B_TEXT = "hello container-b"; -const PROACTIVE_RECEIVER_NAME = "out-receiver-pro"; -const DUPLICATE_RECEIVER_NAME = "out-receiver-dup"; -const PROACTIVE_TEXT = "proactive hello"; -const FIRST_TEXT = "first"; -const SECOND_TEXT = "second"; -const BEFORE_DROP_TEXT = "before drop"; -const AFTER_NEW_CONNECTION_TEXT = "after new connection"; -const LARGE_MESSAGE_CHARACTER = "A"; -const INTEGRATION_GROUP_NAME = "Integration Group"; -const MISSING_AGENT_NAME = "nonexistent-agent-xyz"; - -class RoutingIntegrationError extends Data.TaggedError( - "RoutingIntegrationError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -beforeAll(() => { - wsUrl = inject("wsUrl"); -}); - -describe.skipIf(inject("containerAId") === "")( - "Real OpenClaw gateway integration", - defineGatewayIntegrationSuite, -); - -function defineGatewayIntegrationSuite() { - const harness = gatewayHarness(); - it("gateway starts, loads MoltZap plugin, connects to server", () => - gatewayStarts(harness.containerAId)); - it("DM: alice sends -> OpenClaw dispatch -> echo reply arrives", () => - dmEchoReplyArrives(harness.containerAAgentId)); - it("group: message dispatched through real OpenClaw", () => - groupMessageDispatches(harness.containerAAgentId)); - it("rapid: multiple messages all get echo replies", () => - rapidMessagesGetReplies(harness.containerAAgentId)); - it("two agents: both receive and reply from their own containers", () => - twoAgentsReplyFromOwnContainers(harness)); - it("agent proactively sends to agent:, DM auto-created", () => - proactiveMessageArrives(harness.containerAAgentId)); - it( - "second message to same agent reuses conversation", - duplicateTargetReusesConversation, - ); - it("send to nonexistent agent returns error", missingAgentLookupFails); - it("large message (>4096 chars) is delivered intact", () => - largeMessageDelivered(harness.containerAAgentId)); - it("a new explicit connection recovers after WebSocket close", () => - explicitConnectionRecovers(harness.containerAAgentId)); - it( - "property: scenario timeouts exceed notification waits", - timeoutsCoverNotificationWait, - ); -} - -function gatewayHarness(): GatewayHarness { - return { - containerAId: inject("containerAId"), - containerAAgentId: agentId(inject("containerAAgentId")), - containerBAgentId: agentId(inject("containerBAgentId")), - }; -} - -function gatewayStarts(containerAId: string) { - return Effect.sync(() => { - const logs = getLogs(containerAId); - expect(logs).toContain(GATEWAY_LOG_PATTERN); - expect(logs).toContain(MOLTZAP_LOG_PATTERN); - }); -} - -function dmEchoReplyArrives(containerAAgentId: AgentId) { - return Effect.gen(function* () { - const aliceClient = yield* connectedRegisteredClient("a2a-alice-dm"); - const binding = yield* createDm(aliceClient, containerAAgentId); - // Fork the response-listener BEFORE the trigger send. Stream-based - // subscribe has no historical buffer; the echo reply can arrive in - // the gap between `sendText` returning and the listener registering, - // so the listener must be in place first. - const replyFiber = yield* Effect.fork(waitForReceivedMessage(aliceClient)); - yield* sendText(aliceClient, binding, DM_HELLO_TEXT); - const reply = yield* Fiber.join(replyFiber); - expectEchoReply(reply, binding.conversationId, containerAAgentId); - yield* aliceClient.close(); - }); -} - -function groupMessageDispatches(containerAAgentId: AgentId) { - return Effect.gen(function* () { - const aliceClient = yield* connectedRegisteredClient("a2a-alice-grp"); - const eve = yield* registerAgent("a2a-eve-grp"); - const binding = yield* createGroup(aliceClient, INTEGRATION_GROUP_NAME, [ - containerAAgentId, - eve.agentId, - ]); - yield* Effect.sleep(`${CONVERSATION_EVENT_SETTLE_MS} millis`); - // Fork-before-trigger: listener must be in place before sendText, - // since Stream subscribe has no historical buffer. - const replyFiber = yield* Effect.fork(waitForReceivedMessage(aliceClient)); - yield* sendText(aliceClient, binding, GROUP_HELLO_TEXT); - const reply = yield* Fiber.join(replyFiber); - expect(reply.parts.length).toBeGreaterThan(0); - expect(reply.conversationId).toBe(binding.conversationId); - expect(extractText(reply)).toContain(ECHO_PREFIX); - yield* aliceClient.close(); - }); -} - -function rapidMessagesGetReplies(containerAAgentId: AgentId) { - return Effect.gen(function* () { - const aliceClient = yield* connectedRegisteredClient("a2a-alice-rapid"); - const binding = yield* createDm(aliceClient, containerAAgentId); - // Fork-before-trigger: subscribe for N replies before emitting any - // sends, so no echo can arrive in the gap between the final send and - // the listener registering. - const repliesFiber = yield* Effect.fork( - waitForReceivedMessages(aliceClient, RAPID_MESSAGE_COUNT), - ); - for (let index = 0; index < RAPID_MESSAGE_COUNT; index++) { - yield* sendText(aliceClient, binding, `Message ${index}`); - } - const replies = yield* Fiber.join(repliesFiber); - for (const reply of replies) { - expectEchoReply( - extractMessage(reply), - binding.conversationId, - containerAAgentId, - ); - } - yield* aliceClient.close(); - }); -} - -function twoAgentsReplyFromOwnContainers(harness: GatewayHarness) { - return Effect.gen(function* () { - const aliceClient = yield* connectedRegisteredClient("2a-alice"); - const bindingA = yield* createDm(aliceClient, harness.containerAAgentId); - const bindingB = yield* createDm(aliceClient, harness.containerBAgentId); - // Fork-before-trigger: the wait for the 2 echo replies is registered - // before any send. - const eventsFiber = yield* Effect.fork( - waitForReceivedMessages(aliceClient, TWO_CONTAINER_COUNT), - ); - yield* sendText(aliceClient, bindingA, CONTAINER_A_TEXT); - yield* sendText(aliceClient, bindingB, CONTAINER_B_TEXT); - const events = yield* Fiber.join(eventsFiber); - const messages = events.map(extractMessage); - expectConversationMessageFrom( - messages, - bindingA.conversationId, - harness.containerAAgentId, - ); - expectConversationMessageFrom( - messages, - bindingB.conversationId, - harness.containerBAgentId, - ); - yield* aliceClient.close(); - }); -} - -function proactiveMessageArrives(containerAAgentId: AgentId) { - return Effect.gen(function* () { - const receiver = yield* registerAgent(PROACTIVE_RECEIVER_NAME); - const receiverClient = connectedClient(receiver.apiKey); - yield* receiverClient.connect(); - const senderClient = connectedClient( - redactedAgentKey(inject("containerAApiKey")), - ); - yield* senderClient.connect(); - const binding = yield* createDm( - senderClient, - yield* lookupAgentId(senderClient, PROACTIVE_RECEIVER_NAME), - ); - // Fork-before-trigger. - const receivedFiber = yield* Effect.fork( - waitForReceivedMessage(receiverClient), - ); - yield* sendText(senderClient, binding, PROACTIVE_TEXT); - const received = yield* Fiber.join(receivedFiber); - expect(received.senderId).toBe(containerAAgentId); - expect(extractText(received)).toBe(PROACTIVE_TEXT); - expect(received.conversationId).toBe(binding.conversationId); - yield* senderClient.close(); - yield* receiverClient.close(); - }); -} - -function duplicateTargetReusesConversation() { - return Effect.gen(function* () { - const receiver = yield* registerAgent(DUPLICATE_RECEIVER_NAME); - const receiverClient = connectedClient(receiver.apiKey); - yield* receiverClient.connect(); - const senderClient = connectedClient( - redactedAgentKey(inject("containerAApiKey")), - ); - yield* senderClient.connect(); - const receiverId = yield* lookupAgentId( - senderClient, - DUPLICATE_RECEIVER_NAME, - ); - const binding = yield* createDm(senderClient, receiverId); - // Fork-before-trigger per message. - const msg1Fiber = yield* Effect.fork( - waitForReceivedMessage(receiverClient), - ); - yield* sendText(senderClient, binding, FIRST_TEXT); - const msg1 = yield* Fiber.join(msg1Fiber); - const msg2Fiber = yield* Effect.fork( - waitForReceivedMessage(receiverClient), - ); - yield* sendText(senderClient, binding, SECOND_TEXT); - const msg2 = yield* Fiber.join(msg2Fiber); - expect(msg1.conversationId).toBe(binding.conversationId); - expect(msg2.conversationId).toBe(binding.conversationId); - yield* senderClient.close(); - yield* receiverClient.close(); - }); -} - -function missingAgentLookupFails() { - return Effect.gen(function* () { - const agentClient = yield* connectedRegisteredClient("err-sender"); - const result = yield* agentClient.call(agentsList.name, { - limit: AGENT_LIST_PAGE_SIZE, - }); - expect( - result.agents.some((agent) => agent.name === MISSING_AGENT_NAME), - ).toBe(false); - yield* agentClient.close(); - }); -} - -function largeMessageDelivered(containerAAgentId: AgentId) { - return Effect.gen(function* () { - const aliceClient = yield* connectedRegisteredClient("lg-alice"); - const binding = yield* createDm(aliceClient, containerAAgentId); - const largeText = LARGE_MESSAGE_CHARACTER.repeat(LARGE_MESSAGE_CHARS); - // Fork-before-trigger. - const replyFiber = yield* Effect.fork(waitForReceivedMessage(aliceClient)); - yield* sendText(aliceClient, binding, largeText); - const reply = yield* Fiber.join(replyFiber); - expect(reply.conversationId).toBe(binding.conversationId); - expect(reply.senderId).toBe(containerAAgentId); - const replyText = extractText(reply); - expect(replyText).toContain(ECHO_PREFIX); - expect(replyText.length).toBeGreaterThan(MIN_LARGE_REPLY_CHARS); - yield* aliceClient.close(); - }); -} - -function explicitConnectionRecovers(containerAAgentId: AgentId) { - return Effect.gen(function* () { - const alice = yield* registerAgent("rd-alice"); - const aliceClient = connectedClient(alice.apiKey); - yield* aliceClient.connect(); - const binding = yield* createDm(aliceClient, containerAAgentId); - // Fork-before-trigger for each leg. - const replyFiber1 = yield* Effect.fork(waitForReceivedMessage(aliceClient)); - yield* sendText(aliceClient, binding, BEFORE_DROP_TEXT); - expect(extractText(yield* Fiber.join(replyFiber1))).toContain(ECHO_PREFIX); - yield* aliceClient.close(); - yield* Effect.sleep(`${CONNECTION_SETTLE_MS} millis`); - const aliceClient2 = connectedClient(alice.apiKey); - yield* aliceClient2.connect(); - const replyFiber2 = yield* Effect.fork( - waitForReceivedMessage(aliceClient2), - ); - yield* sendText(aliceClient2, binding, AFTER_NEW_CONNECTION_TEXT); - const reply2 = yield* Fiber.join(replyFiber2); - expect(extractText(reply2)).toContain(ECHO_PREFIX); - expect(reply2.conversationId).toBe(binding.conversationId); - yield* aliceClient2.close(); - }); -} - -function registerAgent(name: string) { - return Effect.tryPromise({ - try: () => registerTestAgent(name), - catch: (cause) => - new RoutingIntegrationError({ message: `register ${name}`, cause }), - }); -} - -function connectedRegisteredClient(name: string) { - return Effect.gen(function* () { - const agent = yield* registerAgent(name); - const client = connectedClient(agent.apiKey); - yield* client.connect(); - return client; - }); -} - -function connectedClient(agentKey: AgentKey) { - return new MoltZapAgentClient({ - serverUrl: stripWsPath(wsUrl), - agentKey, - }); -} - -function createDm( - client: MoltZapAgentClient, - invitee: AgentId, -): Effect.Effect { - return client - .call(agentConversationCreate.name, { - participants: [invitee], - }) - .pipe(Effect.map(extractConversationBinding)); -} - -function createGroup( - client: MoltZapAgentClient, - name: string, - agentIds: readonly AgentId[], -): Effect.Effect { - return client - .call(agentConversationCreate.name, { - name, - participants: agentIds, - }) - .pipe(Effect.map(extractConversationBinding)); -} - -function sendText( - client: MoltZapAgentClient, - binding: ConversationBinding, - text: string, -) { - return client.call(messagesSend.name, { - conversationId: binding.conversationId, - parts: [{ type: TEXT_PART_TYPE, text }], - }); -} - -/** - * Wait for one `messages/received` notification: consume the typed - * `subscribe(def)` Stream with `Stream.runHead` under a timeout, then - * project the decoded payload with `extractMessage`. - * @param client Client used for the operation. - * @returns The wait for received message result. - */ -function waitForReceivedMessage(client: MoltZapAgentClient) { - return client.subscribe(messageReceivedNotificationDefinition).pipe( - Stream.runHead, - Effect.timeoutFail({ - duration: Duration.millis(NOTIFICATION_WAIT_TIMEOUT_MS), - onTimeout: () => - new RoutingIntegrationError({ - message: "timed out waiting for messages/received notification", - }), - }), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new RoutingIntegrationError({ - message: - "messages/received Stream completed before a frame arrived", - }), - ), - onSome: (frame) => Effect.succeed(extractMessage(frame)), - }), - ), - ); -} - -function waitForReceivedMessages(client: MoltZapAgentClient, count: number) { - return client.subscribe(messageReceivedNotificationDefinition).pipe( - Stream.take(count), - Stream.runCollect, - Effect.timeoutFail({ - duration: Duration.millis(NOTIFICATION_WAIT_TIMEOUT_MS), - onTimeout: () => - new RoutingIntegrationError({ - message: `timed out waiting for ${count} messages/received notifications`, - }), - }), - Effect.map((chunk) => Array.from(chunk)), - ); -} - -function expectEchoReply( - reply: Message, - conversationId: string, - senderId: string, -): void { - expect(reply.parts.length).toBeGreaterThan(0); - expect(reply.conversationId).toBe(conversationId); - expect(reply.senderId).toBe(senderId); - expect(extractText(reply)).toContain(ECHO_PREFIX); -} - -function findConversationMessage( - messages: readonly Message[], - conversationId: string, -): Message | undefined { - return messages.find((message) => message.conversationId === conversationId); -} - -function expectConversationMessageFrom( - messages: readonly Message[], - conversationId: string, - senderId: string, -): void { - const message = findConversationMessage(messages, conversationId); - expect(message).toBeDefined(); - if (message === undefined) { - return; - } - expectEchoReply(message, conversationId, senderId); -} - -function lookupAgentId(client: MoltZapAgentClient, name: string) { - return Effect.gen(function* () { - let cursor: ListCursor | undefined = undefined; - for (let page = 0; page < AGENT_LIST_MAX_PAGES; page++) { - const result: ResultOf = yield* client.call( - agentsList.name, - cursor === undefined - ? { limit: AGENT_LIST_PAGE_SIZE } - : { limit: AGENT_LIST_PAGE_SIZE, cursor }, - ); - const found = result.agents.find((agent) => agent.name === name)?.id; - if (found !== undefined) { - return found; - } - if (result.nextCursor === undefined) { - break; - } - cursor = result.nextCursor; - } - return yield* Effect.fail( - new RoutingIntegrationError({ - message: `agent not found: ${name}`, - }), - ); - }); -} - -function timeoutsCoverNotificationWait() { - return Effect.sync(() => { - fc.assert( - fc.property( - fc.constantFrom( - STANDARD_SCENARIO_TIMEOUT_MS, - LONG_SCENARIO_TIMEOUT_MS, - CROSS_CONTAINER_SCENARIO_TIMEOUT_MS, - ), - (scenarioTimeout) => { - expect(scenarioTimeout).toBeGreaterThan(NOTIFICATION_WAIT_TIMEOUT_MS); - }, - ), - ); - }); -} diff --git a/packages/openclaw-channel/src/__tests__/stress.integration.test.ts b/packages/openclaw-channel/src/__tests__/stress.integration.test.ts deleted file mode 100644 index f3a2d7970..000000000 --- a/packages/openclaw-channel/src/__tests__/stress.integration.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -/** - * Stress integration tests: concurrent multi-agent messaging. - * Uses shared container from globalSetup, so each test avoids its own startup. - */ - -import { beforeAll, describe, expect, inject } from "vitest"; -import { live as it } from "@effect/vitest"; -import { Data, Effect } from "effect"; -import { MoltZapAgentClient, type ServiceRpcError } from "@moltzap/client"; -import { stripWsPath } from "@moltzap/client/test-utils"; -import { getLogs } from "../test-utils/container-core.js"; -import { - registerTestAgent, - extractConversationBinding, - extractText, - type ConversationBinding, -} from "./test-helpers.js"; -import type { AgentId, AgentKey } from "@moltzap/protocol/identity"; -import { - type ConversationId, - agentConversationCreate, -} from "@moltzap/protocol/conversation"; -import { - type Message, - messagesList, - messagesSend, -} from "@moltzap/protocol/message"; -import { agentId, waitForValue } from "@moltzap/protocol/testing"; - -interface StressAgent { - readonly apiKey: AgentKey; -} - -interface StressClients { - readonly clientA: MoltZapAgentClient; - readonly clientB: MoltZapAgentClient; - readonly clientC: MoltZapAgentClient; -} - -interface StressConversationIds { - readonly convA: ConversationBinding; - readonly convB: ConversationBinding; - readonly convC: ConversationBinding; -} - -interface StressReplies { - readonly repliesA: readonly Message[]; - readonly repliesB: readonly Message[]; - readonly repliesC: readonly Message[]; -} - -let wsUrl: string; - -const REPLY_POLL_INTERVAL_MS = 250; -const REPLY_WAIT_TIMEOUT_MS = 90_000; -const STRESS_TEST_TIMEOUT_MS = 180_000; -const MESSAGES_FROM_A = 4; -const MESSAGES_FROM_B = 3; -const MESSAGES_FROM_C = 3; -const TOTAL_STRESS_MESSAGE_COUNT = - MESSAGES_FROM_A + MESSAGES_FROM_B + MESSAGES_FROM_C; -const STRESS_AGENT_COUNT = 3; -const ECHO_PREFIX = "ECHO:"; -const AGENT_A_NAME = "stress-a"; -const AGENT_B_NAME = "stress-b"; -const AGENT_C_NAME = "stress-c"; -const TEXT_PART_TYPE = "text"; - -class StressTestError extends Data.TaggedError("StressTestError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -beforeAll(() => { - wsUrl = inject("wsUrl"); -}); - -describe.skipIf(inject("containerAId") === "")( - "Stress: concurrent multi-agent messaging", - defineStressSuite, -); - -function defineStressSuite() { - const receiverAgentId = agentId(inject("containerAAgentId")); - const containerAId = inject("containerAId"); - it( - "10 concurrent messages from 3 agents all get echo replies", - () => runStressScenario(receiverAgentId, containerAId), - STRESS_TEST_TIMEOUT_MS, - ); -} - -function runStressScenario(receiverAgentId: AgentId, containerAId: string) { - return Effect.gen(function* () { - const agents = yield* registerStressAgents(); - const clients = yield* stressClients(agents); - yield* connectStressClients(clients); - const conversations = yield* createStressConversations( - clients, - receiverAgentId, - ); - yield* sendStressMessages(clients, conversations); - const replies = yield* waitForStressReplies( - clients, - conversations, - receiverAgentId, - ); - expectStressReplies(replies, conversations, receiverAgentId); - yield* closeStressClients(clients); - }).pipe(Effect.tapError(() => logContainerFailure(containerAId))); -} - -function registerAgent(name: string) { - return Effect.tryPromise({ - try: () => registerTestAgent(name), - catch: (cause) => - new StressTestError({ - message: `Registration failed for ${name}`, - cause, - }), - }); -} - -function registerStressAgents() { - return Effect.all( - [ - registerAgent(AGENT_A_NAME), - registerAgent(AGENT_B_NAME), - registerAgent(AGENT_C_NAME), - ], - { concurrency: STRESS_AGENT_COUNT }, - ); -} - -function stressClients( - agents: readonly StressAgent[], -): Effect.Effect { - const [agentA, agentB, agentC] = agents; - if (!agentA || !agentB || !agentC) { - return Effect.fail( - new StressTestError({ - message: "Stress agent registration returned too few agents", - }), - ); - } - return Effect.succeed({ - clientA: stressClient(agentA.apiKey), - clientB: stressClient(agentB.apiKey), - clientC: stressClient(agentC.apiKey), - }); -} - -function stressClient(agentKey: AgentKey) { - return new MoltZapAgentClient({ - serverUrl: stripWsPath(wsUrl), - agentKey, - }); -} - -function connectStressClients(clients: StressClients) { - return Effect.all( - [ - clients.clientA.connect(), - clients.clientB.connect(), - clients.clientC.connect(), - ], - { concurrency: STRESS_AGENT_COUNT }, - ); -} - -function createStressConversations( - clients: StressClients, - receiverAgentId: AgentId, -): Effect.Effect { - return Effect.all( - [ - createConversation(clients.clientA, receiverAgentId), - createConversation(clients.clientB, receiverAgentId), - createConversation(clients.clientC, receiverAgentId), - ], - { concurrency: STRESS_AGENT_COUNT }, - ).pipe(Effect.map(([convA, convB, convC]) => ({ convA, convB, convC }))); -} - -function createConversation( - client: MoltZapAgentClient, - receiverAgentId: AgentId, -) { - return client - .call(agentConversationCreate.name, { - participants: [receiverAgentId], - }) - .pipe(Effect.map(extractConversationBinding)); -} - -function sendStressMessages( - clients: StressClients, - conversations: StressConversationIds, -) { - return Effect.all( - [ - ...sendBatch(clients.clientA, conversations.convA, "A", MESSAGES_FROM_A), - ...sendBatch(clients.clientB, conversations.convB, "B", MESSAGES_FROM_B), - ...sendBatch(clients.clientC, conversations.convC, "C", MESSAGES_FROM_C), - ], - { concurrency: STRESS_AGENT_COUNT }, - ); -} - -function sendBatch( - client: MoltZapAgentClient, - binding: ConversationBinding, - prefix: string, - count: number, -) { - return Array.from({ length: count }, (...args) => { - const index = args[1]; - return client.call(messagesSend.name, { - conversationId: binding.conversationId, - parts: [{ type: TEXT_PART_TYPE, text: `${prefix}-msg-${index}` }], - }); - }); -} - -function waitForStressReplies( - clients: StressClients, - conversations: StressConversationIds, - receiverAgentId: AgentId, -): Effect.Effect { - return Effect.all( - [ - waitForRepliesByList({ - client: clients.clientA, - binding: conversations.convA, - receiverAgentId, - expectedCount: MESSAGES_FROM_A, - timeoutMs: REPLY_WAIT_TIMEOUT_MS, - }), - waitForRepliesByList({ - client: clients.clientB, - binding: conversations.convB, - receiverAgentId, - expectedCount: MESSAGES_FROM_B, - timeoutMs: REPLY_WAIT_TIMEOUT_MS, - }), - waitForRepliesByList({ - client: clients.clientC, - binding: conversations.convC, - receiverAgentId, - expectedCount: MESSAGES_FROM_C, - timeoutMs: REPLY_WAIT_TIMEOUT_MS, - }), - ], - { concurrency: STRESS_AGENT_COUNT }, - ).pipe( - Effect.map(([repliesA, repliesB, repliesC]) => ({ - repliesA, - repliesB, - repliesC, - })), - ); -} - -function waitForRepliesByList(params: { - readonly client: MoltZapAgentClient; - readonly binding: ConversationBinding; - readonly receiverAgentId: AgentId; - readonly expectedCount: number; - readonly timeoutMs: number; -}): Effect.Effect { - return waitForValue( - listMatchingReplies(params).pipe( - Effect.map((replies) => - replies.length >= params.expectedCount - ? replies.slice(0, params.expectedCount) - : undefined, - ), - ), - { pollMillis: REPLY_POLL_INTERVAL_MS }, - ); -} - -function listMatchingReplies(params: { - readonly client: MoltZapAgentClient; - readonly binding: ConversationBinding; - readonly receiverAgentId: AgentId; -}) { - return params.client - .call(messagesList.name, { - conversationId: params.binding.conversationId, - limit: TOTAL_STRESS_MESSAGE_COUNT, - }) - .pipe( - Effect.map((result) => - result.messages.filter( - (message) => - message.senderId === params.receiverAgentId && - extractText(message).includes(ECHO_PREFIX), - ), - ), - ); -} - -function expectStressReplies( - replies: StressReplies, - conversations: StressConversationIds, - receiverAgentId: AgentId, -) { - expect(replies.repliesA).toHaveLength(MESSAGES_FROM_A); - expect(replies.repliesB).toHaveLength(MESSAGES_FROM_B); - expect(replies.repliesC).toHaveLength(MESSAGES_FROM_C); - expectReplyBatch( - replies.repliesA, - conversations.convA.conversationId, - receiverAgentId, - ); - expectReplyBatch( - replies.repliesB, - conversations.convB.conversationId, - receiverAgentId, - ); - expectReplyBatch( - replies.repliesC, - conversations.convC.conversationId, - receiverAgentId, - ); - expect(uniqueReplyIds(replies).size).toBe(TOTAL_STRESS_MESSAGE_COUNT); -} - -function expectReplyBatch( - replies: readonly Message[], - conversationId: ConversationId, - receiverAgentId: AgentId, -) { - for (const reply of replies) { - expect(reply.senderId).toBe(receiverAgentId); - expect(reply.conversationId).toBe(conversationId); - expect(extractText(reply)).toContain(ECHO_PREFIX); - } -} - -function uniqueReplyIds(replies: StressReplies) { - return new Set([ - ...replies.repliesA.map((reply) => reply.id), - ...replies.repliesB.map((reply) => reply.id), - ...replies.repliesC.map((reply) => reply.id), - ]); -} - -function closeStressClients(clients: StressClients) { - return Effect.all( - [clients.clientA.close(), clients.clientB.close(), clients.clientC.close()], - { concurrency: STRESS_AGENT_COUNT }, - ); -} - -function logContainerFailure(containerAId: string) { - return Effect.logError(`Stress container logs:\n${getLogs(containerAId)}`); -} diff --git a/packages/openclaw-channel/src/__tests__/test-helpers.ts b/packages/openclaw-channel/src/__tests__/test-helpers.ts deleted file mode 100644 index 2070c1b44..000000000 --- a/packages/openclaw-channel/src/__tests__/test-helpers.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Shared test helpers for openclaw-channel integration tests. - * - * Agent-only: helpers operate exclusively on agent identifiers exposed by - * the shared client registration helper. - */ - -import { inject } from "vitest"; -import type { - Message, - MessageReceivedNotification, -} from "@moltzap/protocol/message"; -import { registerAgent } from "@moltzap/client/auth"; -import { Effect } from "effect"; - -const WAIT_FOR_POLL_INTERVAL_MS = 50; - -class WaitForTimeoutError extends Error { - override readonly name = "WaitForTimeoutError"; -} - -/** - * Registers test agent. - * @param name Name of the operation. - * @returns The register test agent result. - */ -export function registerTestAgent(name: string) { - const baseUrl = inject("baseUrl"); - - return Effect.runPromise( - registerAgent(baseUrl, name).pipe(Effect.withSpan("registerTestAgent")), - ); -} - -import type { ConversationId } from "@moltzap/protocol/conversation"; - -/** - * Executes the extract message operation. - * @param event Value supplied to the operation. - * @returns The extract message result. - */ -export function extractMessage(event: MessageReceivedNotification): Message { - return event.message; -} - -/** - * Executes the extract conv id operation. - * @param result Value supplied to the operation. - * @returns The extract conv id result. - */ -export function extractConvId(result: unknown): string { - return ( - /* Safe because the test fixture establishes this asserted shape. */ - (result as { conversation: { id: string } }).conversation.id - ); -} - -/** Describes a conversation binding. */ -export interface ConversationBinding { - readonly conversationId: ConversationId; -} - -/** - * Executes the extract conversation binding operation. - * @param result Value supplied to the operation. - * @returns The extract conversation binding result. - */ -export function extractConversationBinding( - result: unknown, -): ConversationBinding { - const typed = - /* Safe because the test fixture establishes this asserted shape. */ result as { - conversation: { id: ConversationId }; - }; - return { conversationId: typed.conversation.id }; -} - -/** - * Executes the extract text operation. - * @param message Value supplied to the operation. - * @returns The extract text result. - */ -export function extractText(message: Message): string { - const part = message.parts[0]; - return part && "text" in part ? part.text : ""; -} - -/** - * Waits for for. - * @param predicate Predicate used to select matching values. - * @param timeoutMs Maximum time to wait in milliseconds. - * @returns A promise that completes when the predicate succeeds. - */ -export function waitFor(predicate: () => boolean, timeoutMs: number) { - return new Promise((resolve, reject) => { - const start = Date.now(); - const check = () => { - if (predicate()) { - resolve(undefined); - } else if (Date.now() - start > timeoutMs) { - reject(new WaitForTimeoutError("waitFor timeout")); - } else { - setTimeout(check, WAIT_FOR_POLL_INTERVAL_MS); - } - }; - check(); - }); -} diff --git a/packages/openclaw-channel/src/__tests__/vitest-provided.d.ts b/packages/openclaw-channel/src/__tests__/vitest-provided.d.ts deleted file mode 100644 index d7cbc34c3..000000000 --- a/packages/openclaw-channel/src/__tests__/vitest-provided.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -export {}; - -declare module "vitest" { - export interface ProvidedContext { - baseUrl: string; - wsUrl: string; - containerAId: string; - containerAAgentId: string; - containerAApiKey: string; - containerBId: string; - containerBAgentId: string; - containerBApiKey: string; - } -} diff --git a/packages/openclaw-channel/src/openclaw-entry.delivery.test.ts b/packages/openclaw-channel/src/openclaw-entry.delivery.test.ts index 581259dd8..822109e61 100644 --- a/packages/openclaw-channel/src/openclaw-entry.delivery.test.ts +++ b/packages/openclaw-channel/src/openclaw-entry.delivery.test.ts @@ -1,143 +1,58 @@ import { live as it } from "@effect/vitest"; -import { - buildMessage, - createFakeChannelService, - flushDispatchChainEffect, - testAgentId, - testConversationId, - testMessageId, - type FakeChannelService, -} from "@moltzap/client/test-utils"; -import type { ServiceRpcError } from "@moltzap/client"; -import { agentsList } from "@moltzap/protocol/identity"; -import { messagesSend } from "@moltzap/protocol/message"; -import type { ConversationId } from "@moltzap/protocol/conversation"; -import { - type ParamsOf, - type ResultOf, - type RpcDefinitionAny, - ForbiddenError, -} from "@moltzap/protocol/rpc"; -import { Data, Effect } from "effect"; +import { Effect } from "effect"; import * as fc from "fast-check"; import { afterEach, beforeEach, describe, expect, vi } from "vitest"; -import { createMoltzapChannelPlugin } from "./openclaw-entry.js"; +import { + CONVERSATION_ID, + HarnessFixtureError, + cleanUpStart, + createHarnessFixture, + firstDispatchCall, + makeConfig, + offerHarnessTurn, + runHarnessPromise, + sendHarnessText, + startHarnessGateway, + stopHarnessAccount, + waitForDispatchTimes, + waitForGatewayStart, + waitForHarnessExpectation, + type HarnessFixture, +} from "./test-utils/harness-fixture.js"; -const ACCOUNT_ID = "delivery-test"; -const ACCOUNT_AGENT_NAME = "bob-delivery"; -const SELF_AGENT_ID = testAgentId("550e8400-e29b-41d4-a716-446655440401"); -const SENDER_AGENT_ID = testAgentId("550e8400-e29b-41d4-a716-446655440402"); -const DEFAULT_MESSAGE_ID = testMessageId( - "550e8400-e29b-41d4-a716-446655440403", -); -const DEFAULT_CONVERSATION_ID = testConversationId( - "550e8400-e29b-41d4-a716-446655440404", -); -const OUTBOUND_CONVERSATION_ID = testConversationId( - "550e8400-e29b-41d4-a716-446655440406", -); -const STOP_CONVERSATION_ID = testConversationId( - "550e8400-e29b-41d4-a716-446655440409", -); -const OUTBOUND_TARGET = `conv:${OUTBOUND_CONVERSATION_ID}`; -const STOP_TARGET = `conv:${STOP_CONVERSATION_ID}`; const AGENT_NOVA_TARGET = "agent:nova"; const AGENT_NOVA_NAME = "nova"; -const TRIGGER_TEXT = "Trigger message"; +const CONVERSATION_TARGET = `conv:${CONVERSATION_ID}`; +const UNKNOWN_ACCOUNT_ID = "nonexistent-account"; const REPLY_TEXT = "reply text"; const FIRST_REPLY_TEXT = "first reply"; const SECOND_REPLY_TEXT = "second reply"; const PARTIAL_TEXT = "partial"; -const OUTBOUND_TEXT = "Hello from outbound"; const AGENT_TEXT = "Hello nova"; const BEFORE_STOP_TEXT = "before stop"; const AFTER_STOP_TEXT = "after stop"; -const LOOKUP_FAILED_MESSAGE = "lookup failed"; -const SERVER_REJECTED_MESSAGE = "Server rejected"; -const INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error"; +const START_CONVERSATION_REJECTED_MESSAGE = "Server rejected"; +const REPLY_REJECTED_MESSAGE = "Internal server error"; const DISPATCH_REJECTED_MESSAGE = "dispatch rejected"; -const TEXT_PART_TYPE = "text"; const FINAL_KIND = "final"; const TOOL_KIND = "tool"; -type SendTextInput = Parameters< - ReturnType["outbound"]["sendText"] ->[0]; -type SendTextResult = Awaited< - ReturnType< - ReturnType["outbound"]["sendText"] - > ->; interface DeliverInput { readonly text?: string; readonly body?: string; } -interface DeliverInfo { - readonly kind?: string; -} -type Deliver = ( - payload: DeliverInput, - info?: DeliverInfo, -) => PromiseLike; -interface DispatchCall { - readonly dispatcherOptions: { - readonly deliver: Deliver; - }; -} -type SendFn = ( - conversationId: ConversationId, - text: string, -) => Effect.Effect; -type SendToAgentFn = ( - agentName: string, - text: string, -) => Effect.Effect; -type SendRpcFn = ( - definition: D, - params: ParamsOf, -) => Effect.Effect, ServiceRpcError>; -type TestService = FakeChannelService["service"] & { - readonly send: SendFn; - readonly sendRpc: SendRpcFn; - readonly sendToAgent: SendToAgentFn; -}; - -class DeliveryTestError extends Data.TaggedError("DeliveryTestError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -class SendToAgentTestFailure extends Data.TaggedError( - "SendToAgentTestFailure", -)<{ - readonly reason: string; -}> { - override get message(): string { - return this.reason; - } -} - -const mockSend = vi.fn(); -const mockSendToAgent = vi.fn(); -let started: { - readonly fixture: FakeChannelService; - readonly plugin: ReturnType; -}; -let abortControllers: AbortController[] = []; -let mockDispatch: ReturnType; -let mockLogger: ReturnType; +let fixture: HarnessFixture; +let started: ReturnType; +let logger: ReturnType; beforeEach(() => { - started = startGateway(); + logger = testLogger(); + fixture = createHarnessFixture(); + started = startHarnessGateway(fixture, { log: logger }); }); -afterEach(() => { - for (const controller of abortControllers) { - controller.abort(); - } - abortControllers = []; -}); +afterEach(() => Effect.runPromise(cleanUpStart(started))); describe("Flow 6: Outbound delivery - deliver callback + sendText", () => { it("deliver callback returns true", deliverReturnsTrue); @@ -147,18 +62,24 @@ describe("Flow 6: Outbound delivery - deliver callback + sendText", () => { ); it("each final delivery sends a reply", sendsEachFinalDelivery); it("deliver callback returns true for non-final replies", nonFinalIsIgnored); - it("sendText sends to the right conversation", sendsToConversation); it("resolveTarget accepts agent targets", acceptsAgentTarget); it("resolveTarget normalizes plain agent names", normalizesPlainAgentName); it("resolveTarget accepts conversation IDs", acceptsConversationTarget); it("resolveTarget rejects empty strings", rejectsEmptyTarget); - it("sendText delegates agent targets", delegatesAgentTarget); - it("sendText delegates plain agent names", delegatesPlainAgentName); - it("sendText reports sendToAgent failures", reportsSendToAgentFailure); + it( + "sendText starts a conversation for a plain agent name", + startsConversationForPlainAgentName, + ); it("sendText reports disconnected clients", reportsDisconnectedClient); - it("sendText reports send failures", reportsSendFailure); - it("deliver reports transient RPC send failures", sendFailureIsReported); - it("a later delivery retries after a send failure", retriesAfterSendFailure); + it( + "sendText reports startConversation failures", + reportsStartConversationFailure, + ); + it("deliver reports a rejected turn reply", replyFailureIsReported); + it( + "a later delivery retries after a reply failure", + retriesAfterReplyFailure, + ); it("stopAccount removes client from active pool", stopRemovesClient); it( "property: resolveTarget normalizes generated agent names", @@ -166,89 +87,6 @@ describe("Flow 6: Outbound delivery - deliver callback + sendText", () => { ); }); -function startGateway() { - vi.clearAllMocks(); - mockDispatch = vi.fn().mockResolvedValue({ queuedFinal: true }); - mockLogger = testLogger(); - const fixture = createFakeChannelService({ ownAgentId: SELF_AGENT_ID }); - fixture.state.setConversation(DEFAULT_CONVERSATION_ID, defaultConversation()); - fixture.state.setAgentName(SENDER_AGENT_ID, "Atlas"); - const service = createTestService(fixture); - const plugin = createMoltzapChannelPlugin({ - createService: () => service, - }); - const abortController = new AbortController(); - abortControllers.push(abortController); - Effect.runFork( - Effect.tryPromise({ - try: () => - plugin.gateway.startAccount({ - cfg: makeCfg(), - accountId: ACCOUNT_ID, - account: makeAccount(), - abortSignal: abortController.signal, - log: mockLogger, - setStatus: vi.fn(), - channelRuntime: { - reply: { - dispatchReplyWithBufferedBlockDispatcher: mockDispatch, - }, - }, - }), - catch: (cause) => cause, - }), - ); - return { fixture, plugin }; -} - -function createTestService(fixture: FakeChannelService): TestService { - mockSend.mockImplementation(fixture.service.send.bind(fixture.service)); - mockSendToAgent.mockReturnValue(Effect.void); - return { - ...fixture.service, - send: mockSend, - sendRpc: sendRpcDefault, - sendToAgent: mockSendToAgent, - }; -} - -function sendRpcDefault( - definition: D, -): Effect.Effect, ServiceRpcError> { - if (definition.name === agentsList.name) { - return Effect.succeed( - rpcResult({ - agents: [{ id: SENDER_AGENT_ID, name: "Atlas" }], - }), - ); - } - if (definition.name === messagesSend.name) { - return Effect.succeed(rpcResult({ message: { id: "sent-1" } })); - } - return Effect.succeed(rpcResult({})); -} - -function rpcResult(value: unknown): ResultOf { - return /* Safe because each test branch matches the selected RPC definition. */ value as ResultOf; -} - -function makeAccount() { - return { - id: ACCOUNT_ID, - agentName: ACCOUNT_AGENT_NAME, - }; -} - -function makeCfg() { - return { - channels: { - moltzap: { - accounts: [makeAccount()], - }, - }, - }; -} - function testLogger() { return { info: vi.fn(), @@ -258,134 +96,48 @@ function testLogger() { }; } -function defaultConversation() { - return { - id: DEFAULT_CONVERSATION_ID, - type: "dm", - participants: [agentRef(SENDER_AGENT_ID), agentRef(SELF_AGENT_ID)], - }; -} - -function agentRef(agentId: string): string { - return `agent:${agentId}`; -} - -function makeDeliveryMessage( - overrides: Parameters[0] = {}, -) { - return buildMessage({ - id: DEFAULT_MESSAGE_ID, - conversationId: DEFAULT_CONVERSATION_ID, - senderId: SENDER_AGENT_ID, - parts: [{ type: TEXT_PART_TYPE, text: TRIGGER_TEXT }], - createdAt: "2026-03-16T00:00:00Z", - ...overrides, - }); -} - -function emitMessage(overrides: Parameters[0] = {}) { - return Effect.gen(function* () { - started.fixture.emit.message(makeDeliveryMessage(overrides)); - yield* flushDispatchChainEffect; - }); -} - -function waitForExpectation(assertion: () => void, label: string) { - return Effect.tryPromise({ - try: () => vi.waitFor(assertion), - catch: (cause) => - new DeliveryTestError({ message: `wait for ${label}`, cause }), - }); -} - -function waitForDispatchTimes(count: number) { - return waitForExpectation(() => { - expect(mockDispatch).toHaveBeenCalledTimes(count); - }, "dispatch call"); -} - -function firstDispatchCall(): DispatchCall { - return /* Safe because the test fixture establishes this asserted shape. */ mockDispatch - .mock.calls[0]?.[0] as DispatchCall; -} - function deliverFinal(text: string) { - return deliver(firstDispatchCall().dispatcherOptions.deliver, { - text, - kind: FINAL_KIND, - }); -} - -function deliver( - delivery: Deliver, - input: DeliverInput & { readonly kind: string }, -) { - return Effect.tryPromise({ - try: () => - delivery({ text: input.text, body: input.body }, { kind: input.kind }), - catch: (cause) => - new DeliveryTestError({ message: "deliver failed", cause }), - }); -} - -function sendText(input: SendTextInput) { - return Effect.tryPromise({ - try: () => started.plugin.outbound.sendText(input), - catch: (cause) => - new DeliveryTestError({ message: "sendText failed", cause }), - }); + return deliver({ text }, FINAL_KIND); } -function stopAccount() { - return Effect.tryPromise({ - try: () => - started.plugin.gateway.stopAccount({ - accountId: ACCOUNT_ID, - log: { info: vi.fn() }, - }), - catch: (cause) => - new DeliveryTestError({ message: "stopAccount failed", cause }), - }); -} - -function expectSuccessfulSend(result: SendTextResult): void { - expect(result.ok).toBe(true); +function deliver(payload: DeliverInput, kind: string) { + const delivery = firstDispatchCall(started.dispatch).dispatcherOptions + .deliver; + return runHarnessPromise("deliver failed", () => delivery(payload, { kind })); } function expectFailureMessage( - result: SendTextResult, + result: { readonly ok: boolean; readonly error?: Error }, expectedMessage: string | RegExp, ): void { expect(result.ok).toBe(false); - if (result.ok) { - return; - } if (typeof expectedMessage === "string") { - expect(result.error.message).toBe(expectedMessage); + expect(result.error?.message).toBe(expectedMessage); return; } - expect(result.error.message).toMatch(expectedMessage); + expect(result.error?.message).toMatch(expectedMessage); } function deliverReturnsTrue() { return Effect.gen(function* () { - yield* emitMessage(); - yield* waitForDispatchTimes(1); - const result = yield* deliverFinal(REPLY_TEXT); - expect(result).toBe(true); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); + expect(yield* deliverFinal(REPLY_TEXT)).toBe(true); }); } function rejectedDispatchIsNotFinished() { return Effect.gen(function* () { - mockDispatch.mockRejectedValueOnce(new Error(DISPATCH_REJECTED_MESSAGE)); - yield* emitMessage(); - yield* waitForExpectation(() => { - expect(mockLogger.error).toHaveBeenCalledWith( + started.dispatch.mockRejectedValueOnce( + new HarnessFixtureError({ message: DISPATCH_REJECTED_MESSAGE }), + ); + yield* offerHarnessTurn(fixture); + yield* waitForHarnessExpectation(() => { + expect(logger.error).toHaveBeenCalledWith( expect.stringContaining(DISPATCH_REJECTED_MESSAGE), ); - }, "dispatch error log"); - expect(mockLogger.info).not.toHaveBeenCalledWith( + }, "wait for dispatch error log"); + expect(logger.info).not.toHaveBeenCalledWith( expect.stringContaining("dispatch finished"), ); }); @@ -393,47 +145,23 @@ function rejectedDispatchIsNotFinished() { function sendsEachFinalDelivery() { return Effect.gen(function* () { - yield* emitMessage(); - yield* waitForDispatchTimes(1); - const sendBefore = mockSend.mock.calls.length; - const first = yield* deliverFinal(FIRST_REPLY_TEXT); - const sendAfterFirst = mockSend.mock.calls.length; - const second = yield* deliverFinal(SECOND_REPLY_TEXT); - expect(first).toBe(true); - expect(sendAfterFirst).toBe(sendBefore + 1); - expect(second).toBe(true); - expect(mockSend.mock.calls.length).toBe(sendAfterFirst + 1); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); + expect(yield* deliverFinal(FIRST_REPLY_TEXT)).toBe(true); + expect(yield* deliverFinal(SECOND_REPLY_TEXT)).toBe(true); + expect(fixture.reply.mock.calls).toEqual([ + [FIRST_REPLY_TEXT], + [SECOND_REPLY_TEXT], + ]); }); } function nonFinalIsIgnored() { return Effect.gen(function* () { - yield* emitMessage(); - yield* waitForDispatchTimes(1); - const result = yield* deliver( - firstDispatchCall().dispatcherOptions.deliver, - { - text: PARTIAL_TEXT, - kind: TOOL_KIND, - }, - ); - expect(result).toBe(true); - }); -} - -function sendsToConversation() { - return Effect.gen(function* () { - const result = yield* sendText({ - cfg: makeCfg(), - to: OUTBOUND_TARGET, - text: OUTBOUND_TEXT, - accountId: ACCOUNT_ID, - }); - expectSuccessfulSend(result); - expect(mockSend).toHaveBeenCalledWith( - OUTBOUND_CONVERSATION_ID, - OUTBOUND_TEXT, - ); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); + expect(yield* deliver({ text: PARTIAL_TEXT }, TOOL_KIND)).toBe(true); + expect(fixture.reply).not.toHaveBeenCalled(); }); } @@ -442,7 +170,7 @@ function acceptsAgentTarget() { expect( started.plugin.outbound.resolveTarget({ to: AGENT_NOVA_TARGET, - cfg: makeCfg(), + cfg: makeConfig(), }), ).toMatchObject({ ok: true, to: AGENT_NOVA_TARGET }); }); @@ -453,163 +181,123 @@ function normalizesPlainAgentName() { expect( started.plugin.outbound.resolveTarget({ to: AGENT_NOVA_NAME, - cfg: makeCfg(), + cfg: makeConfig(), }), ).toMatchObject({ ok: true, to: AGENT_NOVA_TARGET }); }); } +// Inbound turns are labelled `conv:`, so target parsing still accepts the +// prefix even though the harness surface has no proactive send into one. function acceptsConversationTarget() { return Effect.sync(() => { expect( started.plugin.outbound.resolveTarget({ - to: OUTBOUND_TARGET, - cfg: makeCfg(), + to: CONVERSATION_TARGET, + cfg: makeConfig(), }), - ).toMatchObject({ ok: true, to: OUTBOUND_TARGET }); + ).toMatchObject({ ok: true, to: CONVERSATION_TARGET }); }); } function rejectsEmptyTarget() { return Effect.sync(() => { - const result = started.plugin.outbound.resolveTarget({ - to: " ", - cfg: makeCfg(), - }); - expect(result.ok).toBe(false); - }); -} - -function delegatesAgentTarget() { - return Effect.gen(function* () { - const result = yield* sendText({ - cfg: makeCfg(), - to: AGENT_NOVA_TARGET, - text: AGENT_TEXT, - accountId: ACCOUNT_ID, - }); - expectSuccessfulSend(result); - expect(mockSendToAgent).toHaveBeenCalledWith(AGENT_NOVA_NAME, AGENT_TEXT); - expect(mockSend).not.toHaveBeenCalled(); + expect( + started.plugin.outbound.resolveTarget({ to: " ", cfg: makeConfig() }).ok, + ).toBe(false); }); } -function delegatesPlainAgentName() { - return Effect.gen(function* () { - const result = yield* sendText({ - cfg: makeCfg(), - to: AGENT_NOVA_NAME, - text: AGENT_TEXT, - accountId: ACCOUNT_ID, - }); - expectSuccessfulSend(result); - expect(mockSendToAgent).toHaveBeenCalledWith(AGENT_NOVA_NAME, AGENT_TEXT); - expect(mockSend).not.toHaveBeenCalled(); - }); -} -function reportsSendToAgentFailure() { +function startsConversationForPlainAgentName() { return Effect.gen(function* () { - mockSendToAgent.mockReturnValue( - Effect.fail( - new SendToAgentTestFailure({ reason: LOOKUP_FAILED_MESSAGE }), - ), + yield* waitForGatewayStart(started); + const result = yield* sendHarnessText( + started.plugin, + AGENT_NOVA_NAME, + AGENT_TEXT, + ); + expect(result.ok).toBe(true); + expect(fixture.startConversation).toHaveBeenCalledExactlyOnceWith( + [AGENT_NOVA_NAME], + AGENT_TEXT, ); - const result = yield* sendText({ - cfg: makeCfg(), - to: AGENT_NOVA_TARGET, - text: AGENT_TEXT, - accountId: ACCOUNT_ID, - }); - expectFailureMessage(result, LOOKUP_FAILED_MESSAGE); }); } function reportsDisconnectedClient() { return Effect.gen(function* () { - const result = yield* sendText({ - cfg: makeCfg(), - to: STOP_TARGET, - text: "hello", - accountId: "nonexistent-account", - }); + yield* waitForGatewayStart(started); + const result = yield* sendHarnessText( + started.plugin, + AGENT_NOVA_TARGET, + AGENT_TEXT, + UNKNOWN_ACCOUNT_ID, + ); expectFailureMessage(result, /not connected/i); }); } -function reportsSendFailure() { +function reportsStartConversationFailure() { return Effect.gen(function* () { - mockSend.mockReturnValueOnce(serverRejected()); - const result = yield* sendText({ - cfg: makeCfg(), - to: STOP_TARGET, - text: "hello", - accountId: ACCOUNT_ID, - }); - expectFailureMessage(result, SERVER_REJECTED_MESSAGE); + yield* waitForGatewayStart(started); + fixture.startConversation.mockReturnValueOnce( + Effect.fail( + new HarnessFixtureError({ + message: START_CONVERSATION_REJECTED_MESSAGE, + }), + ), + ); + const result = yield* sendHarnessText( + started.plugin, + AGENT_NOVA_TARGET, + AGENT_TEXT, + ); + expectFailureMessage(result, START_CONVERSATION_REJECTED_MESSAGE); }); } -function serverRejected(): Effect.Effect { - return Effect.fail( - new ForbiddenError({ - message: SERVER_REJECTED_MESSAGE, - }), - ); -} - -function sendFailureIsReported() { +function replyFailureIsReported() { return Effect.gen(function* () { - mockSend.mockReturnValueOnce( - Effect.fail( - new ForbiddenError({ - message: INTERNAL_SERVER_ERROR_MESSAGE, - }), - ), + fixture.reply.mockReturnValueOnce( + Effect.fail(new HarnessFixtureError({ message: REPLY_REJECTED_MESSAGE })), ); - yield* emitMessage(); - yield* waitForDispatchTimes(1); - const result = yield* deliverFinal(REPLY_TEXT); - expect(result).toBe(false); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); + expect(yield* deliverFinal(REPLY_TEXT)).toBe(false); }); } -function retriesAfterSendFailure() { +function retriesAfterReplyFailure() { return Effect.gen(function* () { - mockSend.mockReturnValueOnce( - Effect.fail( - new ForbiddenError({ - message: INTERNAL_SERVER_ERROR_MESSAGE, - }), - ), + fixture.reply.mockReturnValueOnce( + Effect.fail(new HarnessFixtureError({ message: REPLY_REJECTED_MESSAGE })), ); - yield* emitMessage(); - yield* waitForDispatchTimes(1); - const sendBefore = mockSend.mock.calls.length; - const first = yield* deliverFinal(FIRST_REPLY_TEXT); - expect(first).toBe(false); - expect(mockSend.mock.calls.length).toBe(sendBefore + 1); - const second = yield* deliverFinal(SECOND_REPLY_TEXT); - expect(second).toBe(true); - expect(mockSend.mock.calls.length).toBe(sendBefore + 2); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); + expect(yield* deliverFinal(FIRST_REPLY_TEXT)).toBe(false); + expect(yield* deliverFinal(SECOND_REPLY_TEXT)).toBe(true); + expect(fixture.reply.mock.calls).toEqual([ + [FIRST_REPLY_TEXT], + [SECOND_REPLY_TEXT], + ]); }); } function stopRemovesClient() { return Effect.gen(function* () { - const beforeResult = yield* sendText({ - cfg: makeCfg(), - to: STOP_TARGET, - text: BEFORE_STOP_TEXT, - accountId: ACCOUNT_ID, - }); - expectSuccessfulSend(beforeResult); - yield* stopAccount(); - const afterResult = yield* sendText({ - cfg: makeCfg(), - to: STOP_TARGET, - text: AFTER_STOP_TEXT, - accountId: ACCOUNT_ID, - }); + yield* waitForGatewayStart(started); + const beforeResult = yield* sendHarnessText( + started.plugin, + AGENT_NOVA_TARGET, + BEFORE_STOP_TEXT, + ); + expect(beforeResult.ok).toBe(true); + yield* stopHarnessAccount(started.plugin); + const afterResult = yield* sendHarnessText( + started.plugin, + AGENT_NOVA_TARGET, + AFTER_STOP_TEXT, + ); expectFailureMessage(afterResult, /not connected/i); }); } @@ -620,11 +308,12 @@ function plainAgentNamesResolve() { fc.property( fc.stringMatching(/^[a-z0-9][a-z0-9_-]{1,30}[a-z0-9]$/), (target) => { - const result = started.plugin.outbound.resolveTarget({ - to: target, - cfg: makeCfg(), - }); - expect(result).toMatchObject({ ok: true, to: `agent:${target}` }); + expect( + started.plugin.outbound.resolveTarget({ + to: target, + cfg: makeConfig(), + }), + ).toMatchObject({ ok: true, to: `agent:${target}` }); }, ), ); diff --git a/packages/openclaw-channel/src/openclaw-entry.directory.test.ts b/packages/openclaw-channel/src/openclaw-entry.directory.test.ts deleted file mode 100644 index 8deb35697..000000000 --- a/packages/openclaw-channel/src/openclaw-entry.directory.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { live as it } from "@effect/vitest"; -import { - createFakeChannelService, - testAgentId, - type FakeChannelService, -} from "@moltzap/client/test-utils"; -import type { ServiceRpcError } from "@moltzap/client"; -import type { ChannelService } from "@moltzap/client/channel-base"; -import { agentsList } from "@moltzap/protocol/identity"; -import type { - ParamsOf, - ResultOf, - RpcDefinitionAny, -} from "@moltzap/protocol/rpc"; -import { Data, Effect } from "effect"; -import { afterEach, beforeEach, describe, expect, vi } from "vitest"; -import { createMoltzapChannelPlugin } from "./openclaw-entry.js"; - -class DirectoryTestError extends Data.TaggedError("DirectoryTestError")<{ - readonly message: string; - readonly cause: unknown; -}> {} - -// `agent/identity/agents/list` is bounded to a server-default page. The openclaw directory -// must page through `nextCursor` to enumerate EVERY peer — a user with more -// visible agents than one page must not silently lose the tail. -// These tests drive `plugin.directory.listPeers` against a fake -// `callDefinition` that paginates `agent/identity/agents/list`, and assert the full set is -// resolved. - -const ACCOUNT_ID = "directory-test"; -const ACCOUNT_AGENT_NAME = "owner-directory"; -const SELF_AGENT_ID = testAgentId("550e8400-e29b-41d4-a716-446655440501"); -const SERVER_PAGE_SIZE = 50; -const CONTACT_COUNT = 130; -const EXPECTED_PAGE_CALLS = Math.ceil(CONTACT_COUNT / SERVER_PAGE_SIZE); - -interface PeerAgent { - readonly id: string; - readonly name: string; - readonly displayName: string; - readonly status: "active"; -} - -let fixture: FakeChannelService; -let plugin: ReturnType; -let agentsCallCount: number; -// When set, the fake server returns a CONSTANT non-advancing nextCursor -// on every `agent/identity/agents/list` page — the byzantine case the drain's -// cursor-cycle guard must terminate on (rather than loop forever). -let byzantineConstantCursor: boolean; -const CONSTANT_CURSOR = Buffer.from("stuck", "utf8").toString("base64url"); - -function listPeers() { - return Effect.tryPromise({ - try: () => - plugin.directory.listPeers({ cfg: makeCfg(), accountId: ACCOUNT_ID }), - catch: (cause) => - new DirectoryTestError({ message: "listPeers failed", cause }), - }); -} - -function buildAgents(count: number): readonly PeerAgent[] { - const agents: PeerAgent[] = []; - for (let i = 0; i < count; i++) { - const id = `00000000-0000-4000-8000-${String(i).padStart(12, "0")}`; - agents.push({ - id: testAgentId(id), - name: `peer-${i}`, - displayName: `Peer ${i}`, - status: "active", - }); - } - return agents; -} - -const ALL_AGENTS = buildAgents(CONTACT_COUNT); - -// Server-faithful keyset paging over an opaque cursor: the cursor is the -// index of the first row of the NEXT page, base64url-encoded so the -// consumer treats it as opaque. nextCursor present iff a further page -// exists (Invariant 1). -function agentsPage(cursor: string): { - readonly agents: readonly PeerAgent[]; - readonly nextCursor?: string; -} { - const start = cursor === "" ? 0 : Number(decodeCursor(cursor)); - const slice = ALL_AGENTS.slice(start, start + SERVER_PAGE_SIZE); - const nextStart = start + SERVER_PAGE_SIZE; - const hasMore = nextStart < ALL_AGENTS.length; - return hasMore - ? { agents: slice, nextCursor: encodeCursor(nextStart) } - : { agents: slice }; -} - -function encodeCursor(index: number): string { - return Buffer.from(String(index), "utf8").toString("base64url"); -} - -function decodeCursor(cursor: string): string { - return Buffer.from(cursor, "base64url").toString("utf8"); -} - -function directoryCallDefinition( - definition: D, - params: ParamsOf, -): Effect.Effect, ServiceRpcError> { - if (definition.name === agentsList.name) { - agentsCallCount++; - if (byzantineConstantCursor) { - // Always claims "more" with the same cursor — never advances. - return Effect.succeed( - rpcResult({ - agents: ALL_AGENTS.slice(0, SERVER_PAGE_SIZE), - nextCursor: CONSTANT_CURSOR, - }), - ); - } - const cursor = - /* Safe because the test fixture establishes this asserted shape. */ - (params as { readonly cursor?: string }).cursor ?? ""; - return Effect.succeed(rpcResult(agentsPage(cursor))); - } - return Effect.succeed(rpcResult({})); -} - -function rpcResult(value: unknown): ResultOf { - return /* Safe because each fixture branch matches the selected RPC definition. */ value as ResultOf; -} - -// Production `MoltZapService.sendRpc` is a PROTOTYPE method that reads -// `this.client` inside `Effect.suspend`. Passed as a bare reference its -// receiver is stripped, so the suspend thunk dies with a `this`-undefined -// TypeError that `catchAll` cannot absorb. The standalone -// `directoryCallDefinition` fixture never reads `this`, so it cannot catch a -// receiver-stripping regression. This service's `callDefinition` reads -// `this.live` inside the suspend thunk, mirroring `this.client`: the directory -// code MUST bind `callDefinition` to -// the service before handing it to its drain consumers (binding to the -// service restores `this`), or `listPeers` rejects instead of resolving. -type ReceiverDependentCallDefinition = ( - definition: D, - params: ParamsOf, -) => Effect.Effect, ServiceRpcError>; - -interface ReceiverDependentService extends ChannelService { - readonly live: true; - callDefinition: ReceiverDependentCallDefinition; -} - -function makeReceiverDependentCallDefinition(): ReceiverDependentCallDefinition { - return function callDefinition( - this: ReceiverDependentService, - definition: D, - params: ParamsOf, - ): Effect.Effect, ServiceRpcError> { - return Effect.suspend(() => { - // `this.live` throws synchronously when `this` is undefined (receiver - // stripped), matching `MoltZapService.callDefinition` reading - // `this.client`. The directory MUST bind `service.callDefinition` to the - // service before forwarding it to the drain consumers, or this thunk dies - // on `this`-undefined. - if (!this?.live) { - throw new TypeError("Cannot read properties of undefined"); - } - return directoryCallDefinition(definition, params); - }); - }; -} - -function startGatewayWithService(build: () => ChannelService): void { - vi.clearAllMocks(); - agentsCallCount = 0; - byzantineConstantCursor = false; - fixture = createFakeChannelService({ ownAgentId: SELF_AGENT_ID }); - const service = build(); - plugin = createMoltzapChannelPlugin({ createService: () => service }); - Effect.runFork( - Effect.tryPromise({ - try: () => - plugin.gateway.startAccount({ - cfg: makeCfg(), - accountId: ACCOUNT_ID, - account: makeAccount(), - abortSignal: new AbortController().signal, - setStatus: vi.fn(), - }), - catch: (cause) => cause, - }), - ); -} - -// `ChannelService` plus the optional `callDefinition` the openclaw directory -// reads (`OpenClawClientService.callDefinition`). -type ServiceWithCallDefinition = ChannelService & { - readonly callDefinition: typeof directoryCallDefinition; -}; - -function startDirectoryGateway(): void { - startGatewayWithService( - () => - /* Safe because the test fixture establishes this asserted shape. */ ({ - ...fixture.service, - callDefinition: directoryCallDefinition, - }) satisfies ServiceWithCallDefinition as ChannelService, - ); -} - -beforeEach(startDirectoryGateway); -afterEach(() => vi.clearAllMocks()); - -function enumeratesEveryPeer() { - return Effect.gen(function* () { - const peers = yield* listPeers(); - // Drains all CONTACT_COUNT contacts across every page, not just the - // first server page — the single-page consumer returns SERVER_PAGE_SIZE. - expect(peers).toHaveLength(CONTACT_COUNT); - const names = new Set(peers.map((p) => p.name)); - expect(names.has("Peer 0")).toBe(true); - expect(names.has(`Peer ${SERVER_PAGE_SIZE - 1}`)).toBe(true); - expect(names.has(`Peer ${SERVER_PAGE_SIZE}`)).toBe(true); - expect(names.has(`Peer ${CONTACT_COUNT - 1}`)).toBe(true); - const ids = new Set(peers.map((p) => p.id)); - expect(ids.has("agent:peer-0")).toBe(true); - expect(ids.has(`agent:peer-${CONTACT_COUNT - 1}`)).toBe(true); - }); -} - -function followsNextCursorAcrossPages() { - return Effect.gen(function* () { - yield* listPeers(); - expect(agentsCallCount).toBe(EXPECTED_PAGE_CALLS); - }); -} - -// Non-advancing cursor: the guard must TERMINATE the drain (not hang, not -// truncate-loop). Page 1 records cursor C; page 2 (sent with cursor=C) -// returns C again → already seen → typed fail. The directory's catchAll -// absorbs the error to an empty list, so the observable signal is: the -// call resolves (no hang) after a BOUNDED number of pages. Without the -// guard this loops forever and the test never resolves. -const EXPECTED_BYZANTINE_PAGE_CALLS = 2; - -function terminatesOnNonAdvancingCursor() { - return Effect.gen(function* () { - byzantineConstantCursor = true; - const peers = yield* listPeers(); - expect(peers).toEqual([]); - expect(agentsCallCount).toBe(EXPECTED_BYZANTINE_PAGE_CALLS); - }); -} - -function startReceiverDependentGateway(): void { - // `callDefinition` reads `this.live`, so it only works when invoked with the - // service as its receiver — exactly how a production `MoltZapService` - // instance lands in `activeClients`. The directory code must bind it to - // `service` before forwarding; an unbound forward dies on `this`-undefined. - startGatewayWithService( - () => - /* Safe because the test fixture establishes this asserted shape. */ ({ - ...fixture.service, - live: true, - callDefinition: makeReceiverDependentCallDefinition(), - }) satisfies ReceiverDependentService as ChannelService, - ); -} - -function resolvesWithReceiverStrippedSendRpc() { - return Effect.gen(function* () { - startReceiverDependentGateway(); - const peers = yield* listPeers(); - expect(peers).toHaveLength(CONTACT_COUNT); - const names = new Set(peers.map((p) => p.name)); - expect(names.has("Peer 0")).toBe(true); - expect(names.has(`Peer ${CONTACT_COUNT - 1}`)).toBe(true); - }); -} - -describe("directory: agent/identity/agents/list pagination", () => { - it("enumerates EVERY peer across multiple agent pages", enumeratesEveryPeer); - it( - "follows nextCursor across pages (one agent/identity/agents/list call per page)", - followsNextCursorAcrossPages, - ); - it( - "terminates on a non-advancing nextCursor instead of looping", - terminatesOnNonAdvancingCursor, - ); - it( - "binds the sendRpc receiver so a prototype-method sendRpc does not die", - resolvesWithReceiverStrippedSendRpc, - ); -}); - -function makeAccount() { - return { - id: ACCOUNT_ID, - agentName: ACCOUNT_AGENT_NAME, - }; -} - -function makeCfg() { - return { - channels: { - moltzap: { - accounts: [makeAccount()], - }, - }, - }; -} diff --git a/packages/openclaw-channel/src/openclaw-entry.harness-client.test.ts b/packages/openclaw-channel/src/openclaw-entry.harness-client.test.ts index 50c7bbb5d..e550c8434 100644 --- a/packages/openclaw-channel/src/openclaw-entry.harness-client.test.ts +++ b/packages/openclaw-channel/src/openclaw-entry.harness-client.test.ts @@ -1,232 +1,55 @@ import { live as it } from "@effect/vitest"; -import type { - HarnessClientService, - HarnessTurn, -} from "@moltzap/client/harness-client"; -import { - createFakeChannelService, - testAgentId, - testConversationId, - testMessageId, -} from "@moltzap/client/test-utils"; import { agentName } from "@moltzap/protocol/testing"; -import { Data, Effect, Fiber, Queue, Stream } from "effect"; +import { Effect, Fiber, Queue } from "effect"; import { describe, expect, vi } from "vitest"; import { createMoltzapChannelPlugin } from "./openclaw-entry.js"; +import { + ACCOUNT_ID, + CONVERSATION_ID, + HarnessFixtureError, + INBOUND_TEXT, + SENDER_AGENT_ID, + SENDER_AGENT_NAME, + cleanUpStart, + createHarnessFixture, + firstDispatchCall, + makeAccount, + makeConfig, + offerHarnessTurn, + runHarnessPromise, + sendHarnessText, + startHarnessGateway, + startPluginHarnessGateway, + stopHarnessAccount, + waitForDispatchTimes, + waitForGatewayStart, +} from "./test-utils/harness-fixture.js"; -const ACCOUNT_ID = "harness-account"; -const ACCOUNT_AGENT_NAME = "harness-agent"; -const SELF_AGENT_ID = testAgentId("550e8400-e29b-41d4-a716-446655440801"); -const SENDER_AGENT_ID = testAgentId("550e8400-e29b-41d4-a716-446655440802"); -const CONVERSATION_ID = testConversationId( - "550e8400-e29b-41d4-a716-446655440803", -); -const MESSAGE_ID = testMessageId("550e8400-e29b-41d4-a716-446655440804"); -const STARTED_CONVERSATION_ID = testConversationId( - "550e8400-e29b-41d4-a716-446655440805", -); -const CREATED_AT = "2026-08-04T00:00:00.000Z"; -const INBOUND_TEXT = "injected inbound"; const IDENTICAL_REPLY = "same successful reply"; const TARGET_AGENT_NAME = agentName("target-agent"); const TARGET_AGENT = `agent:${TARGET_AGENT_NAME}`; const TARGET_CONVERSATION = `conv:${CONVERSATION_ID}`; const INITIAL_CONTENT = "begin through Harness"; -type StartConversation = HarnessClientService["startConversation"]; -type TurnReply = HarnessTurn["reply"]; -type Plugin = ReturnType; -type Dispatch = ReturnType; - -interface DispatchCall { - readonly ctx: Record; - readonly dispatcherOptions: { - readonly deliver: ( - payload: { readonly text?: string; readonly body?: string }, - info?: { readonly kind?: string }, - ) => PromiseLike; - }; -} - -class HarnessClientTestError extends Data.TaggedError( - "HarnessClientTestError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -class UnexpectedLegacyConstructionError extends Data.TaggedError( - "UnexpectedLegacyConstructionError", -)> {} - -function makeAccount() { - return { id: ACCOUNT_ID, agentName: ACCOUNT_AGENT_NAME }; -} - -function makeConfig() { - return { - channels: { - moltzap: { - accounts: [makeAccount()], - }, - }, - }; -} - -function makeTurn(reply: TurnReply): HarnessTurn { - return { - id: MESSAGE_ID, - conversationId: CONVERSATION_ID, - sender: { id: SENDER_AGENT_ID, name: "sender-agent" }, - text: INBOUND_TEXT, - isFromMe: false, - createdAt: CREATED_AT, - conversationMeta: { - type: "dm", - participants: [`agent:${SELF_AGENT_ID}`, `agent:${SENDER_AGENT_ID}`], - }, - contextBlocks: {}, - reply, - }; -} - -function createHarnessFixture() { - const turns = Effect.runSync(Queue.unbounded()); - const reply = vi.fn().mockReturnValue(Effect.void); - const startConversation = vi.fn().mockReturnValue( - Effect.succeed({ - id: STARTED_CONVERSATION_ID, - createdBy: SELF_AGENT_ID, - createdAt: CREATED_AT, - updatedAt: CREATED_AT, - participants: [SELF_AGENT_ID, SENDER_AGENT_ID], - }), - ); - const callerClose = vi.fn(); - const client: HarnessClientService & { readonly close: () => void } = { - agentId: SELF_AGENT_ID, - startConversation, - turns: Stream.fromQueue(turns), - close: callerClose, - }; - return { callerClose, client, reply, startConversation, turns }; -} - -function startPluginHarnessGateway(plugin: Plugin, setStatus?: Dispatch) { - const dispatch = vi.fn().mockResolvedValue({ queuedFinal: true }); - const reportStatus = setStatus ?? vi.fn(); - const abortController = new AbortController(); - const startFiber = Effect.runFork( - runPromise("start Harness gateway", () => - plugin.gateway.startAccount({ - cfg: makeConfig(), - accountId: ACCOUNT_ID, - account: makeAccount(), - abortSignal: abortController.signal, - setStatus: reportStatus, - channelRuntime: { - reply: { dispatchReplyWithBufferedBlockDispatcher: dispatch }, - }, - }), - ), - ); - return { - abortController, - dispatch, - plugin, - setStatus: reportStatus, - startFiber, - }; -} - -function startHarnessGateway(fixture: ReturnType) { - const createService = vi.fn(() => { - throw new UnexpectedLegacyConstructionError(); - }); - const harnessClientForAccount = vi.fn(() => fixture.client); - const plugin = createMoltzapChannelPlugin({ - createService, - harnessClientForAccount, - }); - return { - ...startPluginHarnessGateway(plugin), - createService, - harnessClientForAccount, - }; -} - -function runPromise( - message: string, - operation: () => PromiseLike, -): Effect.Effect { - return Effect.tryPromise({ - try: () => Promise.resolve(operation()), - catch: (cause) => new HarnessClientTestError({ message, cause }), - }); -} - -function waitForExpectation(assertion: () => void, message: string) { - return runPromise(message, () => vi.waitFor(assertion)); -} - -function waitForGatewayStart(started: { readonly setStatus: Dispatch }) { - return waitForExpectation(() => { - expect(started.setStatus).toHaveBeenCalledWith( - expect.objectContaining({ accountId: ACCOUNT_ID, connected: true }), - ); - }, "wait for Harness gateway start"); -} - -function firstDispatchCall(dispatch: Dispatch): DispatchCall { - return /* Safe because the fixture waits until dispatch has one call. */ dispatch - .mock.calls[0]?.[0] as DispatchCall; -} - -function sendText(plugin: Plugin, to: string, text: string) { - return runPromise("send Harness text", () => - plugin.outbound.sendText({ - cfg: makeConfig(), - accountId: ACCOUNT_ID, - to, - text, - }), - ); -} - -function stopAccount(plugin: Plugin) { - return runPromise("stop Harness account", () => - plugin.gateway.stopAccount({ accountId: ACCOUNT_ID }), - ); -} - -function cleanUpStart(started: ReturnType) { - return Effect.sync(() => { - started.abortController.abort(); - }).pipe(Effect.zipRight(Fiber.interrupt(started.startFiber)), Effect.asVoid); -} - const injectedIngress = () => { const fixture = createHarnessFixture(); const started = startHarnessGateway(fixture); return Effect.gen(function* () { yield* waitForGatewayStart(started); - yield* Queue.offer(fixture.turns, makeTurn(fixture.reply)); - yield* waitForExpectation(() => { - expect(started.dispatch).toHaveBeenCalledTimes(1); - }, "wait for injected dispatch"); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); expect(firstDispatchCall(started.dispatch).ctx).toMatchObject({ AccountId: ACCOUNT_ID, Body: INBOUND_TEXT, From: `agent:${SENDER_AGENT_ID}`, OriginatingTo: TARGET_CONVERSATION, - SenderName: "sender-agent", + SenderName: SENDER_AGENT_NAME, }); expect(started.harnessClientForAccount).toHaveBeenCalledWith( ACCOUNT_ID, makeAccount(), ); - expect(started.createService).not.toHaveBeenCalled(); }).pipe(Effect.ensuring(cleanUpStart(started))); }; @@ -235,20 +58,18 @@ const identicalSuccessfulRepliesAreSentTwice = () => { const started = startHarnessGateway(fixture); return Effect.gen(function* () { yield* waitForGatewayStart(started); - yield* Queue.offer(fixture.turns, makeTurn(fixture.reply)); - yield* waitForExpectation(() => { - expect(started.dispatch).toHaveBeenCalledTimes(1); - }, "wait for reply dispatch"); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); const deliver = firstDispatchCall(started.dispatch).dispatcherOptions .deliver; expect( - yield* runPromise("deliver first identical reply", () => + yield* runHarnessPromise("deliver first identical reply", () => deliver({ text: IDENTICAL_REPLY }, { kind: "final" }), ), ).toBe(true); expect( - yield* runPromise("deliver second identical reply", () => + yield* runHarnessPromise("deliver second identical reply", () => deliver({ text: IDENTICAL_REPLY }, { kind: "final" }), ), ).toBe(true); @@ -263,15 +84,13 @@ const failedTurnDoesNotStopDrain = () => { const fixture = createHarnessFixture(); const started = startHarnessGateway(fixture); started.dispatch.mockRejectedValueOnce( - new HarnessClientTestError({ message: "first dispatch rejected" }), + new HarnessFixtureError({ message: "first dispatch rejected" }), ); return Effect.gen(function* () { yield* waitForGatewayStart(started); - yield* Queue.offer(fixture.turns, makeTurn(fixture.reply)); - yield* Queue.offer(fixture.turns, makeTurn(fixture.reply)); - yield* waitForExpectation(() => { - expect(started.dispatch).toHaveBeenCalledTimes(2); - }, "wait for dispatch after one rejected turn"); + yield* offerHarnessTurn(fixture); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 2); }).pipe(Effect.ensuring(cleanUpStart(started))); }; @@ -280,14 +99,14 @@ const agentOutboundStartsConversation = () => { const started = startHarnessGateway(fixture); return Effect.gen(function* () { yield* waitForGatewayStart(started); - const result = yield* sendText( + const result = yield* sendHarnessText( started.plugin, TARGET_AGENT, INITIAL_CONTENT, ); if (!result.ok) { - return yield* new HarnessClientTestError({ + return yield* new HarnessFixtureError({ message: result.error.message, cause: result.error, }); @@ -304,7 +123,7 @@ const conversationOutboundHasNoFallback = () => { const started = startHarnessGateway(fixture); return Effect.gen(function* () { yield* waitForGatewayStart(started); - const result = yield* sendText( + const result = yield* sendHarnessText( started.plugin, TARGET_CONVERSATION, INITIAL_CONTENT, @@ -312,7 +131,6 @@ const conversationOutboundHasNoFallback = () => { expect(result.ok).toBe(false); expect(fixture.startConversation).not.toHaveBeenCalled(); - expect(started.createService).not.toHaveBeenCalled(); }).pipe(Effect.ensuring(cleanUpStart(started))); }; @@ -321,12 +139,12 @@ const stopLeavesClientCallerOwned = () => { const started = startHarnessGateway(fixture); return Effect.gen(function* () { yield* waitForGatewayStart(started); - yield* stopAccount(started.plugin); + yield* stopHarnessAccount(started.plugin); yield* Fiber.join(started.startFiber); expect(fixture.callerClose).not.toHaveBeenCalled(); expect(yield* Queue.isShutdown(fixture.turns)).toBe(false); - yield* Queue.offer(fixture.turns, makeTurn(fixture.reply)); + yield* offerHarnessTurn(fixture); yield* Effect.yieldNow(); expect(yield* Queue.size(fixture.turns)).toBe(1); expect(started.dispatch).not.toHaveBeenCalled(); @@ -343,7 +161,7 @@ const abortLeavesClientCallerOwned = () => { expect(fixture.callerClose).not.toHaveBeenCalled(); expect(yield* Queue.isShutdown(fixture.turns)).toBe(false); - yield* Queue.offer(fixture.turns, makeTurn(fixture.reply)); + yield* offerHarnessTurn(fixture); yield* Effect.yieldNow(); expect(yield* Queue.size(fixture.turns)).toBe(1); expect(started.dispatch).not.toHaveBeenCalled(); @@ -353,17 +171,11 @@ const abortLeavesClientCallerOwned = () => { const replacingAccountStopsPreviousDrain = () => { const firstFixture = createHarnessFixture(); const secondFixture = createHarnessFixture(); - const createService = vi.fn(() => { - throw new UnexpectedLegacyConstructionError(); - }); const harnessClientForAccount = vi .fn() .mockReturnValueOnce(firstFixture.client) .mockReturnValueOnce(secondFixture.client); - const plugin = createMoltzapChannelPlugin({ - createService, - harnessClientForAccount, - }); + const plugin = createMoltzapChannelPlugin({ harnessClientForAccount }); const firstStart = startPluginHarnessGateway(plugin); let secondStart: ReturnType | undefined; return Effect.gen(function* () { @@ -372,16 +184,13 @@ const replacingAccountStopsPreviousDrain = () => { yield* waitForGatewayStart(secondStart); yield* Fiber.join(firstStart.startFiber); - yield* Queue.offer(firstFixture.turns, makeTurn(firstFixture.reply)); - yield* Queue.offer(secondFixture.turns, makeTurn(secondFixture.reply)); - yield* waitForExpectation(() => { - expect(secondStart?.dispatch).toHaveBeenCalledTimes(1); - }, "wait for replacement gateway dispatch"); + yield* offerHarnessTurn(firstFixture); + yield* offerHarnessTurn(secondFixture); + yield* waitForDispatchTimes(secondStart.dispatch, 1); expect(firstStart.dispatch).not.toHaveBeenCalled(); expect(yield* Queue.size(firstFixture.turns)).toBe(1); - expect(createService).not.toHaveBeenCalled(); - yield* stopAccount(plugin); + yield* stopHarnessAccount(plugin); yield* Fiber.join(secondStart.startFiber); }).pipe( Effect.ensuring( @@ -398,7 +207,7 @@ const replacingAccountStopsPreviousDrain = () => { const statusFailureReleasesGateway = () => { const fixture = createHarnessFixture(); - const statusFailure = new HarnessClientTestError({ + const statusFailure = new HarnessFixtureError({ message: "status callback failed", }); const setStatus = vi.fn(() => { @@ -410,7 +219,7 @@ const statusFailureReleasesGateway = () => { const abortController = new AbortController(); return Effect.gen(function* () { yield* Effect.flip( - runPromise("start gateway with failed status callback", () => + runHarnessPromise("start gateway with failed status callback", () => plugin.gateway.startAccount({ cfg: makeConfig(), accountId: ACCOUNT_ID, @@ -424,7 +233,11 @@ const statusFailureReleasesGateway = () => { ), ); - const result = yield* sendText(plugin, TARGET_AGENT, INITIAL_CONTENT); + const result = yield* sendHarnessText( + plugin, + TARGET_AGENT, + INITIAL_CONTENT, + ); expect(result.ok).toBe(false); expect(setStatus).toHaveBeenCalledTimes(1); expect(fixture.callerClose).not.toHaveBeenCalled(); @@ -445,7 +258,7 @@ const preAbortedStartDoesNotPublishConnected = () => { const abortController = new AbortController(); abortController.abort(); return Effect.gen(function* () { - yield* runPromise("start pre-aborted gateway", () => + yield* runHarnessPromise("start pre-aborted gateway", () => plugin.gateway.startAccount({ cfg: makeConfig(), accountId: ACCOUNT_ID, @@ -459,104 +272,11 @@ const preAbortedStartDoesNotPublishConnected = () => { ); expect(setStatus).not.toHaveBeenCalled(); - expect(harnessClientForAccount).toHaveBeenCalledExactlyOnceWith( - ACCOUNT_ID, - makeAccount(), - ); - expect((yield* sendText(plugin, TARGET_AGENT, INITIAL_CONTENT)).ok).toBe( - false, - ); - }); -}; - -const staleLegacyAbortKeepsReplacement = () => { - const firstFixture = createFakeChannelService({ - ownAgentId: SELF_AGENT_ID, - }); - const secondFixture = createFakeChannelService({ - ownAgentId: SELF_AGENT_ID, - }); - const createService = vi - .fn() - .mockReturnValueOnce(firstFixture.service) - .mockReturnValueOnce(secondFixture.service); - const plugin = createMoltzapChannelPlugin({ createService }); - const firstStart = startPluginHarnessGateway(plugin); - let secondStart: ReturnType | undefined; - return Effect.gen(function* () { - yield* waitForGatewayStart(firstStart); - secondStart = startPluginHarnessGateway(plugin); - yield* waitForGatewayStart(secondStart); - - expect(firstFixture.state.closeCalls.count).toBe(1); - firstStart.abortController.abort(); - yield* Fiber.join(firstStart.startFiber); - - const result = yield* sendText( - plugin, - TARGET_CONVERSATION, - INITIAL_CONTENT, - ); - expect(result.ok).toBe(true); - expect(secondFixture.state.sent).toHaveLength(1); - yield* stopAccount(plugin); - }).pipe( - Effect.ensuring( - Effect.suspend(() => - secondStart === undefined - ? cleanUpStart(firstStart) - : Effect.all([cleanUpStart(firstStart), cleanUpStart(secondStart)], { - discard: true, - }), - ), - ), - ); -}; - -const replacingLegacyWithHarnessLeavesNoFallback = () => { - const legacyFixture = createFakeChannelService({ - ownAgentId: SELF_AGENT_ID, - }); - const harnessFixture = createHarnessFixture(); - let selectHarness = false; - const plugin = createMoltzapChannelPlugin({ - createService: () => legacyFixture.service, - harnessClientForAccount: () => - selectHarness ? harnessFixture.client : undefined, + expect(harnessClientForAccount).not.toHaveBeenCalled(); + expect( + (yield* sendHarnessText(plugin, TARGET_AGENT, INITIAL_CONTENT)).ok, + ).toBe(false); }); - const legacyStart = startPluginHarnessGateway(plugin); - let harnessStart: ReturnType | undefined; - return Effect.gen(function* () { - yield* waitForGatewayStart(legacyStart); - selectHarness = true; - harnessStart = startPluginHarnessGateway(plugin); - yield* waitForGatewayStart(harnessStart); - - expect(legacyFixture.state.closeCalls.count).toBe(1); - legacyStart.abortController.abort(); - yield* Fiber.join(legacyStart.startFiber); - yield* stopAccount(plugin); - yield* Fiber.join(harnessStart.startFiber); - - const result = yield* sendText( - plugin, - TARGET_CONVERSATION, - INITIAL_CONTENT, - ); - expect(result.ok).toBe(false); - expect(legacyFixture.state.sent).toEqual([]); - }).pipe( - Effect.ensuring( - Effect.suspend(() => - harnessStart === undefined - ? cleanUpStart(legacyStart) - : Effect.all( - [cleanUpStart(legacyStart), cleanUpStart(harnessStart)], - { discard: true }, - ), - ), - ), - ); }; // @agent-code-guard/regression-only: these examples pin the caller-owned HarnessClient seam at OpenClaw's fixed gateway contract. @@ -589,12 +309,4 @@ describe("OpenClaw HarnessClient gateway", () => { "does not publish connected for a pre-aborted start", preAbortedStartDoesNotPublishConnected, ); - it( - "keeps a replacement legacy account after a stale abort", - staleLegacyAbortKeepsReplacement, - ); - it( - "removes legacy fallback when Harness replaces an account", - replacingLegacyWithHarnessLeavesNoFallback, - ); }); diff --git a/packages/openclaw-channel/src/openclaw-entry.inbound-contract.test.ts b/packages/openclaw-channel/src/openclaw-entry.inbound-contract.test.ts index 744e13b3e..28f39eb3f 100644 --- a/packages/openclaw-channel/src/openclaw-entry.inbound-contract.test.ts +++ b/packages/openclaw-channel/src/openclaw-entry.inbound-contract.test.ts @@ -1,35 +1,37 @@ -import { afterEach, beforeEach, describe, expect, vi } from "vitest"; import { live as it } from "@effect/vitest"; -import * as fc from "fast-check"; -import { Data, Effect } from "effect"; import type { CrossConvMessage } from "@moltzap/client/channel-base"; -import { - createFakeChannelService, - flushDispatchChainEffect, - testAgentId, - testConversationId, - testMessageId, - type FakeChannelService, -} from "@moltzap/client/test-utils"; -import type { Message } from "@moltzap/protocol/message"; +import { testAgentId, testConversationId } from "@moltzap/client/test-utils"; +import { Effect, Fiber } from "effect"; +import * as fc from "fast-check"; +import { afterEach, beforeEach, describe, expect, vi } from "vitest"; import { createMoltzapChannelPlugin } from "./openclaw-entry.js"; +import { + ACCOUNT_AGENT_NAME, + ACCOUNT_ID, + CONVERSATION_ID, + CREATED_AT, + SELF_AGENT_ID, + SENDER_AGENT_ID, + SENDER_AGENT_NAME, + cleanUpStart, + createHarnessFixture, + firstDispatchCall, + makeAccount, + makeConfig, + offerHarnessTurn, + runHarnessPromise, + startHarnessGateway, + waitForDispatchTimes, + waitForHarnessExpectation, + type HarnessFixture, +} from "./test-utils/harness-fixture.js"; // Header literal from channel-base's `json-header` markup variant (per spec // C #597 invariant: byte-identical to the pre-refactor openclaw output). const CROSS_CONV_HEADER = "Messages (untrusted metadata):"; -const MESSAGE_DISPATCH_SETTLE_MS = 100; -const TEST_ACCOUNT_ID = "test-account"; const PROFILE_ACCOUNT_ID = "profile-account"; -const DEFAULT_AGENT_NAME = "bob"; const CHANNEL_ID = "moltzap"; -const DEFAULT_MESSAGE_ID = testMessageId( - "550e8400-e29b-41d4-a716-446655440100", -); -const SECOND_MESSAGE_ID = testMessageId("550e8400-e29b-41d4-a716-446655440101"); -const DEFAULT_CONVERSATION_ID = testConversationId( - "550e8400-e29b-41d4-a716-446655440200", -); const ORIGINATING_CONVERSATION_ID = testConversationId( "550e8400-e29b-41d4-a716-446655440201", ); @@ -39,17 +41,10 @@ const GROUP_CONVERSATION_ID = testConversationId( const OTHER_CONVERSATION_ID = testConversationId( "550e8400-e29b-41d4-a716-446655440203", ); -const SENDER_AGENT_ID = testAgentId("550e8400-e29b-41d4-a716-446655440300"); -const SELF_AGENT_ID = testAgentId("550e8400-e29b-41d4-a716-446655440301"); const THIRD_AGENT_ID = testAgentId("550e8400-e29b-41d4-a716-446655440302"); const SELLER_AGENT_ID = testAgentId("550e8400-e29b-41d4-a716-446655440303"); -const CREATED_AT = "2026-03-16T00:00:00Z"; -const DEFAULT_BODY = "Hello from agent"; const TEST_BODY = "Test body content"; const PROJECT_ALPHA = "Project Alpha"; -const ATLAS_PRIME = "Atlas-Prime"; -const CACHED_NAME = "cached-name"; -const MULTILINE_BODY = "Line 1\nLine 2\nLine 3"; const OFFER_QUESTION = "What should I offer?"; const PLAIN_MESSAGE = "Plain message"; const MIN_PRICE_TEXT = "Min $4000"; @@ -62,46 +57,25 @@ const OBJECT_TYPE = "object"; const NUMBER_TYPE = "number"; const DIRECT_CHAT_TYPE = "direct"; const GROUP_CHAT_TYPE = "group"; -const TEXT_PART_TYPE = "text"; - -class InboundContractTestError extends Data.TaggedError( - "InboundContractTestError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -interface DispatchCall { - readonly ctx: Record; - readonly cfg: unknown; - readonly dispatcherOptions: { - readonly deliver: ( - payload: unknown, - info?: unknown, - ) => PromiseLike; - }; -} - -interface StartedGateway { - readonly fixture: FakeChannelService; - readonly plugin: ReturnType; -} -let started: StartedGateway; -let abortControllers: AbortController[] = []; -let mockDispatch: ReturnType; -let setStatusCalls: Array>; +let fixture: HarnessFixture; +let started: ReturnType; +let extraStarts: Array> = []; beforeEach(() => { - resetMocks(); - started = startGateway({ withRuntime: true }); + fixture = createHarnessFixture(); + started = startHarnessGateway(fixture); }); afterEach(() => { - for (const controller of abortControllers) { - controller.abort(); - } - abortControllers = []; + const starts = [started, ...extraStarts]; + extraStarts = []; + return Effect.runPromise( + Effect.all( + starts.map((start) => cleanUpStart(start)), + { discard: true }, + ), + ); }); describe("Flow 5: Inbound contract", () => { @@ -109,15 +83,13 @@ describe("Flow 5: Inbound contract", () => { it("MsgContext has required fields", contextHasRequiredFields); it("OriginatingChannel is moltzap", originatingChannelIsMoltzap); it("OriginatingTo is the conversationId", originatingToIsConversationId); - it("group message includes group metadata", groupMessageIncludesMetadata); - it("DM message has direct ChatType", dmMessageHasDirectChatType); - it("SenderName is resolved from service", senderNameIsResolved); - it("caches sender name lookups across messages", cachesSenderNames); + it("group turn includes group metadata", groupTurnIncludesMetadata); + it("DM turn has direct ChatType", dmTurnHasDirectChatType); + it("SenderName comes from the turn", senderNameComesFromTurn); it("passes cfg through to dispatch", cfgPassesThrough); it("dispatch includes a deliver callback", dispatchIncludesDeliver); it("updates status with lastInboundAt", updatesInboundStatus); it("does not dispatch without channelRuntime", noRuntimeDoesNotDispatch); - it("handles multi-part text messages", joinsMultipartText); it("BodyForAgent includes cross-conversation context", includesCrossConv); it("BodyForAgent equals Body for empty context", emptyContextKeepsBody); it("uses account id as the MoltZap profile name", accountIdIsProfileName); @@ -127,132 +99,23 @@ describe("Flow 5: Inbound contract", () => { ); }); -function resetMocks(): void { - vi.clearAllMocks(); - mockDispatch = vi.fn().mockResolvedValue({ queuedFinal: true }); - setStatusCalls = []; -} - -function startGateway(params: { - readonly withRuntime: boolean; -}): StartedGateway { - const fixture = createFakeChannelService({ ownAgentId: SELF_AGENT_ID }); - seedFixture(fixture); - const plugin = createMoltzapChannelPlugin({ - createService: () => fixture.service, - }); - const abortController = new AbortController(); - abortControllers.push(abortController); - Effect.runFork( - Effect.tryPromise({ - try: () => - plugin.gateway.startAccount({ - cfg: makeCfg(), - accountId: TEST_ACCOUNT_ID, - account: makeAccount(TEST_ACCOUNT_ID), - abortSignal: abortController.signal, - setStatus: (status) => setStatusCalls.push(status), - ...(params.withRuntime ? { channelRuntime: channelRuntime() } : {}), - }), - catch: (cause) => - new InboundContractTestError({ - message: "startAccount failed", - cause, - }), - }).pipe(Effect.ignore), - ); - return { fixture, plugin }; -} - -function seedFixture(fixture: FakeChannelService): void { - fixture.state.setConversation(DEFAULT_CONVERSATION_ID, defaultConversation()); - fixture.state.setAgentName(SENDER_AGENT_ID, `name-of-${SENDER_AGENT_ID}`); -} - -function channelRuntime() { - return { - reply: { - dispatchReplyWithBufferedBlockDispatcher: mockDispatch, - }, - }; -} - -function makeAccount(id: string) { - return { - id, - agentName: DEFAULT_AGENT_NAME, - }; -} - -function makeCfg(accountId = TEST_ACCOUNT_ID) { - return { - channels: { - moltzap: { - accounts: [makeAccount(accountId)], - }, - }, - }; -} - -function makeMessage(overrides: Partial = {}): Message { - return { - id: DEFAULT_MESSAGE_ID, - conversationId: DEFAULT_CONVERSATION_ID, - senderId: SENDER_AGENT_ID, - parts: [{ type: TEXT_PART_TYPE, text: DEFAULT_BODY }], - createdAt: CREATED_AT, - ...overrides, - }; -} - -function defaultConversation() { - return { - id: DEFAULT_CONVERSATION_ID, - type: "dm", - participants: [agentRef(SENDER_AGENT_ID), agentRef(SELF_AGENT_ID)], - }; -} - function agentRef(id: string): string { return `agent:${id}`; } -function waitForDispatchTimes(count: number) { - return waitForExpectation(() => { - expect(mockDispatch).toHaveBeenCalledTimes(count); - }, "dispatch call"); -} - -function waitForExpectation(assertion: () => void, label: string) { - return Effect.tryPromise({ - try: () => vi.waitFor(assertion), - catch: (cause) => - new InboundContractTestError({ message: `wait for ${label}`, cause }), - }); -} - -function emitMessage(message?: Message) { - return Effect.gen(function* () { - started.fixture.emit.message(message ?? makeMessage()); - yield* flushDispatchChainEffect; - }); -} - -function firstDispatchCall(): DispatchCall { - return /* Safe because the test fixture establishes this asserted shape. */ mockDispatch - .mock.calls[0]?.[0] as DispatchCall; +function sessionKey(type: string, id: string): string { + return `agent:main:${CHANNEL_ID}:${type === GROUP_CHAT_TYPE ? "group" : "dm"}:${id}`; } function firstDispatchContext(): Record { - return firstDispatchCall().ctx; + return firstDispatchCall(started.dispatch).ctx; } function dispatchIsCalled() { return Effect.gen(function* () { - yield* emitMessage(); - yield* waitForDispatchTimes(1); - expect(mockDispatch).toHaveBeenCalledTimes(1); - const dispatch = firstDispatchCall(); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); + const dispatch = firstDispatchCall(started.dispatch); expect(typeof dispatch.ctx).toBe(OBJECT_TYPE); expect(typeof dispatch.cfg).toBe(OBJECT_TYPE); expect(typeof dispatch.dispatcherOptions.deliver).toBe(FUNCTION_TYPE); @@ -261,56 +124,55 @@ function dispatchIsCalled() { function contextHasRequiredFields() { return Effect.gen(function* () { - yield* emitMessage( - makeMessage({ parts: [{ type: TEXT_PART_TYPE, text: TEST_BODY }] }), - ); - yield* waitForDispatchTimes(1); + yield* offerHarnessTurn(fixture, { text: TEST_BODY }); + yield* waitForDispatchTimes(started.dispatch, 1); const ctx = firstDispatchContext(); expect(ctx.Body).toBe(TEST_BODY); expect(ctx.BodyForAgent).toBe(TEST_BODY); expect(ctx.From).toBe(agentRef(SENDER_AGENT_ID)); - expect(ctx.To).toBe(DEFAULT_AGENT_NAME); - expect(ctx.SessionKey).toBe( - sessionKey(DIRECT_CHAT_TYPE, DEFAULT_CONVERSATION_ID), - ); + expect(ctx.To).toBe(ACCOUNT_AGENT_NAME); + expect(ctx.SessionKey).toBe(sessionKey(DIRECT_CHAT_TYPE, CONVERSATION_ID)); expect(ctx.Provider).toBe(CHANNEL_ID); expect(ctx.Surface).toBe(CHANNEL_ID); - expect(ctx.AccountId).toBe(TEST_ACCOUNT_ID); + expect(ctx.AccountId).toBe(ACCOUNT_ID); }); } function originatingChannelIsMoltzap() { return Effect.gen(function* () { - yield* emitMessage(); - yield* waitForDispatchTimes(1); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); expect(firstDispatchContext().OriginatingChannel).toBe(CHANNEL_ID); }); } function originatingToIsConversationId() { return Effect.gen(function* () { - yield* emitMessage( - makeMessage({ conversationId: ORIGINATING_CONVERSATION_ID }), - ); - yield* waitForDispatchTimes(1); + yield* offerHarnessTurn(fixture, { + conversationId: ORIGINATING_CONVERSATION_ID, + }); + yield* waitForDispatchTimes(started.dispatch, 1); expect(firstDispatchContext().OriginatingTo).toBe( `conv:${ORIGINATING_CONVERSATION_ID}`, ); }); } -function groupMessageIncludesMetadata() { +function groupTurnIncludesMetadata() { return Effect.gen(function* () { - started.fixture.state.setConversation( - GROUP_CONVERSATION_ID, - groupConversation(), - ); - yield* emitMessage(makeMessage({ conversationId: GROUP_CONVERSATION_ID })); - yield* waitForDispatchTimes(1); + yield* offerHarnessTurn(fixture, { + conversationId: GROUP_CONVERSATION_ID, + conversationMeta: { + type: "group", + name: PROJECT_ALPHA, + participants: groupParticipants(), + }, + }); + yield* waitForDispatchTimes(started.dispatch, 1); const ctx = firstDispatchContext(); expect(ctx.ChatType).toBe(GROUP_CHAT_TYPE); expect(ctx.GroupSubject).toBe(PROJECT_ALPHA); - expect(ctx.GroupMembers).toBe(groupMembers()); + expect(ctx.GroupMembers).toBe(groupParticipants().join(",")); expect(ctx.ConversationLabel).toBe(PROJECT_ALPHA); expect(ctx.SessionKey).toBe( sessionKey(GROUP_CHAT_TYPE, GROUP_CONVERSATION_ID), @@ -318,128 +180,93 @@ function groupMessageIncludesMetadata() { }); } -function groupConversation() { - return { - id: GROUP_CONVERSATION_ID, - type: "group", - name: PROJECT_ALPHA, - participants: [ - agentRef(SENDER_AGENT_ID), - agentRef(SELF_AGENT_ID), - agentRef(THIRD_AGENT_ID), - ], - }; -} - -function groupMembers(): string { +function groupParticipants(): string[] { return [ agentRef(SENDER_AGENT_ID), agentRef(SELF_AGENT_ID), agentRef(THIRD_AGENT_ID), - ].join(","); + ]; } -function dmMessageHasDirectChatType() { +function dmTurnHasDirectChatType() { return Effect.gen(function* () { - yield* emitMessage(); - yield* waitForDispatchTimes(1); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); expect(firstDispatchContext().ChatType).toBe(DIRECT_CHAT_TYPE); }); } -function senderNameIsResolved() { +function senderNameComesFromTurn() { return Effect.gen(function* () { - started.fixture.state.setAgentName(SENDER_AGENT_ID, ATLAS_PRIME); - yield* emitMessage(); - yield* waitForDispatchTimes(1); - expect(firstDispatchContext().SenderName).toBe(ATLAS_PRIME); - }); -} - -function cachesSenderNames() { - return Effect.gen(function* () { - started.fixture.state.setAgentName(SENDER_AGENT_ID, CACHED_NAME); - yield* emitMessage(); - yield* waitForDispatchTimes(1); - yield* emitMessage(makeMessage({ id: SECOND_MESSAGE_ID })); - yield* waitForDispatchTimes(2); - expect( - started.fixture.state.resolveAgentNameCallCount(SENDER_AGENT_ID), - ).toBe(0); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); + expect(firstDispatchContext().SenderName).toBe(SENDER_AGENT_NAME); }); } function cfgPassesThrough() { return Effect.gen(function* () { - yield* emitMessage(); - yield* waitForDispatchTimes(1); - expect(firstDispatchCall().cfg).toEqual(makeCfg()); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); + expect(firstDispatchCall(started.dispatch).cfg).toEqual(makeConfig()); }); } function dispatchIncludesDeliver() { return Effect.gen(function* () { - yield* emitMessage(); - yield* waitForDispatchTimes(1); - expect(typeof firstDispatchCall().dispatcherOptions.deliver).toBe( - FUNCTION_TYPE, - ); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); + expect( + typeof firstDispatchCall(started.dispatch).dispatcherOptions.deliver, + ).toBe(FUNCTION_TYPE); }); } function updatesInboundStatus() { return Effect.gen(function* () { - yield* emitMessage(); - yield* waitForDispatchTimes(1); - const inboundStatus = setStatusCalls.find( - (status) => "lastInboundAt" in status, - ); + yield* offerHarnessTurn(fixture); + yield* waitForDispatchTimes(started.dispatch, 1); + const inboundStatus = started.setStatus.mock.calls + .map(([status]) => status) + .find((status) => "lastInboundAt" in status); expect(inboundStatus).toBeDefined(); - if (inboundStatus === undefined) { - return; - } - expect(inboundStatus.accountId).toBe(TEST_ACCOUNT_ID); - expect(typeof inboundStatus.lastInboundAt).toBe(NUMBER_TYPE); + expect(inboundStatus?.accountId).toBe(ACCOUNT_ID); + expect(typeof inboundStatus?.lastInboundAt).toBe(NUMBER_TYPE); }); } +// The warning proves the turn reached the inbound handler, so the missing +// dispatcher is the reason nothing dispatched. function noRuntimeDoesNotDispatch() { return Effect.gen(function* () { - const before = mockDispatch.mock.calls.length; - const withoutRuntime = startGateway({ withRuntime: false }); - withoutRuntime.fixture.emit.message(makeMessage()); - yield* Effect.sleep(`${MESSAGE_DISPATCH_SETTLE_MS} millis`); - expect(mockDispatch.mock.calls.length).toBe(before); - }); -} - -function joinsMultipartText() { - return Effect.gen(function* () { - yield* emitMessage( - makeMessage({ - parts: [ - { type: TEXT_PART_TYPE, text: "Line 1" }, - { type: TEXT_PART_TYPE, text: "Line 2" }, - { type: TEXT_PART_TYPE, text: "Line 3" }, - ], - }), - ); - yield* waitForDispatchTimes(1); - const ctx = firstDispatchContext(); - expect(ctx.Body).toBe(MULTILINE_BODY); - expect(ctx.BodyForAgent).toBe(MULTILINE_BODY); + const otherFixture = createHarnessFixture(); + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const withoutRuntime = startHarnessGateway(otherFixture, { + log, + withoutChannelRuntime: true, + }); + extraStarts.push(withoutRuntime); + yield* offerHarnessTurn(otherFixture); + yield* waitForHarnessExpectation(() => { + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining( + `no OpenClaw reply dispatcher for ${CONVERSATION_ID}`, + ), + ); + }, "wait for missing dispatcher warning"); + expect(withoutRuntime.dispatch).not.toHaveBeenCalled(); }); } function includesCrossConv() { return Effect.gen(function* () { - started.fixture.state.setFullMessages(DEFAULT_CONVERSATION_ID, [ - crossConversationMessage(), - ]); - yield* emitMessage( - makeMessage({ parts: [{ type: TEXT_PART_TYPE, text: OFFER_QUESTION }] }), - ); - yield* waitForDispatchTimes(1); + yield* offerHarnessTurn(fixture, { + text: OFFER_QUESTION, + contextBlocks: { + crossConversationMessages: [crossConversationMessage()], + }, + }); + yield* waitForDispatchTimes(started.dispatch, 1); const ctx = firstDispatchContext(); expect(ctx.Body).toBe(OFFER_QUESTION); expect(ctx.BodyForAgent).toContain(CROSS_CONV_HEADER); @@ -463,10 +290,8 @@ function crossConversationMessage(): CrossConvMessage { function emptyContextKeepsBody() { return Effect.gen(function* () { - yield* emitMessage( - makeMessage({ parts: [{ type: TEXT_PART_TYPE, text: PLAIN_MESSAGE }] }), - ); - yield* waitForDispatchTimes(1); + yield* offerHarnessTurn(fixture, { text: PLAIN_MESSAGE }); + yield* waitForDispatchTimes(started.dispatch, 1); const ctx = firstDispatchContext(); expect(ctx.Body).toBe(PLAIN_MESSAGE); expect(ctx.BodyForAgent).toBe(PLAIN_MESSAGE); @@ -474,53 +299,50 @@ function emptyContextKeepsBody() { } function accountIdIsProfileName() { - return Effect.gen(function* () { - const fixture = createFakeChannelService({ ownAgentId: SELF_AGENT_ID }); - const calls: Array<{ - readonly profileName: string; - readonly accountId: string; - }> = []; - const plugin = createMoltzapChannelPlugin({ - createService: (profileName, account) => { - calls.push({ profileName, accountId: account.id }); - return fixture.service; - }, - }); - const abortController = new AbortController(); - abortController.abort(); - yield* Effect.tryPromise({ - try: () => - plugin.gateway.startAccount({ - cfg: makeCfg(PROFILE_ACCOUNT_ID), - accountId: PROFILE_ACCOUNT_ID, - account: makeAccount(PROFILE_ACCOUNT_ID), - abortSignal: abortController.signal, - setStatus: vi.fn(), - }), - catch: (cause) => - new InboundContractTestError({ - message: "start profile account", - cause, - }), - }); + const profileFixture = createHarnessFixture(); + const calls: Array<{ + readonly profileName: string; + readonly accountId: string; + }> = []; + const plugin = createMoltzapChannelPlugin({ + harnessClientForAccount: (profileName, account) => { + calls.push({ profileName, accountId: account.id }); + return profileFixture.client; + }, + }); + const abortController = new AbortController(); + const startFiber = Effect.runFork( + runHarnessPromise("start profile account", () => + plugin.gateway.startAccount({ + cfg: makeConfig(PROFILE_ACCOUNT_ID), + accountId: PROFILE_ACCOUNT_ID, + account: makeAccount(PROFILE_ACCOUNT_ID), + abortSignal: abortController.signal, + setStatus: vi.fn(), + }), + ), + ); + return waitForHarnessExpectation(() => { expect(calls).toEqual([ { profileName: PROFILE_ACCOUNT_ID, accountId: PROFILE_ACCOUNT_ID }, ]); - }); + }, "wait for the profile client injection").pipe( + Effect.ensuring( + Effect.sync(() => { + abortController.abort(); + }).pipe(Effect.zipRight(Fiber.interrupt(startFiber)), Effect.asVoid), + ), + ); } function accountIdsRoundTrip() { return Effect.sync(() => { fc.assert( fc.property(fc.string({ minLength: 1 }), (accountId) => { - expect(makeCfg(accountId).channels.moltzap.accounts[0]?.id).toBe( + expect(makeConfig(accountId).channels.moltzap.accounts[0]?.id).toBe( accountId, ); }), ); }); } - -function sessionKey(type: string, id: string): string { - return `agent:main:${CHANNEL_ID}:${type === GROUP_CHAT_TYPE ? "group" : "dm"}:${id}`; -} diff --git a/packages/openclaw-channel/src/openclaw-entry.ts b/packages/openclaw-channel/src/openclaw-entry.ts index 52cc318a5..b3312b2fb 100644 --- a/packages/openclaw-channel/src/openclaw-entry.ts +++ b/packages/openclaw-channel/src/openclaw-entry.ts @@ -13,19 +13,15 @@ * `Effect.runPromise` tax at the plugin surface. */ -import { MoltZapService, type ServiceRpcError } from "@moltzap/client"; +import { harnessClientForProfile } from "@moltzap/client"; import type { HarnessClientService, HarnessTurn, } from "@moltzap/client/harness-client"; -import { drainPaginatedList } from "@moltzap/client/pagination"; import { - MoltZapChannelCore, formatCrossConv, getGroupFields, - type ChannelService, type CrossConvMessage, - type EnrichedInboundMessage, type GroupFields, } from "@moltzap/client/channel-base"; import { @@ -45,9 +41,7 @@ import { } from "./context-log.js"; import { createHarnessReplyDeliver } from "./harness-turn-delivery.js"; import { - disconnectLegacyGateway, finishHarnessClient, - registerLegacyGatewayAbort, stopActiveGatewayAccount, type ActiveHarnessClient, } from "./openclaw-gateway-lifecycle.js"; @@ -57,13 +51,6 @@ import { TARGET_HINT, TARGET_PREFIX_CONVERSATION, } from "./openclaw-target.js"; -import { agentsList } from "@moltzap/protocol/identity"; -import { - type ConversationId, - conversationId, - conversationList, -} from "@moltzap/protocol/conversation"; -import type { ResultOf } from "@moltzap/protocol/rpc"; const CHANNEL_ID = "moltzap"; const INBOUND_LOG_PREVIEW_CHARS = 80; @@ -98,16 +85,6 @@ class MoltZapClientNotConnectedError extends Data.TaggedError( } } -class MoltZapAgentTargetUnsupportedError extends Data.TaggedError( - "MoltZapAgentTargetUnsupportedError", -)<{ - readonly accountId: string; -}> { - override get message(): string { - return `MoltZap client for account ${this.accountId} cannot resolve agent targets`; - } -} - class MoltZapConversationTargetUnsupportedError extends Data.TaggedError( "MoltZapConversationTargetUnsupportedError", )<{ @@ -201,7 +178,8 @@ const moltZapChannelConfigSchema = Schema.Struct({ export const makeMoltZapChannelConfigJsonSchema = () => JSONSchema.make(moltZapChannelConfigSchema); -interface OpenClawConfig { +/** OpenClaw's config object; the plugin reads only its `channels.moltzap` section. */ +export interface OpenClawConfig { readonly [key: string]: unknown; readonly channels?: { readonly moltzap?: { @@ -228,7 +206,8 @@ type OpenClawReplyDispatcher = (params: { dispatcherOptions: { deliver: OpenClawDeliver }; }) => PromiseLike<{ queuedFinal: boolean }>; -interface OpenClawStartAccountContext { +/** What OpenClaw hands the plugin when it starts one configured account. */ +export interface OpenClawStartAccountContext { cfg: OpenClawConfig; accountId: string; account: MoltZapAccount; @@ -242,7 +221,8 @@ interface OpenClawStartAccountContext { }; } -interface OpenClawStopAccountContext { +/** What OpenClaw hands the plugin when it stops one configured account. */ +export interface OpenClawStopAccountContext { accountId: string; log?: Pick; } @@ -259,21 +239,7 @@ interface InboundDispatchInput { readonly turn: HarnessTurn; } -interface OpenClawClientService extends ChannelService { - /** - * The agent service's descriptor-based call. Optional because the fake - * channel service used in tests may omit it. - */ - callDefinition?: MoltZapService["callDefinition"]; - sendToAgent?(agentName: string, text: string): Effect.Effect; -} - interface MoltzapChannelPluginDeps { - readonly createService?: ( - profileName: string, - account: MoltZapAccount, - ) => OpenClawClientService; - readonly createCore?: (service: ChannelService) => MoltZapChannelCore; /** * Selects a caller-acquired client for one configured account. The plugin * owns only its turn drain and never discovers, acquires, or closes the @@ -285,14 +251,8 @@ interface MoltzapChannelPluginDeps { ) => HarnessClientService | undefined; } -interface OpenClawDirectoryParams { - readonly cfg: OpenClawConfig; - readonly accountId?: string | null; - readonly query?: string | null; - readonly limit?: number | null; -} - -interface OpenClawResolveTargetParams { +/** One target-resolution request from OpenClaw's targeting layer. */ +export interface OpenClawResolveTargetParams { readonly cfg: OpenClawConfig; readonly accountId?: string | null; readonly input: string; @@ -466,127 +426,6 @@ function createMessagingSection() { }; } -function createDirectorySection( - activeClients: Map, -) { - return { - listPeers(params: OpenClawDirectoryParams) { - return Effect.runPromise(listPeersEffect(activeClients, params)); - }, - listGroups(params: OpenClawDirectoryParams) { - return Effect.runPromise(listGroupsEffect(activeClients, params)); - }, - }; -} - -interface ActiveServiceResolution { - readonly accountId: string; - readonly service: OpenClawClientService; -} - -function getActiveService( - activeClients: Map, - accountId?: string | null, -): ActiveServiceResolution | undefined { - const requested = accountId?.trim(); - if (requested) { - const service = activeClients.get(requested); - return service === undefined - ? undefined - : { accountId: requested, service }; - } - if (activeClients.size !== 1) { - return undefined; - } - const first = activeClients.entries().next().value; - return first === undefined - ? undefined - : { accountId: first[0], service: first[1] }; -} - -function listPeersEffect( - activeClients: Map, - params: OpenClawDirectoryParams, -) { - return Effect.gen(function* () { - const active = getActiveService(activeClients, params.accountId); - if (!active?.service.callDefinition) { - return []; - } - // `service.callDefinition` is a prototype method reading `this.client` inside - // `Effect.suspend`; passed as a bare reference its receiver is stripped, - // so the suspend thunk dies with a `this`-undefined TypeError that - // `catchAll` (a failure-channel handler) cannot absorb. Bind so the drain - // consumer keeps the service receiver. - const sendRpc = active.service.callDefinition.bind(active.service); - // Drain ALL visible-agent pages so every peer in the directory resolves. - const agents = yield* drainPaginatedList< - ServiceRpcError, - typeof agentsList, - ResultOf["agents"][number], - NonNullable["nextCursor"]> - >({ - sendRpc, - definition: agentsList, - paramsForCursor: (cursor) => (cursor === undefined ? {} : { cursor }), - rowsForPage: (page) => page.agents, - nextCursorForPage: (page) => page.nextCursor, - }); - return agents.map((agent) => ({ - id: `agent:${agent.name}`, - name: agent.displayName ?? agent.name, - kind: "user" as const, - })); - }).pipe( - Effect.withSpan("createMoltzapChannelPlugin.listPeers"), - Effect.orElseSucceed(() => []), - ); -} - -function listGroupsEffect( - activeClients: Map, - params: OpenClawDirectoryParams, -) { - return Effect.gen(function* () { - const active = getActiveService(activeClients, params.accountId); - if (!active?.service.callDefinition) { - return []; - } - // Bind so the drain consumer keeps the service receiver (see listPeersEffect). - const sendRpc = active.service.callDefinition.bind(active.service); - // Drain ALL conversation pages so named groups past the first page resolve. - const items = yield* drainPaginatedList< - ServiceRpcError, - typeof conversationList, - ResultOf["items"][number], - NonNullable["nextCursor"]> - >({ - sendRpc, - definition: conversationList, - paramsForCursor: (cursor) => (cursor === undefined ? {} : { cursor }), - rowsForPage: (page) => page.items, - nextCursorForPage: (page) => page.nextCursor, - }); - return items - .filter((item) => isNamedGroup(item.conversation)) - .map((item) => ({ - id: `${TARGET_PREFIX_CONVERSATION}${item.conversation.id}`, - name: /* Safe because the surrounding invariant establishes this asserted shape. */ item - .conversation.name!, - kind: "group" as const, - })); - }).pipe( - Effect.withSpan("createMoltzapChannelPlugin.listGroups"), - Effect.orElseSucceed(() => []), - ); -} - -function isNamedGroup(conversation: { - readonly name?: string; -}): conversation is { readonly name: string } { - return typeof conversation.name === "string" && conversation.name.length > 0; -} - function createConfigSection() { return { listAccountIds(cfg: OpenClawConfig): string[] { @@ -612,43 +451,35 @@ function createConfigSection() { } function createGatewaySection( - activeClients: Map, activeHarnessClients: Map, deps: MoltzapChannelPluginDeps, ) { return { startAccount(ctx: OpenClawStartAccountContext) { - return startGatewayAccount( - ctx, - activeClients, - activeHarnessClients, - deps, - ); + return startGatewayAccount(ctx, activeHarnessClients, deps); }, stopAccount(ctx: OpenClawStopAccountContext) { - return stopGatewayAccount(ctx, activeClients, activeHarnessClients); + return stopGatewayAccount(ctx, activeHarnessClients); }, }; } function startGatewayAccount( ctx: OpenClawStartAccountContext, - activeClients: Map, activeHarnessClients: Map, deps: MoltzapChannelPluginDeps, ) { return Effect.runPromise( - startGatewayAccountEffect(ctx, activeClients, activeHarnessClients, deps), + startGatewayAccountEffect(ctx, activeHarnessClients, deps), ); } function startGatewayAccountEffect( ctx: OpenClawStartAccountContext, - activeClients: Map, activeHarnessClients: Map, deps: MoltzapChannelPluginDeps, ): Effect.Effect { - const { accountId, account, abortSignal, log, setStatus } = ctx; + const { accountId, account, abortSignal, log } = ctx; const profileName = accountId.trim(); const contextLogDir = readOpenClawContextLogDir(); log?.info?.(`MoltZap: connecting as ${account.agentName ?? accountId}`); @@ -656,43 +487,22 @@ function startGatewayAccountEffect( if (profileName.length === 0) { return yield* new MoltZapAccountProfileMissingError(); } - const harnessClient = deps.harnessClientForAccount?.(profileName, account); - if (harnessClient !== undefined) { - if (abortSignal.aborted) { - return; - } - return yield* runHarnessGateway(ctx, harnessClient, { - activeClients, - activeHarnessClients, - contextLogDir, - }); + if (abortSignal.aborted) { + return; } - const service = yield* createGatewayService(profileName, account, deps); - const core = createGatewayCore(service, deps); - const binding = { core, service }; - registerInboundHandler({ - core, - ctx, - service, + // The OpenClaw account id names the profile slot, so the daemon, its + // loopback endpoint, and the checkpoint store all follow from it. + const injected = deps.harnessClientForAccount?.(profileName, account); + const harnessClient = + injected ?? (yield* harnessClientForProfile(profileName)); + return yield* runHarnessGateway(ctx, harnessClient, { + activeHarnessClients, contextLogDir, }); - registerConnectionStatus(core, ctx); - yield* stopActiveGatewayAccount( - activeClients, - activeHarnessClients, - accountId, - ); - yield* Effect.sync(() => activeClients.set(accountId, service)); - if (abortSignal.aborted) { - return yield* disconnectLegacyGateway(binding, activeClients, accountId); - } - registerLegacyGatewayAbort(abortSignal, binding, activeClients, accountId); - yield* connectGatewayCore(core, service, ctx, setStatus); - }); + }).pipe(Effect.scoped); } interface HarnessGatewayRuntime { - readonly activeClients: Map; readonly activeHarnessClients: Map; readonly contextLogDir?: string; } @@ -702,15 +512,11 @@ function runHarnessGateway( client: HarnessClientService, runtime: HarnessGatewayRuntime, ): Effect.Effect { - const { activeClients, activeHarnessClients, contextLogDir } = runtime; + const { activeHarnessClients, contextLogDir } = runtime; return Effect.gen(function* () { const stopSignal = yield* Deferred.make(); const active = { client, stopSignal }; - yield* stopActiveGatewayAccount( - activeClients, - activeHarnessClients, - ctx.accountId, - ); + yield* stopActiveGatewayAccount(activeHarnessClients, ctx.accountId); yield* Effect.sync(() => activeHarnessClients.set(ctx.accountId, active)); yield* reportHarnessConnected(client, ctx).pipe( Effect.zipRight( @@ -774,56 +580,6 @@ function reportHarnessConnected( }); } -function createGatewayService( - profileName: string, - account: MoltZapAccount, - deps: MoltzapChannelPluginDeps, -): Effect.Effect { - if (deps.createService) { - return Effect.succeed(deps.createService(profileName, account)); - } - return MoltZapService.make(profileName); -} - -function createGatewayCore( - service: OpenClawClientService, - deps: MoltzapChannelPluginDeps, -): MoltZapChannelCore { - if (deps.createCore) { - return deps.createCore(service); - } - return new MoltZapChannelCore({ service }); -} - -interface RegisterInboundHandlerParams { - readonly core: MoltZapChannelCore; - readonly ctx: OpenClawStartAccountContext; - readonly service: OpenClawClientService; - readonly contextLogDir?: string; -} - -function registerInboundHandler(params: RegisterInboundHandlerParams): void { - params.core.onInbound((enriched) => { - const turn = bindCoreInboundTurn(params.core, enriched); - return handleInboundMessage({ - ctx: params.ctx, - ownAgentId: params.service.ownAgentId, - contextLogDir: params.contextLogDir, - turn, - }).pipe(Effect.withSpan("createMoltzapChannelPlugin.inboundDispatch")); - }); -} - -function bindCoreInboundTurn( - core: MoltZapChannelCore, - enriched: EnrichedInboundMessage, -): HarnessTurn { - return { - ...enriched, - reply: (payload) => core.sendReply(enriched.conversationId, payload), - }; -} - function handleInboundMessage(params: InboundHandlerParams) { return Effect.gen(function* () { const data = inboundRuntimeData(params.turn, params.ownAgentId); @@ -988,78 +744,19 @@ function logUnqueuedDispatch( ); } -function registerConnectionStatus( - core: MoltZapChannelCore, - ctx: OpenClawStartAccountContext, -): void { - core.onDisconnect(() => { - ctx.log?.warn?.("MoltZap: disconnected"); - ctx.setStatus({ - accountId: ctx.accountId, - connected: false, - lastDisconnect: { at: Date.now() }, - }); - }); -} - -function connectGatewayCore( - core: MoltZapChannelCore, - service: OpenClawClientService, - ctx: OpenClawStartAccountContext, - setStatus: (next: Record) => void, -) { - return core.connect().pipe( - Effect.tap(() => reportConnected(service, ctx, setStatus)), - Effect.zipRight(waitForAbort(ctx.abortSignal)), - Effect.catchAll((err) => logConnectionFailure(err, ctx.log)), - ); -} - -function reportConnected( - service: OpenClawClientService, - ctx: OpenClawStartAccountContext, - setStatus: (next: Record) => void, -) { - return Effect.sync(() => { - ctx.log?.info?.( - `MoltZap: connected as ${ctx.account.agentName} (${service.ownAgentId})`, - ); - setStatus({ - accountId: ctx.accountId, - connected: true, - lastConnectedAt: Date.now(), - }); - }); -} - -function logConnectionFailure(err: unknown, log?: OpenClawLogger) { - return Effect.sync(() => { - log?.error?.(`MoltZap: connection failed: ${err}`); - }).pipe(Effect.zipRight(Effect.fail(err))); -} - function stopGatewayAccount( ctx: OpenClawStopAccountContext, - activeClients: Map, activeHarnessClients: Map, ) { - if ( - activeHarnessClients.has(ctx.accountId) || - activeClients.has(ctx.accountId) - ) { + if (activeHarnessClients.has(ctx.accountId)) { ctx.log?.info?.("MoltZap: stopping"); } return Effect.runPromise( - stopActiveGatewayAccount( - activeClients, - activeHarnessClients, - ctx.accountId, - ), + stopActiveGatewayAccount(activeHarnessClients, ctx.accountId), ); } function createOutboundSection( - activeClients: Map, activeHarnessClients: Map, ) { return { @@ -1078,9 +775,7 @@ function createOutboundSection( text: string; accountId?: string | null; }) { - return Effect.runPromise( - sendTextEffect(activeClients, activeHarnessClients, ctx), - ); + return Effect.runPromise(sendTextEffect(activeHarnessClients, ctx)); }, }; } @@ -1111,100 +806,32 @@ class MoltZapTargetMalformedError extends Data.TaggedError( } } -interface ConversationTarget { - readonly conversationId: ConversationId; -} - -/** - * Decode an outbound target into the conversation to send to. - * @param to Value supplied to the operation. - * @returns The parsed conversation target. - */ -function parseConversationTarget( - to: string, -): Effect.Effect { - const body = to.startsWith(TARGET_PREFIX_CONVERSATION) - ? to.slice(TARGET_PREFIX_CONVERSATION.length) - : to; - return Effect.try({ - try: () => ({ - conversationId: Schema.decodeUnknownSync(conversationId)(body), - }), - catch: () => new MoltZapTargetMalformedError({ target: to }), - }); -} - -function dispatchOutbound( - service: OpenClawClientService, - accountId: string, - ctx: { - to: string; - text: string; - }, -) { - return Effect.gen(function* () { - const target = normalizeMoltZapTarget(ctx.to); - if (target === null) { - return yield* Effect.fail( - new MoltZapTargetMalformedError({ target: ctx.to }), - ); - } - if (target.kind === "user") { - if (!service.sendToAgent) { - return yield* new MoltZapAgentTargetUnsupportedError({ accountId }); - } - return yield* service.sendToAgent(target.display, ctx.text); - } - const parsed = yield* parseConversationTarget(target.to); - return yield* service.send(parsed.conversationId, ctx.text); - }); -} - -interface ActiveLegacyOutbound { - readonly _tag: "legacy"; - readonly accountId: string; - readonly service: OpenClawClientService; -} - interface ActiveHarnessOutbound { readonly _tag: "harness"; readonly accountId: string; readonly client: HarnessClientService; } -type ActiveOutbound = ActiveLegacyOutbound | ActiveHarnessOutbound; +type ActiveOutbound = ActiveHarnessOutbound; function getActiveOutbound( - activeClients: Map, activeHarnessClients: Map, accountId?: string | null, ): ActiveOutbound | undefined { const requested = accountId?.trim(); if (requested) { const harness = activeHarnessClients.get(requested); - if (harness !== undefined) { - return { _tag: "harness", accountId: requested, client: harness.client }; - } - const service = activeClients.get(requested); - return service === undefined + return harness === undefined ? undefined - : { _tag: "legacy", accountId: requested, service }; + : { _tag: "harness", accountId: requested, client: harness.client }; } - if (activeClients.size + activeHarnessClients.size !== 1) { + if (activeHarnessClients.size !== 1) { return undefined; } - const harness = activeHarnessClients.entries().next().value; - if (harness !== undefined) { - return { - _tag: "harness", - accountId: harness[0], - client: harness[1].client, - }; - } - const legacy = activeClients.entries().next().value; - return legacy === undefined + const first = activeHarnessClients.entries().next().value; + return first === undefined ? undefined - : { _tag: "legacy", accountId: legacy[0], service: legacy[1] }; + : { _tag: "harness", accountId: first[0], client: first[1].client }; } function dispatchHarnessOutbound( @@ -1230,7 +857,6 @@ function dispatchHarnessOutbound( } function sendTextEffect( - activeClients: Map, activeHarnessClients: Map, ctx: { cfg: OpenClawConfig; @@ -1241,21 +867,13 @@ function sendTextEffect( ) { const requestedAccountId = ctx.accountId ?? "(unspecified)"; return Effect.gen(function* () { - const active = getActiveOutbound( - activeClients, - activeHarnessClients, - ctx.accountId, - ); + const active = getActiveOutbound(activeHarnessClients, ctx.accountId); if (active === undefined) { return yield* new MoltZapClientNotConnectedError({ accountId: requestedAccountId, }); } - if (active._tag === "harness") { - yield* dispatchHarnessOutbound(active.client, active.accountId, ctx); - } else { - yield* dispatchOutbound(active.service, active.accountId, ctx); - } + yield* dispatchHarnessOutbound(active.client, active.accountId, ctx); return new OpenClawSendTextSuccess(); }).pipe( Effect.withSpan("createMoltzapChannelPlugin.sendText"), @@ -1283,27 +901,20 @@ function sendTextEffect( * sequenceDiagram * participant OC as openclaw runtime * participant Plugin as moltzap plugin - * participant Harness as caller-owned HarnessClient - * participant Core as MoltZapChannelCore - * participant Server as MoltZap server + * participant Harness as HarnessClient + * participant Daemon as moltzapd * OC->>Plugin: startAccount(ctx) - * alt HarnessClient is injected - * Plugin->>Harness: drain turns sequentially - * Harness-->>Plugin: originating HarnessTurn - * else legacy profile client - * Plugin->>Core: new MoltZapAgentClient → MoltZapChannelCore - * Plugin->>Core: core.connect() — WS auth - * Plugin->>Core: core.onInbound(handler) — register dispatch - * Core->>Plugin: enriched message arrives - * Plugin->>Plugin: bind HarnessTurn reply authority - * end + * Plugin->>Harness: harnessClientForProfile(accountId) + * Harness->>Daemon: start the slot child and connect over loopback MCP + * Plugin->>Harness: drain turns sequentially + * Harness-->>Plugin: HarnessTurn carrying its bound reply * Plugin->>OC: dispatchReplyWithBufferedBlockDispatcher * note over OC: agent pipeline → LLM * OC->>Plugin: deliver(payload, opts) — createHarnessReplyDeliver * Plugin->>Plugin: turn.reply(text) - * Plugin->>Server: core ingress bridge sends reply + * Harness->>Daemon: reply routed to its originating conversation * OC->>Plugin: stopAccount(ctx) - * Plugin->>Plugin: stop owned drain or disconnect owned core + * Plugin->>Plugin: signal the drain to stop * ``` * * `deliver` returns `PromiseLike<boolean>` per openclaw contract; @@ -1318,7 +929,6 @@ function sendTextEffect( export function createMoltzapChannelPlugin( deps: MoltzapChannelPluginDeps = {}, ) { - const activeClients = new Map(); const activeHarnessClients = new Map(); return { @@ -1326,10 +936,9 @@ export function createMoltzapChannelPlugin( meta: createPluginMeta(), capabilities: { chatTypes: ["dm" as const, "group" as const] }, messaging: createMessagingSection(), - directory: createDirectorySection(activeClients), config: createConfigSection(), - gateway: createGatewaySection(activeClients, activeHarnessClients, deps), - outbound: createOutboundSection(activeClients, activeHarnessClients), + gateway: createGatewaySection(activeHarnessClients, deps), + outbound: createOutboundSection(activeHarnessClients), }; } diff --git a/packages/openclaw-channel/src/openclaw-gateway-lifecycle.ts b/packages/openclaw-channel/src/openclaw-gateway-lifecycle.ts index b1cdd831c..db1cbd4a1 100644 --- a/packages/openclaw-channel/src/openclaw-gateway-lifecycle.ts +++ b/packages/openclaw-channel/src/openclaw-gateway-lifecycle.ts @@ -1,16 +1,6 @@ import type { HarnessClientService } from "@moltzap/client/harness-client"; -import type { MoltZapChannelCore } from "@moltzap/client/channel-base"; import { Deferred, Effect } from "effect"; -interface ClosableGatewayService { - readonly close: () => void; -} - -interface LegacyGatewayBinding { - readonly core: MoltZapChannelCore; - readonly service: Service; -} - /** One caller-owned Harness client with the adapter's private drain signal. */ export interface ActiveHarnessClient { readonly client: HarnessClientService; @@ -18,32 +8,23 @@ export interface ActiveHarnessClient { } /** - * Stops every adapter binding for an account without closing a Harness client. - * @param activeClients Legacy services owned by the adapter. + * Stops the adapter's binding for an account without closing its Harness + * client, whose scope belongs to the caller that acquired it. * @param activeHarnessClients Harness drains owned by the adapter. * @param accountId Account whose active binding is stopped. * @returns A lazy stop operation for the selected account. */ -export function stopActiveGatewayAccount< - Service extends ClosableGatewayService, ->( - activeClients: Map, +export function stopActiveGatewayAccount( activeHarnessClients: Map, accountId: string, ): Effect.Effect { return Effect.gen(function* () { const harness = activeHarnessClients.get(accountId); - if (harness !== undefined) { - activeHarnessClients.delete(accountId); - yield* Deferred.succeed(harness.stopSignal, undefined); - } - const service = activeClients.get(accountId); - if (service !== undefined) { - activeClients.delete(accountId); - yield* Effect.sync(() => { - service.close(); - }); + if (harness === undefined) { + return; } + activeHarnessClients.delete(accountId); + yield* Deferred.succeed(harness.stopSignal, undefined); }).pipe(Effect.withSpan("stopActiveGatewayAccount")); } @@ -65,59 +46,3 @@ export function finishHarnessClient( } }); } - -const removeLegacyClientIfActive = ( - activeClients: Map, - accountId: string, - service: Service, -): Effect.Effect => - Effect.sync(() => { - if (activeClients.get(accountId) === service) { - activeClients.delete(accountId); - } - }); - -/** - * Disconnects one legacy generation without removing a newer replacement. - * @param binding Core and service belonging to one generation. - * @param activeClients Active legacy service by account. - * @param accountId Account owned by the generation. - * @returns A lazy disconnect operation. - */ -export function disconnectLegacyGateway( - binding: LegacyGatewayBinding, - activeClients: Map, - accountId: string, -): Effect.Effect { - return binding.core - .disconnect() - .pipe( - Effect.ensuring( - removeLegacyClientIfActive(activeClients, accountId, binding.service), - ), - ); -} - -/** - * Binds one legacy gateway generation to its account abort signal. - * @param signal Host-owned lifecycle signal. - * @param binding Core and service belonging to one generation. - * @param activeClients Active legacy service by account. - * @param accountId Account owned by the generation. - */ -export function registerLegacyGatewayAbort( - signal: AbortSignal, - binding: LegacyGatewayBinding, - activeClients: Map, - accountId: string, -): void { - signal.addEventListener( - "abort", - () => { - Effect.runFork( - disconnectLegacyGateway(binding, activeClients, accountId), - ); - }, - { once: true }, - ); -} diff --git a/packages/openclaw-channel/src/test-utils/container-core.ts b/packages/openclaw-channel/src/test-utils/container-core.ts deleted file mode 100644 index 27003f923..000000000 --- a/packages/openclaw-channel/src/test-utils/container-core.ts +++ /dev/null @@ -1,661 +0,0 @@ -/** - * Shared Docker container management for OpenClaw integration tests and evals. - * Both test tiers import from here to avoid duplicating config-building and lifecycle logic. - */ - -import { - execFileSync, - spawn, - type ChildProcessWithoutNullStreams, -} from "node:child_process"; -import { randomInt } from "node:crypto"; -import path from "node:path"; -import os from "node:os"; -import { FileSystem } from "@effect/platform"; -import { NodeFileSystem } from "@effect/platform-node"; -import { Data, Effect, Redacted } from "effect"; -import type { AgentId, AgentKey } from "@moltzap/protocol/identity"; -import { serverBaseUrl } from "@moltzap/protocol/network"; - -const CONTROL_UI_PORT = 18789; -const OPENCLAW_TOKEN_RADIX = 36; -const DEFAULT_PORT_RANGE_START = 19000; -const DEFAULT_PORT_RANGE_END = 19999; -const CONTAINER_UNUSED_MCP_PORT = 1; -const JSON_INDENT_SPACES = 2; -const MS_PER_SECOND = 1000; -const DEFAULT_READY_TIMEOUT_MS = 180_000; -const GATEWAY_READY_PATTERN = "[gateway]"; -const CHANNEL_READY_PATTERNS = ["[moltzap]", "connected as"] as const; -const DOCKER_BIN = "/usr/bin/docker"; - -const IMAGE_NAME = "moltzap-eval-agent:local"; -const OPENCLAW_STATE_DIR = "/home/node/.openclaw"; - -class OpenClawContainerError extends Error { - override readonly name = "OpenClawContainerError"; -} - -class DockerCleanupError extends Data.TaggedError("DockerCleanupError")<{ - readonly cause: unknown; - readonly message: string; -}> {} - -interface StartContainerOptions { - readonly name: string; - readonly agentName: string; - readonly moltzapProfile?: { - readonly agentId: AgentId; - readonly apiKey: AgentKey; - }; - readonly envVars?: Record; - readonly portRange?: [number, number]; -} - -interface LogWaitState { - readonly containerId: string; - readonly required: readonly string[]; - readonly matched: Set; - readonly proc: ChildProcessWithoutNullStreams; - timer?: ReturnType; - readonly resolve: () => void; - readonly reject: (error: Error) => void; - settled: boolean; - buffer: string; -} - -function logContainerHelperFailure(action: string, cause: unknown): void { - const message = cause instanceof Error ? cause.message : String(cause); - Effect.runFork( - Effect.logWarning(`[openclaw-container] ${action}: ${message}`), - ); -} - -function logContainerHelperFailureEffect(action: string, cause: unknown) { - return Effect.sync(() => { - logContainerHelperFailure(action, cause); - }); -} - -/** Describes container model config. */ -export interface ContainerModelConfig { - modelString: string; - providerConfig?: { - provider: string; - modelId: string; - baseUrl: string; - api: string; - apiKey: Redacted.Redacted; - }; -} - -/** Describes open claw container. */ -export interface OpenClawContainer { - containerId: string; - controlPort: number; - tmpDir: string; -} - -/** - * Checks whether image available. - * @returns Whether image available. - */ -export function isImageAvailable(): boolean { - try { - execFileSync(DOCKER_BIN, ["image", "inspect", IMAGE_NAME], { - stdio: "pipe", - }); - return true; - } catch (cause) { - logContainerHelperFailure("docker image inspect failed", cause); - return false; - } -} - -interface BuildOpenClawConfigOptions { - model: ContainerModelConfig; - agentName: string; -} - -// Containers reach the host's loopback only through the Docker gateway alias. -/** - * Normalizes container server url. - * @param serverUrl Value supplied to the operation. - * @returns The normalize container server url result. - */ -export function normalizeContainerServerUrl(serverUrl: string): string { - return serverBaseUrl(serverUrl) - .replace(/^ws/, "http") - .replace("localhost", "host.docker.internal") - .replace("127.0.0.1", "host.docker.internal"); -} - -function baseOpenClawConfig( - opts: BuildOpenClawConfigOptions, -): Record { - return { - agents: { - defaults: { - model: { primary: opts.model.modelString }, - workspace: `${OPENCLAW_STATE_DIR}/workspace`, - compaction: { mode: "safeguard" }, - }, - }, - commands: { - native: "auto", - nativeSkills: "auto", - restart: true, - ownerDisplay: "raw", - }, - messages: { - // Keep one inbound -> one outbound behavior in integration tests. - queue: { mode: "queue", debounceMs: 0, cap: 100, drop: "new" }, - }, - channels: { - moltzap: { - accounts: [ - { - id: opts.agentName, - agentName: opts.agentName, - }, - ], - }, - }, - gateway: { - mode: "local", - controlUi: { - dangerouslyAllowHostHeaderOriginFallback: true, - dangerouslyDisableDeviceAuth: true, - }, - auth: { - mode: "token", - token: `e2e-${Date.now().toString(OPENCLAW_TOKEN_RADIX)}`, - }, - }, - meta: { - lastTouchedVersion: "2026.3.14", - lastTouchedAt: new Date().toISOString(), - }, - }; -} - -function providerModelsConfig( - providerConfig: NonNullable, -) { - return { - models: { - providers: { - [providerConfig.provider]: { - baseUrl: providerConfig.baseUrl, - api: providerConfig.api, - apiKey: Redacted.value(providerConfig.apiKey), - models: [ - { id: providerConfig.modelId, name: providerConfig.modelId }, - ], - }, - }, - }, - }; -} - -/** - * Build openclaw.json config for a container. - * @param opts Value supplied to the operation. - * @returns The created open claw config. - */ -export function buildOpenClawConfig( - opts: BuildOpenClawConfigOptions, -): Record { - const config = baseOpenClawConfig(opts); - return opts.model.providerConfig - ? { ...config, ...providerModelsConfig(opts.model.providerConfig) } - : config; -} - -/** - * Create, configure, and start an OpenClaw Docker container. - * @param config Documentation generation configuration. - * @param opts Value supplied to the operation. - * @returns The start raw container result. - */ -export function startRawContainer( - config: Record, - opts: StartContainerOptions, -): Effect.Effect { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - Effect.gen(function* () { - const tmpDir = yield* createContainerFiles(fileSystem, config, opts); - const controlPort = allocateControlPort(opts); - const containerId = createContainerProcess(opts, controlPort); - copyAndStartContainer(containerId, tmpDir); - chownContainerState(containerId); - return { containerId, controlPort, tmpDir }; - }), - ), - Effect.withSpan("startRawContainer"), - Effect.provide(NodeFileSystem.layer), - ); -} - -function createContainerFiles( - fileSystem: FileSystem.FileSystem, - config: Record, - opts: StartContainerOptions, -) { - return Effect.gen(function* () { - const tmpDir = yield* fileSystem.makeTempDirectory({ - directory: os.tmpdir(), - prefix: "openclaw-e2e-", - }); - yield* fileSystem.writeFileString( - path.join(tmpDir, "openclaw.json"), - JSON.stringify(config, null, JSON_INDENT_SPACES), - ); - yield* createContainerSubdirectories(fileSystem, tmpDir); - yield* writeContainerMoltZapConfig(fileSystem, tmpDir, opts); - yield* fileSystem.writeFileString( - path.join(tmpDir, "workspace", "IDENTITY.md"), - `---\nName: ${opts.agentName}\nCreature: AI agent\nVibe: helpful\n---\n`, - ); - return tmpDir; - }); -} - -function createContainerSubdirectories( - fileSystem: FileSystem.FileSystem, - tmpDir: string, -) { - return Effect.all( - ["workspace", "logs", ".moltzap"].map((sub) => - fileSystem.makeDirectory(path.join(tmpDir, sub), { recursive: true }), - ), - { concurrency: 2 }, - ); -} - -function writeContainerMoltZapConfig( - fileSystem: FileSystem.FileSystem, - tmpDir: string, - opts: StartContainerOptions, -) { - if (opts.moltzapProfile === undefined) { - return Effect.void; - } - return fileSystem.writeFileString( - path.join(tmpDir, ".moltzap", "config.json"), - JSON.stringify( - { - profiles: { - [opts.agentName]: { - agentName: opts.agentName, - // The container runs no daemon of its own; the slot needs a port to - // decode, and this one is deliberately never bound. - mcpPort: CONTAINER_UNUSED_MCP_PORT, - agentId: opts.moltzapProfile.agentId, - apiKey: Redacted.value(opts.moltzapProfile.apiKey), - }, - }, - }, - null, - JSON_INDENT_SPACES, - ), - ); -} - -function allocateControlPort(opts: StartContainerOptions): number { - const [lo, hi] = opts.portRange ?? [ - DEFAULT_PORT_RANGE_START, - DEFAULT_PORT_RANGE_END, - ]; - return randomInt(lo, hi); -} - -function containerEnvArgs(envVars?: Record) { - const envParts = [ - "-e", - `OPENCLAW_STATE_DIR=${OPENCLAW_STATE_DIR}`, - "-e", - `MOLTZAP_CONFIG_HOME=${OPENCLAW_STATE_DIR}/.moltzap`, - ]; - for (const [key, value] of Object.entries(envVars ?? {})) { - envParts.push("-e", `${key}=${value}`); - } - return envParts; -} - -function createContainerProcess( - opts: StartContainerOptions, - controlPort: number, -): string { - return execFileSync(DOCKER_BIN, createContainerArgs(opts, controlPort), { - encoding: "utf-8", - }).trim(); -} - -function createContainerArgs( - opts: StartContainerOptions, - controlPort: number, -): string[] { - const containerName = `moltzap-e2e-${opts.name}-${Date.now()}`; - const startedEpoch = Math.floor(Date.now() / MS_PER_SECOND); - return [ - "create", - "--name", - containerName, - "--label", - "moltzap-eval=true", - "--label", - `moltzap-eval-started=${startedEpoch}`, - "--stop-timeout", - "5", - ...containerEnvArgs(opts.envVars), - "--add-host", - "host.docker.internal:host-gateway", - "-p", - `${controlPort}:${CONTROL_UI_PORT}`, - IMAGE_NAME, - "node", - "openclaw.mjs", - "gateway", - "run", - "--allow-unconfigured", - "--bind", - "lan", - ]; -} - -function copyAndStartContainer(containerId: string, tmpDir: string): void { - execFileSync(DOCKER_BIN, [ - "cp", - `${tmpDir}/.`, - `${containerId}:${OPENCLAW_STATE_DIR}/`, - ]); - execFileSync(DOCKER_BIN, ["start", containerId]); -} - -function chownContainerState(containerId: string): void { - execFileSync(DOCKER_BIN, [ - "exec", - "-u", - "root", - containerId, - "chown", - "node:node", - `${OPENCLAW_STATE_DIR}/openclaw.json`, - ]); - execFileSync(DOCKER_BIN, [ - "exec", - "-u", - "root", - containerId, - "chown", - "-R", - "node:node", - `${OPENCLAW_STATE_DIR}/workspace`, - `${OPENCLAW_STATE_DIR}/logs`, - `${OPENCLAW_STATE_DIR}/.moltzap`, - ]); -} - -/** - * Returns logs. - * @param containerId Value supplied to the operation. - * @returns The get logs result. - */ -export function getLogs(containerId: string): string { - try { - return execFileSync(DOCKER_BIN, ["logs", containerId], { - encoding: "utf-8", - }); - } catch (cause) { - logContainerHelperFailure("docker logs failed", cause); - return ""; - } -} - -/** - * Stream `docker logs -f` and resolve when all patterns appear. - * @param containerId Value supplied to the operation. - * @param patterns Value supplied to the operation. - * @param timeoutMs Maximum time to wait in milliseconds. - * @returns A promise that completes when every pattern has appeared. - */ -function waitForLogMatch( - containerId: string, - patterns: string | string[], - timeoutMs: number, -) { - const required = Array.isArray(patterns) ? patterns : [patterns]; - - return new Promise((resolve, reject) => { - const inspectFailure = inspectContainerForLogStream(containerId); - if (inspectFailure) { - reject(inspectFailure); - return; - } - const proc = spawn(DOCKER_BIN, ["logs", "-f", containerId]); - const state: LogWaitState = { - containerId, - required, - matched: new Set(), - proc, - resolve: () => { - resolve(undefined); - }, - reject, - settled: false, - buffer: "", - }; - state.timer = setTimeout(() => { - failLogWait(state, logMatchTimeoutError(state, timeoutMs)); - }, timeoutMs); - wireLogWaitProcess(state); - }); -} - -function inspectContainerForLogStream( - containerId: string, -): OpenClawContainerError | undefined { - try { - const status = execFileSync( - DOCKER_BIN, - ["inspect", containerId, "--format={{.State.Status}}"], - { encoding: "utf-8" }, - ).trim(); - return status === "running" - ? undefined - : new OpenClawContainerError( - `Container not running (status: ${status}) before log stream.\nLogs:\n${getLogs(containerId)}`, - ); - } catch (cause) { - return new OpenClawContainerError( - `Failed to inspect container ${containerId}: ${String(cause)}`, - ); - } -} - -function wireLogWaitProcess(state: LogWaitState): void { - state.proc.stdout.on("data", (chunk: Buffer) => { - processLogChunk(state, chunk); - }); - state.proc.stderr.on("data", (chunk: Buffer) => { - processLogChunk(state, chunk); - }); - state.proc.on("error", (err) => { - failLogWait( - state, - new OpenClawContainerError( - `docker logs process error: ${err.message}\nLogs:\n${getLogs(state.containerId)}`, - ), - ); - }); - state.proc.on("close", (code) => { - handleLogStreamClose(state, code); - }); -} - -function processLogChunk(state: LogWaitState, chunk: Buffer): void { - state.buffer += chunk.toString(); - const lines = state.buffer.split("\n"); - state.buffer = lines.pop() ?? ""; - for (const line of lines) { - addLineMatches(state, line); - if (allPatternsMatched(state)) { - succeedLogWait(state); - return; - } - } -} - -function addLineMatches(state: LogWaitState, line: string): void { - for (const pattern of state.required) { - if (!state.matched.has(pattern) && line.includes(pattern)) { - state.matched.add(pattern); - } - } -} - -function handleLogStreamClose(state: LogWaitState, code: number | null): void { - if (state.settled) { - return; - } - addBufferMatches(state); - if (allPatternsMatched(state)) { - succeedLogWait(state); - return; - } - const exitCode = code ?? "unknown"; - failLogWait(state, logMatchExitError(state, exitCode)); -} - -function addBufferMatches(state: LogWaitState): void { - if (state.buffer.length === 0) { - return; - } - for (const pattern of state.required) { - if (state.buffer.includes(pattern)) { - state.matched.add(pattern); - } - } -} - -function allPatternsMatched(state: LogWaitState): boolean { - return state.matched.size === state.required.length; -} - -function succeedLogWait(state: LogWaitState): void { - finishLogWait(state); - state.resolve(); -} - -function failLogWait(state: LogWaitState, error: Error): void { - finishLogWait(state); - state.reject(error); -} - -function finishLogWait(state: LogWaitState): void { - if (state.settled) { - return; - } - state.settled = true; - if (state.timer !== undefined) { - clearTimeout(state.timer); - } - state.proc.kill(); -} - -function missingPatterns(state: LogWaitState): string[] { - return state.required.filter((pattern) => !state.matched.has(pattern)); -} - -function logMatchTimeoutError( - state: LogWaitState, - timeoutMs: number, -): OpenClawContainerError { - return new OpenClawContainerError( - `waitForLogMatch timed out after ${timeoutMs}ms.\n` + - logMatchStateSummary(state), - ); -} - -function logMatchExitError( - state: LogWaitState, - code: number | "unknown", -): OpenClawContainerError { - return new OpenClawContainerError( - `docker logs exited (code ${code}) before all patterns matched.\n` + - logMatchStateSummary(state), - ); -} - -function logMatchStateSummary(state: LogWaitState): string { - return ( - `Matched: [${[...state.matched].join(", ")}]\n` + - `Missing: [${missingPatterns(state).join(", ")}]\n` + - `Logs:\n${getLogs(state.containerId)}` - ); -} - -/** - * Wait for both gateway and channel to be ready (single log stream). - * @param containerId Value supplied to the operation. - * @returns The wait for ready result. - */ -export function waitForReady(containerId: string) { - return waitForLogMatch( - containerId, - [GATEWAY_READY_PATTERN, ...CHANNEL_READY_PATTERNS], - DEFAULT_READY_TIMEOUT_MS, - ); -} - -/** - * Stop and remove a container, clean up temp files. - * @param container Value supplied to the operation. - * @returns The stop container result. - */ -export function stopContainer( - container: OpenClawContainer, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* Effect.try({ - try: () => - execFileSync(DOCKER_BIN, ["rm", "-f", container.containerId], { - stdio: "pipe", - }), - catch: (cause: unknown) => - new DockerCleanupError({ - message: "docker rm failed during cleanup", - cause, - }), - }).pipe( - Effect.catchAll((cause) => - Effect.sync(() => { - logContainerHelperFailure("docker rm failed during cleanup", cause); - }), - ), - ); - yield* removeTempDir(fileSystem, container.tmpDir); - }).pipe( - Effect.withSpan("stopContainer"), - Effect.provide(NodeFileSystem.layer), - ); -} - -function removeTempDir( - fileSystem: FileSystem.FileSystem, - tmpDir: string, -): Effect.Effect { - return fileSystem - .remove(tmpDir, { recursive: true, force: true }) - .pipe( - Effect.catchAll((cause) => - logContainerHelperFailureEffect( - "temporary directory cleanup failed", - cause, - ), - ), - ); -} diff --git a/packages/openclaw-channel/src/test-utils/harness-fixture.ts b/packages/openclaw-channel/src/test-utils/harness-fixture.ts new file mode 100644 index 000000000..493768b3c --- /dev/null +++ b/packages/openclaw-channel/src/test-utils/harness-fixture.ts @@ -0,0 +1,385 @@ +/** + * Shared OpenClaw gateway fixture. + * + * The plugin reaches its client only through the caller-owned + * `harnessClientForAccount` seam, so every gateway suite needs the same three + * things: an injected client whose turn stream the test drives, OpenClaw's + * fixed `startAccount` argument shape, and a teardown that leaves the client + * untouched. They live here so each suite asserts behaviour instead of + * rebuilding the seam. + */ + +import type { + HarnessClientService, + HarnessTurn, +} from "@moltzap/client/harness-client"; +import { + testAgentId, + testConversationId, + testMessageId, +} from "@moltzap/client/test-utils"; +import { Data, Effect, Fiber, Queue, Stream } from "effect"; +import { expect, vi } from "vitest"; +import { + createMoltzapChannelPlugin, + type MoltzapChannelPlugin, +} from "../openclaw-entry.js"; + +/** OpenClaw account slot; the id also names the MoltZap profile. */ +export const ACCOUNT_ID = "harness-account"; + +/** Configured agent name, reported to OpenClaw as the inbound `To` field. */ +export const ACCOUNT_AGENT_NAME = "harness-agent"; + +/** Identity the injected client reports as its own. */ +export const SELF_AGENT_ID = testAgentId( + "550e8400-e29b-41d4-a716-446655440801", +); + +/** Identity that authors every fixture turn. */ +export const SENDER_AGENT_ID = testAgentId( + "550e8400-e29b-41d4-a716-446655440802", +); + +/** Presentation name the turn already carries for its sender. */ +export const SENDER_AGENT_NAME = "sender-agent"; + +/** Conversation every fixture turn arrives on. */ +export const CONVERSATION_ID = testConversationId( + "550e8400-e29b-41d4-a716-446655440803", +); + +/** Fixed timestamp so a turn never depends on wall-clock time. */ +export const CREATED_AT = "2026-08-04T00:00:00.000Z"; + +/** Body a fixture turn carries unless the caller overrides it. */ +export const INBOUND_TEXT = "injected inbound"; + +const MESSAGE_ID = testMessageId("550e8400-e29b-41d4-a716-446655440804"); +const STARTED_CONVERSATION_ID = testConversationId( + "550e8400-e29b-41d4-a716-446655440805", +); + +type StartConversation = HarnessClientService["startConversation"]; +type TurnReply = HarnessTurn["reply"]; +type Dispatch = ReturnType; +type SetStatus = ReturnType< + typeof vi.fn<(next: Record) => void> +>; + +interface HarnessGatewayLogger { + readonly info?: (...args: unknown[]) => void; + readonly warn?: (...args: unknown[]) => void; + readonly error?: (...args: unknown[]) => void; + readonly debug?: (...args: unknown[]) => void; +} + +interface HarnessGatewayOverrides { + readonly log?: HarnessGatewayLogger; + /** Starts without the reply dispatcher, so inbound turns have no sink. */ + readonly withoutChannelRuntime?: boolean; +} + +interface StartedHarnessGateway { + readonly abortController: AbortController; + readonly dispatch: Dispatch; + readonly plugin: MoltzapChannelPlugin; + readonly setStatus: SetStatus; + readonly startFiber: Fiber.RuntimeFiber; +} + +interface StartedInjectedHarnessGateway extends StartedHarnessGateway { + readonly harnessClientForAccount: ReturnType< + typeof vi.fn<() => InjectedHarnessClient> + >; +} + +type InjectedHarnessClient = HarnessClientService & { + readonly close: () => void; +}; + +/** OpenClaw's send-text verdict, named so the fixture can surface it. */ +type SendTextResult = Awaited< + ReturnType +>; + +interface HarnessDispatchCall { + readonly ctx: Record; + readonly cfg: unknown; + readonly dispatcherOptions: { + readonly deliver: ( + payload: { readonly text?: string; readonly body?: string }, + info?: { readonly kind?: string }, + ) => PromiseLike; + }; +} + +/** Failure raised when a fixture-driven Promise boundary rejects. */ +export class HarnessFixtureError extends Data.TaggedError( + "HarnessFixtureError", +)<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** + * Builds the configured OpenClaw account entry. + * @param id Account id, which is also the MoltZap profile name. + * @returns The account entry OpenClaw passes to `startAccount`. + */ +export function makeAccount(id: string = ACCOUNT_ID) { + return { id, agentName: ACCOUNT_AGENT_NAME }; +} + +/** + * Builds the OpenClaw config holding exactly one MoltZap account. + * @param id Account id to configure. + * @returns The OpenClaw config value. + */ +export function makeConfig(id: string = ACCOUNT_ID) { + return { + channels: { + moltzap: { + accounts: [makeAccount(id)], + }, + }, + }; +} + +/** + * Wraps a Promise-returning OpenClaw boundary call as an Effect. + * @param message Label attached to a rejection. + * @param operation The boundary call. + * @returns An Effect that fails with {@link HarnessFixtureError}. + * @failure HarnessFixtureError when the boundary call rejects. + */ +export function runHarnessPromise( + message: string, + operation: () => PromiseLike, +): Effect.Effect { + return Effect.tryPromise({ + try: () => Promise.resolve(operation()), + catch: (cause) => new HarnessFixtureError({ message, cause }), + }); +} + +/** + * Retries an assertion until it holds. + * @param assertion Assertion to poll. + * @param message Label attached to a timeout. + * @returns An Effect that succeeds once the assertion holds. + * @failure HarnessFixtureError when the assertion never holds. + */ +export function waitForHarnessExpectation( + assertion: () => void, + message: string, +) { + return runHarnessPromise(message, () => vi.waitFor(assertion)); +} + +/** + * Creates the caller-owned client the plugin drains. + * + * `close` is not part of `HarnessClientService`; it is here so a test can + * prove the plugin never closes a client it did not acquire. + * @returns The injected client plus the handles a test drives it with. + */ +export function createHarnessFixture() { + const turns = Effect.runSync(Queue.unbounded()); + const reply = vi.fn().mockReturnValue(Effect.void); + const startConversation = vi.fn().mockReturnValue( + Effect.succeed({ + id: STARTED_CONVERSATION_ID, + createdBy: SELF_AGENT_ID, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + participants: [SELF_AGENT_ID, SENDER_AGENT_ID], + }), + ); + const callerClose = vi.fn(); + const client: InjectedHarnessClient = { + agentId: SELF_AGENT_ID, + startConversation, + turns: Stream.fromQueue(turns), + close: callerClose, + }; + return { callerClose, client, reply, startConversation, turns }; +} + +/** Fixture handle a suite drives one injected client through. */ +export type HarnessFixture = ReturnType; + +function makeHarnessTurn( + reply: TurnReply, + overrides: Partial>, +): HarnessTurn { + return { + id: MESSAGE_ID, + conversationId: CONVERSATION_ID, + sender: { id: SENDER_AGENT_ID, name: SENDER_AGENT_NAME }, + text: INBOUND_TEXT, + isFromMe: false, + createdAt: CREATED_AT, + conversationMeta: { + type: "dm", + participants: [`agent:${SELF_AGENT_ID}`, `agent:${SENDER_AGENT_ID}`], + }, + contextBlocks: {}, + ...overrides, + reply, + }; +} + +/** + * Offers one turn onto the injected client's stream. + * @param fixture Fixture owning the turn stream. + * @param overrides Turn fields that differ from the default DM turn. + * @returns An Effect that completes once the turn is queued. + */ +export function offerHarnessTurn( + fixture: HarnessFixture, + overrides: Partial> = {}, +) { + return Queue.offer(fixture.turns, makeHarnessTurn(fixture.reply, overrides)); +} + +/** + * Starts one account on an already-built plugin. + * + * Two starts of the same plugin model an account restart, so this stays + * separate from {@link startHarnessGateway}. + * @param plugin Plugin under test. + * @param overrides Optional logger and channel-runtime selection. + * @returns The started gateway handle. + */ +export function startPluginHarnessGateway( + plugin: MoltzapChannelPlugin, + overrides: HarnessGatewayOverrides = {}, +): StartedHarnessGateway { + const dispatch = vi.fn().mockResolvedValue({ queuedFinal: true }); + const setStatus = vi.fn<(next: Record) => void>(); + const abortController = new AbortController(); + const startFiber = Effect.runFork( + runHarnessPromise("start Harness gateway", () => + plugin.gateway.startAccount({ + cfg: makeConfig(), + accountId: ACCOUNT_ID, + account: makeAccount(), + abortSignal: abortController.signal, + setStatus, + ...(overrides.log === undefined ? {} : { log: overrides.log }), + ...(overrides.withoutChannelRuntime === true + ? {} + : { + channelRuntime: { + reply: { dispatchReplyWithBufferedBlockDispatcher: dispatch }, + }, + }), + }), + ), + ); + return { abortController, dispatch, plugin, setStatus, startFiber }; +} + +/** + * Builds a plugin bound to one injected client and starts its account. + * @param fixture Fixture whose client the plugin receives. + * @param overrides Optional logger and channel-runtime selection. + * @returns The started gateway handle plus the injection spy. + */ +export function startHarnessGateway( + fixture: HarnessFixture, + overrides: HarnessGatewayOverrides = {}, +): StartedInjectedHarnessGateway { + const harnessClientForAccount = vi.fn(() => fixture.client); + const plugin: MoltzapChannelPlugin = createMoltzapChannelPlugin({ + harnessClientForAccount, + }); + return { + ...startPluginHarnessGateway(plugin, overrides), + harnessClientForAccount, + }; +} + +/** + * Waits until the gateway publishes its connected status. + * @param started Started gateway handle. + * @param started.setStatus OpenClaw's status callback spy. + * @returns An Effect that completes once the status is published. + * @failure HarnessFixtureError when the status never arrives. + */ +export function waitForGatewayStart(started: { + readonly setStatus: SetStatus; +}) { + return waitForHarnessExpectation(() => { + expect(started.setStatus).toHaveBeenCalledWith( + expect.objectContaining({ accountId: ACCOUNT_ID, connected: true }), + ); + }, "wait for Harness gateway start"); +} + +/** + * Waits until OpenClaw's reply dispatcher has been called `count` times. + * @param dispatch Reply dispatcher spy. + * @param count Expected call count. + * @returns An Effect that completes once the count is reached. + * @failure HarnessFixtureError when the count is never reached. + */ +export function waitForDispatchTimes(dispatch: Dispatch, count: number) { + return waitForHarnessExpectation(() => { + expect(dispatch).toHaveBeenCalledTimes(count); + }, `wait for ${count} dispatch calls`); +} + +/** + * Reads the first reply-dispatch call. + * @param dispatch Reply dispatcher spy. + * @returns The first dispatch argument. + */ +export function firstDispatchCall(dispatch: Dispatch): HarnessDispatchCall { + return /* Safe because callers wait until dispatch has one call. */ dispatch + .mock.calls[0]?.[0] as HarnessDispatchCall; +} + +/** + * Sends outbound text through the plugin's OpenClaw surface. + * @param plugin Plugin under test. + * @param to OpenClaw target. + * @param text Message body. + * @param accountId Account the send is attributed to. + * @returns An Effect carrying OpenClaw's send result. + * @failure HarnessFixtureError when the boundary call rejects. + */ +export function sendHarnessText( + plugin: MoltzapChannelPlugin, + to: string, + text: string, + accountId: string = ACCOUNT_ID, +): Effect.Effect { + return runHarnessPromise("send Harness text", () => + plugin.outbound.sendText({ cfg: makeConfig(), accountId, to, text }), + ); +} + +/** + * Stops the account through the plugin's OpenClaw surface. + * @param plugin Plugin under test. + * @returns An Effect that completes once the stop returns. + * @failure HarnessFixtureError when the boundary call rejects. + */ +export function stopHarnessAccount(plugin: MoltzapChannelPlugin) { + return runHarnessPromise("stop Harness account", () => + plugin.gateway.stopAccount({ accountId: ACCOUNT_ID }), + ); +} + +/** + * Releases a started gateway whether or not the example stopped it. + * @param started Started gateway handle. + * @returns An Effect that completes once the start fiber is gone. + */ +export function cleanUpStart(started: StartedHarnessGateway) { + return Effect.sync(() => { + started.abortController.abort(); + }).pipe(Effect.zipRight(Fiber.interrupt(started.startFiber)), Effect.asVoid); +} diff --git a/packages/openclaw-channel/tsconfig.json b/packages/openclaw-channel/tsconfig.json index 189c6788e..c017d6eed 100644 --- a/packages/openclaw-channel/tsconfig.json +++ b/packages/openclaw-channel/tsconfig.json @@ -14,6 +14,7 @@ "exclude": [ "src/**/*.test.ts", "src/__tests__", + "src/test-utils", "dist" ], "references": [ diff --git a/packages/openclaw-channel/vitest.integration.config.mjs b/packages/openclaw-channel/vitest.integration.config.mjs deleted file mode 100644 index d3c341409..000000000 --- a/packages/openclaw-channel/vitest.integration.config.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["src/**/*.integration.test.ts"], - globalSetup: ["vitest.integration.globalSetup.ts"], - fileParallelism: true, - testTimeout: 90_000, - hookTimeout: 300_000, - }, -}); diff --git a/packages/openclaw-channel/vitest.integration.globalSetup.ts b/packages/openclaw-channel/vitest.integration.globalSetup.ts deleted file mode 100644 index ed634e55f..000000000 --- a/packages/openclaw-channel/vitest.integration.globalSetup.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { - PostgreSqlContainer, - type StartedPostgreSqlContainer, -} from "@testcontainers/postgresql"; -import type { RegisterResponse } from "@moltzap/client/auth"; -import { registerStandaloneAgentPair } from "@moltzap/client/test-utils"; -import { Data, Effect, Redacted } from "effect"; -import type { TestProject } from "vitest/node"; -import { - startEchoServer, - type EchoServer, -} from "./src/__tests__/echo-server.js"; -import { echoModelConfig } from "./src/__tests__/openclaw-container.js"; -import { - isImageAvailable, - buildOpenClawConfig, - normalizeContainerServerUrl, - startRawContainer, - waitForReady, - stopContainer, - type OpenClawContainer, -} from "./src/test-utils/container-core.js"; -import { - spawnTestServer, - stopSpawnedServer, - type SpawnedServer, -} from "./src/__tests__/spawn-server.js"; - -const POSTGRES_IMAGE = "postgres:16-alpine"; -const POSTGRES_TEMPLATE_DATABASE = "moltzap_template"; -const POSTGRES_TEST_USER = "test"; -const POSTGRES_TEST_PASSWORD = "test"; -const POSTGRES_PORT = 5432; -const EMPTY_CONTAINER_ID = ""; - -let pgContainer: StartedPostgreSqlContainer | null = null; -let echoServer: EchoServer | null = null; -let containerA: OpenClawContainer | null = null; -let containerB: OpenClawContainer | null = null; -let spawnedServer: SpawnedServer | null = null; - -class OpenClawIntegrationSetupError extends Data.TaggedError( - "OpenClawIntegrationSetupError", -)<{ - readonly operation: string; - readonly cause: unknown; -}> {} - -/** - * Boots the shared OpenClaw integration fixture. - * @param project Vitest project used to publish fixture values. - * @returns The integration fixture teardown callback. - */ -export function setup(project: TestProject) { - const { provide } = project; - return Effect.runPromise(setupIntegrationTests(provide)); -} - -function setupIntegrationTests(provide: TestProject["provide"]) { - return Effect.gen(function* () { - const prerequisites = yield* startPrerequisites(); - pgContainer = prerequisites.pg; - echoServer = prerequisites.echo; - - const server = yield* startServer(prerequisites.pg); - spawnedServer = server; - - const { first: agentA, second: agentB } = - yield* registerStandaloneAgentPair(server.baseUrl, { - first: "container-agent-a", - second: "container-agent-b", - }); - - yield* startOpenClawContainers(prerequisites.echo, server, agentA, agentB); - provideIntegrationValues(provide, server, agentA, agentB); - - return () => Effect.runPromise(teardownIntegrationTests()); - }); -} - -function startPrerequisites() { - return Effect.all( - { - pg: startPostgres(), - echo: startEcho(), - }, - { concurrency: 2 }, - ); -} - -function startPostgres(): Effect.Effect< - StartedPostgreSqlContainer, - OpenClawIntegrationSetupError -> { - return Effect.tryPromise({ - try: () => - new PostgreSqlContainer(POSTGRES_IMAGE) - .withDatabase(POSTGRES_TEMPLATE_DATABASE) - .withUsername(POSTGRES_TEST_USER) - .withPassword(POSTGRES_TEST_PASSWORD) - .start(), - catch: (cause) => - setupError("start PostgreSQL integration container", cause), - }); -} - -function startEcho(): Effect.Effect { - return Effect.tryPromise({ - try: () => startEchoServer(), - catch: (cause) => setupError("start echo model server", cause), - }); -} - -function startServer( - pg: StartedPostgreSqlContainer, -): Effect.Effect { - return Effect.tryPromise({ - try: () => spawnTestServer(pg.getHost(), pg.getMappedPort(POSTGRES_PORT)), - catch: (cause) => setupError("start MoltZap test server", cause), - }); -} - -function startSharedOpenClawContainer(input: { - readonly model: ReturnType; - readonly server: SpawnedServer; - readonly slot: "shared-a" | "shared-b"; - readonly agentName: string; - readonly agent: RegisterResponse; - readonly portRange?: [number, number]; -}) { - return startRawContainer( - buildOpenClawConfig({ - model: input.model, - agentName: input.agentName, - }), - { - name: input.slot, - agentName: input.agentName, - moltzapProfile: { - agentId: input.agent.agentId, - apiKey: input.agent.apiKey, - }, - envVars: { - MOLTZAP_SERVER_URL: normalizeContainerServerUrl(input.server.baseUrl), - }, - ...(input.portRange !== undefined ? { portRange: input.portRange } : {}), - }, - ); -} - -function startOpenClawContainers( - echo: EchoServer, - server: SpawnedServer, - agentA: RegisterResponse, - agentB: RegisterResponse, -) { - if (!isImageAvailable()) { - return Effect.void; - } - const model = echoModelConfig(echo.port); - return Effect.gen(function* () { - const [firstContainer, secondContainer] = yield* Effect.all( - [ - startSharedOpenClawContainer({ - model, - server, - slot: "shared-a", - agentName: "container-agent-a", - agent: agentA, - }), - startSharedOpenClawContainer({ - model, - server, - slot: "shared-b", - agentName: "container-agent-b", - agent: agentB, - portRange: [19500, 19999], - }), - ], - { concurrency: 2 }, - ); - - containerA = firstContainer; - containerB = secondContainer; - - yield* waitForContainer(firstContainer); - yield* waitForContainer(secondContainer); - }); -} - -function waitForContainer( - container: OpenClawContainer, -): Effect.Effect { - return Effect.tryPromise({ - try: () => waitForReady(container.containerId), - catch: (cause) => - setupError(`wait for OpenClaw container ${container.containerId}`, cause), - }); -} - -function provideIntegrationValues( - provide: TestProject["provide"], - server: SpawnedServer, - agentA: RegisterResponse, - agentB: RegisterResponse, -): void { - provide("baseUrl", server.baseUrl); - provide("wsUrl", server.wsUrl); - provide("containerAId", containerA?.containerId ?? EMPTY_CONTAINER_ID); - provide("containerAAgentId", agentA.agentId); - provide("containerAApiKey", Redacted.value(agentA.apiKey)); - provide("containerBId", containerB?.containerId ?? EMPTY_CONTAINER_ID); - provide("containerBAgentId", agentB.agentId); - provide("containerBApiKey", Redacted.value(agentB.apiKey)); -} - -function teardownIntegrationTests() { - return Effect.gen(function* () { - const firstContainer = containerA; - containerA = null; - if (firstContainer !== null) { - yield* stopContainer(firstContainer); - } - - const secondContainer = containerB; - containerB = null; - if (secondContainer !== null) { - yield* stopContainer(secondContainer); - } - - const server = spawnedServer; - spawnedServer = null; - if (server !== null) { - yield* stopServer(server); - } - - echoServer?.close(); - echoServer = null; - - const postgres = pgContainer; - pgContainer = null; - if (postgres !== null) { - yield* stopPostgres(postgres); - } - }); -} - -function stopServer( - server: SpawnedServer, -): Effect.Effect { - return Effect.tryPromise({ - try: () => stopSpawnedServer(server), - catch: (cause) => setupError("stop MoltZap test server", cause), - }); -} - -function stopPostgres( - pg: StartedPostgreSqlContainer, -): Effect.Effect { - return Effect.tryPromise({ - try: () => pg.stop(), - catch: (cause) => - setupError("stop PostgreSQL integration container", cause), - }); -} - -function setupError( - operation: string, - cause: unknown, -): OpenClawIntegrationSetupError { - return new OpenClawIntegrationSetupError({ operation, cause }); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a79d21832..f584eda03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,9 +240,6 @@ importers: '@effect/vitest': specifier: ^0.30.0 version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) - '@testcontainers/postgresql': - specifier: ^10.18.0 - version: 10.28.0 '@types/node': specifier: ^25.5.0 version: 25.5.0