Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/modules/client/src.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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#L201)
### [`acquireHarnessClient`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L196)

_Function_

Expand Down Expand Up @@ -76,7 +76,7 @@ export interface ConversationMeta {

Describes conversation meta.

### [`ConversationWithParticipants`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness/runtime.ts#L95)
### [`ConversationWithParticipants`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness/runtime.ts#L105)

_TypeAlias_

Expand Down Expand Up @@ -147,7 +147,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#L232)
### [`makeHarnessClientLayer`](https://github.com/chughtapan/moltzap/blob/main/packages/client/src/harness-client.ts#L227)

_Function_

Expand Down
6 changes: 3 additions & 3 deletions packages/client/src/MODULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Public barrel for the MoltZap client package.

## Public surface

### [`acquireHarnessClient`](./harness-client.ts#L201)
### [`acquireHarnessClient`](./harness-client.ts#L196)

_Function_

Expand Down Expand Up @@ -71,7 +71,7 @@ export interface ConversationMeta {

Describes conversation meta.

### [`ConversationWithParticipants`](./harness/runtime.ts#L95)
### [`ConversationWithParticipants`](./harness/runtime.ts#L105)

_TypeAlias_

Expand Down Expand Up @@ -142,7 +142,7 @@ export interface HarnessTurn extends EnrichedInboundMessage {

Existing adapter presentation with reply authority bound to its live turn.

### [`makeHarnessClientLayer`](./harness-client.ts#L232)
### [`makeHarnessClientLayer`](./harness-client.ts#L227)

_Function_

Expand Down
12 changes: 9 additions & 3 deletions packages/client/src/harness-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,13 @@ import {
type HarnessStartConversationInput,
type HarnessStartConversationResult,
type HarnessTurnEvent,
harnessStatusInputJsonSchema,
harnessStatusResultJsonSchema,
} from "./harness/index.js";
import {
makeHarnessMcpSubscriptionHandler,
type HarnessMcpSubscriptionHandler,
} from "./harness-mcp-subscription.js";
import { statusCommandRpc } from "./local-daemon-rpc.js";

const SERVER_IMPLEMENTATION = {
name: "harness-client-test",
Expand Down Expand Up @@ -208,6 +209,11 @@ const startConversationResultSchema =
/* Safe because Effect and MCP expose the same JSON Schema wire shape with different array mutability declarations. */ harnessStartConversationResultJsonSchema as JsonSchemaType,
);

const jsonSchemaToMcpSchema = <A>(schema: unknown) =>
fromJsonSchema<A>(
/* Safe because Effect and MCP expose the same JSON Schema wire shape with different array mutability declarations. */ schema as JsonSchemaType,
);

const effectSchemaToMcpSchema = <A>(schema: Schema.Schema.AnyNoContext) =>
fromJsonSchema<A>(
/* Safe because Effect and MCP expose the same JSON Schema wire shape with different array mutability declarations. */ JSONSchema.make(
Expand Down Expand Up @@ -257,8 +263,8 @@ const registerStatusTool = (server: McpServer): void => {
server.registerTool(
HARNESS_STATUS_TOOL,
{
inputSchema: effectSchemaToMcpSchema(statusCommandRpc.payloadSchema),
outputSchema: effectSchemaToMcpSchema(statusCommandRpc.successSchema),
inputSchema: jsonSchemaToMcpSchema(harnessStatusInputJsonSchema),
outputSchema: jsonSchemaToMcpSchema(harnessStatusResultJsonSchema),
},
() =>
Effect.runPromise(
Expand Down
9 changes: 2 additions & 7 deletions packages/client/src/harness-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ import {
HARNESS_STATUS_TOOL,
decodeHarnessSearchConversationsResult,
decodeHarnessStartConversationResult,
decodeHarnessStatusResult,
type ConversationWithParticipants,
type HarnessClientInternalService,
type HarnessTurnInternal,
} from "./harness/index.js";
import { statusCommandRpc } from "./local-daemon-rpc.js";

/** MCP-local conversation projection including participant identities. */
export type { ConversationWithParticipants } from "./harness/index.js";
Expand Down Expand Up @@ -103,12 +103,7 @@ const readActiveAgentId = (
session: HarnessClientInternalService,
): Effect.Effect<AgentId, Error> =>
session.callTool(HARNESS_STATUS_TOOL, {}).pipe(
Effect.flatMap((result) =>
Schema.decodeUnknown(statusCommandRpc.successSchema)(
result,
strictDecodeOptions,
),
),
Effect.flatMap((result) => decodeHarnessStatusResult(result)),
Effect.mapError(asError),
Effect.flatMap((status) => {
if (status.agentId === undefined) {
Expand Down
32 changes: 6 additions & 26 deletions packages/client/src/harness-mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,10 @@ import {
} from "effect";
import { afterEach, describe, expect, it, vi } from "vitest";
import { conversationCheckpoint } from "@moltzap/protocol/message";
import type { AgentId } from "@moltzap/protocol/identity";
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";
import { makeLocalDaemonHandlers } from "./service-local-daemon.js";
import { acquireHarnessMcpHttpServer } from "./harness-mcp-server.js";
import { makeHarnessMcpSubscriptionHandler } from "./harness-mcp-subscription.js";

Expand Down Expand Up @@ -374,25 +373,17 @@ const closesActiveSubscriptionWhenScopeReleases = async () => {
expect(running.server.listening).toBe(false);
};

const makeStatusHandler = (ownAgentId: AgentId, conversations: number) => () =>
Effect.succeed({ agentId: ownAgentId, connected: true, conversations });

const makeSubscriptionHarnessHandlers = () => {
const ownAgentId = agentId("550e8400-e29b-41d4-a716-446655440041");
const localHandlers = makeLocalDaemonHandlers({
ownAgentId,
connected: () => true,
conversationCount: () => 0,
call: () => {
throw new Error("subscription must not call an agent RPC");
},
handleHistoryRequest: () => {
throw new Error("subscription must not read local history");
},
});
return makeHarnessMcpHttpHandlers({
implementation: SERVER_IMPLEMENTATION,
...makeReadPlaneHandlers(),
reply: () => Effect.void,
startConversation: makeStartConversationHandler(),
status: localHandlers[localDaemonCommands.status],
status: makeStatusHandler(ownAgentId, 0),
});
};

Expand Down Expand Up @@ -682,25 +673,14 @@ const expectStartConversationTool = async (

const exposesActiveTools = async () => {
const ownAgentId = agentId("550e8400-e29b-41d4-a716-446655440040");
const localHandlers = makeLocalDaemonHandlers({
ownAgentId,
connected: () => true,
conversationCount: () => 3,
call: () => {
throw new Error("status must not call an agent RPC");
},
handleHistoryRequest: () => {
throw new Error("status must not read local history");
},
});
const readPlane = makeReadPlaneHandlers();
const startConversation = makeStartConversationHandler();
const handlers = makeHarnessMcpHttpHandlers({
implementation: SERVER_IMPLEMENTATION,
...readPlane,
reply: () => Effect.void,
startConversation,
status: localHandlers[localDaemonCommands.status],
status: makeStatusHandler(ownAgentId, 3),
});
const baseUrl = await makeServerWithHandlers(
handlers.registration,
Expand Down
46 changes: 13 additions & 33 deletions packages/client/src/harness-mcp-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import {
type JsonSchemaType,
type McpHttpHandler,
} from "@modelcontextprotocol/server";
import { Headers } from "@effect/platform";
import { Rpc } from "@effect/rpc";
import { Effect, JSONSchema, type Schema } from "effect";
import {
conversationSearch,
Expand All @@ -34,26 +32,25 @@ import {
harnessReplyResultJsonSchema,
harnessStartConversationInputJsonSchema,
harnessStartConversationResultJsonSchema,
harnessStatusInputJsonSchema,
harnessStatusResultJsonSchema,
type HarnessReplyInput,
type HarnessReplyResult,
type HarnessSearchConversationsResult,
type HarnessStartConversationInput,
type HarnessStartConversationResult,
type HarnessStatusInput,
type HarnessStatusResult,
type HarnessTurnEvent,
} from "./harness/index.js";
import {
makeHarnessMcpSubscriptionHandler,
type HarnessMcpSubscriptionHandler,
} from "./harness-mcp-subscription.js";
import {
statusCommandRpc,
type localDaemonCommands,
type LocalDaemonHandlers,
} from "./local-daemon-rpc.js";

type StatusPayload = Schema.Schema.Type<typeof statusCommandRpc.payloadSchema>;
type StatusResult = Schema.Schema.Type<typeof statusCommandRpc.successSchema>;
type StatusHandler = LocalDaemonHandlers[typeof localDaemonCommands.status];
type StatusPayload = HarnessStatusInput;
type StatusResult = HarnessStatusResult;
type StatusHandler = (payload: StatusPayload) => Effect.Effect<StatusResult>;
type ReplyHandler = (
conversationId: ConversationId,
payload: string,
Expand Down Expand Up @@ -87,16 +84,10 @@ const effectSchemaToMcpSchema = <A>(schema: Schema.Schema.AnyNoContext) =>
);

const statusInputSchema = fromJsonSchema<StatusPayload>(
/* Safe because Effect and MCP expose the same JSON Schema wire shape with different array mutability declarations. */ JSONSchema.make(
statusCommandRpc.payloadSchema,
{ target: "jsonSchema2020-12" },
) as JsonSchemaType,
/* Safe because Effect and MCP expose the same JSON Schema wire shape with different array mutability declarations. */ harnessStatusInputJsonSchema as JsonSchemaType,
);
const statusOutputSchema = fromJsonSchema<StatusResult>(
/* Safe because Effect and MCP expose the same JSON Schema wire shape with different array mutability declarations. */ JSONSchema.make(
statusCommandRpc.successSchema,
{ target: "jsonSchema2020-12" },
) as JsonSchemaType,
/* Safe because Effect and MCP expose the same JSON Schema wire shape with different array mutability declarations. */ harnessStatusResultJsonSchema as JsonSchemaType,
);

const replyInputSchema = fromJsonSchema<HarnessReplyInput>(
Expand Down Expand Up @@ -214,24 +205,13 @@ const registerStatusTool = (server: McpServer, status: StatusHandler): void => {
inputSchema: statusInputSchema,
outputSchema: statusOutputSchema,
},
(payload) => {
const response = status(payload, {
clientId: 0,
headers: Headers.empty,
});
const effect = Rpc.isWrapper(response) ? response.value : response;
const runnableEffect =
/* Safe because the local daemon handler closes over all services while HandlersFrom widens that known-empty environment to `any`. */ effect as Effect.Effect<
StatusResult,
unknown
>;
return Effect.runPromise(
Effect.map(runnableEffect, (result) => ({
(payload) =>
Effect.runPromise(
Effect.map(status(payload), (result) => ({
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
})),
);
},
),
);
};

Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/harness/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
decodeHarnessReplyRoute,
decodeHarnessSearchConversationsResult,
decodeHarnessStartConversationResult,
decodeHarnessStatusResult,
HARNESS_EVENTS_EXTENSION,
HARNESS_READ_CONVERSATION_TOOL,
HARNESS_REPLY_TOOL,
Expand All @@ -30,5 +31,9 @@ export {
harnessStartConversationResultJsonSchema,
type HarnessStartConversationInput,
type HarnessStartConversationResult,
harnessStatusInputJsonSchema,
harnessStatusResultJsonSchema,
type HarnessStatusInput,
type HarnessStatusResult,
type HarnessTurnEvent,
} from "./runtime.js";
41 changes: 41 additions & 0 deletions packages/client/src/harness/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ const harnessReplyRouteSchema = Schema.Struct({
conversationId,
});

/** Status takes no arguments; the daemon reports on the slot it already owns. */
const harnessStatusInputSchema = Schema.Struct({});

/** Active daemon identity and connection state. */
const harnessStatusResultSchema = Schema.Struct({
agentId: Schema.optional(agentId),
connected: Schema.Boolean,
conversations: Schema.Number.pipe(Schema.int(), Schema.nonNegative()),
});

/** Decoded harness turn event. */
export type HarnessTurnEvent = Schema.Schema.Type<
typeof harnessTurnEventSchema
Expand Down Expand Up @@ -126,6 +136,16 @@ export type HarnessReplyRoute = Schema.Schema.Type<
typeof harnessReplyRouteSchema
>;

/** Decoded status input. */
export type HarnessStatusInput = Schema.Schema.Type<
typeof harnessStatusInputSchema
>;

/** Decoded status result. */
export type HarnessStatusResult = Schema.Schema.Type<
typeof harnessStatusResultSchema
>;

const strictDecodeOptions = { onExcessProperty: "error" } as const;
const decodeTurnEvent = Schema.decodeUnknown(harnessTurnEventSchema);
const decodeSearchConversationsResult = Schema.decodeUnknown(
Expand All @@ -135,6 +155,7 @@ const decodeStartConversationResult = Schema.decodeUnknown(
harnessStartConversationResultSchema,
);
const decodeReplyRoute = Schema.decodeUnknown(harnessReplyRouteSchema);
const decodeStatusResult = Schema.decodeUnknown(harnessStatusResultSchema);

/** JSON Schema advertised for start-conversation arguments. */
export const harnessStartConversationInputJsonSchema = JSONSchema.make(
Expand Down Expand Up @@ -166,6 +187,18 @@ export const harnessReplyResultJsonSchema = JSONSchema.make(
{ target: "jsonSchema2020-12" },
);

/** JSON Schema advertised for the empty status arguments. */
export const harnessStatusInputJsonSchema = JSONSchema.make(
harnessStatusInputSchema,
{ target: "jsonSchema2020-12" },
);

/** JSON Schema advertised for the status result. */
export const harnessStatusResultJsonSchema = JSONSchema.make(
harnessStatusResultSchema,
{ target: "jsonSchema2020-12" },
);

/**
* Strictly decode a turn event received from the MCP boundary.
* @param value Untrusted notification parameters.
Expand All @@ -190,6 +223,14 @@ export const decodeHarnessSearchConversationsResult = (value: unknown) =>
export const decodeHarnessStartConversationResult = (value: unknown) =>
decodeStartConversationResult(value, strictDecodeOptions);

/**
* Strictly decode the daemon status reported over the MCP boundary.
* @param value Untrusted structured tool content.
* @returns The decoded identity and connection state.
*/
export const decodeHarnessStatusResult = (value: unknown) =>
decodeStatusResult(value, strictDecodeOptions);

/**
* Build the private request metadata consumed by the production harness client.
* @param originatingConversationId Conversation associated with the live turn.
Expand Down
8 changes: 6 additions & 2 deletions packages/client/src/local-daemon-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ const UUID_V4_RE =
const CONVERSATION_TARGET_PREFIX = "conv:";
const PARTICIPANT_PREFIX = "agent:";

// The harness plane owns the status contract. This socket surface states the
// same shape independently so the surviving plane never imports the dying one;
// a schema constructor cannot be shared across them. Drift is a compile error
// either way, because service-local-daemon.ts and moltzapd.ts each implement a
// handler against its own side.
const emptyPayload = Schema.Struct({});
const localDaemonStatusResultSchema = Schema.Struct({
agentId: Schema.optional(agentId),
Expand Down Expand Up @@ -236,8 +241,7 @@ export const toLocalDaemonError = (error: unknown): LocalDaemonError =>
? error
: new LocalDaemonInputError({ message: errorMessage(error) });

/** Provides the status command rpc runtime value. */
export const statusCommandRpc = Rpc.make(localDaemonCommands.status, {
const statusCommandRpc = Rpc.make(localDaemonCommands.status, {
payload: emptyPayload,
success: localDaemonStatusResultSchema,
error: localDaemonErrorSchema,
Expand Down
Loading
Loading