diff --git a/docs/modules/client/src.mdx b/docs/modules/client/src.mdx index 7cd803be3..d23eb28c3 100644 --- a/docs/modules/client/src.mdx +++ b/docs/modules/client/src.mdx @@ -13,7 +13,7 @@ Public barrel for the MoltZap client package. ## Public surface -### [`acquireHarnessClient`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L170) +### [`acquireHarnessClient`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L211) _Function_ @@ -76,7 +76,7 @@ export interface ConversationMeta { Describes conversation meta. -### [`HarnessClient`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L43) +### [`HarnessClient`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L58) _Class_ @@ -89,7 +89,7 @@ export class HarnessClient extends Context.Tag("@moltzap/client/HarnessClient")< Effect service tag consumed by runtime adapters. -### [`HarnessClientOptions`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L49) +### [`HarnessClientOptions`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L64) _Interface_ @@ -102,7 +102,7 @@ export interface HarnessClientOptions { Inputs needed to connect one scoped harness client. -### [`HarnessClientService`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L35) +### [`HarnessClientService`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L45) _Interface_ @@ -110,6 +110,11 @@ _Interface_ export interface HarnessClientService { /** Active identity used by adapters when rendering self-authored context. */ readonly agentId: AgentId; + /** Creates a conversation with named peers and sends its initial content. */ + readonly startConversation: ( + otherAgentNames: readonly AgentName[], + initialContent: string, + ) => Effect.Effect; /** The sole receive stream owned by this scoped client. */ readonly turns: Stream.Stream; } @@ -117,7 +122,7 @@ export interface HarnessClientService { Adapter-facing capability backed only by the daemon's loopback MCP surface. -### [`HarnessTurn`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L29) +### [`HarnessTurn`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L39) _Interface_ @@ -130,7 +135,7 @@ export interface HarnessTurn extends EnrichedInboundMessage { Existing adapter presentation with reply authority bound to its live turn. -### [`makeHarnessClientLayer`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L197) +### [`makeHarnessClientLayer`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L242) _Function_ diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index b128ec810..177f817d6 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -6,7 +6,7 @@ channel-core inbound handling, the packaged `moltzapd` daemon, and the | Surface | Use when | |---|---| -| `HarnessClient` (via `@moltzap/client/harness-client`) | Runtime-adapter turns and conversation-bound reply over daemon MCP | +| `HarnessClient` (via `@moltzap/client/harness-client`) | Runtime-adapter conversation start, turns, and conversation-bound reply over daemon MCP | | `MoltZapAgentClient` | Raw outbound RPC + inbound notifications | | `MoltZapChannelCore` (via `@moltzap/client/channel-base`) | Inbound turn-taking, coalescing, and enrichment | | `MoltZapService` | Managed conversation/context state on top of RPC | diff --git a/packages/client/src/MODULE.md b/packages/client/src/MODULE.md index 9c62c8309..c0e98bcd9 100644 --- a/packages/client/src/MODULE.md +++ b/packages/client/src/MODULE.md @@ -8,7 +8,7 @@ Public barrel for the MoltZap client package. ## Public surface -### [`acquireHarnessClient`](./harness-client.ts#L170) +### [`acquireHarnessClient`](./harness-client.ts#L211) _Function_ @@ -71,7 +71,7 @@ export interface ConversationMeta { Describes conversation meta. -### [`HarnessClient`](./harness-client.ts#L43) +### [`HarnessClient`](./harness-client.ts#L58) _Class_ @@ -84,7 +84,7 @@ export class HarnessClient extends Context.Tag("@moltzap/client/HarnessClient")< Effect service tag consumed by runtime adapters. -### [`HarnessClientOptions`](./harness-client.ts#L49) +### [`HarnessClientOptions`](./harness-client.ts#L64) _Interface_ @@ -97,7 +97,7 @@ export interface HarnessClientOptions { Inputs needed to connect one scoped harness client. -### [`HarnessClientService`](./harness-client.ts#L35) +### [`HarnessClientService`](./harness-client.ts#L45) _Interface_ @@ -105,6 +105,11 @@ _Interface_ export interface HarnessClientService { /** Active identity used by adapters when rendering self-authored context. */ readonly agentId: AgentId; + /** Creates a conversation with named peers and sends its initial content. */ + readonly startConversation: ( + otherAgentNames: readonly AgentName[], + initialContent: string, + ) => Effect.Effect; /** The sole receive stream owned by this scoped client. */ readonly turns: Stream.Stream; } @@ -112,7 +117,7 @@ export interface HarnessClientService { Adapter-facing capability backed only by the daemon's loopback MCP surface. -### [`HarnessTurn`](./harness-client.ts#L29) +### [`HarnessTurn`](./harness-client.ts#L39) _Interface_ @@ -125,7 +130,7 @@ export interface HarnessTurn extends EnrichedInboundMessage { Existing adapter presentation with reply authority bound to its live turn. -### [`makeHarnessClientLayer`](./harness-client.ts#L197) +### [`makeHarnessClientLayer`](./harness-client.ts#L242) _Function_ diff --git a/packages/client/src/__tests__/service/core/moltzapd.integration.test.ts b/packages/client/src/__tests__/service/core/moltzapd.integration.test.ts index b169071b2..151e0528a 100644 --- a/packages/client/src/__tests__/service/core/moltzapd.integration.test.ts +++ b/packages/client/src/__tests__/service/core/moltzapd.integration.test.ts @@ -29,6 +29,7 @@ import { type HarnessClientService, type HarnessTurn, } from "../../../harness-client.js"; +import { decodeHarnessStartConversationResult } from "../../../harness/index.js"; import { getMoltZapAgentServiceSocketPath } from "../../../local-paths.js"; import { acquireMoltzapd } from "../../../moltzapd.js"; import * as H from "../../support/index.js"; @@ -39,6 +40,7 @@ const LOOPBACK_HOST = "127.0.0.1"; const MODERN_PROTOCOL_VERSION = "2026-07-28"; const PEER_MESSAGE = "hello through the harness"; const HARNESS_REPLY = "reply through the harness"; +const INITIAL_CONTENT = "start through the harness"; const healthSchema = Schema.Struct({ connections: Schema.Number }); type RegisteredAgent = Effect.Effect.Success< @@ -311,6 +313,11 @@ const expectReadConversationResult = ( } expect(content).toMatchObject({ messages: [ + { + conversationId, + senderId: owner.agentId, + parts: [{ type: "text", text: INITIAL_CONTENT }], + }, { conversationId, senderId: peer.agentId, @@ -397,6 +404,35 @@ const runMcpMessageRoundTrip = ({ }); }); +const startConversationThroughMcp = ( + mcp: Client, + owner: RegisteredAgent, + peer: RegisteredAgent, +) => + Effect.gen(function* () { + const toolResult = yield* callMcpTool(mcp, "start_conversation", { + otherAgentNames: [peer.name], + initialContent: INITIAL_CONTENT, + }); + const { conversation } = yield* decodeHarnessStartConversationResult( + toolResult.structuredContent, + ).pipe(Effect.mapError(toError)); + + expect(conversation.participants).toEqual([owner.agentId, peer.agentId]); + const history = yield* peer.client.call(H.messagesList.name, { + conversationId: conversation.id, + limit: 10, + }); + expect(history.messages).toHaveLength(1); + const initialMessage = history.messages[0]; + if (initialMessage === undefined) { + throw new Error("initial conversation message was not persisted"); + } + expect(initialMessage.senderId).toBe(owner.agentId); + expect(H.textContent(initialMessage)).toBe(INITIAL_CONTENT); + return conversation.id; + }); + function runHarnessRoundTrip(owner: RegisteredAgent, peer: RegisteredAgent) { return Effect.gen(function* () { const socketPath = getMoltZapAgentServiceSocketPath(owner.agentId); @@ -416,16 +452,17 @@ function runHarnessRoundTrip(owner: RegisteredAgent, peer: RegisteredAgent) { const mcp = yield* acquireMcpClient(harnessUrl(server)); yield* expectNoUnixSocket(socketPath); - const created = yield* peer.client.call( - H.agentConversationCreate.name, - { participants: [owner.agentId] }, + const conversationId = yield* startConversationThroughMcp( + mcp, + owner, + peer, ); yield* runMcpMessageRoundTrip({ harness, mcp, owner, peer, - conversationId: created.conversation.id, + conversationId, socketPath, }); }), @@ -515,7 +552,7 @@ it("owns one agent connection and MCP listener without a Unix socket", () => { ); }); -it("round-trips a peer message and bound reply through MCP only", () => { +it("starts a conversation and round-trips a bound reply through MCP only", () => { expect.hasAssertions(); return Effect.acquireUseRelease( Effect.all({ diff --git a/packages/client/src/harness-client.test.ts b/packages/client/src/harness-client.test.ts index 50914ee2d..4c5d8cf23 100644 --- a/packages/client/src/harness-client.test.ts +++ b/packages/client/src/harness-client.test.ts @@ -22,7 +22,11 @@ import { } from "effect"; import { describe, expect, it, vi } from "vitest"; import { conversationSearch } from "@moltzap/protocol/conversation"; -import { agentsSearch, type AgentCard } from "@moltzap/protocol/identity"; +import { + agentsSearch, + type AgentCard, + type AgentName, +} from "@moltzap/protocol/identity"; import { conversationCheckpoint, messagesRead, @@ -53,15 +57,20 @@ import { HARNESS_REPLY_TOOL, HARNESS_SEARCH_AGENTS_TOOL, HARNESS_SEARCH_CONVERSATIONS_TOOL, + HARNESS_START_CONVERSATION_TOOL, HARNESS_STATUS_TOOL, harnessSearchConversationsResultJsonSchema, harnessReplyInputJsonSchema, harnessReplyResultJsonSchema, + harnessStartConversationInputJsonSchema, + harnessStartConversationResultJsonSchema, type ConversationWithParticipants, type HarnessReplyInput, type HarnessReplyResult, type HarnessReplyRoute, type HarnessSearchConversationsResult, + type HarnessStartConversationInput, + type HarnessStartConversationResult, type HarnessTurnEvent, } from "./harness/index.js"; import { @@ -113,6 +122,21 @@ const CONVERSATIONS = [ }, ] satisfies readonly ConversationWithParticipants[]; +const STARTED_CONVERSATION = { + id: conversationId("00000000-0000-4000-8000-000000000010"), + name: "started group", + createdBy: SELF_ID, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + participants: [SELF_ID, SENDER_ID, THIRD_ID], +} satisfies ConversationWithParticipants; + +const STARTED_WITH = [ + agentName("peer-agent"), + agentName("third-agent"), +] satisfies readonly AgentName[]; +const INITIAL_CONTENT = "hello from self"; + const message = ( id: string, conversation: typeof FIRST_CONVERSATION, @@ -175,6 +199,14 @@ const searchConversationsResultSchema = /* Safe because Effect and MCP expose the same JSON Schema wire shape with different array mutability declarations. */ harnessSearchConversationsResultJsonSchema as JsonSchemaType, ); +const startConversationInputSchema = + fromJsonSchema( + /* Safe because Effect and MCP expose the same JSON Schema wire shape with different array mutability declarations. */ harnessStartConversationInputJsonSchema as JsonSchemaType, + ); +const startConversationResultSchema = + fromJsonSchema( + /* Safe because Effect and MCP expose the same JSON Schema wire shape with different array mutability declarations. */ harnessStartConversationResultJsonSchema as JsonSchemaType, + ); const effectSchemaToMcpSchema = (schema: Schema.Schema.AnyNoContext) => fromJsonSchema( @@ -278,9 +310,33 @@ const registerReadPlaneTools = (server: McpServer): void => { ); }; +const registerStartConversationTool = ( + server: McpServer, + observed: HarnessStartConversationInput[], +): void => { + server.registerTool( + HARNESS_START_CONVERSATION_TOOL, + { + inputSchema: startConversationInputSchema, + outputSchema: startConversationResultSchema, + }, + (input) => { + observed.push(input); + const result = { conversation: STARTED_CONVERSATION }; + return Effect.runPromise( + Effect.succeed({ + content: [{ type: "text" as const, text: JSON.stringify(result) }], + structuredContent: result, + }), + ); + }, + ); +}; + const makeHarnessHandler = ( observed: ObservedReply[], advertiseExtension = true, + observedStarts: HarnessStartConversationInput[] = [], ): HarnessMcpSubscriptionHandler => { const delegate = createMcpHandler( () => { @@ -290,6 +346,7 @@ const makeHarnessHandler = ( : {}, }); registerReadPlaneTools(server); + registerStartConversationTool(server, observedStarts); server.registerTool( HARNESS_REPLY_TOOL, { @@ -512,6 +569,44 @@ const rejectsUnexpectedTurnFields = async () => { } }; +const startsConversationWithCanonicalProjection = async () => { + const observedStarts: HarnessStartConversationInput[] = []; + const running = await startHarnessServer( + makeHarnessHandler([], true, observedStarts), + ); + try { + const started = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const harness = yield* acquireHarnessClient({ + url: running.url.href, + }); + return yield* harness.startConversation( + STARTED_WITH, + INITIAL_CONTENT, + ); + }), + ).pipe(Effect.provide(KeyValueStore.layerMemory)), + ); + expect(observedStarts).toEqual([ + { + otherAgentNames: STARTED_WITH, + initialContent: INITIAL_CONTENT, + }, + ]); + expect(started).toEqual({ + id: STARTED_CONVERSATION.id, + name: STARTED_CONVERSATION.name, + createdBy: STARTED_CONVERSATION.createdBy, + createdAt: STARTED_CONVERSATION.createdAt, + updatedAt: STARTED_CONVERSATION.updatedAt, + }); + expect(started).not.toHaveProperty("participants"); + } finally { + await Effect.runPromise(Scope.close(running.scope, Exit.void)); + } +}; + interface ReplyCallObservation { count: number; signal?: AbortSignal; @@ -580,8 +675,10 @@ const abortsReplyCallWhenInterrupted = async () => { } }; -// @agent-code-guard/regression-only: the scoped loopback boundary pins every reply closure to its originating turn without suppression. +// @agent-code-guard/regression-only: the scoped loopback boundary pins the canonical start projection and every reply closure to its originating turn without suppression. describe("HarnessClient", () => { + it("starts a conversation and projects its MCP-local result to the canonical shape", () => + startsConversationWithCanonicalProjection()); it("sends every reply through the originating conversation after later turns", () => preservesBoundConversation()); it("rejects a server without the harness events extension", () => diff --git a/packages/client/src/harness-client.ts b/packages/client/src/harness-client.ts index 737f55857..992fd1931 100644 --- a/packages/client/src/harness-client.ts +++ b/packages/client/src/harness-client.ts @@ -1,7 +1,14 @@ import * as KeyValueStore from "@effect/platform/KeyValueStore"; import { Context, Effect, Layer, Schema, Stream, type Scope } from "effect"; -import type { conversationSearch } from "@moltzap/protocol/conversation"; -import { agentsSearch, type AgentId } from "@moltzap/protocol/identity"; +import type { + Conversation, + conversationSearch, +} from "@moltzap/protocol/conversation"; +import { + agentsSearch, + type AgentId, + type AgentName, +} from "@moltzap/protocol/identity"; import { messagesRead } from "@moltzap/protocol/message"; import type { ParamsOf, @@ -18,8 +25,11 @@ import { HARNESS_READ_CONVERSATION_TOOL, HARNESS_SEARCH_AGENTS_TOOL, HARNESS_SEARCH_CONVERSATIONS_TOOL, + HARNESS_START_CONVERSATION_TOOL, HARNESS_STATUS_TOOL, decodeHarnessSearchConversationsResult, + decodeHarnessStartConversationResult, + type ConversationWithParticipants, type HarnessClientInternalService, type HarnessTurnInternal, } from "./harness/index.js"; @@ -35,6 +45,11 @@ export interface HarnessTurn extends EnrichedInboundMessage { export interface HarnessClientService { /** Active identity used by adapters when rendering self-authored context. */ readonly agentId: AgentId; + /** Creates a conversation with named peers and sends its initial content. */ + readonly startConversation: ( + otherAgentNames: readonly AgentName[], + initialContent: string, + ) => Effect.Effect; /** The sole receive stream owned by this scoped client. */ readonly turns: Stream.Stream; } @@ -106,6 +121,32 @@ const readActiveAgentId = ( }), ); +const projectConversation = ( + conversation: ConversationWithParticipants, +): Conversation => ({ + id: conversation.id, + ...(conversation.name === undefined ? {} : { name: conversation.name }), + createdBy: conversation.createdBy, + createdAt: conversation.createdAt, + updatedAt: conversation.updatedAt, +}); + +const startConversation = ( + session: HarnessClientInternalService, + otherAgentNames: readonly AgentName[], + initialContent: string, +): Effect.Effect => + session + .callTool(HARNESS_START_CONVERSATION_TOOL, { + otherAgentNames, + initialContent, + }) + .pipe( + Effect.flatMap(decodeHarnessStartConversationResult), + Effect.map(({ conversation }) => projectConversation(conversation)), + Effect.mapError(asError), + ); + const searchConversations = ( session: HarnessClientInternalService, params: ParamsOf, @@ -180,6 +221,10 @@ export const acquireHarnessClient = ( const agentId = yield* readActiveAgentId(session); return { agentId, + startConversation: ( + otherAgentNames: readonly AgentName[], + initialContent: string, + ) => startConversation(session, otherAgentNames, initialContent), turns: session.turns.pipe( Stream.mapEffect((turn) => projectTurn(session, checkpointStore, agentId, turn), diff --git a/packages/client/src/harness-mcp-server.test.ts b/packages/client/src/harness-mcp-server.test.ts index ebfcbb118..780d0aebb 100644 --- a/packages/client/src/harness-mcp-server.test.ts +++ b/packages/client/src/harness-mcp-server.test.ts @@ -32,7 +32,7 @@ import { } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; import { conversationCheckpoint } from "@moltzap/protocol/message"; -import { agentId, conversationId } from "@moltzap/protocol/testing"; +import { agentId, agentName, conversationId } from "@moltzap/protocol/testing"; import { makeHarnessMcpHttpHandlers } from "./harness-mcp-wire.js"; import { HARNESS_EVENTS_EXTENSION } from "./harness/index.js"; import { localDaemonCommands } from "./local-daemon-rpc.js"; @@ -61,6 +61,17 @@ const SERVER_IMPLEMENTATION = { const READ_CHECKPOINT = Schema.decodeSync(conversationCheckpoint)( "harness-read-checkpoint", ); +const START_OTHER_AGENT_NAME = agentName("peer-agent"); +const START_CONVERSATION = { + id: conversationId("550e8400-e29b-41d4-a716-446655440043"), + createdBy: agentId("550e8400-e29b-41d4-a716-446655440044"), + participants: [ + agentId("550e8400-e29b-41d4-a716-446655440044"), + agentId("550e8400-e29b-41d4-a716-446655440045"), + ], + createdAt: "2026-08-04T12:00:00.000Z", + updatedAt: "2026-08-04T12:00:00.000Z", +}; const openServerScopes = new Set(); const openHandlers = new Set(); @@ -76,6 +87,10 @@ const makeReadPlaneHandlers = () => ({ }); type ReadPlaneHandlers = ReturnType; +const makeStartConversationHandler = () => + vi.fn(() => Effect.succeed({ conversation: START_CONVERSATION })); +type StartConversationHandler = ReturnType; + const makeHandler = (name: string, onCreate?: () => void): McpHttpHandler => { const handler = createMcpHandler(() => { onCreate?.(); @@ -376,6 +391,7 @@ const makeSubscriptionHarnessHandlers = () => { implementation: SERVER_IMPLEMENTATION, ...makeReadPlaneHandlers(), reply: () => Effect.void, + startConversation: makeStartConversationHandler(), status: localHandlers[localDaemonCommands.status], }); }; @@ -534,6 +550,27 @@ const closesDespiteBackpressuredReader = async () => { expect(running.server.listening).toBe(false); }; +const expectStartConversationInputSchema = (inputSchema: unknown) => { + expect(inputSchema).toMatchObject({ + additionalProperties: false, + properties: { + otherAgentNames: { + type: "array", + minItems: 1, + items: { + type: "string", + minLength: 3, + maxLength: 32, + pattern: "^[a-z0-9][a-z0-9_-]{1,30}[a-z0-9]$", + }, + }, + initialContent: { type: "string", minLength: 1 }, + }, + required: ["otherAgentNames", "initialContent"], + type: "object", + }); +}; + const expectActiveToolCatalog = async (harnessClient: Client) => { expect(harnessClient.getDiscoverResult()?.capabilities.extensions).toEqual({ [HARNESS_EVENTS_EXTENSION]: {}, @@ -543,6 +580,7 @@ const expectActiveToolCatalog = async (harnessClient: Client) => { "status", "search_agents", "search_conversations", + "start_conversation", "read_conversation", "reply", ]); @@ -564,6 +602,9 @@ const expectActiveToolCatalog = async (harnessClient: Client) => { tools.find(({ name }) => name === "search_conversations")?.inputSchema .properties, ).not.toHaveProperty("count"); + expectStartConversationInputSchema( + tools.find(({ name }) => name === "start_conversation")?.inputSchema, + ); }; const expectStatusTool = async (harnessClient: Client, ownAgentId: string) => { @@ -614,6 +655,31 @@ const expectReadPlaneTools = async ( }); }; +const expectStartConversationTool = async ( + harnessClient: Client, + startConversation: StartConversationHandler, +) => { + const input = { + otherAgentNames: [START_OTHER_AGENT_NAME], + initialContent: "Hello from the harness", + }; + const result = await harnessClient.callTool({ + name: "start_conversation", + arguments: input, + }); + + expect(startConversation).toHaveBeenCalledWith(input); + expect(result.structuredContent).toEqual({ + conversation: START_CONVERSATION, + }); + expect(result.content).toEqual([ + { + type: "text", + text: JSON.stringify({ conversation: START_CONVERSATION }), + }, + ]); +}; + const exposesActiveTools = async () => { const ownAgentId = agentId("550e8400-e29b-41d4-a716-446655440040"); const localHandlers = makeLocalDaemonHandlers({ @@ -628,10 +694,12 @@ const exposesActiveTools = async () => { }, }); const readPlane = makeReadPlaneHandlers(); + const startConversation = makeStartConversationHandler(); const handlers = makeHarnessMcpHttpHandlers({ implementation: SERVER_IMPLEMENTATION, ...readPlane, reply: () => Effect.void, + startConversation, status: localHandlers[localDaemonCommands.status], }); const baseUrl = await makeServerWithHandlers( @@ -649,6 +717,7 @@ const exposesActiveTools = async () => { await expectActiveToolCatalog(harnessClient); await expectStatusTool(harnessClient, ownAgentId); await expectReadPlaneTools(harnessClient, readPlane); + await expectStartConversationTool(harnessClient, startConversation); }; // @agent-code-guard/regression-only: this finite matrix pins the two HTTP routes and the official SDK's interoperability and guard behavior. diff --git a/packages/client/src/harness-mcp-wire.ts b/packages/client/src/harness-mcp-wire.ts index 854bf8455..8a16cc24b 100644 --- a/packages/client/src/harness-mcp-wire.ts +++ b/packages/client/src/harness-mcp-wire.ts @@ -27,13 +27,18 @@ import { HARNESS_REPLY_TOOL, HARNESS_SEARCH_AGENTS_TOOL, HARNESS_SEARCH_CONVERSATIONS_TOOL, + HARNESS_START_CONVERSATION_TOOL, HARNESS_STATUS_TOOL, harnessSearchConversationsResultJsonSchema, harnessReplyInputJsonSchema, harnessReplyResultJsonSchema, + harnessStartConversationInputJsonSchema, + harnessStartConversationResultJsonSchema, type HarnessReplyInput, type HarnessReplyResult, type HarnessSearchConversationsResult, + type HarnessStartConversationInput, + type HarnessStartConversationResult, type HarnessTurnEvent, } from "./harness/index.js"; import { @@ -59,6 +64,9 @@ type DescriptorHandler = ( type SearchConversationsHandler = ( payload: ParamsOf, ) => Effect.Effect; +type StartConversationHandler = ( + payload: HarnessStartConversationInput, +) => Effect.Effect; interface HarnessMcpHandlerOptions { readonly implementation: Implementation; @@ -66,6 +74,7 @@ interface HarnessMcpHandlerOptions { readonly reply: ReplyHandler; readonly searchAgents: DescriptorHandler; readonly searchConversations: SearchConversationsHandler; + readonly startConversation: StartConversationHandler; readonly status: StatusHandler; } @@ -100,6 +109,14 @@ const searchConversationsOutputSchema = fromJsonSchema( /* Safe because Effect and MCP expose the same JSON Schema wire shape with different array mutability declarations. */ harnessSearchConversationsResultJsonSchema as JsonSchemaType, ); +const startConversationInputSchema = + fromJsonSchema( + /* Safe because Effect and MCP expose the same JSON Schema wire shape with different array mutability declarations. */ harnessStartConversationInputJsonSchema as JsonSchemaType, + ); +const startConversationOutputSchema = + fromJsonSchema( + /* Safe because Effect and MCP expose the same JSON Schema wire shape with different array mutability declarations. */ harnessStartConversationResultJsonSchema as JsonSchemaType, + ); const registerDescriptorTool = ( server: McpServer, @@ -164,6 +181,29 @@ const registerSearchConversationsTool = ( ); }; +const registerStartConversationTool = ( + server: McpServer, + handler: StartConversationHandler, +): void => { + server.registerTool( + HARNESS_START_CONVERSATION_TOOL, + { + inputSchema: startConversationInputSchema, + outputSchema: startConversationOutputSchema, + }, + (payload, context) => + Effect.runPromise( + handler(payload).pipe( + Effect.map((result) => ({ + content: [{ type: "text" as const, text: JSON.stringify(result) }], + structuredContent: result, + })), + ), + { signal: context.mcpReq.signal }, + ), + ); +}; + const makeRegistrationServer = (implementation: Implementation): McpServer => new McpServer(implementation); @@ -224,6 +264,7 @@ const makeActiveServer = ({ reply, searchAgents, searchConversations, + startConversation, status, }: HarnessMcpHandlerOptions): McpServer => { const server = new McpServer(implementation, { @@ -239,6 +280,7 @@ const makeActiveServer = ({ searchAgents, ); registerSearchConversationsTool(server, searchConversations); + registerStartConversationTool(server, startConversation); registerDescriptorTool( server, HARNESS_READ_CONVERSATION_TOOL, @@ -258,6 +300,7 @@ const makeActiveServer = ({ * @param options.reply Conversation-bound raw reply handler. * @param options.searchAgents Agent directory search handler. * @param options.searchConversations Conversation directory search handler. + * @param options.startConversation Conversation creation and initial-content handler. * @param options.status Existing local daemon status handler. * @returns The registration and active-agent HTTP handlers. */ @@ -267,6 +310,7 @@ export const makeHarnessMcpHttpHandlers = ({ reply, searchAgents, searchConversations, + startConversation, status, }: HarnessMcpHandlerOptions): { readonly registration: McpHttpHandler; @@ -280,6 +324,7 @@ export const makeHarnessMcpHttpHandlers = ({ reply, searchAgents, searchConversations, + startConversation, status, }), { legacy: "reject" }, diff --git a/packages/client/src/harness/index.ts b/packages/client/src/harness/index.ts index d761f66f7..9d8373b63 100644 --- a/packages/client/src/harness/index.ts +++ b/packages/client/src/harness/index.ts @@ -8,11 +8,13 @@ export { export { decodeHarnessReplyRoute, decodeHarnessSearchConversationsResult, + decodeHarnessStartConversationResult, HARNESS_EVENTS_EXTENSION, HARNESS_READ_CONVERSATION_TOOL, HARNESS_REPLY_TOOL, HARNESS_SEARCH_AGENTS_TOOL, HARNESS_SEARCH_CONVERSATIONS_TOOL, + HARNESS_START_CONVERSATION_TOOL, HARNESS_STATUS_TOOL, HARNESS_TURN_READY_FILTER, HARNESS_TURN_READY_NOTIFICATION, @@ -24,5 +26,9 @@ export { type HarnessReplyResult, type HarnessReplyRoute, type HarnessSearchConversationsResult, + harnessStartConversationInputJsonSchema, + harnessStartConversationResultJsonSchema, + type HarnessStartConversationInput, + type HarnessStartConversationResult, type HarnessTurnEvent, } from "./runtime.js"; diff --git a/packages/client/src/harness/runtime.test.ts b/packages/client/src/harness/runtime.test.ts index 2d9755658..cecc2a466 100644 --- a/packages/client/src/harness/runtime.test.ts +++ b/packages/client/src/harness/runtime.test.ts @@ -18,9 +18,11 @@ import { acquireHarnessMcpHttpServer } from "../harness-mcp-server.js"; import { decodeHarnessReplyRoute, decodeHarnessSearchConversationsResult, + decodeHarnessStartConversationResult, decodeHarnessTurnEvent, HARNESS_EVENTS_EXTENSION, HARNESS_REPLY_TOOL, + harnessStartConversationInputJsonSchema, harnessReplyInputJsonSchema, harnessReplyRequestMeta, harnessReplyResultJsonSchema, @@ -118,6 +120,47 @@ const keepsConversationMembershipOnMcpOnly = () => { expect(conversationSearch.validateResult(page)).toBe(false); }; +const keepsStartConversationContractClosed = async () => { + const result = { conversation: conversationWithParticipants }; + await expect( + Effect.runPromise(decodeHarnessStartConversationResult(result)), + ).resolves.toEqual(result); + await expect( + Effect.runPromise( + decodeHarnessStartConversationResult({ + conversation: { + ...conversationWithParticipants, + participants: undefined, + }, + }), + ), + ).rejects.toBeDefined(); + await expect( + Effect.runPromise( + decodeHarnessStartConversationResult({ ...result, invented: true }), + ), + ).rejects.toBeDefined(); + + expect(harnessStartConversationInputJsonSchema).toMatchObject({ + type: "object", + properties: { + otherAgentNames: { + type: "array", + minItems: 1, + items: { + type: "string", + minLength: 3, + maxLength: 32, + pattern: "^[a-z0-9][a-z0-9_-]{1,30}[a-z0-9]$", + }, + }, + initialContent: { type: "string", minLength: 1 }, + }, + required: ["otherAgentNames", "initialContent"], + additionalProperties: false, + }); +}; + interface ObservedReply { arguments?: unknown; route?: HarnessReplyRoute; @@ -227,6 +270,8 @@ describe("Harness MCP runtime contract", () => { it("adds conversation membership only on the MCP projection", () => { keepsConversationMembershipOnMcpOnly(); }); + it("keeps start input canonical and its enriched result closed", () => + keepsStartConversationContractClosed()); it("preserves the private route through an official MCP client call", () => preservesPrivateRoute()); }); diff --git a/packages/client/src/harness/runtime.ts b/packages/client/src/harness/runtime.ts index c9423586a..897b7b49f 100644 --- a/packages/client/src/harness/runtime.ts +++ b/packages/client/src/harness/runtime.ts @@ -5,7 +5,7 @@ import { conversationSearch, type ConversationId, } from "@moltzap/protocol/conversation"; -import { agentId } from "@moltzap/protocol/identity"; +import { agentId, agentName } from "@moltzap/protocol/identity"; import { messageReceivedNotificationDefinition } from "@moltzap/protocol/message"; /** Harness MCP extension carrying the runtime event contract. */ @@ -33,6 +33,9 @@ export const HARNESS_SEARCH_CONVERSATIONS_TOOL = "search_conversations"; /** Tool reading one checkpointed conversation history. */ export const HARNESS_READ_CONVERSATION_TOOL = "read_conversation"; +/** Tool creating a conversation and sending its initial content. */ +export const HARNESS_START_CONVERSATION_TOOL = "start_conversation"; + const messageSchema = messageReceivedNotificationDefinition.paramsSchema.fields.message; @@ -41,6 +44,17 @@ const conversationWithParticipantsSchema = Schema.Struct({ participants: Schema.Array(agentId), }); +/** Arguments for creating a conversation through the harness. */ +const harnessStartConversationInputSchema = Schema.Struct({ + otherAgentNames: Schema.NonEmptyArray(agentName), + initialContent: Schema.String.pipe(Schema.minLength(1)), +}); + +/** Conversation returned after its initial content has been sent. */ +const harnessStartConversationResultSchema = Schema.Struct({ + conversation: conversationWithParticipantsSchema, +}); + /** MCP-local search result used to reconstruct endpoint presentation. */ const harnessSearchConversationsResultSchema = Schema.Struct({ ...conversationSearch.resultSchema.fields, @@ -82,6 +96,16 @@ export type ConversationWithParticipants = Schema.Schema.Type< typeof conversationWithParticipantsSchema >; +/** Decoded start-conversation input. */ +export type HarnessStartConversationInput = Schema.Schema.Type< + typeof harnessStartConversationInputSchema +>; + +/** Decoded start-conversation result. */ +export type HarnessStartConversationResult = Schema.Schema.Type< + typeof harnessStartConversationResultSchema +>; + /** Decoded MCP-local conversation search page. */ export type HarnessSearchConversationsResult = Schema.Schema.Type< typeof harnessSearchConversationsResultSchema @@ -107,8 +131,23 @@ const decodeTurnEvent = Schema.decodeUnknown(harnessTurnEventSchema); const decodeSearchConversationsResult = Schema.decodeUnknown( harnessSearchConversationsResultSchema, ); +const decodeStartConversationResult = Schema.decodeUnknown( + harnessStartConversationResultSchema, +); const decodeReplyRoute = Schema.decodeUnknown(harnessReplyRouteSchema); +/** JSON Schema advertised for start-conversation arguments. */ +export const harnessStartConversationInputJsonSchema = JSONSchema.make( + harnessStartConversationInputSchema, + { target: "jsonSchema2020-12" }, +); + +/** JSON Schema advertised for the start-conversation result. */ +export const harnessStartConversationResultJsonSchema = JSONSchema.make( + harnessStartConversationResultSchema, + { target: "jsonSchema2020-12" }, +); + /** JSON Schema advertised for the MCP-local conversation search result. */ export const harnessSearchConversationsResultJsonSchema = JSONSchema.make( harnessSearchConversationsResultSchema, @@ -143,6 +182,14 @@ export const decodeHarnessTurnEvent = (value: unknown) => export const decodeHarnessSearchConversationsResult = (value: unknown) => decodeSearchConversationsResult(value, strictDecodeOptions); +/** + * Strictly decode a conversation created through the harness MCP boundary. + * @param value Untrusted structured tool content. + * @returns The created conversation with MCP-local membership. + */ +export const decodeHarnessStartConversationResult = (value: unknown) => + decodeStartConversationResult(value, strictDecodeOptions); + /** * Build the private request metadata consumed by the production harness client. * @param originatingConversationId Conversation associated with the live turn. diff --git a/packages/client/src/moltzapd.ts b/packages/client/src/moltzapd.ts index 4e13cf854..d96e23f79 100644 --- a/packages/client/src/moltzapd.ts +++ b/packages/client/src/moltzapd.ts @@ -1,16 +1,23 @@ import type { Implementation } from "@modelcontextprotocol/server"; import { Effect, ExecutionStrategy, Exit, Scope } from "effect"; import { + agentConversationCreate, conversationList, conversationSearch, } from "@moltzap/protocol/conversation"; -import { agentsSearch } from "@moltzap/protocol/identity"; -import { messagesRead } from "@moltzap/protocol/message"; +import { + AgentNotFoundError, + agentsSearch, + type AgentName, +} from "@moltzap/protocol/identity"; +import { messagesRead, messagesSend } from "@moltzap/protocol/message"; import type { ParamsOf } from "@moltzap/protocol/rpc"; import packageJson from "../package.json" with { type: "json" }; import { MoltZapChannelCore } from "./channel-core.js"; import type { HarnessSearchConversationsResult, + HarnessStartConversationInput, + HarnessStartConversationResult, HarnessTurnEvent, } from "./harness/index.js"; import { acquireHarnessMcpHttpServer } from "./harness-mcp-server.js"; @@ -100,6 +107,72 @@ const searchConversationsForHarness = ( }; }).pipe(Effect.withSpan("moltzapd.searchConversations")); +const agentNotFound = (agentName: AgentName): AgentNotFoundError => + new AgentNotFoundError({ + message: `Agent not found: ${agentName}`, + data: { agentName }, + }); + +const resolveAgentByName = (service: MoltZapService, name: AgentName) => + service.callDefinition(agentsSearch, { query: name }).pipe( + Effect.flatMap(({ agents }) => { + const agent = agents.find((candidate) => candidate.name === name); + return agent === undefined + ? Effect.fail(agentNotFound(name)) + : Effect.succeed(agent); + }), + ); + +const startConversationForHarness = ( + service: MoltZapService, + input: HarnessStartConversationInput, +): Effect.Effect => + Effect.gen(function* () { + if (new Set(input.otherAgentNames).size !== input.otherAgentNames.length) { + // eslint-disable-next-line agent-code-guard/effect-error-erasure -- Local MCP validation stays on the established broad Error boundary without adding a portable protocol error. + return yield* Effect.fail( + new Error("Conversation participants must be unique"), + ); + } + + const participants = yield* Effect.forEach( + input.otherAgentNames, + (name) => resolveAgentByName(service, name), + { concurrency: 2 }, + ); + const ownAgentId = service.ownAgentId; + if (ownAgentId === undefined) { + // eslint-disable-next-line agent-code-guard/effect-error-erasure -- A missing daemon identity is rejected at the local composition boundary whose existing contract is Error. + return yield* Effect.fail(new Error("Daemon has no agent identity")); + } + if (participants.some((participant) => participant.id === ownAgentId)) { + // eslint-disable-next-line agent-code-guard/effect-error-erasure -- Local MCP validation stays on the established broad Error boundary without adding a portable protocol error. + return yield* Effect.fail( + new Error("The daemon agent is an implicit conversation participant"), + ); + } + + const created = yield* service.callDefinition(agentConversationCreate, { + participants: participants.map((participant) => participant.id), + }); + yield* service.callDefinition(messagesSend, { + conversationId: created.conversation.id, + parts: [{ type: "text", text: input.initialContent }], + }); + + // Participants are endpoint-owned context on the MCP boundary. The + // canonical Conversation value sent over the network remains closed. + return { + conversation: { + ...created.conversation, + participants: [ + ownAgentId, + ...participants.map((participant) => participant.id), + ], + }, + }; + }).pipe(Effect.withSpan("moltzapd.startConversation")); + /** * Owns one registered agent's service, channel core, network connection, and * guarded loopback MCP listener for the lifetime of the caller's scope. @@ -151,6 +224,8 @@ export const acquireMoltzapd = ( service.callDefinition(agentsSearch, payload), searchConversations: (payload) => searchConversationsForHarness(service, payload), + startConversation: (payload) => + startConversationForHarness(service, payload), status: makeStatusHandler(service, core), }); installTurnPublisher(core, handlers.active.publish);