From b9131d39438169fc70eb8c76fa01414aa467feb0 Mon Sep 17 00:00:00 2001 From: Jackson Miller Date: Sun, 16 Aug 2026 22:31:29 -0400 Subject: [PATCH 01/12] feat: A2 - send deliver reply loop --- FORK.md | 7 + apps/server/src/j5/a2a/DeliveryTransport.ts | 176 ++++++ apps/server/src/j5/a2a/DeliveryWorker.test.ts | 528 ++++++++++++++++++ apps/server/src/j5/a2a/DeliveryWorker.ts | 423 ++++++++++++++ .../src/j5/a2a/EnvelopeFormatter.test.ts | 40 ++ apps/server/src/j5/a2a/EnvelopeFormatter.ts | 57 ++ apps/server/src/j5/a2a/LedgerService.test.ts | 30 +- apps/server/src/j5/a2a/LedgerService.ts | 303 ++++++++++ apps/server/src/j5/a2a/Migrations.test.ts | 51 +- apps/server/src/j5/a2a/Migrations.ts | 6 +- apps/server/src/j5/a2a/README.md | 5 + apps/server/src/j5/a2a/SendService.test.ts | 313 +++++++++++ apps/server/src/j5/a2a/SendService.ts | 462 +++++++++++++++ apps/server/src/j5/a2a/contracts.ts | 98 ++++ .../server/src/j5/a2a/delivery-config.v1.json | 6 + apps/server/src/j5/a2a/envelopes.v1.json | 10 + apps/server/src/j5/a2a/mcp/handlers.ts | 49 ++ apps/server/src/j5/a2a/mcp/registration.ts | 10 + apps/server/src/j5/a2a/mcp/tools.ts | 72 +++ .../j5/a2a/migrations/002_SendDeliverReply.ts | 83 +++ apps/server/src/j5/a2a/runtimeLayer.ts | 22 + apps/server/src/mcp/McpHttpServer.ts | 3 + .../toolkits/worktree/registration.test.ts | 17 + .../src/orchestration-v2/runtimeLayer.test.ts | 48 ++ apps/server/src/server.ts | 3 + 25 files changed, 2811 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/j5/a2a/DeliveryTransport.ts create mode 100644 apps/server/src/j5/a2a/DeliveryWorker.test.ts create mode 100644 apps/server/src/j5/a2a/DeliveryWorker.ts create mode 100644 apps/server/src/j5/a2a/EnvelopeFormatter.test.ts create mode 100644 apps/server/src/j5/a2a/EnvelopeFormatter.ts create mode 100644 apps/server/src/j5/a2a/README.md create mode 100644 apps/server/src/j5/a2a/SendService.test.ts create mode 100644 apps/server/src/j5/a2a/SendService.ts create mode 100644 apps/server/src/j5/a2a/delivery-config.v1.json create mode 100644 apps/server/src/j5/a2a/envelopes.v1.json create mode 100644 apps/server/src/j5/a2a/mcp/handlers.ts create mode 100644 apps/server/src/j5/a2a/mcp/registration.ts create mode 100644 apps/server/src/j5/a2a/mcp/tools.ts create mode 100644 apps/server/src/j5/a2a/migrations/002_SendDeliverReply.ts create mode 100644 apps/server/src/j5/a2a/runtimeLayer.ts diff --git a/FORK.md b/FORK.md index f6f0e4c6fdfa..639b4f6fc4ec 100644 --- a/FORK.md +++ b/FORK.md @@ -35,6 +35,13 @@ Treat these upstream areas as off-limits except for those explicit appended case - existing provider adapters and shared runtime modules - vendored references under `.repos` +### Sanctioned appended integration cases + +- `apps/server/src/persistence/Layers/Sqlite.ts` runs the independent J5 migration lane after upstream migrations. +- `apps/server/src/mcp/McpHttpServer.ts` registers exactly one shared J5 MCP toolkit. A2 owns the bootstrap and A6 reuses it by adding tools only inside `apps/server/src/j5/a2a/mcp/`, without another protected-file registration edit. +- `apps/server/src/server.ts` provides the J5 A2A runtime independently of the MCP transport so the ledger and startup delivery reconciliation are always active. +- Focused upstream integration tests may append a case that proves a J5 dependency contract at the pinned runtime without changing upstream production behavior. + If a required change cannot fit this discipline, stop and review the exception before implementing it. ### Fork-owned migration lane diff --git a/apps/server/src/j5/a2a/DeliveryTransport.ts b/apps/server/src/j5/a2a/DeliveryTransport.ts new file mode 100644 index 000000000000..deea698e1221 --- /dev/null +++ b/apps/server/src/j5/a2a/DeliveryTransport.ts @@ -0,0 +1,176 @@ +import { CommandId, MessageId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import { formatHumanEnvelope, formatPeerEnvelope } from "./EnvelopeFormatter.ts"; +import { + EpicId, + ExchangeId, + GLOBAL_HUMAN_PARTICIPANT_ID, + Participant, + ParticipantId, + type LedgerMessageId, +} from "./contracts.ts"; + +export class A2ADeliveryTargetError extends Schema.TaggedErrorClass()( + "A2ADeliveryTargetError", + { + participantId: Schema.String, + state: Schema.String, + }, +) { + override get message(): string { + return `Cannot deliver to ${this.participantId}: ${this.state}. Call list_participants before sending again.`; + } +} + +export class A2ADeliveryTransportError extends Schema.TaggedErrorClass()( + "A2ADeliveryTransportError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) {} + +export interface AgentDeliveryInput { + readonly originEpicId: EpicId; + readonly receiverEpicId: EpicId; + readonly messageId: LedgerMessageId; + readonly senderId: ParticipantId; + readonly receiverId: ParticipantId; + readonly exchangeId: ExchangeId | null; + readonly message: string; +} + +export interface HumanDeliveryInput extends AgentDeliveryInput { + readonly createdAt: string; +} + +export interface A2ADeliveryTransportShape { + readonly deliverAgent: ( + input: AgentDeliveryInput, + ) => Effect.Effect; + readonly deliverHuman: ( + input: HumanDeliveryInput, + ) => Effect.Effect; +} + +export class A2ADeliveryTransport extends Context.Service< + A2ADeliveryTransport, + A2ADeliveryTransportShape +>()("t3/j5/a2a/DeliveryTransport/A2ADeliveryTransport") {} + +const stablePart = (value: string) => encodeURIComponent(value); + +/** + * Every retry reuses this upstream command/message pair. A rejected upstream + * receipt therefore exhausts into the visible alarm instead of rotating an id + * that could double-inject after an ambiguous post-commit failure. + */ +export const deliveryCommandId = (messageId: LedgerMessageId) => + CommandId.make(`command:j5:a2a:delivery:${stablePart(messageId)}`); + +export const deliveryMessageId = (messageId: LedgerMessageId) => + MessageId.make(`message:j5:a2a:delivery:${stablePart(messageId)}`); + +interface MembershipRow { + readonly payload: string; +} + +const decodeParticipant = Schema.decodeUnknownEffect(Schema.fromJsonString(Participant)); + +export const live: Layer.Layer< + A2ADeliveryTransport, + never, + ThreadManagement.ThreadManagementService | SqlClient.SqlClient +> = Layer.effect( + A2ADeliveryTransport, + Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const sql = yield* SqlClient.SqlClient; + + return A2ADeliveryTransport.of({ + deliverAgent: (input) => + Effect.gen(function* () { + const rows = yield* sql` + SELECT payload + FROM j5_a2a_epic_membership + WHERE epic_id = ${input.receiverEpicId} + AND participant_id = ${input.receiverId} + LIMIT 1 + `; + const row = rows[0]; + if (row === undefined) { + return yield* new A2ADeliveryTargetError({ + participantId: input.receiverId, + state: "membership disappeared before delivery", + }); + } + const participant = yield* decodeParticipant(row.payload); + if (participant.kind !== "agent") { + return yield* new A2ADeliveryTargetError({ + participantId: input.receiverId, + state: "participant is not an addressable agent thread", + }); + } + const target = yield* threads.getThreadProjection(participant.threadId); + const envelope = + input.senderId === GLOBAL_HUMAN_PARTICIPANT_ID + ? formatHumanEnvelope({ + senderId: input.senderId, + exchangeId: input.exchangeId, + message: input.message, + }) + : formatPeerEnvelope({ + senderId: input.senderId, + originEpicId: input.originEpicId, + exchangeId: input.exchangeId, + message: input.message, + }); + yield* threads.sendToThread({ + projectId: target.thread.projectId, + commandId: deliveryCommandId(input.messageId), + threadId: participant.threadId, + messageId: deliveryMessageId(input.messageId), + text: envelope, + attachments: [], + mode: "auto", + createdBy: "agent", + creationSource: "mcp", + }); + }).pipe( + Effect.mapError( + (cause) => new A2ADeliveryTransportError({ operation: "deliver agent", cause }), + ), + ), + deliverHuman: (input) => + sql` + INSERT INTO j5_a2a_human_inbox_data ( + origin_epic_id, + message_id, + exchange_id, + sender_id, + payload, + created_at + ) VALUES ( + ${input.originEpicId}, + ${input.messageId}, + ${input.exchangeId}, + ${input.senderId}, + ${input.message}, + ${input.createdAt} + ) + ON CONFLICT(origin_epic_id, message_id) DO NOTHING + `.pipe( + Effect.asVoid, + Effect.mapError( + (cause) => new A2ADeliveryTransportError({ operation: "deliver human", cause }), + ), + ), + }); + }), +); diff --git a/apps/server/src/j5/a2a/DeliveryWorker.test.ts b/apps/server/src/j5/a2a/DeliveryWorker.test.ts new file mode 100644 index 000000000000..36d5b7abbbfc --- /dev/null +++ b/apps/server/src/j5/a2a/DeliveryWorker.test.ts @@ -0,0 +1,528 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ThreadId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as NodeSqliteClient from "../../persistence/NodeSqliteClient.ts"; +import { ThreadManagementService } from "../../orchestration-v2/ThreadManagementService.ts"; +import { + A2ADeliveryHookError, + A2ADeliveryHooks, + A2ADeliveryWorker, + layerWithHooks as deliveryWorkerLayerWithHooks, +} from "./DeliveryWorker.ts"; +import { + A2ADeliveryTransport, + A2ADeliveryTransportError, + deliveryCommandId, + deliveryMessageId, + live as deliveryTransportLive, + type A2ADeliveryTransportShape, +} from "./DeliveryTransport.ts"; +import { A2ALedger, layer as ledgerLayer } from "./LedgerService.ts"; +import { runJ5A2AMigrations } from "./Migrations.ts"; +import { A2ASendService, layer as sendLayer } from "./SendService.ts"; +import { + CommCommandId, + EpicId, + GLOBAL_HUMAN_PARTICIPANT_ID, + ParticipantId, + type AgentParticipant, +} from "./contracts.ts"; + +const timestamp = "2026-08-16T12:00:00.000Z"; +const sender: AgentParticipant = { + kind: "agent", + id: ParticipantId.make("agent:delivery-sender"), + threadId: ThreadId.make("thread:delivery-sender"), +}; +const receiver: AgentParticipant = { + kind: "agent", + id: ParticipantId.make("agent:delivery-receiver"), + threadId: ThreadId.make("thread:delivery-receiver"), +}; + +const makeTestLayer = ( + transport: A2ADeliveryTransportShape, + hooks: A2ADeliveryHooks["Service"] = A2ADeliveryHooks.of({ + afterTransportSuccess: () => Effect.void, + }), +) => { + const database = NodeSqliteClient.layerMemory(); + const ledger = ledgerLayer.pipe(Layer.provide(database)); + const send = sendLayer.pipe(Layer.provide(ledger), Layer.provide(database)); + const transportLayer = Layer.succeed(A2ADeliveryTransport, A2ADeliveryTransport.of(transport)); + const worker = deliveryWorkerLayerWithHooks(false).pipe( + Layer.provide(ledger), + Layer.provide(database), + Layer.provide(transportLayer), + Layer.provide(Layer.succeed(A2ADeliveryHooks, hooks)), + ); + return Layer.mergeAll(database, ledger, send, transportLayer, worker); +}; + +const join = Effect.fn("test.j5.a2a.delivery.join")(function* ( + epicId: EpicId, + participant: AgentParticipant, + index: string, +) { + yield* (yield* A2ALedger).append({ + commandId: CommCommandId.make(`command:delivery:join:${index}`), + epicId, + acceptedAt: timestamp, + event: { + kind: "participant.joined", + sender: null, + receiver: participant.id, + exchangeId: null, + correlationId: null, + payload: { participant }, + createdAt: timestamp, + }, + }); +}); + +const seedSend = Effect.fn("test.j5.a2a.delivery.seedSend")(function* (crossEpic: boolean) { + yield* runJ5A2AMigrations(); + const ledgerService = yield* A2ALedger; + const senderEpicId = EpicId.make("epic:delivery:sender"); + const receiverEpicId = crossEpic ? EpicId.make("epic:delivery:receiver") : senderEpicId; + yield* ledgerService.createEpic({ + epic: { id: senderEpicId, name: "Delivery sender", createdAt: timestamp }, + }); + if (crossEpic) { + yield* ledgerService.createEpic({ + epic: { id: receiverEpicId, name: "Delivery receiver", createdAt: timestamp }, + }); + } + yield* join(senderEpicId, sender, "sender"); + yield* join(receiverEpicId, receiver, "receiver"); + const sent = yield* (yield* A2ASendService).send({ + commandId: CommCommandId.make( + crossEpic ? "command:delivery:cross-epic" : "command:delivery:same-epic", + ), + senderThreadId: sender.threadId, + to: receiver.id, + message: "Delivery crash-window probe", + acceptedAt: timestamp, + }); + return { senderEpicId, receiverEpicId, sent }; +}); + +const crashWindowScenario = (poisonIds: boolean, crossEpic: boolean) => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const injections = yield* Ref.make(0); + const seen = yield* Ref.make(new Set()); + const hookCalls = yield* Ref.make(0); + const transport: A2ADeliveryTransportShape = { + deliverAgent: (input) => + Effect.gen(function* () { + const call = yield* Ref.updateAndGet(calls, (count) => count + 1); + const stableKey = `${deliveryCommandId(input.messageId)}:${deliveryMessageId(input.messageId)}`; + const key = poisonIds ? `${stableKey}:attempt:${call}` : stableKey; + const inserted = yield* Ref.modify(seen, (keys) => { + if (keys.has(key)) return [false, keys] as const; + const next = new Set(keys); + next.add(key); + return [true, next] as const; + }); + if (inserted) yield* Ref.update(injections, (count) => count + 1); + }).pipe( + Effect.mapError( + (cause) => new A2ADeliveryTransportError({ operation: "test delivery", cause }), + ), + ), + deliverHuman: () => Effect.void, + }; + const hooks = A2ADeliveryHooks.of({ + afterTransportSuccess: () => + Ref.updateAndGet(hookCalls, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.fail( + new A2ADeliveryHookError({ + cause: "simulated crash after injection before delivery receipt", + }), + ) + : Effect.void, + ), + ), + }); + + const result = yield* Effect.gen(function* () { + const { receiverEpicId, sent } = yield* seedSend(crossEpic); + const worker = yield* A2ADeliveryWorker; + const sql = yield* SqlClient.SqlClient; + if (crossEpic) { + const before = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM j5_a2a_comm_event + WHERE epic_id = ${receiverEpicId} AND kind = 'message.received' + `; + assert.equal(before[0]?.count, 0, "sender commit precedes receiver double-entry"); + const senderState = yield* sql<{ readonly status: string }>` + SELECT status + FROM j5_a2a_delivery + WHERE message_id = ${sent.messageId} + `; + assert.deepStrictEqual(senderState, [{ status: "pending" }]); + } + + const first = yield* worker.runOnce; + assert.equal(first?.state, "retry_scheduled"); + assert.equal(first?.attempt, 1); + yield* TestClock.adjust("250 millis"); + const replay = yield* worker.runOnce; + assert.equal(replay?.state, "delivered"); + assert.equal(replay?.attempt, 2); + + const received = crossEpic + ? yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM j5_a2a_comm_event + WHERE epic_id = ${receiverEpicId} + AND kind = 'message.received' + AND correlation_id IS NOT NULL + ` + : [{ count: 0 }]; + const senderState = yield* sql<{ readonly status: string }>` + SELECT status + FROM j5_a2a_delivery + WHERE message_id = ${sent.messageId} + `; + return { + messageId: sent.messageId, + injectionCount: yield* Ref.get(injections), + receivedCount: received[0]?.count ?? 0, + senderState: senderState[0]?.status, + }; + }).pipe(Effect.provide(makeTestLayer(transport, hooks))); + return result; + }); + +it.effect("recovers the injected-but-unrecorded window with one upstream injection", () => + Effect.gen(function* () { + const result = yield* crashWindowScenario(false, false); + assert.equal(result.injectionCount, 1); + }), +); + +it.effect("negative control: poisoned retry ids are detected as a double injection", () => + Effect.gen(function* () { + const poisoned = yield* crashWindowScenario(true, false); + assert.equal(poisoned.injectionCount, 2); + assert.notEqual(poisoned.injectionCount, 1); + }), +); + +it.effect("cross-epic half-write recovery records exactly one receiver entry", () => + Effect.gen(function* () { + const result = yield* crashWindowScenario(false, true); + assert.equal(result.injectionCount, 1); + assert.equal(result.receivedCount, 1); + assert.equal(result.senderState, "delivered"); + }), +); + +it.effect("closes a cross-epic exchange through the paired reply entry", () => + Effect.gen(function* () { + const injections = yield* Ref.make(0); + const transport: A2ADeliveryTransportShape = { + deliverAgent: () => Ref.update(injections, (count) => count + 1), + deliverHuman: () => Effect.void, + }; + yield* Effect.gen(function* () { + yield* runJ5A2AMigrations(); + const ledgerService = yield* A2ALedger; + const sendService = yield* A2ASendService; + const worker = yield* A2ADeliveryWorker; + const sql = yield* SqlClient.SqlClient; + const senderEpicId = EpicId.make("epic:exchange:sender"); + const receiverEpicId = EpicId.make("epic:exchange:receiver"); + yield* ledgerService.createEpic({ + epic: { id: senderEpicId, name: "Exchange sender", createdAt: timestamp }, + }); + yield* ledgerService.createEpic({ + epic: { id: receiverEpicId, name: "Exchange receiver", createdAt: timestamp }, + }); + yield* join(senderEpicId, sender, "exchange-sender"); + yield* join(receiverEpicId, receiver, "exchange-receiver"); + + const opened = yield* sendService.send({ + commandId: CommCommandId.make("command:exchange:cross:open"), + senderThreadId: sender.threadId, + to: receiver.id, + message: "Please reply across epics.", + expectReply: true, + intent: "Prove cross-epic reply closure", + acceptedAt: timestamp, + }); + assert.equal(opened.exchangeState, "open"); + assert.equal((yield* worker.runOnce)?.state, "delivered"); + + const replyInput = { + commandId: CommCommandId.make("command:exchange:cross:reply"), + senderThreadId: receiver.threadId, + to: sender.id, + message: "Cross-epic reply delivered.", + exchangeId: opened.exchangeId!, + acceptedAt: timestamp, + } as const; + const reply = yield* sendService.send(replyInput); + assert.equal(reply.exchangeState, "closing"); + + const duplicateReply = yield* Effect.flip( + sendService.send({ + ...replyInput, + commandId: CommCommandId.make("command:exchange:cross:duplicate-reply"), + }), + ); + assert.equal(duplicateReply._tag, "A2AExchangeAlreadyAnsweredError"); + assert.equal((yield* worker.runOnce)?.state, "delivered"); + + const replay = yield* sendService.send(replyInput); + assert.deepStrictEqual(replay, reply, "command replay preserves its pre-delivery result"); + const exchange = yield* sql<{ readonly status: string }>` + SELECT status + FROM j5_a2a_exchange + WHERE exchange_id = ${opened.exchangeId!} + `; + assert.deepStrictEqual(exchange, [{ status: "closed" }]); + const paired = yield* sql<{ readonly epic_id: string; readonly count: number }>` + SELECT epic_id, COUNT(*) AS count + FROM j5_a2a_comm_event + WHERE kind = 'message.received' + AND epic_id IN (${senderEpicId}, ${receiverEpicId}) + GROUP BY epic_id + ORDER BY epic_id + `; + assert.deepStrictEqual( + paired, + [ + { epic_id: receiverEpicId, count: 1 }, + { epic_id: senderEpicId, count: 1 }, + ].sort((left, right) => left.epic_id.localeCompare(right.epic_id)), + ); + const pairedPayloads = yield* sql<{ + readonly epic_id: string; + readonly payload: string; + }>` + SELECT epic_id, payload + FROM j5_a2a_comm_event + WHERE kind = 'message.received' + AND epic_id IN (${senderEpicId}, ${receiverEpicId}) + ORDER BY epic_id + `; + assert.deepStrictEqual( + pairedPayloads.map((row) => ({ + epicId: row.epic_id, + exchangeRole: (JSON.parse(row.payload) as { message: { exchangeRole: string } }).message + .exchangeRole, + })), + [ + { epicId: receiverEpicId, exchangeRole: "ask" }, + { epicId: senderEpicId, exchangeRole: "reply" }, + ].sort((left, right) => left.epicId.localeCompare(right.epicId)), + ); + assert.equal(yield* Ref.get(injections), 2); + }).pipe(Effect.provide(makeTestLayer(transport))); + }), +); + +it.effect("startup reconciliation drains a persisted cross-epic half-write after restart", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "j5-a2a-restart-" }); + const filename = path.join(directory, "state.sqlite"); + const firstDatabase = NodeSqliteClient.layer({ filename }); + const firstLedger = ledgerLayer.pipe(Layer.provide(firstDatabase)); + const firstSend = sendLayer.pipe(Layer.provide(firstLedger), Layer.provide(firstDatabase)); + const firstLayer = Layer.mergeAll(firstDatabase, firstLedger, firstSend); + const persisted = yield* Effect.scoped( + Effect.gen(function* () { + const seeded = yield* seedSend(true); + const sql = yield* SqlClient.SqlClient; + const received = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM j5_a2a_comm_event + WHERE epic_id = ${seeded.receiverEpicId} AND kind = 'message.received' + `; + assert.equal(received[0]?.count, 0); + return seeded; + }).pipe(Effect.provide(firstLayer)), + ); + + const injectionCount = yield* Ref.make(0); + const transportEntered = yield* Deferred.make(); + const releaseTransport = yield* Deferred.make(); + const transport: A2ADeliveryTransportShape = { + deliverAgent: () => Ref.update(injectionCount, (count) => count + 1), + deliverHuman: () => Effect.void, + }; + const secondDatabase = NodeSqliteClient.layer({ filename }); + const secondLedger = ledgerLayer.pipe(Layer.provide(secondDatabase)); + const secondTransport = Layer.succeed( + A2ADeliveryTransport, + A2ADeliveryTransport.of(transport), + ); + const secondWorker = deliveryWorkerLayerWithHooks(true).pipe( + Layer.provide(secondLedger), + Layer.provide(secondDatabase), + Layer.provide(secondTransport), + Layer.provide( + Layer.succeed( + A2ADeliveryHooks, + A2ADeliveryHooks.of({ + afterTransportSuccess: () => + Deferred.succeed(transportEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseTransport)), + ), + }), + ), + ), + ); + const secondLayer = Layer.mergeAll( + secondDatabase, + secondLedger, + secondTransport, + secondWorker, + ); + yield* Effect.scoped( + Effect.gen(function* () { + const worker = yield* A2ADeliveryWorker; + yield* Deferred.await(transportEntered); + const milestoneStream = yield* worker.subscribeMilestones; + const milestoneFiber = yield* milestoneStream.pipe( + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(releaseTransport, undefined); + const reconciled = yield* Fiber.join(milestoneFiber).pipe(Effect.map(Option.getOrThrow)); + assert.equal(reconciled?.state, "delivered"); + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM j5_a2a_comm_event + WHERE epic_id = ${persisted.receiverEpicId} + AND kind = 'message.received' + `; + assert.equal(rows[0]?.count, 1); + assert.equal(yield* Ref.get(injectionCount), 1); + }).pipe(Effect.provide(secondLayer)), + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("forces repeated delivery failure into a visible alarm", () => + Effect.gen(function* () { + const failure = new A2ADeliveryTransportError({ + operation: "forced failure", + cause: "recipient unavailable", + }); + const transport: A2ADeliveryTransportShape = { + deliverAgent: () => Effect.fail(failure), + deliverHuman: () => Effect.fail(failure), + }; + yield* Effect.gen(function* () { + yield* seedSend(false); + const worker = yield* A2ADeliveryWorker; + assert.equal((yield* worker.runOnce)?.state, "retry_scheduled"); + yield* TestClock.adjust("250 millis"); + assert.equal((yield* worker.runOnce)?.state, "retry_scheduled"); + yield* TestClock.adjust("500 millis"); + const alarm = yield* worker.runOnce; + assert.equal(alarm?.state, "alarmed"); + assert.equal(alarm?.attempt, 3); + const alarms = yield* worker.listAlarms; + assert.lengthOf(alarms, 1); + assert.equal(alarms[0]?.messageId, alarm?.messageId); + assert.include(alarms[0]?.lastError ?? "", "recipient unavailable"); + }).pipe(Effect.provide(makeTestLayer(transport))); + }), +); + +it.effect("delivers to the human through the idempotent inbox-data transport", () => + Effect.gen(function* () { + const database = NodeSqliteClient.layerMemory(); + const ledger = ledgerLayer.pipe(Layer.provide(database)); + const send = sendLayer.pipe(Layer.provide(ledger), Layer.provide(database)); + const threadManagement = Layer.mock(ThreadManagementService)({}); + const transport = deliveryTransportLive.pipe( + Layer.provide(database), + Layer.provide(threadManagement), + ); + const worker = deliveryWorkerLayerWithHooks(false).pipe( + Layer.provide(ledger), + Layer.provide(database), + Layer.provide(transport), + Layer.provide( + Layer.succeed( + A2ADeliveryHooks, + A2ADeliveryHooks.of({ afterTransportSuccess: () => Effect.void }), + ), + ), + ); + const layer = Layer.mergeAll(database, ledger, send, transport, worker); + + yield* Effect.gen(function* () { + yield* runJ5A2AMigrations(); + const ledgerService = yield* A2ALedger; + const epicId = EpicId.make("epic:delivery:human"); + yield* ledgerService.createEpic({ + epic: { id: epicId, name: "Human delivery", createdAt: timestamp }, + }); + yield* join(epicId, sender, "human-sender"); + yield* ledgerService.append({ + commandId: CommCommandId.make("command:delivery:join:human"), + epicId, + acceptedAt: timestamp, + event: { + kind: "participant.joined", + sender: null, + receiver: GLOBAL_HUMAN_PARTICIPANT_ID, + exchangeId: null, + correlationId: null, + payload: { participant: { kind: "human" } }, + createdAt: timestamp, + }, + }); + const sent = yield* (yield* A2ASendService).send({ + commandId: CommCommandId.make("command:delivery:human"), + senderThreadId: sender.threadId, + to: GLOBAL_HUMAN_PARTICIPANT_ID, + message: "Human inbox payload", + acceptedAt: timestamp, + }); + const delivery = yield* (yield* A2ADeliveryWorker).runOnce; + assert.equal(delivery?.state, "delivered"); + + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ + readonly message_id: string; + readonly payload: string; + }>` + SELECT message_id, payload + FROM j5_a2a_human_inbox_data + WHERE origin_epic_id = ${epicId} + `; + assert.deepStrictEqual(rows, [ + { message_id: sent.messageId, payload: "Human inbox payload" }, + ]); + assert.isNull(yield* (yield* A2ADeliveryWorker).runOnce); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/j5/a2a/DeliveryWorker.ts b/apps/server/src/j5/a2a/DeliveryWorker.ts new file mode 100644 index 000000000000..5bcaa4cba4ae --- /dev/null +++ b/apps/server/src/j5/a2a/DeliveryWorker.ts @@ -0,0 +1,423 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Semaphore from "effect/Semaphore"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; +import type * as Scope from "effect/Scope"; + +import config from "./delivery-config.v1.json" with { type: "json" }; +import { A2ADeliveryTransport, type A2ADeliveryTransportError } from "./DeliveryTransport.ts"; +import { + CommCommandId, + type CommEvent, + CorrelationId, + EpicId, + ExchangeId, + GLOBAL_HUMAN_PARTICIPANT_ID, + LedgerMessageId, + ParticipantId, + type DeliveryAlarm, + type DeliveryMilestone, +} from "./contracts.ts"; +import { A2ALedger, type A2ALedgerError } from "./LedgerService.ts"; + +export const A2A_DELIVERY_CONFIG_VERSION = config.version; + +interface DeliveryRow { + readonly epic_id: string; + readonly message_id: string; + readonly sent_seq: number; + readonly sender_id: string; + readonly receiver_id: string; + readonly receiver_epic_id: string; + readonly exchange_id: string | null; + readonly exchange_role: "none" | "ask" | "followup" | "reply"; + readonly correlation_id: string; + readonly message_text: string; + readonly status: "pending" | "retry_scheduled" | "delivered" | "alarmed"; + readonly attempts: number; + readonly created_at: string; +} + +interface OpenExchangeRow { + readonly epic_id: string; + readonly exchange_id: string; + readonly sender_id: string; + readonly receiver_id: string; +} + +export interface DeliveryAttempt { + readonly epicId: EpicId; + readonly messageId: LedgerMessageId; + readonly attempt: number; +} + +export interface A2ADeliveryHooksShape { + readonly afterTransportSuccess: ( + attempt: DeliveryAttempt, + ) => Effect.Effect; +} + +export class A2ADeliveryHookError extends Schema.TaggedErrorClass()( + "A2ADeliveryHookError", + { cause: Schema.Defect() }, +) {} + +export class A2ADeliveryWorkerError extends Schema.TaggedErrorClass()( + "A2ADeliveryWorkerError", + { operation: Schema.String, cause: Schema.Defect() }, +) {} + +export class A2ADeliveryHooks extends Context.Service()( + "t3/j5/a2a/DeliveryWorker/A2ADeliveryHooks", +) {} + +export const noopHooks = Layer.succeed( + A2ADeliveryHooks, + A2ADeliveryHooks.of({ afterTransportSuccess: () => Effect.void }), +); + +export interface A2ADeliveryWorkerShape { + readonly notify: Effect.Effect; + readonly runOnce: Effect.Effect; + readonly drain: Effect.Effect, A2ADeliveryWorkerError>; + readonly listAlarms: Effect.Effect, A2ADeliveryWorkerError>; + readonly subscribeMilestones: Effect.Effect, never, Scope.Scope>; +} + +export class A2ADeliveryWorker extends Context.Service()( + "t3/j5/a2a/DeliveryWorker/A2ADeliveryWorker", +) {} + +type A2ADeliveryAttemptError = + | A2ALedgerError + | A2ADeliveryTransportError + | A2ADeliveryHookError + | SqlError; + +const errorText = (cause: Cause.Cause) => + Cause.pretty(cause).slice(0, 4_000); + +const workerError = (operation: string) => (cause: unknown) => + new A2ADeliveryWorkerError({ operation, cause }); + +const backoffMs = (attempt: number) => + Math.min(config.initialBackoffMs * 2 ** Math.max(0, attempt - 1), config.maximumBackoffMs); + +const commandId = (operation: string, messageId: LedgerMessageId, attempt?: number) => + CommCommandId.make( + ["command", "j5", "a2a", operation, encodeURIComponent(messageId), attempt] + .filter((part) => part !== undefined) + .join(":"), + ); + +const makeLayer = (daemon: boolean) => + Layer.effect( + A2ADeliveryWorker, + Effect.gen(function* () { + const ledger = yield* A2ALedger; + const transport = yield* A2ADeliveryTransport; + const hooks = yield* A2ADeliveryHooks; + const sql = yield* SqlClient.SqlClient; + const wakeups = yield* Queue.unbounded(); + const milestones = yield* PubSub.unbounded(); + const drainPermit = yield* Semaphore.make(1); + + const appendReceiverEntry = Effect.fn("j5.a2a.delivery.appendReceiverEntry")(function* ( + row: DeliveryRow, + ) { + if (row.epic_id === row.receiver_epic_id) return; + const originEpicId = EpicId.make(row.epic_id); + const receiverEpicId = EpicId.make(row.receiver_epic_id); + const exchangeId = row.exchange_id === null ? null : ExchangeId.make(row.exchange_id); + const receivedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const events: Array = [ + { + kind: "message.received", + sender: ParticipantId.make(row.sender_id), + receiver: ParticipantId.make(row.receiver_id), + exchangeId, + correlationId: CorrelationId.make(row.correlation_id), + payload: { + originEpicId, + message: { + messageId: row.message_id, + text: row.message_text, + originEpicId, + receiverEpicId, + exchangeRole: row.exchange_role, + }, + }, + createdAt: receivedAt, + }, + ]; + if (exchangeId !== null) { + const exchanges = yield* sql` + SELECT epic_id, exchange_id, sender_id, receiver_id + FROM j5_a2a_exchange + WHERE epic_id = ${receiverEpicId} + AND exchange_id = ${exchangeId} + AND status = 'open' + AND sender_id = ${row.receiver_id} + AND receiver_id = ${row.sender_id} + LIMIT 1 + `; + if (exchanges[0] !== undefined) { + events.push({ + kind: "exchange.closed", + sender: ParticipantId.make(row.sender_id), + receiver: ParticipantId.make(row.receiver_id), + exchangeId, + correlationId: CorrelationId.make(row.correlation_id), + payload: { replyMessageId: LedgerMessageId.make(row.message_id) }, + createdAt: receivedAt, + }); + } + } + yield* ledger.appendEvents({ + commandId: commandId("receive", LedgerMessageId.make(row.message_id)), + epicId: receiverEpicId, + acceptedAt: receivedAt, + events, + }); + }); + + const attemptDelivery = Effect.fn("j5.a2a.delivery.attempt")(function* ( + row: DeliveryRow, + attempt: number, + ) { + const originEpicId = EpicId.make(row.epic_id); + const receiverEpicId = EpicId.make(row.receiver_epic_id); + const messageId = LedgerMessageId.make(row.message_id); + const senderId = ParticipantId.make(row.sender_id); + const receiverId = ParticipantId.make(row.receiver_id); + const exchangeId = row.exchange_id === null ? null : ExchangeId.make(row.exchange_id); + yield* appendReceiverEntry(row); + if (receiverId === GLOBAL_HUMAN_PARTICIPANT_ID) { + yield* transport.deliverHuman({ + originEpicId, + receiverEpicId, + messageId, + senderId, + receiverId, + exchangeId, + message: row.message_text, + createdAt: row.created_at, + }); + } else { + yield* transport.deliverAgent({ + originEpicId, + receiverEpicId, + messageId, + senderId, + receiverId, + exchangeId, + message: row.message_text, + }); + } + yield* hooks.afterTransportSuccess({ epicId: originEpicId, messageId, attempt }); + const deliveredAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + yield* ledger.appendEvents({ + commandId: commandId("delivered", messageId), + epicId: originEpicId, + acceptedAt: deliveredAt, + events: [ + { + kind: "message.delivered", + sender: senderId, + receiver: receiverId, + exchangeId, + correlationId: CorrelationId.make(row.correlation_id), + payload: { + messageId, + attempt, + channel: receiverId === GLOBAL_HUMAN_PARTICIPANT_ID ? "human" : "agent", + }, + createdAt: deliveredAt, + }, + ], + }); + }); + + const recordFailure = Effect.fn("j5.a2a.delivery.recordFailure")(function* ( + row: DeliveryRow, + attempt: number, + cause: Cause.Cause, + ) { + const failedAtDate = yield* DateTime.now; + const failedAt = DateTime.formatIso(failedAtDate); + const alarmed = attempt >= config.alarmAfterAttempts; + const nextAttemptAt = alarmed + ? null + : DateTime.formatIso(DateTime.add(failedAtDate, { milliseconds: backoffMs(attempt) })); + const messageId = LedgerMessageId.make(row.message_id); + yield* ledger.appendEvents({ + commandId: commandId("failed", messageId, attempt), + epicId: EpicId.make(row.epic_id), + acceptedAt: failedAt, + events: [ + { + kind: "message.delivery_failed", + sender: ParticipantId.make(row.sender_id), + receiver: ParticipantId.make(row.receiver_id), + exchangeId: row.exchange_id === null ? null : ExchangeId.make(row.exchange_id), + correlationId: CorrelationId.make(row.correlation_id), + payload: { + messageId, + attempt, + error: errorText(cause), + nextAttemptAt, + alarmed, + }, + createdAt: failedAt, + }, + ], + }); + return { + epicId: EpicId.make(row.epic_id), + messageId, + state: alarmed ? ("alarmed" as const) : ("retry_scheduled" as const), + attempt, + } satisfies DeliveryMilestone; + }); + + const runOnceRaw = Effect.gen(function* () { + const now = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const rows = yield* sql` + SELECT + epic_id, + message_id, + sent_seq, + sender_id, + receiver_id, + receiver_epic_id, + exchange_id, + exchange_role, + correlation_id, + message_text, + status, + attempts, + created_at + FROM j5_a2a_delivery + WHERE status IN ('pending', 'retry_scheduled') + AND (next_attempt_at IS NULL OR next_attempt_at <= ${now}) + ORDER BY sent_seq, epic_id, message_id + LIMIT 1 + `; + const row = rows[0]; + if (row === undefined) return null; + const attempt = row.attempts + 1; + const exit = yield* Effect.exit(attemptDelivery(row, attempt)); + const milestone = + exit._tag === "Success" + ? ({ + epicId: EpicId.make(row.epic_id), + messageId: LedgerMessageId.make(row.message_id), + state: "delivered", + attempt, + } satisfies DeliveryMilestone) + : yield* recordFailure(row, attempt, exit.cause); + yield* PubSub.publish(milestones, milestone); + return milestone; + }); + const runOnce: A2ADeliveryWorkerShape["runOnce"] = runOnceRaw.pipe( + Effect.mapError(workerError("run one delivery")), + ); + + const drainEffect = Effect.fn("j5.a2a.delivery.drain")(function* () { + const completed: Array = []; + while (true) { + const milestone = yield* runOnce; + if (milestone === null) return completed; + completed.push(milestone); + } + }); + const drain: A2ADeliveryWorkerShape["drain"] = drainPermit.withPermit(drainEffect()); + + const nextDelay = Effect.fn("j5.a2a.delivery.nextDelay")(function* () { + const rows = yield* sql<{ readonly next_attempt_at: string | null }>` + SELECT next_attempt_at + FROM j5_a2a_delivery + WHERE status IN ('pending', 'retry_scheduled') + ORDER BY next_attempt_at IS NOT NULL, next_attempt_at, sent_seq + LIMIT 1 + `; + const value = rows[0]?.next_attempt_at; + if (value === undefined) return null; + if (value === null) return 0; + const now = yield* DateTime.now; + return Math.max(0, Date.parse(value) - DateTime.toEpochMillis(now)); + }); + + const runDaemon = Effect.forever( + Effect.gen(function* () { + yield* drain; + const delay = yield* nextDelay(); + if (delay === null) { + yield* Queue.take(wakeups); + } else if (delay === 0) { + yield* Effect.yieldNow; + } else { + yield* Effect.raceFirst(Queue.take(wakeups), Effect.sleep(Duration.millis(delay))); + } + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("J5 A2A delivery drain failed", { cause }).pipe( + Effect.andThen( + Effect.raceFirst( + Queue.take(wakeups), + Effect.sleep(Duration.millis(config.initialBackoffMs)), + ), + ), + ), + ), + ), + ); + if (daemon) { + yield* Effect.forkScoped(runDaemon); + } + yield* Queue.offer(wakeups, undefined); + + return A2ADeliveryWorker.of({ + notify: Queue.offer(wakeups, undefined).pipe(Effect.asVoid), + runOnce, + drain, + listAlarms: sql<{ + readonly epic_id: string; + readonly message_id: string; + readonly attempts: number; + readonly last_error: string; + }>` + SELECT epic_id, message_id, attempts, last_error + FROM j5_a2a_delivery + WHERE status = 'alarmed' + ORDER BY updated_at, epic_id, message_id + `.pipe( + Effect.map((rows) => + rows.map((row) => ({ + epicId: EpicId.make(row.epic_id), + messageId: LedgerMessageId.make(row.message_id), + attempts: row.attempts, + lastError: row.last_error, + })), + ), + Effect.mapError(workerError("list delivery alarms")), + ), + subscribeMilestones: PubSub.subscribe(milestones).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + }); + }), + ); + +export const manualLayer = makeLayer(false).pipe(Layer.provide(noopHooks)); +export const layer = makeLayer(true).pipe(Layer.provide(noopHooks)); +export const layerWithHooks = (daemon: boolean) => makeLayer(daemon); diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts new file mode 100644 index 000000000000..fee9ea7aa8dd --- /dev/null +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts @@ -0,0 +1,40 @@ +import { assert, it } from "@effect/vitest"; + +import { + A2A_ENVELOPE_VERSION, + A2A_LIST_TOOL_DESCRIPTION, + A2A_SEND_TOOL_DESCRIPTION, + formatHumanEnvelope, + formatPeerEnvelope, +} from "./EnvelopeFormatter.ts"; +import { EpicId, ExchangeId, ParticipantId } from "./contracts.ts"; + +it("renders the versioned peer envelope with exact reply semantics", () => { + const rendered = formatPeerEnvelope({ + senderId: ParticipantId.make("agent:sender"), + originEpicId: EpicId.make("epic:origin"), + exchangeId: ExchangeId.make("exchange:one"), + message: "Please verify the worker.", + }); + + assert.equal(A2A_ENVELOPE_VERSION, 1); + assert.include(rendered, "agent:sender"); + assert.include(rendered, "epic:origin"); + assert.include(rendered, "Please verify the worker."); + assert.include(rendered, 'send_message(to="agent:sender", exchange_id="exchange:one"'); + assert.include(rendered, "Reply once"); + assert.notInclude(rendered, "{{"); +}); + +it("tells agents that human-origin exchanges require an explicit tool reply", () => { + const rendered = formatHumanEnvelope({ + senderId: ParticipantId.make("human:global"), + exchangeId: ExchangeId.make("exchange:human"), + message: "Please report status.", + }); + + assert.include(rendered, "The human is not watching this chat"); + assert.include(rendered, 'exchange_id="exchange:human"'); + assert.include(A2A_SEND_TOOL_DESCRIPTION, "returns after the sender ledger commit"); + assert.include(A2A_LIST_TOOL_DESCRIPTION, "reachable J5 A2A participants"); +}); diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.ts new file mode 100644 index 000000000000..6aca19c4eac2 --- /dev/null +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.ts @@ -0,0 +1,57 @@ +import config from "./envelopes.v1.json" with { type: "json" }; + +import type { EpicId, ExchangeId, ParticipantId } from "./contracts.ts"; + +export const A2A_ENVELOPE_VERSION = config.version; +export const A2A_SEND_TOOL_DESCRIPTION = config.sendToolDescription; +export const A2A_LIST_TOOL_DESCRIPTION = config.listToolDescription; + +const render = (template: string, values: Readonly>): string => + Object.entries(values).reduce( + (output, [name, value]) => output.replaceAll(`{{${name}}}`, value), + template, + ); + +const deliveryInstruction = (input: { + readonly senderId: ParticipantId; + readonly exchangeId: ExchangeId | null; +}) => + input.exchangeId === null + ? config.oneShotInstruction + : render(config.replyInstruction, { + senderId: input.senderId, + exchangeId: input.exchangeId, + }); + +export const formatPeerEnvelope = (input: { + readonly senderId: ParticipantId; + readonly originEpicId: EpicId; + readonly exchangeId: ExchangeId | null; + readonly message: string; +}): string => + render(config.peerMessage, { + senderId: input.senderId, + originEpicId: input.originEpicId, + message: input.message, + exchangeInstruction: deliveryInstruction(input), + }); + +export const formatHumanEnvelope = (input: { + readonly senderId: ParticipantId; + readonly exchangeId: ExchangeId | null; + readonly message: string; +}): string => + render(config.humanMessage, { + message: input.message, + exchangeInstruction: deliveryInstruction(input), + }); + +/** A3 supplies notice derivation; A2 owns this channel's stable rendering shape. */ +export const formatSilenceNoticeEnvelope = (input: { + readonly noticeType: string; + readonly message: string; +}): string => + render(config.silenceNotice, { + noticeType: input.noticeType, + message: input.message, + }); diff --git a/apps/server/src/j5/a2a/LedgerService.test.ts b/apps/server/src/j5/a2a/LedgerService.test.ts index d1141fd47c82..bcada622075e 100644 --- a/apps/server/src/j5/a2a/LedgerService.test.ts +++ b/apps/server/src/j5/a2a/LedgerService.test.ts @@ -364,7 +364,15 @@ it.effect("enforces one message.received correlation per receiver epic", () => createdAt: timestamp, }; yield* ledger.append(appendCommand(epicId, 1, receivedEvent)); - const error = yield* Effect.flip(ledger.append(appendCommand(epicId, 2, receivedEvent))); + const failedCommand = CommCommandId.make(`command:${epicId}:2`); + const error = yield* Effect.flip( + ledger.appendEvents({ + commandId: failedCommand, + epicId, + acceptedAt: timestamp, + events: [receivedEvent], + }), + ); assert.isTrue(isA2AStorageError(error)); const rows = yield* sql<{ readonly count: number }>` SELECT COUNT(*) AS count @@ -372,5 +380,25 @@ it.effect("enforces one message.received correlation per receiver epic", () => WHERE epic_id = ${epicId} AND kind = 'message.received' `; assert.equal(rows[0]?.count, 1); + + const receipts = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM j5_a2a_comm_command_receipt + WHERE command_id = ${failedCommand} + `; + assert.equal(receipts[0]?.count, 0, "the failed event insert rolls back its receipt"); + + const retried = yield* ledger.appendEvents({ + commandId: failedCommand, + epicId, + acceptedAt: timestamp, + events: [ + { + ...receivedEvent, + correlationId: CorrelationId.make("correlation:retry-after-rollback"), + }, + ], + }); + assert.isTrue(retried.committed, "the rolled-back command id remains reusable"); }).pipe(Effect.provide(memoryLedgerLayer())), ); diff --git a/apps/server/src/j5/a2a/LedgerService.ts b/apps/server/src/j5/a2a/LedgerService.ts index 5f683afd297b..3237cf37a627 100644 --- a/apps/server/src/j5/a2a/LedgerService.ts +++ b/apps/server/src/j5/a2a/LedgerService.ts @@ -9,11 +9,17 @@ import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { + type AppendCommEventsCommand, CommCommandReceipt, type AppendCommEventCommand, type CommEventPage, type CreateEpicCommand, Epic, + ExchangeClosedPayload, + ExchangeOpenedPayload, + MessageDeliveredPayload, + MessageDeliveryFailedPayload, + MessageSentPayload, type EpicId, type LedgerCursor, Membership, @@ -79,11 +85,20 @@ export interface AppendResult { readonly committed: boolean; } +export interface AppendEventsResult { + readonly receipt: CommCommandReceipt; + readonly events: ReadonlyArray; + readonly committed: boolean; +} + export interface A2ALedgerShape { readonly createEpic: (command: CreateEpicCommand) => Effect.Effect; readonly listEpics: () => Effect.Effect, A2ALedgerError>; readonly readEpic: (epicId: EpicId) => Effect.Effect; readonly append: (command: AppendCommEventCommand) => Effect.Effect; + readonly appendEvents: ( + command: AppendCommEventsCommand, + ) => Effect.Effect; readonly readEvents: (input: { readonly epicId: EpicId; readonly cursor: LedgerCursor; @@ -139,6 +154,11 @@ const decodeEpic = Schema.decodeUnknownEffect(Epic); const decodeStoredEvent = Schema.decodeUnknownEffect(StoredCommEvent); const decodeReceipt = Schema.decodeUnknownEffect(CommCommandReceipt); const decodeMembership = Schema.decodeUnknownEffect(Membership); +const decodeExchangeOpened = Schema.decodeUnknownEffect(ExchangeOpenedPayload); +const decodeExchangeClosed = Schema.decodeUnknownEffect(ExchangeClosedPayload); +const decodeMessageSent = Schema.decodeUnknownEffect(MessageSentPayload); +const decodeMessageDelivered = Schema.decodeUnknownEffect(MessageDeliveredPayload); +const decodeMessageDeliveryFailed = Schema.decodeUnknownEffect(MessageDeliveryFailedPayload); const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json)); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Json)); @@ -239,6 +259,147 @@ export const layer: Layer.Layer = Layer.e `; }); + const applyA2Projection = Effect.fn("j5.a2a.applyA2Projection")(function* ( + event: StoredCommEvent, + commandId: string, + ) { + switch (event.kind) { + case "exchange.opened": { + const payload = yield* decodeExchangeOpened(event.payload); + if (event.sender === null || event.receiver === null || event.exchangeId === null) { + return yield* new A2AStorageError({ operation: "project opened exchange" }); + } + yield* sql` + INSERT INTO j5_a2a_exchange ( + epic_id, + exchange_id, + sender_id, + receiver_id, + status, + intent, + urgency, + opened_seq, + closed_seq, + created_at, + updated_at + ) VALUES ( + ${event.epicId}, + ${event.exchangeId}, + ${event.sender}, + ${event.receiver}, + 'open', + ${payload.intent}, + ${payload.urgency}, + ${event.seq}, + NULL, + ${event.createdAt}, + ${event.createdAt} + ) + `; + return; + } + case "exchange.closed": { + yield* decodeExchangeClosed(event.payload); + if (event.exchangeId === null) { + return yield* new A2AStorageError({ operation: "project closed exchange" }); + } + yield* sql` + UPDATE j5_a2a_exchange + SET + status = 'closed', + closed_seq = ${event.seq}, + updated_at = ${event.createdAt} + WHERE epic_id = ${event.epicId} + AND exchange_id = ${event.exchangeId} + AND status = 'open' + `; + return; + } + case "message.sent": { + const payload = yield* decodeMessageSent(event.payload); + if (event.sender === null || event.receiver === null || event.correlationId === null) { + return yield* new A2AStorageError({ operation: "project sent message" }); + } + yield* sql` + INSERT INTO j5_a2a_delivery ( + epic_id, + message_id, + command_id, + sent_seq, + sender_id, + receiver_id, + receiver_epic_id, + exchange_id, + exchange_role, + correlation_id, + message_text, + status, + attempts, + last_error, + next_attempt_at, + delivered_seq, + created_at, + updated_at + ) VALUES ( + ${event.epicId}, + ${payload.messageId}, + ${commandId}, + ${event.seq}, + ${event.sender}, + ${event.receiver}, + ${payload.receiverEpicId}, + ${event.exchangeId}, + ${payload.exchangeRole}, + ${event.correlationId}, + ${payload.text}, + 'pending', + 0, + NULL, + NULL, + NULL, + ${event.createdAt}, + ${event.createdAt} + ) + `; + return; + } + case "message.delivered": { + const payload = yield* decodeMessageDelivered(event.payload); + yield* sql` + UPDATE j5_a2a_delivery + SET + status = 'delivered', + attempts = ${payload.attempt}, + last_error = NULL, + next_attempt_at = NULL, + delivered_seq = ${event.seq}, + updated_at = ${event.createdAt} + WHERE epic_id = ${event.epicId} AND message_id = ${payload.messageId} + `; + return; + } + case "message.delivery_failed": { + const payload = yield* decodeMessageDeliveryFailed(event.payload); + yield* sql` + UPDATE j5_a2a_delivery + SET + status = ${payload.alarmed ? "alarmed" : "retry_scheduled"}, + attempts = ${payload.attempt}, + last_error = ${payload.error}, + next_attempt_at = ${payload.nextAttemptAt}, + updated_at = ${event.createdAt} + WHERE epic_id = ${event.epicId} AND message_id = ${payload.messageId} + `; + return; + } + case "message.received": + case "silence.notice": + case "participant.joined": + case "participant.left": + return; + } + }); + const listMembershipEffect = Effect.fn("j5.a2a.listMembership")(function* (epicId: EpicId) { yield* ensureEpic(epicId); const rows = yield* sql` @@ -372,6 +533,144 @@ export const layer: Layer.Layer = Layer.e return result; }); + const appendEventsEffect = Effect.fn("j5.a2a.appendEvents")(function* ( + command: AppendCommEventsCommand, + ) { + const result = yield* sql.withTransaction( + Effect.gen(function* () { + yield* ensureEpic(command.epicId); + const sequenceRows = yield* sql<{ readonly next_seq: number }>` + SELECT COALESCE(MAX(seq), 0) + 1 AS next_seq + FROM j5_a2a_comm_event + WHERE epic_id = ${command.epicId} + `; + const firstSeq = sequenceRows[0]?.next_seq; + if (firstSeq === undefined) { + return yield* new A2AStorageError({ operation: "allocate communication sequences" }); + } + const resultSeq = firstSeq + command.events.length - 1; + const reserved = yield* sql<{ readonly command_id: string }>` + INSERT INTO j5_a2a_comm_command_receipt ( + command_id, + epic_id, + command_type, + accepted_at, + result_seq + ) VALUES ( + ${command.commandId}, + ${command.epicId}, + 'comm.append', + ${command.acceptedAt}, + ${resultSeq} + ) + ON CONFLICT(command_id) DO NOTHING + RETURNING command_id + `; + + if (reserved[0] === undefined) { + const receiptRows = yield* sql` + SELECT command_id, epic_id, command_type, accepted_at, result_seq + FROM j5_a2a_comm_command_receipt + WHERE command_id = ${command.commandId} + LIMIT 1 + `; + const row = receiptRows[0]; + if (row === undefined) { + return yield* new A2AStorageError({ operation: "read replayed batch receipt" }); + } + if (row.epic_id !== command.epicId) { + return yield* new CommCommandConflictError({ + commandId: command.commandId, + requestedEpicId: command.epicId, + existingEpicId: row.epic_id, + }); + } + const eventRows = yield* sql` + SELECT + seq, + epic_id, + kind, + sender, + receiver, + exchange_id, + correlation_id, + payload, + created_at + FROM j5_a2a_comm_event + WHERE epic_id = ${command.epicId} AND command_id = ${command.commandId} + ORDER BY seq + `; + if (eventRows.length === 0) { + return yield* new A2AStorageError({ operation: "read replayed batch events" }); + } + return { + receipt: yield* receiptFromRow(row), + events: yield* Effect.forEach(eventRows, eventFromRow, { concurrency: 1 }), + committed: false as const, + }; + } + + const events: Array = []; + for (const [index, candidate] of command.events.entries()) { + const pending = decideAppendCommEvent({ + commandId: command.commandId, + epicId: command.epicId, + acceptedAt: command.acceptedAt, + event: candidate, + })[0]; + const seq = firstSeq + index; + const payload = yield* encodeJson(pending.payload); + yield* sql` + INSERT INTO j5_a2a_comm_event ( + seq, + epic_id, + kind, + sender, + receiver, + exchange_id, + correlation_id, + payload, + created_at, + command_id + ) VALUES ( + ${seq}, + ${pending.epicId}, + ${pending.kind}, + ${pending.sender}, + ${pending.receiver}, + ${pending.exchangeId}, + ${pending.correlationId}, + ${payload}, + ${pending.createdAt}, + ${command.commandId} + ) + `; + const event = yield* decodeStoredEvent({ seq, ...pending }); + yield* applyMembership(event); + yield* applyA2Projection(event, command.commandId); + events.push(event); + } + return { + receipt: yield* decodeReceipt({ + commandId: command.commandId, + epicId: command.epicId, + commandType: "comm.append", + acceptedAt: command.acceptedAt, + resultSeq, + }), + events, + committed: true as const, + }; + }), + ); + if (result.committed) { + for (const event of result.events) { + yield* PubSub.publish(committed, event); + } + } + return result; + }); + return A2ALedger.of({ createEpic: (command) => Effect.gen(function* () { @@ -401,6 +700,10 @@ export const layer: Layer.Layer = Layer.e appendPermit .withPermit(appendEffect(command)) .pipe(Effect.mapError(preserveDomainError("append communication event"))), + appendEvents: (command) => + appendPermit + .withPermit(appendEventsEffect(command)) + .pipe(Effect.mapError(preserveDomainError("append communication events"))), readEvents: ({ epicId, cursor, limit }) => Effect.gen(function* () { yield* ensureEpic(epicId); diff --git a/apps/server/src/j5/a2a/Migrations.test.ts b/apps/server/src/j5/a2a/Migrations.test.ts index 31c423c7c4d1..26a79f62a2ff 100644 --- a/apps/server/src/j5/a2a/Migrations.test.ts +++ b/apps/server/src/j5/a2a/Migrations.test.ts @@ -29,10 +29,13 @@ it.effect("tracks J5 A2A migrations independently from upstream migrations", () `; assert.equal(upstream[0]?.migration_id, upstreamMigrationManifest.at(-1)?.[0]); - assert.deepStrictEqual(j5, [{ migration_id: 1, name: "EpicCommunicationLedger" }]); + assert.deepStrictEqual(j5, [ + { migration_id: 1, name: "EpicCommunicationLedger" }, + { migration_id: 2, name: "SendDeliverReply" }, + ]); assert.deepStrictEqual( migrationEntries.map(([id]) => id), - [1], + [1, 2], ); }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), ); @@ -49,7 +52,10 @@ it.effect("creates the exact namespaced ledger schema and receiver correlation c 'j5_a2a_epic', 'j5_a2a_comm_event', 'j5_a2a_comm_command_receipt', - 'j5_a2a_epic_membership' + 'j5_a2a_epic_membership', + 'j5_a2a_exchange', + 'j5_a2a_delivery', + 'j5_a2a_human_inbox_data' ) ORDER BY name `; @@ -59,7 +65,11 @@ it.effect("creates the exact namespaced ledger schema and receiver correlation c WHERE type = 'index' AND name IN ( 'j5_a2a_comm_command_receipt_epic_seq_idx', - 'j5_a2a_comm_event_received_correlation_idx' + 'j5_a2a_comm_event_received_correlation_idx', + 'j5_a2a_comm_event_command_idx', + 'j5_a2a_exchange_open_pair_idx', + 'j5_a2a_delivery_drain_idx', + 'j5_a2a_delivery_one_reply_idx' ) ORDER BY name `; @@ -80,14 +90,37 @@ it.effect("creates the exact namespaced ledger schema and receiver correlation c assert.deepStrictEqual(tables, [ { name: "j5_a2a_comm_command_receipt" }, { name: "j5_a2a_comm_event" }, + { name: "j5_a2a_delivery" }, { name: "j5_a2a_epic" }, { name: "j5_a2a_epic_membership" }, + { name: "j5_a2a_exchange" }, + { name: "j5_a2a_human_inbox_data" }, ]); - assert.equal(indexes[0]?.name, "j5_a2a_comm_command_receipt_epic_seq_idx"); - assert.include(indexes[0]?.sql ?? "", "ON j5_a2a_comm_command_receipt(epic_id, result_seq)"); - assert.equal(indexes[1]?.name, "j5_a2a_comm_event_received_correlation_idx"); - assert.include(indexes[1]?.sql ?? "", "ON j5_a2a_comm_event(epic_id, correlation_id)"); - assert.include(indexes[1]?.sql ?? "", "WHERE kind = 'message.received'"); + const indexesByName = new Map(indexes.map((index) => [index.name, index.sql])); + assert.include( + indexesByName.get("j5_a2a_comm_command_receipt_epic_seq_idx") ?? "", + "ON j5_a2a_comm_command_receipt(epic_id, result_seq)", + ); + assert.include( + indexesByName.get("j5_a2a_comm_event_received_correlation_idx") ?? "", + "WHERE kind = 'message.received'", + ); + assert.include( + indexesByName.get("j5_a2a_comm_event_command_idx") ?? "", + "ON j5_a2a_comm_event(command_id, epic_id, seq)", + ); + assert.include( + indexesByName.get("j5_a2a_exchange_open_pair_idx") ?? "", + "WHERE status = 'open'", + ); + assert.include( + indexesByName.get("j5_a2a_delivery_drain_idx") ?? "", + "ON j5_a2a_delivery(status, next_attempt_at, sent_seq)", + ); + assert.include( + indexesByName.get("j5_a2a_delivery_one_reply_idx") ?? "", + "WHERE exchange_id IS NOT NULL AND exchange_role = 'reply'", + ); assert.deepStrictEqual(unprefixed, []); }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), ); diff --git a/apps/server/src/j5/a2a/Migrations.ts b/apps/server/src/j5/a2a/Migrations.ts index 2e4db65ad737..e8debdc8a26c 100644 --- a/apps/server/src/j5/a2a/Migrations.ts +++ b/apps/server/src/j5/a2a/Migrations.ts @@ -2,10 +2,14 @@ import * as Effect from "effect/Effect"; import * as Migrator from "effect/unstable/sql/Migrator"; import Migration0001 from "./migrations/001_EpicCommunicationLedger.ts"; +import Migration0002 from "./migrations/002_SendDeliverReply.ts"; export const J5_A2A_MIGRATIONS_TABLE = "j5_a2a_migrations"; -export const migrationEntries = [[1, "EpicCommunicationLedger", Migration0001]] as const; +export const migrationEntries = [ + [1, "EpicCommunicationLedger", Migration0001], + [2, "SendDeliverReply", Migration0002], +] as const; const makeMigrationLoader = (throughId?: number) => Migrator.fromRecord( diff --git a/apps/server/src/j5/a2a/README.md b/apps/server/src/j5/a2a/README.md new file mode 100644 index 000000000000..32e01727a306 --- /dev/null +++ b/apps/server/src/j5/a2a/README.md @@ -0,0 +1,5 @@ +# J5 A2A runtime configuration + +`envelopes.v1.json` is the human-owned wording source for every injected A2A envelope and the two MCP tool descriptions. Templates use literal `{{name}}` placeholders; `EnvelopeFormatter.ts` is the only renderer. Change wording in the JSON file, keep every referenced placeholder, and bump `version` when the rendered contract changes. + +`delivery-config.v1.json` owns the retry backoff and alarm threshold. Attempts reuse one upstream command/message id pair derived from the durable ledger message id. Increasing an attempt never rotates those ids: a permanently rejected upstream receipt must become a visible alarm rather than risk a second injection. diff --git a/apps/server/src/j5/a2a/SendService.test.ts b/apps/server/src/j5/a2a/SendService.test.ts new file mode 100644 index 000000000000..3cee451bb559 --- /dev/null +++ b/apps/server/src/j5/a2a/SendService.test.ts @@ -0,0 +1,313 @@ +import { assert, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as NodeSqliteClient from "../../persistence/NodeSqliteClient.ts"; +import { A2ALedger, layer as ledgerLayer } from "./LedgerService.ts"; +import { runJ5A2AMigrations } from "./Migrations.ts"; +import { A2ASendService, layer as sendLayer } from "./SendService.ts"; +import { CommCommandId, EpicId, ParticipantId, type AgentParticipant } from "./contracts.ts"; + +const timestamp = "2026-08-16T12:00:00.000Z"; + +const database = NodeSqliteClient.layerMemory(); +const ledger = ledgerLayer.pipe(Layer.provide(database)); +const send = sendLayer.pipe(Layer.provide(ledger), Layer.provide(database)); +const testLayer = Layer.mergeAll(database, ledger, send); + +const sender: AgentParticipant = { + kind: "agent", + id: ParticipantId.make("agent:sender"), + threadId: ThreadId.make("thread:sender"), +}; +const receiver: AgentParticipant = { + kind: "agent", + id: ParticipantId.make("agent:receiver"), + threadId: ThreadId.make("thread:receiver"), +}; + +const setupSameEpic = Effect.fn("test.j5.a2a.setupSameEpic")(function* () { + yield* runJ5A2AMigrations(); + const ledgerService = yield* A2ALedger; + const epicId = EpicId.make("epic:exchange"); + yield* ledgerService.createEpic({ + epic: { id: epicId, name: "Exchange", createdAt: timestamp }, + }); + for (const [index, participant] of [sender, receiver].entries()) { + yield* ledgerService.append({ + commandId: CommCommandId.make(`command:join:${index}`), + epicId, + acceptedAt: timestamp, + event: { + kind: "participant.joined", + sender: null, + receiver: participant.id, + exchangeId: null, + correlationId: null, + payload: { participant }, + createdAt: timestamp, + }, + }); + } + return epicId; +}); + +it.effect("opens once per sender-receiver pair, joins follow-ups, and one reply closes", () => + Effect.gen(function* () { + const epicId = yield* setupSameEpic(); + const service = yield* A2ASendService; + const sql = yield* SqlClient.SqlClient; + + const first = yield* service.send({ + commandId: CommCommandId.make("command:exchange:first"), + senderThreadId: sender.threadId, + to: receiver.id, + message: "Can you verify delivery?", + expectReply: true, + intent: "Verify the delivery path", + acceptedAt: timestamp, + }); + const followup = yield* service.send({ + commandId: CommCommandId.make("command:exchange:followup"), + senderThreadId: sender.threadId, + to: receiver.id, + message: "Please include the crash window.", + expectReply: true, + acceptedAt: timestamp, + }); + assert.equal(followup.exchangeId, first.exchangeId); + assert.isTrue(followup.joinedExistingExchange); + + const reply = yield* service.send({ + commandId: CommCommandId.make("command:exchange:reply"), + senderThreadId: receiver.threadId, + to: sender.id, + message: "Verified.", + exchangeId: first.exchangeId!, + acceptedAt: timestamp, + }); + assert.equal(reply.exchangeState, "closed"); + assert.deepStrictEqual( + yield* service.send({ + commandId: CommCommandId.make("command:exchange:first"), + senderThreadId: sender.threadId, + to: receiver.id, + message: "Can you verify delivery?", + expectReply: true, + intent: "Verify the delivery path", + acceptedAt: timestamp, + }), + first, + "the opening command replays its original result after closure", + ); + assert.deepStrictEqual( + yield* service.send({ + commandId: CommCommandId.make("command:exchange:followup"), + senderThreadId: sender.threadId, + to: receiver.id, + message: "Please include the crash window.", + expectReply: true, + acceptedAt: timestamp, + }), + followup, + "the follow-up command replays its original result after closure", + ); + + const rows = yield* sql<{ readonly kind: string; readonly count: number }>` + SELECT kind, COUNT(*) AS count + FROM j5_a2a_comm_event + WHERE epic_id = ${epicId} + AND kind IN ('exchange.opened', 'message.sent', 'exchange.closed') + GROUP BY kind + ORDER BY kind + `; + assert.deepStrictEqual(rows, [ + { kind: "exchange.closed", count: 1 }, + { kind: "exchange.opened", count: 1 }, + { kind: "message.sent", count: 3 }, + ]); + + const closedError = yield* Effect.flip( + service.send({ + commandId: CommCommandId.make("command:exchange:second-reply"), + senderThreadId: receiver.threadId, + to: sender.id, + message: "A duplicate reply.", + exchangeId: first.exchangeId!, + acceptedAt: timestamp, + }), + ); + assert.equal(closedError._tag, "A2AExchangeNotOpenError"); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("validates intent and human-only urgency at exchange open", () => + Effect.gen(function* () { + const epicId = yield* setupSameEpic(); + const ledgerService = yield* A2ALedger; + const service = yield* A2ASendService; + yield* ledgerService.append({ + commandId: CommCommandId.make("command:join:human"), + epicId, + acceptedAt: timestamp, + event: { + kind: "participant.joined", + sender: null, + receiver: ParticipantId.make("human:global"), + exchangeId: null, + correlationId: null, + payload: { participant: { kind: "human" } }, + createdAt: timestamp, + }, + }); + + const missingIntent = yield* Effect.flip( + service.send({ + commandId: CommCommandId.make("command:missing-intent"), + senderThreadId: sender.threadId, + to: receiver.id, + message: "Question", + expectReply: true, + acceptedAt: timestamp, + }), + ); + assert.equal(missingIntent._tag, "A2AIntentRequiredError"); + + const missingUrgency = yield* Effect.flip( + service.send({ + commandId: CommCommandId.make("command:missing-urgency"), + senderThreadId: sender.threadId, + to: ParticipantId.make("human:global"), + message: "Human question", + expectReply: true, + intent: "Obtain a human ruling", + acceptedAt: timestamp, + }), + ); + assert.equal(missingUrgency._tag, "A2AUrgencyRequiredError"); + + const wrongUrgency = yield* Effect.flip( + service.send({ + commandId: CommCommandId.make("command:wrong-urgency"), + senderThreadId: sender.threadId, + to: receiver.id, + message: "Agent question", + expectReply: true, + intent: "Ask an agent", + urgency: "soon", + acceptedAt: timestamp, + }), + ); + assert.equal(wrongUrgency._tag, "A2AUrgencyNotAcceptedError"); + + const oneShotUrgency = yield* Effect.flip( + service.send({ + commandId: CommCommandId.make("command:one-shot-urgency"), + senderThreadId: sender.threadId, + to: ParticipantId.make("human:global"), + message: "One-shot human message", + urgency: "fyi", + acceptedAt: timestamp, + }), + ); + assert.equal(oneShotUrgency._tag, "A2AUrgencyRequiresExchangeError"); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("rolls back the send receipt when its projection write fails", () => + Effect.gen(function* () { + yield* setupSameEpic(); + const service = yield* A2ASendService; + const sql = yield* SqlClient.SqlClient; + const command = CommCommandId.make("command:receipt-rollback"); + yield* sql` + CREATE TRIGGER j5_a2a_test_fail_exchange_projection + BEFORE INSERT ON j5_a2a_exchange + WHEN NEW.exchange_id LIKE 'exchange:j5:a2a:%' + BEGIN + SELECT RAISE(ABORT, 'forced projection failure'); + END + `; + + yield* Effect.flip( + service.send({ + commandId: command, + senderThreadId: sender.threadId, + to: receiver.id, + message: "This transaction must roll back.", + expectReply: true, + intent: "Prove receipt rollback", + acceptedAt: timestamp, + }), + ); + const poisoned = yield* sql<{ readonly receipts: number; readonly events: number }>` + SELECT + (SELECT COUNT(*) FROM j5_a2a_comm_command_receipt WHERE command_id = ${command}) AS receipts, + (SELECT COUNT(*) FROM j5_a2a_comm_event WHERE command_id = ${command}) AS events + `; + assert.deepStrictEqual(poisoned, [{ receipts: 0, events: 0 }]); + + yield* sql`DROP TRIGGER j5_a2a_test_fail_exchange_projection`; + const retry = yield* service.send({ + commandId: command, + senderThreadId: sender.threadId, + to: receiver.id, + message: "This transaction must roll back.", + expectReply: true, + intent: "Prove receipt rollback", + acceptedAt: timestamp, + }); + assert.equal(retry.exchangeState, "open"); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("lists membership-derived participant capabilities", () => + Effect.gen(function* () { + const epicId = yield* setupSameEpic(); + yield* (yield* A2ALedger).append({ + commandId: CommCommandId.make("command:list:join:human"), + epicId, + acceptedAt: timestamp, + event: { + kind: "participant.joined", + sender: null, + receiver: ParticipantId.make("human:global"), + exchangeId: null, + correlationId: null, + payload: { participant: { kind: "human" } }, + createdAt: timestamp, + }, + }); + const rows = yield* (yield* A2ASendService).listParticipants(sender.threadId); + assert.deepStrictEqual( + rows.map((row) => ({ + id: row.participantId, + canReceiveMessage: row.canReceiveMessage, + canOpenExchange: row.canOpenExchange, + acceptsUrgency: row.acceptsUrgency, + })), + [ + { + id: receiver.id, + canReceiveMessage: true, + canOpenExchange: true, + acceptsUrgency: false, + }, + { + id: sender.id, + canReceiveMessage: true, + canOpenExchange: true, + acceptsUrgency: false, + }, + { + id: ParticipantId.make("human:global"), + canReceiveMessage: true, + canOpenExchange: true, + acceptsUrgency: true, + }, + ], + ); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/j5/a2a/SendService.ts b/apps/server/src/j5/a2a/SendService.ts new file mode 100644 index 000000000000..f9ecd11a4e62 --- /dev/null +++ b/apps/server/src/j5/a2a/SendService.ts @@ -0,0 +1,462 @@ +import type { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { + CommCommandId, + type CommEvent, + CorrelationId, + EpicId, + ExchangeId, + GLOBAL_HUMAN_PARTICIPANT_ID, + LedgerMessageId, + Participant, + type ParticipantDirectoryRow, + type ParticipantId, + type SendMessageInput, + type SendMessageResult, + participantId, +} from "./contracts.ts"; +import { A2ALedger, type A2ALedgerError } from "./LedgerService.ts"; + +export class A2ASenderNotJoinedError extends Schema.TaggedErrorClass()( + "A2ASenderNotJoinedError", + { threadId: Schema.String }, +) { + override get message(): string { + return `Thread ${this.threadId} is not an active A2A participant. Join it to an epic, then call list_participants again.`; + } +} + +export class A2AParticipantNotFoundError extends Schema.TaggedErrorClass()( + "A2AParticipantNotFoundError", + { participantId: Schema.String }, +) { + override get message(): string { + return `Participant ${this.participantId} is not currently reachable. Call list_participants and choose a row with canReceiveMessage=true.`; + } +} + +export class A2AAmbiguousParticipantError extends Schema.TaggedErrorClass()( + "A2AAmbiguousParticipantError", + { participantId: Schema.String }, +) { + override get message(): string { + return `Participant ${this.participantId} is active in more than one epic. Resolve the duplicate membership, then call list_participants again.`; + } +} + +export class A2AIntentRequiredError extends Schema.TaggedErrorClass()( + "A2AIntentRequiredError", + {}, +) { + override get message(): string { + return "Opening an exchange requires intent. Retry send_message with a one-line intent summary."; + } +} + +export class A2AUrgencyRequiredError extends Schema.TaggedErrorClass()( + "A2AUrgencyRequiredError", + {}, +) { + override get message(): string { + return "Opening an exchange to the human requires urgency=blocking|soon|fyi. Retry send_message with urgency."; + } +} + +export class A2AUrgencyNotAcceptedError extends Schema.TaggedErrorClass()( + "A2AUrgencyNotAcceptedError", + { participantId: Schema.String }, +) { + override get message(): string { + return `Participant ${this.participantId} does not accept urgency. Retry send_message without urgency.`; + } +} + +export class A2AUrgencyRequiresExchangeError extends Schema.TaggedErrorClass()( + "A2AUrgencyRequiresExchangeError", + {}, +) { + override get message(): string { + return "Urgency applies only when opening a reply-expected exchange to the human. Retry without urgency, or set expect_reply=true with intent and urgency."; + } +} + +export class A2AExchangeNotOpenError extends Schema.TaggedErrorClass()( + "A2AExchangeNotOpenError", + { exchangeId: Schema.String }, +) { + override get message(): string { + return `Exchange ${this.exchangeId} is not open. Call send_message without exchange_id to start a new message or exchange.`; + } +} + +export class A2AExchangeParticipantMismatchError extends Schema.TaggedErrorClass()( + "A2AExchangeParticipantMismatchError", + { + exchangeId: Schema.String, + senderId: Schema.String, + receiverId: Schema.String, + }, +) { + override get message(): string { + return `Exchange ${this.exchangeId} does not connect ${this.senderId} to ${this.receiverId}. Call list_participants and use the exchange's original peer.`; + } +} + +export class A2AExchangeAlreadyAnsweredError extends Schema.TaggedErrorClass()( + "A2AExchangeAlreadyAnsweredError", + { exchangeId: Schema.String }, +) { + override get message(): string { + return `Exchange ${this.exchangeId} already has its one durable reply and is closing or closed. Call send_message without exchange_id to start a new message or exchange.`; + } +} + +export type A2ASendError = + | A2ALedgerError + | Schema.SchemaError + | SqlError + | A2ASenderNotJoinedError + | A2AParticipantNotFoundError + | A2AAmbiguousParticipantError + | A2AIntentRequiredError + | A2AUrgencyRequiredError + | A2AUrgencyNotAcceptedError + | A2AUrgencyRequiresExchangeError + | A2AExchangeNotOpenError + | A2AExchangeAlreadyAnsweredError + | A2AExchangeParticipantMismatchError; + +interface MembershipRow { + readonly epic_id: string; + readonly participant_id: string; + readonly thread_id: string | null; + readonly payload: string; +} + +interface ExchangeRow { + readonly epic_id: string; + readonly exchange_id: string; + readonly sender_id: string; + readonly receiver_id: string; + readonly status: "open" | "closed"; +} + +interface ExistingMessageRow { + readonly epic_id: string; + readonly sender_id: string; + readonly receiver_id: string; + readonly exchange_id: string | null; + readonly exchange_role: "none" | "ask" | "followup" | "reply"; + readonly sent_seq: number; +} + +const decodeParticipant = Schema.decodeUnknownEffect(Schema.fromJsonString(Participant)); + +const messageIdFor = (commandId: CommCommandId) => + LedgerMessageId.make(`message:j5:a2a:${encodeURIComponent(commandId)}`); + +const exchangeIdFor = (commandId: CommCommandId) => + ExchangeId.make(`exchange:j5:a2a:${encodeURIComponent(commandId)}`); + +const correlationIdFor = (commandId: CommCommandId) => + CorrelationId.make(`correlation:j5:a2a:${encodeURIComponent(commandId)}`); + +export interface A2ASendServiceShape { + readonly send: (input: SendMessageInput) => Effect.Effect; + readonly listParticipants: ( + senderThreadId: ThreadId, + ) => Effect.Effect, A2ASendError>; +} + +export class A2ASendService extends Context.Service()( + "t3/j5/a2a/SendService/A2ASendService", +) {} + +export const layer: Layer.Layer = + Layer.effect( + A2ASendService, + Effect.gen(function* () { + const ledger = yield* A2ALedger; + const sql = yield* SqlClient.SqlClient; + + const membershipRows = Effect.fn("j5.a2a.send.membershipRows")(function* () { + return yield* sql` + SELECT epic_id, participant_id, thread_id, payload + FROM j5_a2a_epic_membership + ORDER BY epic_id, participant_id + `; + }); + + const senderMembership = Effect.fn("j5.a2a.send.senderMembership")(function* ( + threadId: ThreadId, + ) { + const matches = (yield* membershipRows()).filter((row) => row.thread_id === threadId); + if (matches.length !== 1) { + return yield* new A2ASenderNotJoinedError({ threadId }); + } + const row = matches[0]!; + return { + epicId: EpicId.make(row.epic_id), + participantId: row.participant_id as ParticipantId, + }; + }); + + const participantMembership = Effect.fn("j5.a2a.send.participantMembership")(function* ( + id: ParticipantId, + senderEpicId: EpicId, + ) { + const matches = (yield* membershipRows()).filter((row) => row.participant_id === id); + if (id === GLOBAL_HUMAN_PARTICIPANT_ID) { + const local = matches.find((row) => row.epic_id === senderEpicId); + if (local === undefined) { + return yield* new A2AParticipantNotFoundError({ participantId: id }); + } + return { epicId: senderEpicId, participant: yield* decodeParticipant(local.payload) }; + } + if (matches.length === 0) { + return yield* new A2AParticipantNotFoundError({ participantId: id }); + } + if (matches.length > 1) { + return yield* new A2AAmbiguousParticipantError({ participantId: id }); + } + return { + epicId: EpicId.make(matches[0]!.epic_id), + participant: yield* decodeParticipant(matches[0]!.payload), + }; + }); + + const listParticipants: A2ASendServiceShape["listParticipants"] = (senderThreadId) => + Effect.gen(function* () { + const sender = yield* senderMembership(senderThreadId); + const rows = yield* membershipRows(); + const selected = rows.filter( + (row) => + row.participant_id !== GLOBAL_HUMAN_PARTICIPANT_ID || row.epic_id === sender.epicId, + ); + return yield* Effect.forEach( + selected, + (row) => + decodeParticipant(row.payload).pipe( + Effect.map((participant) => ({ + epicId: EpicId.make(row.epic_id), + participantId: participantId(participant), + participant, + canReceiveMessage: true, + canOpenExchange: true, + acceptsUrgency: participant.kind === "human", + })), + ), + { concurrency: 1 }, + ); + }); + + const replayedSend = Effect.fn("j5.a2a.send.replayedSend")(function* ( + messageId: LedgerMessageId, + senderId: ParticipantId, + ) { + const rows = yield* sql` + SELECT + epic_id, + sender_id, + receiver_id, + exchange_id, + exchange_role, + sent_seq + FROM j5_a2a_delivery + WHERE message_id = ${messageId} + AND sender_id = ${senderId} + LIMIT 2 + `; + if (rows.length !== 1) return null; + const row = rows[0]!; + const exchange = + row.exchange_id === null + ? [] + : yield* sql` + SELECT epic_id, exchange_id, sender_id, receiver_id, status + FROM j5_a2a_exchange + WHERE exchange_id = ${row.exchange_id} + LIMIT 1 + `; + const isCrossEpicReply = + exchange[0] !== undefined && + exchange[0].epic_id !== row.epic_id && + exchange[0].receiver_id === row.sender_id && + exchange[0].sender_id === row.receiver_id; + return { + messageId, + exchangeId: row.exchange_id === null ? null : ExchangeId.make(row.exchange_id), + exchangeState: + row.exchange_role === "none" + ? ("none" as const) + : row.exchange_role !== "reply" + ? ("open" as const) + : isCrossEpicReply + ? ("closing" as const) + : ("closed" as const), + joinedExistingExchange: row.exchange_role === "followup", + durableAtSeq: row.sent_seq, + } satisfies SendMessageResult; + }); + + const send: A2ASendServiceShape["send"] = (input) => + Effect.gen(function* () { + const messageId = messageIdFor(input.commandId); + const sender = yield* senderMembership(input.senderThreadId); + const replay = yield* replayedSend(messageId, sender.participantId); + if (replay !== null) return replay; + + const receiver = yield* participantMembership(input.to, sender.epicId); + const receiverId = participantId(receiver.participant); + let exchangeId: ExchangeId | null = null; + let exchangeState: SendMessageResult["exchangeState"] = "none"; + let exchangeRole: "none" | "ask" | "followup" | "reply" = "none"; + let joinedExistingExchange = false; + let openEvent: CommEvent | undefined; + let closeEvent: CommEvent | undefined; + + if (input.exchangeId !== undefined) { + if (input.urgency !== undefined) { + return yield* new A2AUrgencyRequiresExchangeError(); + } + const rows = yield* sql` + SELECT epic_id, exchange_id, sender_id, receiver_id, status + FROM j5_a2a_exchange + WHERE exchange_id = ${input.exchangeId} + LIMIT 2 + `; + const exchange = rows.length === 1 ? rows[0] : undefined; + if (exchange === undefined || exchange.status !== "open") { + return yield* new A2AExchangeNotOpenError({ exchangeId: input.exchangeId }); + } + const isFollowup = + exchange.sender_id === sender.participantId && exchange.receiver_id === receiverId; + const isReply = + exchange.receiver_id === sender.participantId && exchange.sender_id === receiverId; + if (!isFollowup && !isReply) { + return yield* new A2AExchangeParticipantMismatchError({ + exchangeId: input.exchangeId, + senderId: sender.participantId, + receiverId, + }); + } + exchangeId = input.exchangeId; + joinedExistingExchange = isFollowup; + if (isReply) { + const acceptedReplies = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM j5_a2a_delivery + WHERE exchange_id = ${exchangeId} AND exchange_role = 'reply' + `; + if ((acceptedReplies[0]?.count ?? 0) > 0) { + return yield* new A2AExchangeAlreadyAnsweredError({ exchangeId }); + } + exchangeRole = "reply"; + exchangeState = exchange.epic_id === sender.epicId ? "closed" : "closing"; + if (exchange.epic_id === sender.epicId) { + closeEvent = { + kind: "exchange.closed", + sender: sender.participantId, + receiver: receiverId, + exchangeId, + correlationId: correlationIdFor(input.commandId), + payload: { replyMessageId: messageId }, + createdAt: input.acceptedAt, + }; + } + } else { + exchangeRole = "followup"; + exchangeState = "open"; + } + } else if (input.expectReply === true) { + const existing = yield* sql` + SELECT epic_id, exchange_id, sender_id, receiver_id, status + FROM j5_a2a_exchange + WHERE epic_id = ${sender.epicId} + AND sender_id = ${sender.participantId} + AND receiver_id = ${receiverId} + AND status = 'open' + LIMIT 1 + `; + if (existing[0] !== undefined) { + exchangeId = ExchangeId.make(existing[0].exchange_id); + joinedExistingExchange = true; + exchangeRole = "followup"; + } else { + if (input.intent === undefined) return yield* new A2AIntentRequiredError(); + if (receiver.participant.kind === "human" && input.urgency === undefined) { + return yield* new A2AUrgencyRequiredError(); + } + if (receiver.participant.kind !== "human" && input.urgency !== undefined) { + return yield* new A2AUrgencyNotAcceptedError({ participantId: receiverId }); + } + exchangeId = exchangeIdFor(input.commandId); + exchangeRole = "ask"; + openEvent = { + kind: "exchange.opened", + sender: sender.participantId, + receiver: receiverId, + exchangeId, + correlationId: correlationIdFor(input.commandId), + payload: { + intent: input.intent, + urgency: input.urgency ?? null, + }, + createdAt: input.acceptedAt, + }; + } + exchangeState = "open"; + } else if (input.urgency !== undefined) { + return yield* new A2AUrgencyRequiresExchangeError(); + } + + const correlationId = correlationIdFor(input.commandId); + const result = yield* ledger.appendEvents({ + commandId: input.commandId, + epicId: sender.epicId, + acceptedAt: input.acceptedAt, + events: [ + ...(openEvent === undefined ? [] : [openEvent]), + { + kind: "message.sent", + sender: sender.participantId, + receiver: receiverId, + exchangeId, + correlationId, + payload: { + messageId, + text: input.message, + originEpicId: sender.epicId, + receiverEpicId: receiver.epicId, + exchangeRole, + }, + createdAt: input.acceptedAt, + }, + ...(closeEvent === undefined ? [] : [closeEvent]), + ], + }); + const sent = result.events.find((event) => event.kind === "message.sent"); + if (sent === undefined) { + return yield* new A2AParticipantNotFoundError({ participantId: receiverId }); + } + const opened = result.events.some((event) => event.kind === "exchange.opened"); + const closed = result.events.some((event) => event.kind === "exchange.closed"); + return { + messageId, + exchangeId, + exchangeState: closed ? "closed" : exchangeState, + joinedExistingExchange: + exchangeId !== null && !opened && !closed ? joinedExistingExchange : false, + durableAtSeq: result.receipt.resultSeq, + } satisfies SendMessageResult; + }); + + return A2ASendService.of({ send, listParticipants }); + }), + ); diff --git a/apps/server/src/j5/a2a/contracts.ts b/apps/server/src/j5/a2a/contracts.ts index 99f5b021123c..91191257838d 100644 --- a/apps/server/src/j5/a2a/contracts.ts +++ b/apps/server/src/j5/a2a/contracts.ts @@ -23,6 +23,12 @@ export type ParticipantId = typeof ParticipantId.Type; export const CommCommandId = Identifier.pipe(Schema.brand("J5A2ACommCommandId")); export type CommCommandId = typeof CommCommandId.Type; +export const LedgerMessageId = Identifier.pipe(Schema.brand("J5A2ALedgerMessageId")); +export type LedgerMessageId = typeof LedgerMessageId.Type; + +export const Urgency = Schema.Literals(["blocking", "soon", "fyi"]); +export type Urgency = typeof Urgency.Type; + export const GLOBAL_HUMAN_PARTICIPANT_ID = ParticipantId.make("human:global"); export const AgentParticipant = Schema.Struct({ @@ -156,6 +162,98 @@ export const CommCommandReceipt = Schema.Struct({ }); export type CommCommandReceipt = typeof CommCommandReceipt.Type; +export const AppendCommEventsCommand = Schema.Struct({ + commandId: CommCommandId, + epicId: EpicId, + acceptedAt: Schema.String, + events: Schema.Array(CommEvent).pipe(Schema.check(Schema.isMinLength(1))), +}); +export type AppendCommEventsCommand = typeof AppendCommEventsCommand.Type; + +export const ExchangeOpenedPayload = Schema.Struct({ + intent: Schema.String.check(Schema.isNonEmpty()), + urgency: Schema.NullOr(Urgency), +}); +export type ExchangeOpenedPayload = typeof ExchangeOpenedPayload.Type; + +export const MessageSentPayload = Schema.Struct({ + messageId: LedgerMessageId, + text: Schema.String.check(Schema.isNonEmpty()), + originEpicId: EpicId, + receiverEpicId: EpicId, + exchangeRole: Schema.Literals(["none", "ask", "followup", "reply"]), +}); +export type MessageSentPayload = typeof MessageSentPayload.Type; + +export const MessageDeliveredPayload = Schema.Struct({ + messageId: LedgerMessageId, + attempt: PositiveInt, + channel: Schema.Literals(["agent", "human"]), +}); +export type MessageDeliveredPayload = typeof MessageDeliveredPayload.Type; + +export const MessageDeliveryFailedPayload = Schema.Struct({ + messageId: LedgerMessageId, + attempt: PositiveInt, + error: Schema.String.check(Schema.isNonEmpty()), + nextAttemptAt: Schema.NullOr(Schema.String), + alarmed: Schema.Boolean, +}); +export type MessageDeliveryFailedPayload = typeof MessageDeliveryFailedPayload.Type; + +export const ExchangeClosedPayload = Schema.Struct({ + replyMessageId: LedgerMessageId, +}); +export type ExchangeClosedPayload = typeof ExchangeClosedPayload.Type; + +export const SendMessageInput = Schema.Struct({ + commandId: CommCommandId, + senderThreadId: ThreadId, + to: ParticipantId, + message: Schema.String.check(Schema.isNonEmpty()), + expectReply: Schema.optional(Schema.Boolean), + exchangeId: Schema.optional(ExchangeId), + intent: Schema.optional(Schema.String.check(Schema.isNonEmpty())), + urgency: Schema.optional(Urgency), + acceptedAt: Schema.String, +}); +export type SendMessageInput = typeof SendMessageInput.Type; + +export const SendMessageResult = Schema.Struct({ + messageId: LedgerMessageId, + exchangeId: Schema.NullOr(ExchangeId), + exchangeState: Schema.Literals(["none", "open", "closing", "closed"]), + joinedExistingExchange: Schema.Boolean, + durableAtSeq: PositiveInt, +}); +export type SendMessageResult = typeof SendMessageResult.Type; + +export const ParticipantDirectoryRow = Schema.Struct({ + epicId: EpicId, + participantId: ParticipantId, + participant: Participant, + canReceiveMessage: Schema.Boolean, + canOpenExchange: Schema.Boolean, + acceptsUrgency: Schema.Boolean, +}); +export type ParticipantDirectoryRow = typeof ParticipantDirectoryRow.Type; + +export const DeliveryAlarm = Schema.Struct({ + epicId: EpicId, + messageId: LedgerMessageId, + attempts: PositiveInt, + lastError: Schema.String, +}); +export type DeliveryAlarm = typeof DeliveryAlarm.Type; + +export const DeliveryMilestone = Schema.Struct({ + epicId: EpicId, + messageId: LedgerMessageId, + state: Schema.Literals(["delivered", "retry_scheduled", "alarmed"]), + attempt: PositiveInt, +}); +export type DeliveryMilestone = typeof DeliveryMilestone.Type; + export const LedgerCursor = Schema.Struct({ afterSeq: NonNegativeInt, snapshotEnd: Schema.optional(NonNegativeInt), diff --git a/apps/server/src/j5/a2a/delivery-config.v1.json b/apps/server/src/j5/a2a/delivery-config.v1.json new file mode 100644 index 000000000000..71c4550055ce --- /dev/null +++ b/apps/server/src/j5/a2a/delivery-config.v1.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "alarmAfterAttempts": 3, + "initialBackoffMs": 250, + "maximumBackoffMs": 5000 +} diff --git a/apps/server/src/j5/a2a/envelopes.v1.json b/apps/server/src/j5/a2a/envelopes.v1.json new file mode 100644 index 000000000000..2e2de9699057 --- /dev/null +++ b/apps/server/src/j5/a2a/envelopes.v1.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "peerMessage": "[J5 A2A message from {{senderId}} in {{originEpicId}}]\n\n{{message}}\n\n{{exchangeInstruction}}", + "humanMessage": "[Message from the human]\n\n{{message}}\n\nThe human is not watching this chat. They see only what you send back on this exchange.\n\n{{exchangeInstruction}}", + "silenceNotice": "[J5 system notice: {{noticeType}}]\n\n{{message}}\n\nThis is a platform-authored delivery signal, not a peer reply.", + "replyInstruction": "Reply once with send_message(to=\"{{senderId}}\", exchange_id=\"{{exchangeId}}\", message=\"...\") to close the exchange. Follow-ups from the asker carrying this id join the same exchange.", + "oneShotInstruction": "No reply is required. Use send_message without exchange_id only if a new message is needed.", + "sendToolDescription": "Durably send one J5 A2A message. expect_reply=true opens or joins one sender-to-receiver exchange and requires intent; a reply is send_message carrying exchange_id, and one reply closes that exchange. urgency is required only when opening an exchange to the human. The call returns after the sender ledger commit; delivery continues asynchronously.", + "listToolDescription": "List reachable J5 A2A participants and the capabilities accepted by each row. Use this before send_message instead of discovering participant state through failures." +} diff --git a/apps/server/src/j5/a2a/mcp/handlers.ts b/apps/server/src/j5/a2a/mcp/handlers.ts new file mode 100644 index 000000000000..a463aa68e3ce --- /dev/null +++ b/apps/server/src/j5/a2a/mcp/handlers.ts @@ -0,0 +1,49 @@ +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import { McpInvocationContext } from "../../../mcp/McpInvocationContext.ts"; +import { A2ADeliveryWorker } from "../DeliveryWorker.ts"; +import { A2ASendService } from "../SendService.ts"; +import { CommCommandId } from "../contracts.ts"; +import { J5Toolkit, type J5McpFailure } from "./tools.ts"; + +const failure = (error: unknown): J5McpFailure => ({ + code: + typeof error === "object" && error !== null && "_tag" in error + ? String(error._tag) + : "J5A2AError", + message: error instanceof Error ? error.message : String(error), +}); + +const handlers = { + send_message: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const crypto = yield* Crypto.Crypto; + const service = yield* A2ASendService; + const worker = yield* A2ADeliveryWorker; + const acceptedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const result = yield* service.send({ + commandId: CommCommandId.make(`command:j5:a2a:mcp:${yield* crypto.randomUUIDv4}`), + senderThreadId: scope.threadId, + to: input.to, + message: input.message, + ...(input.expect_reply === undefined ? {} : { expectReply: input.expect_reply }), + ...(input.exchange_id === undefined ? {} : { exchangeId: input.exchange_id }), + ...(input.intent === undefined ? {} : { intent: input.intent }), + ...(input.urgency === undefined ? {} : { urgency: input.urgency }), + acceptedAt, + }); + yield* worker.notify; + return result; + }).pipe(Effect.mapError(failure)), + list_participants: () => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* A2ASendService; + return { participants: yield* service.listParticipants(scope.threadId) }; + }).pipe(Effect.mapError(failure)), +} satisfies Parameters[0]; + +export const J5ToolkitHandlersLive = J5Toolkit.toLayer(handlers); diff --git a/apps/server/src/j5/a2a/mcp/registration.ts b/apps/server/src/j5/a2a/mcp/registration.ts new file mode 100644 index 000000000000..d65683acc839 --- /dev/null +++ b/apps/server/src/j5/a2a/mcp/registration.ts @@ -0,0 +1,10 @@ +import * as Layer from "effect/Layer"; +import { McpServer } from "effect/unstable/ai"; + +import { J5ToolkitHandlersLive } from "./handlers.ts"; +import { J5Toolkit } from "./tools.ts"; + +/** The single shared J5 MCP registration; later J5 milestones extend J5Toolkit only. */ +export const J5ToolkitRegistrationLive = McpServer.toolkit(J5Toolkit).pipe( + Layer.provide(J5ToolkitHandlersLive), +); diff --git a/apps/server/src/j5/a2a/mcp/tools.ts b/apps/server/src/j5/a2a/mcp/tools.ts new file mode 100644 index 000000000000..2792e8d5ce5e --- /dev/null +++ b/apps/server/src/j5/a2a/mcp/tools.ts @@ -0,0 +1,72 @@ +import { Tool, Toolkit } from "effect/unstable/ai"; +import * as Schema from "effect/Schema"; + +import * as Crypto from "effect/Crypto"; +import * as McpInvocationContext from "../../../mcp/McpInvocationContext.ts"; +import { A2A_LIST_TOOL_DESCRIPTION, A2A_SEND_TOOL_DESCRIPTION } from "../EnvelopeFormatter.ts"; +import { A2ADeliveryWorker } from "../DeliveryWorker.ts"; +import { A2ASendService } from "../SendService.ts"; +import { + ExchangeId, + ParticipantDirectoryRow, + ParticipantId, + SendMessageResult, + Urgency, +} from "../contracts.ts"; + +export const J5McpFailure = Schema.Struct({ + code: Schema.String, + message: Schema.String, +}); +export type J5McpFailure = typeof J5McpFailure.Type; + +export const J5SendMessageInput = Schema.Struct({ + to: ParticipantId, + message: Schema.String.check(Schema.isNonEmpty()), + expect_reply: Schema.optional(Schema.Boolean), + exchange_id: Schema.optional(ExchangeId), + intent: Schema.optional(Schema.String.check(Schema.isNonEmpty())), + urgency: Schema.optional(Urgency), +}); +export type J5SendMessageInput = typeof J5SendMessageInput.Type; + +export const J5ListParticipantsResult = Schema.Struct({ + participants: Schema.Array(ParticipantDirectoryRow), +}); + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + A2ASendService, + A2ADeliveryWorker, + Crypto.Crypto, +]; + +export const J5SendMessageTool = Tool.make("send_message", { + description: A2A_SEND_TOOL_DESCRIPTION, + parameters: J5SendMessageInput, + success: SendMessageResult, + failure: J5McpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Send a J5 A2A message") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, true); + +export const J5ListParticipantsTool = Tool.make("list_participants", { + description: A2A_LIST_TOOL_DESCRIPTION, + success: J5ListParticipantsResult, + failure: J5McpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "List J5 A2A participants") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +/** Shared J5 toolkit bootstrap. Later J5 milestones append their tools here. */ +export const J5Toolkit = Toolkit.make(J5SendMessageTool, J5ListParticipantsTool); diff --git a/apps/server/src/j5/a2a/migrations/002_SendDeliverReply.ts b/apps/server/src/j5/a2a/migrations/002_SendDeliverReply.ts new file mode 100644 index 000000000000..2eb25cc036a0 --- /dev/null +++ b/apps/server/src/j5/a2a/migrations/002_SendDeliverReply.ts @@ -0,0 +1,83 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql`ALTER TABLE j5_a2a_comm_event ADD COLUMN command_id TEXT`; + yield* sql` + CREATE INDEX j5_a2a_comm_event_command_idx + ON j5_a2a_comm_event(command_id, epic_id, seq) + WHERE command_id IS NOT NULL + `; + + yield* sql` + CREATE TABLE j5_a2a_exchange ( + epic_id TEXT NOT NULL, + exchange_id TEXT NOT NULL, + sender_id TEXT NOT NULL, + receiver_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('open', 'closed')), + intent TEXT NOT NULL CHECK (length(trim(intent)) > 0), + urgency TEXT CHECK (urgency IN ('blocking', 'soon', 'fyi')), + opened_seq INTEGER NOT NULL, + closed_seq INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (epic_id, exchange_id), + FOREIGN KEY (epic_id) REFERENCES j5_a2a_epic(id) ON DELETE CASCADE + ) + `; + yield* sql` + CREATE UNIQUE INDEX j5_a2a_exchange_open_pair_idx + ON j5_a2a_exchange(epic_id, sender_id, receiver_id) + WHERE status = 'open' + `; + + yield* sql` + CREATE TABLE j5_a2a_delivery ( + epic_id TEXT NOT NULL, + message_id TEXT NOT NULL, + command_id TEXT NOT NULL, + sent_seq INTEGER NOT NULL, + sender_id TEXT NOT NULL, + receiver_id TEXT NOT NULL, + receiver_epic_id TEXT NOT NULL, + exchange_id TEXT, + exchange_role TEXT NOT NULL CHECK (exchange_role IN ('none', 'ask', 'followup', 'reply')), + correlation_id TEXT NOT NULL, + message_text TEXT NOT NULL CHECK (length(message_text) > 0), + status TEXT NOT NULL CHECK (status IN ('pending', 'retry_scheduled', 'delivered', 'alarmed')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + last_error TEXT, + next_attempt_at TEXT, + delivered_seq INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (epic_id, message_id), + FOREIGN KEY (epic_id) REFERENCES j5_a2a_epic(id) ON DELETE CASCADE + ) + `; + yield* sql` + CREATE INDEX j5_a2a_delivery_drain_idx + ON j5_a2a_delivery(status, next_attempt_at, sent_seq) + `; + yield* sql` + CREATE UNIQUE INDEX j5_a2a_delivery_one_reply_idx + ON j5_a2a_delivery(exchange_id) + WHERE exchange_id IS NOT NULL AND exchange_role = 'reply' + `; + + yield* sql` + CREATE TABLE j5_a2a_human_inbox_data ( + origin_epic_id TEXT NOT NULL, + message_id TEXT NOT NULL, + exchange_id TEXT, + sender_id TEXT NOT NULL, + payload TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (origin_epic_id, message_id), + FOREIGN KEY (origin_epic_id) REFERENCES j5_a2a_epic(id) ON DELETE CASCADE + ) + `; +}); diff --git a/apps/server/src/j5/a2a/runtimeLayer.ts b/apps/server/src/j5/a2a/runtimeLayer.ts new file mode 100644 index 000000000000..7377eca65c68 --- /dev/null +++ b/apps/server/src/j5/a2a/runtimeLayer.ts @@ -0,0 +1,22 @@ +import * as Layer from "effect/Layer"; + +import { layer as deliveryWorkerLayer } from "./DeliveryWorker.ts"; +import { live as deliveryTransportLayer } from "./DeliveryTransport.ts"; +import { layer as ledgerLayer } from "./LedgerService.ts"; +import { layer as sendServiceLayer } from "./SendService.ts"; + +const ledgerProvided = ledgerLayer; +const deliveryTransportProvided = deliveryTransportLayer; +const sendServiceProvided = sendServiceLayer.pipe(Layer.provide(ledgerProvided)); +const deliveryWorkerProvided = deliveryWorkerLayer.pipe( + Layer.provide(ledgerProvided), + Layer.provide(deliveryTransportProvided), +); + +/** Production J5 A2A services; SQL and V2 thread management stay shared runtime dependencies. */ +export const J5A2ARuntimeLayer = Layer.mergeAll( + ledgerProvided, + deliveryTransportProvided, + sendServiceProvided, + deliveryWorkerProvided, +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 03492c3ef366..f9df569748a3 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -28,6 +28,7 @@ import { import { WorktreeToolkitHandlersLive } from "./toolkits/worktree/handlers.ts"; import { WorktreeToolkit } from "./toolkits/worktree/tools.ts"; import * as WorktreeMcpService from "./WorktreeMcpService.ts"; +import { J5ToolkitRegistrationLive } from "../j5/a2a/mcp/registration.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -243,4 +244,6 @@ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, OrchestratorToolkitRegistrationLive, WorktreeToolkitRegistrationLive, + // J5 fork extension: one shared toolkit registration for all J5-owned tools. + J5ToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 3300a869fe67..37856839cc7c 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -16,6 +16,8 @@ import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts import { ScheduledTaskService } from "../../../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../../vcs/VcsStatusBroadcaster.ts"; +import { A2ADeliveryWorker } from "../../../j5/a2a/DeliveryWorker.ts"; +import { A2ASendService } from "../../../j5/a2a/SendService.ts"; import * as McpHttpServer from "../../McpHttpServer.ts"; import * as McpSessionRegistry from "../../McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; @@ -29,6 +31,8 @@ const StubServicesLive = Layer.mergeAll( Layer.mock(GitWorkflowService.GitWorkflowService)({}), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), Layer.mock(VcsStatusBroadcaster)({}), + Layer.mock(A2ASendService)({}), + Layer.mock(A2ADeliveryWorker)({}), ); const ToolsListPayload = Schema.fromJsonString( @@ -78,6 +82,15 @@ it.effect("production mcp layer lists worktree tools over http", () => expect(credential).toBeDefined(); const httpClient = yield* HttpClient.HttpClient; + const unauthorizedResponse = yield* httpClient.post("/mcp", { + headers: { accept: "application/json, text/event-stream" }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"unauthorized","version":"1.0.0"}}}`, + "application/json", + ), + }); + expect(unauthorizedResponse.status).toBe(401); + const auth = credential!.config.authorizationHeader; const initResponse = yield* httpClient.post("/mcp", { headers: { @@ -114,6 +127,10 @@ it.effect("production mcp layer lists worktree tools over http", () => // than replacing them. expect(toolNames).toContain("preview_status"); expect(toolNames).toContain("delegate_task"); + // J5 uses the same authenticated transport and one shared registration + // that later J5 milestones extend inside the fork-owned toolkit. + expect(toolNames).toContain("send_message"); + expect(toolNames).toContain("list_participants"); // The handoff tool mutates thread state, reaches the network (origin // fetch), and runs project setup scripts, so its MCP hints must not diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 4200c6aa3d43..100b86e041fd 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -185,6 +185,54 @@ it.layer(TestLayer)("OrchestrationV2LayerLive", (it) => { }), ); + it.effect("replays an internal thread send without injecting a second message", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const threadManagement = yield* ThreadManagementService; + const threadId = ThreadId.make("runtime-layer-idempotent-thread-send"); + const projectId = ProjectId.make("runtime-layer-idempotent-thread-send-project"); + const commandId = CommandId.make("runtime-layer-idempotent-thread-send-command"); + const messageId = MessageId.make("runtime-layer-idempotent-thread-send-message"); + + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-idempotent-thread-send-create"), + threadId, + projectId, + title: "Idempotent internal send", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: "/tmp/runtime-layer-idempotent-thread-send", + }); + + const input = { + projectId, + commandId, + threadId, + messageId, + text: "Deliver exactly once", + attachments: [], + mode: "auto" as const, + createdBy: "agent" as const, + creationSource: "mcp" as const, + }; + const first = yield* threadManagement.sendToThread(input); + const replay = yield* threadManagement.sendToThread(input); + const projection = yield* threadManagement.getThreadProjection(threadId); + + assert.equal(replay.dispatch.sequence, first.dispatch.sequence); + assert.deepEqual(replay.dispatch.storedEvents, first.dispatch.storedEvents); + assert.equal(replay.message.id, first.message.id); + assert.equal(replay.run.id, first.run.id); + assert.equal(projection.messages.filter((message) => message.id === messageId).length, 1); + assert.equal(projection.runs.filter((run) => run.userMessageId === messageId).length, 1); + }), + ); + it.effect("merges an explicit provider-finished run while checkpoint capture is pending", () => Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 73af833e3b75..e9a154f177a8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -47,6 +47,7 @@ import * as PortScanner from "./preview/PortScanner.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; +import { J5A2ARuntimeLayer } from "./j5/a2a/runtimeLayer.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; @@ -346,6 +347,8 @@ const OrchestrationApplicationLayerLive = CheckpointDiffQuery.layer.pipe( const RuntimeCoreDependenciesBaseLive = AgentAwarenessRelay.layer.pipe( // Core Services Layer.provideMerge(OrchestrationApplicationLayerLive), + // J5 fork extension: durable A2A ledger plus startup delivery reconciliation. + Layer.provideMerge(J5A2ARuntimeLayer.pipe(Layer.provide(OrchestrationV2RuntimeLayerLive))), Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), From 7ebdeaf5849226b75baa9513404320fb5f4b06ff Mon Sep 17 00:00:00 2001 From: Jackson Miller Date: Sun, 16 Aug 2026 22:58:10 -0400 Subject: [PATCH 02/12] fix: address A2 review findings --- FORK.md | 12 +- .../src/j5/a2a/EnvelopeFormatter.test.ts | 2 + apps/server/src/j5/a2a/EnvelopeFormatter.ts | 1 + .../src/j5/a2a/EpicBootstrapService.test.ts | 142 ++++++++++++++ .../server/src/j5/a2a/EpicBootstrapService.ts | 178 ++++++++++++++++++ apps/server/src/j5/a2a/LedgerService.test.ts | 50 ++++- apps/server/src/j5/a2a/LedgerService.ts | 152 +++------------ apps/server/src/j5/a2a/README.md | 6 + apps/server/src/j5/a2a/SendService.test.ts | 47 +++++ apps/server/src/j5/a2a/SendService.ts | 30 ++- apps/server/src/j5/a2a/contracts.ts | 15 ++ apps/server/src/j5/a2a/envelopes.v1.json | 5 +- apps/server/src/j5/a2a/mcp/handlers.test.ts | 98 ++++++++++ apps/server/src/j5/a2a/mcp/handlers.ts | 28 ++- apps/server/src/j5/a2a/mcp/registration.ts | 6 + apps/server/src/j5/a2a/mcp/tools.ts | 31 ++- apps/server/src/j5/a2a/runtimeLayer.test.ts | 44 +++++ apps/server/src/j5/a2a/runtimeLayer.ts | 31 +-- apps/server/src/mcp/McpHttpServer.ts | 4 +- .../toolkits/worktree/registration.test.ts | 7 +- apps/server/src/server.ts | 3 - 21 files changed, 727 insertions(+), 165 deletions(-) create mode 100644 apps/server/src/j5/a2a/EpicBootstrapService.test.ts create mode 100644 apps/server/src/j5/a2a/EpicBootstrapService.ts create mode 100644 apps/server/src/j5/a2a/mcp/handlers.test.ts create mode 100644 apps/server/src/j5/a2a/runtimeLayer.test.ts diff --git a/FORK.md b/FORK.md index 639b4f6fc4ec..e593bfe20401 100644 --- a/FORK.md +++ b/FORK.md @@ -37,10 +37,14 @@ Treat these upstream areas as off-limits except for those explicit appended case ### Sanctioned appended integration cases -- `apps/server/src/persistence/Layers/Sqlite.ts` runs the independent J5 migration lane after upstream migrations. -- `apps/server/src/mcp/McpHttpServer.ts` registers exactly one shared J5 MCP toolkit. A2 owns the bootstrap and A6 reuses it by adding tools only inside `apps/server/src/j5/a2a/mcp/`, without another protected-file registration edit. -- `apps/server/src/server.ts` provides the J5 A2A runtime independently of the MCP transport so the ledger and startup delivery reconciliation are always active. -- Focused upstream integration tests may append a case that proves a J5 dependency contract at the pinned runtime without changing upstream production behavior. +Against upstream pin `993407dd9e57f1edf2f5681d70140bfefeca93cc`, the complete A2A exception inventory is exactly these four cases. Line numbers identify this revision; the named symbol or test is the durable anchor after nearby upstream movement. + +1. A1's independent migration lane: `apps/server/src/persistence/Layers/Sqlite.ts:10` imports `runJ5A2AMigrations`, and `:42` runs it after upstream migrations. Introduced by `a064a87ac40ea2d2d936ba72008c95edeb8bbc2b` and merged in `521c50aa9bb6b4c7f55bc10a772822ec31129f2d`. +2. The one shared authenticated J5 MCP/runtime seam: `apps/server/src/mcp/McpHttpServer.ts:31` imports `J5McpIntegrationLive`, and `:247-248` append its sole entry to `layer`. Registration and runtime composition stay in `apps/server/src/j5/a2a/mcp/registration.ts`; A6 extends the J5-owned toolkit without another protected-file registration. +3. The internal delivery-dedup contract proof: `apps/server/src/orchestration-v2/runtimeLayer.test.ts:188-234`, test `replays an internal thread send without injecting a second message`. +4. The authenticated shared-toolkit integration proof: `apps/server/src/mcp/toolkits/worktree/registration.test.ts:15,70,83-90,128-132`, within test `production mcp layer lists worktree tools over http`. + +These are per-instance Director/Jackson-authorized exceptions, not standing category permission. The earlier fork rebrand is separately complete at `0c0de1acefea00a34f9529bb97be32ff5056cfcc`; its rebase-critical boundary is recorded in `BRANDING.md:1-5,24-35`. The supporting fork setup plan (`artifacts/fork-setup-plan/index.md:8-12`) and its six T1-T6 ticket artifacts are internal project records and are not present in this repository. If a required change cannot fit this discipline, stop and review the exception before implementing it. diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts index fee9ea7aa8dd..18137eec416c 100644 --- a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import { A2A_ENVELOPE_VERSION, + A2A_JOIN_TOOL_DESCRIPTION, A2A_LIST_TOOL_DESCRIPTION, A2A_SEND_TOOL_DESCRIPTION, formatHumanEnvelope, @@ -37,4 +38,5 @@ it("tells agents that human-origin exchanges require an explicit tool reply", () assert.include(rendered, 'exchange_id="exchange:human"'); assert.include(A2A_SEND_TOOL_DESCRIPTION, "returns after the sender ledger commit"); assert.include(A2A_LIST_TOOL_DESCRIPTION, "reachable J5 A2A participants"); + assert.include(A2A_JOIN_TOOL_DESCRIPTION, "authenticated thread"); }); diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.ts index 6aca19c4eac2..2e79f86f38db 100644 --- a/apps/server/src/j5/a2a/EnvelopeFormatter.ts +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.ts @@ -5,6 +5,7 @@ import type { EpicId, ExchangeId, ParticipantId } from "./contracts.ts"; export const A2A_ENVELOPE_VERSION = config.version; export const A2A_SEND_TOOL_DESCRIPTION = config.sendToolDescription; export const A2A_LIST_TOOL_DESCRIPTION = config.listToolDescription; +export const A2A_JOIN_TOOL_DESCRIPTION = config.joinToolDescription; const render = (template: string, values: Readonly>): string => Object.entries(values).reduce( diff --git a/apps/server/src/j5/a2a/EpicBootstrapService.test.ts b/apps/server/src/j5/a2a/EpicBootstrapService.test.ts new file mode 100644 index 000000000000..eef88ff1eeee --- /dev/null +++ b/apps/server/src/j5/a2a/EpicBootstrapService.test.ts @@ -0,0 +1,142 @@ +import { assert, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as NodeSqliteClient from "../../persistence/NodeSqliteClient.ts"; +import { A2AEpicBootstrap, layer as bootstrapLayer } from "./EpicBootstrapService.ts"; +import { A2ALedger, layer as ledgerLayer } from "./LedgerService.ts"; +import { runJ5A2AMigrations } from "./Migrations.ts"; +import { CommCommandId, EpicId, ParticipantId } from "./contracts.ts"; + +const timestamp = "2026-08-16T12:00:00.000Z"; +const threadId = ThreadId.make("thread:bootstrap"); + +const database = NodeSqliteClient.layerMemory(); +const ledger = ledgerLayer.pipe(Layer.provide(database)); +const bootstrap = bootstrapLayer.pipe(Layer.provide(ledger)); +const testLayer = Layer.mergeAll(database, ledger, bootstrap); + +it.effect("creates, reuses, and explicitly changes the caller's selected epic", () => + Effect.gen(function* () { + yield* runJ5A2AMigrations(); + const service = yield* A2AEpicBootstrap; + const ledgerService = yield* A2ALedger; + const sql = yield* SqlClient.SqlClient; + + const created = yield* service.joinEpic({ senderThreadId: threadId, acceptedAt: timestamp }); + assert.equal(created.state, "created"); + const rejoined = yield* service.joinEpic({ senderThreadId: threadId, acceptedAt: timestamp }); + assert.equal(rejoined.state, "selected"); + assert.equal(rejoined.epicId, created.epicId); + assert.equal(rejoined.participantId, created.participantId); + + const firstJoinEvents = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM j5_a2a_comm_event + WHERE epic_id = ${created.epicId} + AND kind = 'participant.joined' + AND receiver = ${created.participantId} + `; + assert.equal(firstJoinEvents[0]?.count, 1, "idempotent rejoin does not append ledger junk"); + + const selectedEpicId = EpicId.make("epic:bootstrap:selected"); + const selected = yield* service.joinEpic({ + senderThreadId: threadId, + epicId: selectedEpicId, + acceptedAt: timestamp, + }); + assert.equal(selected.state, "created"); + assert.deepStrictEqual(selected.previousEpicIds, [created.epicId]); + assert.deepStrictEqual(yield* ledgerService.listMembership(created.epicId), []); + assert.equal( + (yield* ledgerService.listMembership(selectedEpicId))[0]?.participant.kind, + "agent", + ); + + const selectedAgain = yield* service.joinEpic({ + senderThreadId: threadId, + epicId: selectedEpicId, + acceptedAt: timestamp, + }); + assert.equal(selectedAgain.state, "selected"); + const eventCounts = yield* sql<{ readonly kind: string; readonly count: number }>` + SELECT kind, COUNT(*) AS count + FROM j5_a2a_comm_event + WHERE receiver = ${created.participantId} + AND kind IN ('participant.joined', 'participant.left') + GROUP BY kind + ORDER BY kind + `; + assert.deepStrictEqual(eventCounts, [ + { kind: "participant.joined", count: 2 }, + { kind: "participant.left", count: 1 }, + ]); + + const selectedBack = yield* service.joinEpic({ + senderThreadId: threadId, + epicId: created.epicId, + acceptedAt: timestamp, + }); + assert.equal(selectedBack.state, "joined"); + assert.deepStrictEqual(selectedBack.previousEpicIds, [selectedEpicId]); + assert.equal((yield* ledgerService.listMembership(created.epicId)).length, 1); + assert.deepStrictEqual(yield* ledgerService.listMembership(selectedEpicId), []); + + const finalEventCounts = yield* sql<{ readonly kind: string; readonly count: number }>` + SELECT kind, COUNT(*) AS count + FROM j5_a2a_comm_event + WHERE receiver = ${created.participantId} + AND kind IN ('participant.joined', 'participant.left') + GROUP BY kind + ORDER BY kind + `; + assert.deepStrictEqual(finalEventCounts, [ + { kind: "participant.joined", count: 3 }, + { kind: "participant.left", count: 2 }, + ]); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("requires explicit selection when legacy membership is ambiguous", () => + Effect.gen(function* () { + yield* runJ5A2AMigrations(); + const ledgerService = yield* A2ALedger; + const participant = { + kind: "agent" as const, + id: ParticipantId.make("agent:bootstrap:ambiguous"), + threadId, + }; + for (const [index, epicId] of [ + EpicId.make("epic:bootstrap:ambiguous:a"), + EpicId.make("epic:bootstrap:ambiguous:b"), + ].entries()) { + yield* ledgerService.createEpic({ + epic: { id: epicId, name: `Ambiguous ${index}`, createdAt: timestamp }, + }); + yield* ledgerService.appendEvents({ + commandId: CommCommandId.make(`command:bootstrap:ambiguous:${index}`), + epicId, + acceptedAt: timestamp, + events: [ + { + kind: "participant.joined", + sender: null, + receiver: participant.id, + exchangeId: null, + correlationId: null, + payload: { participant }, + createdAt: timestamp, + }, + ], + }); + } + + const error = yield* Effect.flip( + (yield* A2AEpicBootstrap).joinEpic({ senderThreadId: threadId, acceptedAt: timestamp }), + ); + assert.equal(error._tag, "A2AEpicSelectionRequiredError"); + assert.include(error.message, "Retry join_epic with one explicit epic_id"); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/j5/a2a/EpicBootstrapService.ts b/apps/server/src/j5/a2a/EpicBootstrapService.ts new file mode 100644 index 000000000000..fd4c0ffcabbc --- /dev/null +++ b/apps/server/src/j5/a2a/EpicBootstrapService.ts @@ -0,0 +1,178 @@ +import type { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { + CommCommandId, + EpicId, + type JoinEpicInput, + type JoinEpicResult, + type Membership, + ParticipantId, +} from "./contracts.ts"; +import { A2ALedger, type A2ALedgerError } from "./LedgerService.ts"; + +export class A2AEpicSelectionRequiredError extends Schema.TaggedErrorClass()( + "A2AEpicSelectionRequiredError", + { epicIds: Schema.Array(Schema.String) }, +) { + override get message(): string { + return `This thread belongs to multiple epics (${this.epicIds.join(", ")}). Retry join_epic with one explicit epic_id to select it.`; + } +} + +export type A2AEpicBootstrapError = A2ALedgerError | A2AEpicSelectionRequiredError; + +export interface A2AEpicBootstrapShape { + readonly joinEpic: (input: JoinEpicInput) => Effect.Effect; +} + +export class A2AEpicBootstrap extends Context.Service()( + "t3/j5/a2a/EpicBootstrapService/A2AEpicBootstrap", +) {} + +const stablePart = (value: string) => encodeURIComponent(value); + +const defaultEpicId = (threadId: ThreadId) => EpicId.make(`epic:j5:a2a:${stablePart(threadId)}`); + +const defaultParticipantId = (threadId: ThreadId) => + ParticipantId.make(`agent:j5:a2a:${stablePart(threadId)}`); + +const membershipCommandId = ( + operation: "join" | "leave", + threadId: ThreadId, + epicId: EpicId, + incarnation: string, +) => + CommCommandId.make( + `command:j5:a2a:bootstrap:${operation}:${stablePart(threadId)}:${stablePart(epicId)}:${stablePart(incarnation)}`, + ); + +export const layer: Layer.Layer = Layer.effect( + A2AEpicBootstrap, + Effect.gen(function* () { + const ledger = yield* A2ALedger; + + const membershipsForThread = Effect.fn("j5.a2a.bootstrap.membershipsForThread")(function* ( + threadId: ThreadId, + ) { + const epics = yield* ledger.listEpics(); + const memberships = yield* Effect.forEach(epics, (epic) => ledger.listMembership(epic.id), { + concurrency: 1, + }); + return memberships + .flat() + .filter( + (membership): membership is Membership & { participant: { kind: "agent" } } => + membership.participant.kind === "agent" && membership.participant.threadId === threadId, + ); + }); + + const joinEpic: A2AEpicBootstrapShape["joinEpic"] = (input) => + Effect.gen(function* () { + const existing = yield* membershipsForThread(input.senderThreadId); + if (input.epicId === undefined && existing.length > 1) { + return yield* new A2AEpicSelectionRequiredError({ + epicIds: existing.map((membership) => membership.epicId), + }); + } + if (input.epicId === undefined && existing[0] !== undefined) { + return { + epicId: existing[0].epicId, + participantId: existing[0].participant.id, + state: "selected", + previousEpicIds: [], + }; + } + + const targetEpicId = input.epicId ?? defaultEpicId(input.senderThreadId); + const targetMembership = existing.find((membership) => membership.epicId === targetEpicId); + const participantId = + targetMembership?.participant.id ?? + existing[0]?.participant.id ?? + defaultParticipantId(input.senderThreadId); + const currentEpics = yield* ledger.listEpics(); + const targetEpic = currentEpics.find((epic) => epic.id === targetEpicId); + const created = targetEpic === undefined; + if (created) { + yield* ledger.createEpic({ + epic: { + id: targetEpicId, + name: `J5 epic ${targetEpicId}`, + createdAt: input.acceptedAt, + }, + }); + } + + const previous = existing.filter((membership) => membership.epicId !== targetEpicId); + for (const membership of previous) { + yield* ledger.appendEvents({ + commandId: membershipCommandId( + "leave", + input.senderThreadId, + membership.epicId, + String(membership.joinedSeq), + ), + epicId: membership.epicId, + acceptedAt: input.acceptedAt, + events: [ + { + kind: "participant.left", + sender: null, + receiver: participantId, + exchangeId: null, + correlationId: null, + payload: { participant: membership.participant }, + createdAt: input.acceptedAt, + }, + ], + }); + } + + if (targetMembership === undefined) { + const sourceIncarnation = + existing + .map((membership) => `${membership.epicId}:${membership.joinedSeq}`) + .sort() + .join(",") || "initial"; + const participant = { + kind: "agent" as const, + id: participantId, + threadId: input.senderThreadId, + }; + yield* ledger.appendEvents({ + commandId: membershipCommandId( + "join", + input.senderThreadId, + targetEpicId, + sourceIncarnation, + ), + epicId: targetEpicId, + acceptedAt: input.acceptedAt, + events: [ + { + kind: "participant.joined", + sender: null, + receiver: participantId, + exchangeId: null, + correlationId: null, + payload: { participant }, + createdAt: input.acceptedAt, + }, + ], + }); + } + + return { + epicId: targetEpicId, + participantId, + state: created ? "created" : targetMembership === undefined ? "joined" : "selected", + previousEpicIds: previous.map((membership) => membership.epicId), + }; + }); + + return A2AEpicBootstrap.of({ joinEpic }); + }), +); diff --git a/apps/server/src/j5/a2a/LedgerService.test.ts b/apps/server/src/j5/a2a/LedgerService.test.ts index bcada622075e..553a23205919 100644 --- a/apps/server/src/j5/a2a/LedgerService.test.ts +++ b/apps/server/src/j5/a2a/LedgerService.test.ts @@ -24,6 +24,7 @@ import { CorrelationId, Epic, EpicId, + LedgerMessageId, ParticipantId, type AppendCommEventCommand, type CommEvent, @@ -42,7 +43,7 @@ const fileLedgerLayer = (filename: string) => ledgerLayer.pipe(Layer.provideMerge(NodeSqliteClient.layer({ filename }))); const messageEvent = (index: number): CommEvent => ({ - kind: "message.sent", + kind: "silence.notice", sender: ParticipantId.make("agent:sender"), receiver: ParticipantId.make("agent:receiver"), exchangeId: null, @@ -51,6 +52,53 @@ const messageEvent = (index: number): CommEvent => ({ createdAt: timestamp, }); +it.effect("routes single-event append through command ids and A2 projections", () => + Effect.gen(function* () { + yield* runJ5A2AMigrations(); + const ledger = yield* A2ALedger; + const sql = yield* SqlClient.SqlClient; + const epicId = EpicId.make("epic:single-append-projection"); + const commandId = CommCommandId.make("command:single-append-projection"); + const messageId = LedgerMessageId.make("message:single-append-projection"); + yield* ledger.createEpic({ + epic: { id: epicId, name: "Single append projection", createdAt: timestamp }, + }); + yield* ledger.append({ + commandId, + epicId, + acceptedAt: timestamp, + event: { + kind: "message.sent", + sender: ParticipantId.make("agent:single:sender"), + receiver: ParticipantId.make("agent:single:receiver"), + exchangeId: null, + correlationId: CorrelationId.make("correlation:single-append-projection"), + payload: { + messageId, + text: "Single append remains deliverable.", + originEpicId: epicId, + receiverEpicId: epicId, + exchangeRole: "none", + }, + createdAt: timestamp, + }, + }); + + const rows = yield* sql<{ + readonly command_id: string; + readonly message_id: string; + readonly status: string; + }>` + SELECT command_id, message_id, status + FROM j5_a2a_delivery + WHERE message_id = ${messageId} + `; + assert.deepStrictEqual(rows, [ + { command_id: commandId, message_id: messageId, status: "pending" }, + ]); + }).pipe(Effect.provide(memoryLedgerLayer())), +); + const appendCommand = ( epicId: EpicId, index: number, diff --git a/apps/server/src/j5/a2a/LedgerService.ts b/apps/server/src/j5/a2a/LedgerService.ts index 3237cf37a627..6eccdd303cc2 100644 --- a/apps/server/src/j5/a2a/LedgerService.ts +++ b/apps/server/src/j5/a2a/LedgerService.ts @@ -411,128 +411,6 @@ export const layer: Layer.Layer = Layer.e return yield* Effect.forEach(rows, membershipFromRow, { concurrency: 1 }); }); - const appendEffect = Effect.fn("j5.a2a.append")(function* (command: AppendCommEventCommand) { - const result = yield* sql.withTransaction( - Effect.gen(function* () { - yield* ensureEpic(command.epicId); - const pending = decideAppendCommEvent(command)[0]; - const sequenceRows = yield* sql<{ readonly next_seq: number }>` - SELECT COALESCE(MAX(seq), 0) + 1 AS next_seq - FROM j5_a2a_comm_event - WHERE epic_id = ${command.epicId} - `; - const seq = sequenceRows[0]?.next_seq; - if (seq === undefined) { - return yield* new A2AStorageError({ operation: "allocate communication sequence" }); - } - const reserved = yield* sql<{ readonly command_id: string }>` - INSERT INTO j5_a2a_comm_command_receipt ( - command_id, - epic_id, - command_type, - accepted_at, - result_seq - ) VALUES ( - ${command.commandId}, - ${command.epicId}, - 'comm.append', - ${command.acceptedAt}, - ${seq} - ) - ON CONFLICT(command_id) DO NOTHING - RETURNING command_id - `; - - if (reserved[0] === undefined) { - const receiptRows = yield* sql` - SELECT command_id, epic_id, command_type, accepted_at, result_seq - FROM j5_a2a_comm_command_receipt - WHERE command_id = ${command.commandId} - LIMIT 1 - `; - const row = receiptRows[0]; - if (row === undefined) { - return yield* new A2AStorageError({ - operation: "read replayed command receipt", - }); - } - if (row.epic_id !== command.epicId) { - return yield* new CommCommandConflictError({ - commandId: command.commandId, - requestedEpicId: command.epicId, - existingEpicId: row.epic_id, - }); - } - const eventRows = yield* sql` - SELECT - seq, - epic_id, - kind, - sender, - receiver, - exchange_id, - correlation_id, - payload, - created_at - FROM j5_a2a_comm_event - WHERE epic_id = ${command.epicId} AND seq = ${row.result_seq} - LIMIT 1 - `; - const eventRow = eventRows[0]; - if (eventRow === undefined) { - return yield* new A2AStorageError({ - operation: "read replayed command event", - }); - } - return { - receipt: yield* receiptFromRow(row), - event: yield* eventFromRow(eventRow), - committed: false as const, - }; - } - - const payload = yield* encodeJson(pending.payload); - yield* sql` - INSERT INTO j5_a2a_comm_event ( - seq, - epic_id, - kind, - sender, - receiver, - exchange_id, - correlation_id, - payload, - created_at - ) VALUES ( - ${seq}, - ${pending.epicId}, - ${pending.kind}, - ${pending.sender}, - ${pending.receiver}, - ${pending.exchangeId}, - ${pending.correlationId}, - ${payload}, - ${pending.createdAt} - ) - `; - const event = yield* decodeStoredEvent({ seq, ...pending }); - yield* applyMembership(event); - const receipt = yield* decodeReceipt({ - commandId: command.commandId, - epicId: command.epicId, - commandType: "comm.append", - acceptedAt: command.acceptedAt, - resultSeq: seq, - }); - return { receipt, event, committed: true as const }; - }), - ); - if (result.committed) { - yield* PubSub.publish(committed, result.event); - } - return result; - }); - const appendEventsEffect = Effect.fn("j5.a2a.appendEvents")(function* ( command: AppendCommEventsCommand, ) { @@ -677,8 +555,16 @@ export const layer: Layer.Layer = Layer.e yield* sql` INSERT INTO j5_a2a_epic (id, name, created_at) VALUES (${command.epic.id}, ${command.epic.name}, ${command.epic.createdAt}) + ON CONFLICT(id) DO NOTHING `; - return command.epic; + return yield* epicFromRow( + (yield* sql` + SELECT id, name, created_at + FROM j5_a2a_epic + WHERE id = ${command.epic.id} + LIMIT 1 + `)[0]!, + ); }).pipe(Effect.mapError(preserveDomainError("create epic"))), listEpics: () => Effect.gen(function* () { @@ -698,7 +584,25 @@ export const layer: Layer.Layer = Layer.e }).pipe(Effect.mapError(preserveDomainError("read epic"))), append: (command) => appendPermit - .withPermit(appendEffect(command)) + .withPermit( + appendEventsEffect({ + commandId: command.commandId, + epicId: command.epicId, + acceptedAt: command.acceptedAt, + events: [command.event], + }).pipe( + Effect.flatMap((result) => { + const event = result.events[0]; + return event === undefined + ? Effect.fail(new A2AStorageError({ operation: "read single appended event" })) + : Effect.succeed({ + receipt: result.receipt, + event, + committed: result.committed, + }); + }), + ), + ) .pipe(Effect.mapError(preserveDomainError("append communication event"))), appendEvents: (command) => appendPermit diff --git a/apps/server/src/j5/a2a/README.md b/apps/server/src/j5/a2a/README.md index 32e01727a306..196340664278 100644 --- a/apps/server/src/j5/a2a/README.md +++ b/apps/server/src/j5/a2a/README.md @@ -3,3 +3,9 @@ `envelopes.v1.json` is the human-owned wording source for every injected A2A envelope and the two MCP tool descriptions. Templates use literal `{{name}}` placeholders; `EnvelopeFormatter.ts` is the only renderer. Change wording in the JSON file, keep every referenced placeholder, and bump `version` when the rendered contract changes. `delivery-config.v1.json` owns the retry backoff and alarm threshold. Attempts reuse one upstream command/message id pair derived from the durable ledger message id. Increasing an attempt never rotates those ids: a permanently rejected upstream receipt must become a visible alarm rather than risk a second injection. + +Agent-side epic bootstrap is deliberately explicit through the authenticated `join_epic` MCP tool. `send_message` and `list_participants` never auto-join. Human/UI epic creation, selection, and management belong to the item-4 surface milestone and are not part of A2. + +For a cross-epic send, the receiver ledger's idempotent `message.received` records durable acceptance of the sender's act before transport is attempted. It does not claim successful thread injection: delivery success, retries, and the terminal alarm remain in the sender epic's delivery projection. + +Byte-equivalent rebuilds for the A2 exchange and delivery projections are deferred to the measured-projections milestone (M5). A2 keeps those tables derivable from the communication ledger but does not expose their rebuild operation. diff --git a/apps/server/src/j5/a2a/SendService.test.ts b/apps/server/src/j5/a2a/SendService.test.ts index 3cee451bb559..e71936b4a3e1 100644 --- a/apps/server/src/j5/a2a/SendService.test.ts +++ b/apps/server/src/j5/a2a/SendService.test.ts @@ -311,3 +311,50 @@ it.effect("lists membership-derived participant capabilities", () => ); }).pipe(Effect.provide(testLayer)), ); + +it.effect("marks ambiguous participant rows unavailable before send", () => + Effect.gen(function* () { + yield* setupSameEpic(); + const ledgerService = yield* A2ALedger; + const duplicateEpicId = EpicId.make("epic:exchange:duplicate-receiver"); + yield* ledgerService.createEpic({ + epic: { id: duplicateEpicId, name: "Duplicate receiver", createdAt: timestamp }, + }); + yield* ledgerService.appendEvents({ + commandId: CommCommandId.make("command:join:duplicate-receiver"), + epicId: duplicateEpicId, + acceptedAt: timestamp, + events: [ + { + kind: "participant.joined", + sender: null, + receiver: receiver.id, + exchangeId: null, + correlationId: null, + payload: { participant: receiver }, + createdAt: timestamp, + }, + ], + }); + + const service = yield* A2ASendService; + const rows = (yield* service.listParticipants(sender.threadId)).filter( + (row) => row.participantId === receiver.id, + ); + assert.lengthOf(rows, 2); + assert.isTrue(rows.every((row) => !row.canReceiveMessage && !row.canOpenExchange)); + + const error = yield* Effect.flip( + service.send({ + commandId: CommCommandId.make("command:ambiguous-receiver"), + senderThreadId: sender.threadId, + to: receiver.id, + message: "This must fail before append.", + acceptedAt: timestamp, + }), + ); + assert.equal(error._tag, "A2AAmbiguousParticipantError"); + assert.include(error.message, "choose a participantId with canReceiveMessage=true"); + assert.include(error.message, "ask the human"); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/j5/a2a/SendService.ts b/apps/server/src/j5/a2a/SendService.ts index f9ecd11a4e62..ffab6122e0ee 100644 --- a/apps/server/src/j5/a2a/SendService.ts +++ b/apps/server/src/j5/a2a/SendService.ts @@ -46,7 +46,7 @@ export class A2AAmbiguousParticipantError extends Schema.TaggedErrorClass(); + for (const row of rows) { + membershipCounts.set( + row.participant_id, + (membershipCounts.get(row.participant_id) ?? 0) + 1, + ); + } const selected = rows.filter( (row) => row.participant_id !== GLOBAL_HUMAN_PARTICIPANT_ID || row.epic_id === sender.epicId, @@ -243,14 +250,19 @@ export const layer: Layer.Layer decodeParticipant(row.payload).pipe( - Effect.map((participant) => ({ - epicId: EpicId.make(row.epic_id), - participantId: participantId(participant), - participant, - canReceiveMessage: true, - canOpenExchange: true, - acceptsUrgency: participant.kind === "human", - })), + Effect.map((participant) => { + const id = participantId(participant); + const addressable = + id === GLOBAL_HUMAN_PARTICIPANT_ID || membershipCounts.get(id) === 1; + return { + epicId: EpicId.make(row.epic_id), + participantId: id, + participant, + canReceiveMessage: addressable, + canOpenExchange: addressable, + acceptsUrgency: participant.kind === "human", + }; + }), ), { concurrency: 1 }, ); diff --git a/apps/server/src/j5/a2a/contracts.ts b/apps/server/src/j5/a2a/contracts.ts index 91191257838d..e53d5d21c85a 100644 --- a/apps/server/src/j5/a2a/contracts.ts +++ b/apps/server/src/j5/a2a/contracts.ts @@ -228,6 +228,21 @@ export const SendMessageResult = Schema.Struct({ }); export type SendMessageResult = typeof SendMessageResult.Type; +export const JoinEpicInput = Schema.Struct({ + senderThreadId: ThreadId, + epicId: Schema.optional(EpicId), + acceptedAt: Schema.String, +}); +export type JoinEpicInput = typeof JoinEpicInput.Type; + +export const JoinEpicResult = Schema.Struct({ + epicId: EpicId, + participantId: ParticipantId, + state: Schema.Literals(["created", "joined", "selected"]), + previousEpicIds: Schema.Array(EpicId), +}); +export type JoinEpicResult = typeof JoinEpicResult.Type; + export const ParticipantDirectoryRow = Schema.Struct({ epicId: EpicId, participantId: ParticipantId, diff --git a/apps/server/src/j5/a2a/envelopes.v1.json b/apps/server/src/j5/a2a/envelopes.v1.json index 2e2de9699057..f2ee6875c858 100644 --- a/apps/server/src/j5/a2a/envelopes.v1.json +++ b/apps/server/src/j5/a2a/envelopes.v1.json @@ -5,6 +5,7 @@ "silenceNotice": "[J5 system notice: {{noticeType}}]\n\n{{message}}\n\nThis is a platform-authored delivery signal, not a peer reply.", "replyInstruction": "Reply once with send_message(to=\"{{senderId}}\", exchange_id=\"{{exchangeId}}\", message=\"...\") to close the exchange. Follow-ups from the asker carrying this id join the same exchange.", "oneShotInstruction": "No reply is required. Use send_message without exchange_id only if a new message is needed.", - "sendToolDescription": "Durably send one J5 A2A message. expect_reply=true opens or joins one sender-to-receiver exchange and requires intent; a reply is send_message carrying exchange_id, and one reply closes that exchange. urgency is required only when opening an exchange to the human. The call returns after the sender ledger commit; delivery continues asynchronously.", - "listToolDescription": "List reachable J5 A2A participants and the capabilities accepted by each row. Use this before send_message instead of discovering participant state through failures." + "sendToolDescription": "Durably send one J5 A2A message. client_request_id makes retries of the same logical call idempotent. expect_reply=true opens or joins one sender-to-receiver exchange and requires intent; a reply is send_message carrying exchange_id, and one reply closes that exchange. urgency is required only when opening an exchange to the human. The call returns after the sender ledger commit; delivery continues asynchronously.", + "listToolDescription": "List reachable J5 A2A participants and the capabilities accepted by each row. Use this before send_message instead of discovering participant state through failures.", + "joinToolDescription": "Select this authenticated thread's J5 epic membership. With no epic_id, return the sole current membership or create a minimal epic. With an explicit epic_id, create or select it and leave prior epic memberships. The caller thread always comes from the authenticated MCP scope." } diff --git a/apps/server/src/j5/a2a/mcp/handlers.test.ts b/apps/server/src/j5/a2a/mcp/handlers.test.ts new file mode 100644 index 000000000000..7bc7d677a810 --- /dev/null +++ b/apps/server/src/j5/a2a/mcp/handlers.test.ts @@ -0,0 +1,98 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; + +import { McpInvocationContext } from "../../../mcp/McpInvocationContext.ts"; +import { A2ADeliveryWorker } from "../DeliveryWorker.ts"; +import { A2AEpicBootstrap } from "../EpicBootstrapService.ts"; +import { A2ASendService } from "../SendService.ts"; +import { EpicId, LedgerMessageId, ParticipantId, type SendMessageInput } from "../contracts.ts"; +import { J5ToolkitHandlersLive } from "./handlers.ts"; +import { J5Toolkit } from "./tools.ts"; + +const invocation = { + environmentId: EnvironmentId.make("environment:j5:mcp-handler"), + threadId: ThreadId.make("thread:j5:mcp-handler"), + providerSessionId: "provider-session:j5:mcp-handler", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"] as const), + issuedAt: 1, +}; + +it.effect("derives send idempotency and epic bootstrap identity from authenticated scope", () => + Effect.gen(function* () { + const sends = yield* Ref.make>([]); + const bootstrapThreads = yield* Ref.make>([]); + const epicId = EpicId.make("epic:j5:mcp-handler"); + const participantId = ParticipantId.make("agent:j5:mcp-handler"); + const sendService = Layer.succeed( + A2ASendService, + A2ASendService.of({ + send: (input) => + Ref.update(sends, (items) => [...items, input]).pipe( + Effect.as({ + messageId: LedgerMessageId.make("message:j5:mcp-handler"), + exchangeId: null, + exchangeState: "none" as const, + joinedExistingExchange: false, + durableAtSeq: 1, + }), + ), + listParticipants: () => Effect.succeed([]), + }), + ); + const bootstrapService = Layer.succeed( + A2AEpicBootstrap, + A2AEpicBootstrap.of({ + joinEpic: (input) => + Ref.update(bootstrapThreads, (threads) => [...threads, input.senderThreadId]).pipe( + Effect.as({ + epicId: input.epicId ?? epicId, + participantId, + state: "selected" as const, + previousEpicIds: [], + }), + ), + }), + ); + const dependencies = Layer.mergeAll( + sendService, + bootstrapService, + Layer.mock(A2ADeliveryWorker)({ notify: Effect.void }), + NodeServices.layer, + ); + const layer = J5ToolkitHandlersLive.pipe(Layer.provideMerge(dependencies)); + + yield* Effect.gen(function* () { + const toolkit = yield* J5Toolkit; + const call = (name: "send_message" | "join_epic", args: Record) => + toolkit + .handle(name, args) + .pipe( + Stream.unwrap, + Stream.run(Sink.last()), + Effect.flatMap(Effect.fromOption), + Effect.provideService(McpInvocationContext, invocation), + ); + const sendArguments = { + to: participantId, + message: "Idempotent MCP send", + client_request_id: "logical-send-1", + }; + yield* call("send_message", sendArguments); + yield* call("send_message", sendArguments); + const captured = yield* Ref.get(sends); + assert.lengthOf(captured, 2); + assert.equal(captured[0]?.commandId, captured[1]?.commandId); + assert.equal(captured[0]?.senderThreadId, invocation.threadId); + + yield* call("join_epic", { epic_id: epicId }); + assert.deepStrictEqual(yield* Ref.get(bootstrapThreads), [invocation.threadId]); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/j5/a2a/mcp/handlers.ts b/apps/server/src/j5/a2a/mcp/handlers.ts index a463aa68e3ce..5aca682c0090 100644 --- a/apps/server/src/j5/a2a/mcp/handlers.ts +++ b/apps/server/src/j5/a2a/mcp/handlers.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import { McpInvocationContext } from "../../../mcp/McpInvocationContext.ts"; import { A2ADeliveryWorker } from "../DeliveryWorker.ts"; +import { A2AEpicBootstrap } from "../EpicBootstrapService.ts"; import { A2ASendService } from "../SendService.ts"; import { CommCommandId } from "../contracts.ts"; import { J5Toolkit, type J5McpFailure } from "./tools.ts"; @@ -16,6 +17,16 @@ const failure = (error: unknown): J5McpFailure => ({ message: error instanceof Error ? error.message : String(error), }); +const stablePart = (value: string) => encodeURIComponent(value); + +export const commandIdForRequest = (input: { + readonly providerSessionId: string; + readonly requestKey: string; +}) => + CommCommandId.make( + `command:j5:a2a:mcp:${stablePart(input.providerSessionId)}:${stablePart(input.requestKey)}`, + ); + const handlers = { send_message: (input) => Effect.gen(function* () { @@ -24,8 +35,12 @@ const handlers = { const service = yield* A2ASendService; const worker = yield* A2ADeliveryWorker; const acceptedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const requestKey = input.client_request_id ?? (yield* crypto.randomUUIDv4); const result = yield* service.send({ - commandId: CommCommandId.make(`command:j5:a2a:mcp:${yield* crypto.randomUUIDv4}`), + commandId: commandIdForRequest({ + providerSessionId: scope.providerSessionId, + requestKey, + }), senderThreadId: scope.threadId, to: input.to, message: input.message, @@ -44,6 +59,17 @@ const handlers = { const service = yield* A2ASendService; return { participants: yield* service.listParticipants(scope.threadId) }; }).pipe(Effect.mapError(failure)), + join_epic: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* A2AEpicBootstrap; + const acceptedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + return yield* service.joinEpic({ + senderThreadId: scope.threadId, + ...(input.epic_id === undefined ? {} : { epicId: input.epic_id }), + acceptedAt, + }); + }).pipe(Effect.mapError(failure)), } satisfies Parameters[0]; export const J5ToolkitHandlersLive = J5Toolkit.toLayer(handlers); diff --git a/apps/server/src/j5/a2a/mcp/registration.ts b/apps/server/src/j5/a2a/mcp/registration.ts index d65683acc839..d37e6f4dd14b 100644 --- a/apps/server/src/j5/a2a/mcp/registration.ts +++ b/apps/server/src/j5/a2a/mcp/registration.ts @@ -3,8 +3,14 @@ import { McpServer } from "effect/unstable/ai"; import { J5ToolkitHandlersLive } from "./handlers.ts"; import { J5Toolkit } from "./tools.ts"; +import { J5A2ARuntimeLayer } from "../runtimeLayer.ts"; /** The single shared J5 MCP registration; later J5 milestones extend J5Toolkit only. */ export const J5ToolkitRegistrationLive = McpServer.toolkit(J5Toolkit).pipe( Layer.provide(J5ToolkitHandlersLive), ); + +/** Authenticated toolkit plus its independently drainable A2A runtime. */ +export const J5McpIntegrationLive = J5ToolkitRegistrationLive.pipe( + Layer.provideMerge(J5A2ARuntimeLayer), +); diff --git a/apps/server/src/j5/a2a/mcp/tools.ts b/apps/server/src/j5/a2a/mcp/tools.ts index 2792e8d5ce5e..196c4c00e810 100644 --- a/apps/server/src/j5/a2a/mcp/tools.ts +++ b/apps/server/src/j5/a2a/mcp/tools.ts @@ -3,11 +3,18 @@ import * as Schema from "effect/Schema"; import * as Crypto from "effect/Crypto"; import * as McpInvocationContext from "../../../mcp/McpInvocationContext.ts"; -import { A2A_LIST_TOOL_DESCRIPTION, A2A_SEND_TOOL_DESCRIPTION } from "../EnvelopeFormatter.ts"; +import { + A2A_JOIN_TOOL_DESCRIPTION, + A2A_LIST_TOOL_DESCRIPTION, + A2A_SEND_TOOL_DESCRIPTION, +} from "../EnvelopeFormatter.ts"; import { A2ADeliveryWorker } from "../DeliveryWorker.ts"; +import { A2AEpicBootstrap } from "../EpicBootstrapService.ts"; import { A2ASendService } from "../SendService.ts"; import { + EpicId, ExchangeId, + JoinEpicResult, ParticipantDirectoryRow, ParticipantId, SendMessageResult, @@ -23,6 +30,7 @@ export type J5McpFailure = typeof J5McpFailure.Type; export const J5SendMessageInput = Schema.Struct({ to: ParticipantId, message: Schema.String.check(Schema.isNonEmpty()), + client_request_id: Schema.optional(Schema.String.check(Schema.isNonEmpty())), expect_reply: Schema.optional(Schema.Boolean), exchange_id: Schema.optional(ExchangeId), intent: Schema.optional(Schema.String.check(Schema.isNonEmpty())), @@ -34,10 +42,15 @@ export const J5ListParticipantsResult = Schema.Struct({ participants: Schema.Array(ParticipantDirectoryRow), }); +export const J5JoinEpicInput = Schema.Struct({ + epic_id: Schema.optional(EpicId), +}); + const dependencies = [ McpInvocationContext.McpInvocationContext, A2ASendService, A2ADeliveryWorker, + A2AEpicBootstrap, Crypto.Crypto, ]; @@ -68,5 +81,19 @@ export const J5ListParticipantsTool = Tool.make("list_participants", { .annotate(Tool.Idempotent, true) .annotate(Tool.OpenWorld, false); +export const J5JoinEpicTool = Tool.make("join_epic", { + description: A2A_JOIN_TOOL_DESCRIPTION, + parameters: J5JoinEpicInput, + success: JoinEpicResult, + failure: J5McpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Join a J5 epic") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + /** Shared J5 toolkit bootstrap. Later J5 milestones append their tools here. */ -export const J5Toolkit = Toolkit.make(J5SendMessageTool, J5ListParticipantsTool); +export const J5Toolkit = Toolkit.make(J5SendMessageTool, J5ListParticipantsTool, J5JoinEpicTool); diff --git a/apps/server/src/j5/a2a/runtimeLayer.test.ts b/apps/server/src/j5/a2a/runtimeLayer.test.ts new file mode 100644 index 000000000000..ee94b0285570 --- /dev/null +++ b/apps/server/src/j5/a2a/runtimeLayer.test.ts @@ -0,0 +1,44 @@ +import { assert, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as NodeSqliteClient from "../../persistence/NodeSqliteClient.ts"; +import { ThreadManagementService } from "../../orchestration-v2/ThreadManagementService.ts"; +import { layer as ledgerLayer } from "./LedgerService.ts"; +import { runJ5A2AMigrations } from "./Migrations.ts"; +import { makeJ5A2ARuntimeLayer } from "./runtimeLayer.ts"; + +it.effect("shares one ledger and thread-management instance across the runtime graph", () => + Effect.scoped( + Effect.gen(function* () { + const databaseContext = yield* Layer.build(NodeSqliteClient.layerMemory()); + const database = Layer.succeed( + SqlClient.SqlClient, + Context.get(databaseContext, SqlClient.SqlClient), + ); + yield* runJ5A2AMigrations().pipe(Effect.provide(database)); + + let ledgerBuilds = 0; + let threadManagementBuilds = 0; + const countedLedger = ledgerLayer.pipe( + Layer.tap(() => Effect.sync(() => (ledgerBuilds += 1))), + ); + const countedThreadManagement = Layer.mock(ThreadManagementService)({}).pipe( + Layer.tap(() => Effect.sync(() => (threadManagementBuilds += 1))), + ); + const secondThreadConsumer = Layer.effectDiscard(ThreadManagementService.pipe(Effect.asVoid)); + const runtime = makeJ5A2ARuntimeLayer({ ledger: countedLedger }); + yield* Layer.build( + Layer.mergeAll(runtime, secondThreadConsumer).pipe( + Layer.provide(countedThreadManagement), + Layer.provide(database), + ), + ); + + assert.equal(ledgerBuilds, 1); + assert.equal(threadManagementBuilds, 1); + }), + ), +); diff --git a/apps/server/src/j5/a2a/runtimeLayer.ts b/apps/server/src/j5/a2a/runtimeLayer.ts index 7377eca65c68..b988e860d776 100644 --- a/apps/server/src/j5/a2a/runtimeLayer.ts +++ b/apps/server/src/j5/a2a/runtimeLayer.ts @@ -2,21 +2,26 @@ import * as Layer from "effect/Layer"; import { layer as deliveryWorkerLayer } from "./DeliveryWorker.ts"; import { live as deliveryTransportLayer } from "./DeliveryTransport.ts"; +import { layer as epicBootstrapLayer } from "./EpicBootstrapService.ts"; import { layer as ledgerLayer } from "./LedgerService.ts"; import { layer as sendServiceLayer } from "./SendService.ts"; -const ledgerProvided = ledgerLayer; -const deliveryTransportProvided = deliveryTransportLayer; -const sendServiceProvided = sendServiceLayer.pipe(Layer.provide(ledgerProvided)); -const deliveryWorkerProvided = deliveryWorkerLayer.pipe( - Layer.provide(ledgerProvided), - Layer.provide(deliveryTransportProvided), -); +export const makeJ5A2ARuntimeLayer = ( + options: { + readonly ledger?: typeof ledgerLayer; + readonly deliveryTransport?: typeof deliveryTransportLayer; + } = {}, +) => { + const ledgerProvided = options.ledger ?? ledgerLayer; + const deliveryTransportProvided = options.deliveryTransport ?? deliveryTransportLayer; + const deliveryWorkerProvided = deliveryWorkerLayer.pipe( + Layer.provideMerge(deliveryTransportProvided), + ); + + return Layer.mergeAll(sendServiceLayer, epicBootstrapLayer, deliveryWorkerProvided).pipe( + Layer.provideMerge(ledgerProvided), + ); +}; /** Production J5 A2A services; SQL and V2 thread management stay shared runtime dependencies. */ -export const J5A2ARuntimeLayer = Layer.mergeAll( - ledgerProvided, - deliveryTransportProvided, - sendServiceProvided, - deliveryWorkerProvided, -); +export const J5A2ARuntimeLayer = makeJ5A2ARuntimeLayer(); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index f9df569748a3..066565db0275 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -28,7 +28,7 @@ import { import { WorktreeToolkitHandlersLive } from "./toolkits/worktree/handlers.ts"; import { WorktreeToolkit } from "./toolkits/worktree/tools.ts"; import * as WorktreeMcpService from "./WorktreeMcpService.ts"; -import { J5ToolkitRegistrationLive } from "../j5/a2a/mcp/registration.ts"; +import { J5McpIntegrationLive } from "../j5/a2a/mcp/registration.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -245,5 +245,5 @@ export const layer = Layer.mergeAll( OrchestratorToolkitRegistrationLive, WorktreeToolkitRegistrationLive, // J5 fork extension: one shared toolkit registration for all J5-owned tools. - J5ToolkitRegistrationLive, + J5McpIntegrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 37856839cc7c..6e8418f2c8fd 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -12,12 +12,11 @@ import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; import { ThreadManagementService } from "../../../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; +import { SqlitePersistenceMemory } from "../../../persistence/Layers/Sqlite.ts"; import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts"; import { ScheduledTaskService } from "../../../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../../vcs/VcsStatusBroadcaster.ts"; -import { A2ADeliveryWorker } from "../../../j5/a2a/DeliveryWorker.ts"; -import { A2ASendService } from "../../../j5/a2a/SendService.ts"; import * as McpHttpServer from "../../McpHttpServer.ts"; import * as McpSessionRegistry from "../../McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; @@ -31,8 +30,6 @@ const StubServicesLive = Layer.mergeAll( Layer.mock(GitWorkflowService.GitWorkflowService)({}), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), Layer.mock(VcsStatusBroadcaster)({}), - Layer.mock(A2ASendService)({}), - Layer.mock(A2ADeliveryWorker)({}), ); const ToolsListPayload = Schema.fromJsonString( @@ -70,6 +67,7 @@ it.effect("production mcp layer lists worktree tools over http", () => }), ), Layer.provide(PreviewAutomationBroker.layer), + Layer.provide(SqlitePersistenceMemory), Layer.provide(StubServicesLive), Layer.build, ); @@ -131,6 +129,7 @@ it.effect("production mcp layer lists worktree tools over http", () => // that later J5 milestones extend inside the fork-owned toolkit. expect(toolNames).toContain("send_message"); expect(toolNames).toContain("list_participants"); + expect(toolNames).toContain("join_epic"); // The handoff tool mutates thread state, reaches the network (origin // fetch), and runs project setup scripts, so its MCP hints must not diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e9a154f177a8..73af833e3b75 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -47,7 +47,6 @@ import * as PortScanner from "./preview/PortScanner.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; -import { J5A2ARuntimeLayer } from "./j5/a2a/runtimeLayer.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; @@ -347,8 +346,6 @@ const OrchestrationApplicationLayerLive = CheckpointDiffQuery.layer.pipe( const RuntimeCoreDependenciesBaseLive = AgentAwarenessRelay.layer.pipe( // Core Services Layer.provideMerge(OrchestrationApplicationLayerLive), - // J5 fork extension: durable A2A ledger plus startup delivery reconciliation. - Layer.provideMerge(J5A2ARuntimeLayer.pipe(Layer.provide(OrchestrationV2RuntimeLayerLive))), Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), From f364c930949409031a8f31ee51eb87b63f318167 Mon Sep 17 00:00:00 2001 From: Jackson Miller Date: Sun, 16 Aug 2026 23:10:04 -0400 Subject: [PATCH 03/12] test: cover A2 bootstrap replay boundary --- .../src/j5/a2a/EpicBootstrapService.test.ts | 53 +++++++++++++++++++ apps/server/src/j5/a2a/README.md | 2 + 2 files changed, 55 insertions(+) diff --git a/apps/server/src/j5/a2a/EpicBootstrapService.test.ts b/apps/server/src/j5/a2a/EpicBootstrapService.test.ts index eef88ff1eeee..89315fa1a327 100644 --- a/apps/server/src/j5/a2a/EpicBootstrapService.test.ts +++ b/apps/server/src/j5/a2a/EpicBootstrapService.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import { ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as NodeSqliteClient from "../../persistence/NodeSqliteClient.ts"; @@ -99,6 +100,58 @@ it.effect("creates, reuses, and explicitly changes the caller's selected epic", }).pipe(Effect.provide(testLayer)), ); +it.effect("derives one command id for concurrent attempts to join the same epic", () => + Effect.gen(function* () { + const commandIds = yield* Ref.make>([]); + const mockedLedger = Layer.mock(A2ALedger)({ + listEpics: () => Effect.succeed([]), + listMembership: () => Effect.succeed([]), + createEpic: ({ epic }) => Effect.succeed(epic), + appendEvents: (command) => + Ref.update(commandIds, (ids) => [...ids, command.commandId]).pipe( + Effect.as({ + receipt: { + commandId: command.commandId, + epicId: command.epicId, + commandType: "comm.append" as const, + acceptedAt: command.acceptedAt, + resultSeq: command.events.length, + }, + events: command.events.map((event, index) => ({ + ...event, + epicId: command.epicId, + seq: index + 1, + })), + committed: true, + }), + ), + }); + const serviceLayer = bootstrapLayer.pipe(Layer.provide(mockedLedger)); + + const results = yield* Effect.all( + [ + A2AEpicBootstrap.pipe( + Effect.flatMap((service) => + service.joinEpic({ senderThreadId: threadId, acceptedAt: timestamp }), + ), + ), + A2AEpicBootstrap.pipe( + Effect.flatMap((service) => + service.joinEpic({ senderThreadId: threadId, acceptedAt: timestamp }), + ), + ), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.provide(serviceLayer)); + + assert.equal(results[0].epicId, results[1].epicId); + assert.equal(results[0].participantId, results[1].participantId); + const captured = yield* Ref.get(commandIds); + assert.lengthOf(captured, 2); + assert.equal(captured[0], captured[1]); + }), +); + it.effect("requires explicit selection when legacy membership is ambiguous", () => Effect.gen(function* () { yield* runJ5A2AMigrations(); diff --git a/apps/server/src/j5/a2a/README.md b/apps/server/src/j5/a2a/README.md index 196340664278..6b2cbff0e526 100644 --- a/apps/server/src/j5/a2a/README.md +++ b/apps/server/src/j5/a2a/README.md @@ -6,6 +6,8 @@ Agent-side epic bootstrap is deliberately explicit through the authenticated `join_epic` MCP tool. `send_message` and `list_participants` never auto-join. Human/UI epic creation, selection, and management belong to the item-4 surface milestone and are not part of A2. +An explicit `join_epic` reassignment takes effect immediately: it does not transfer or cancel pending deliveries and open exchanges in the previous epic. A delivery whose recipient has left follows the normal retry-to-alarm path, while an open exchange remains durable in its original ledger. Before item 4 exposes human-managed reassignment, it must define and test an explicit disposition (for example, block, cancel by ledger event, or transfer by ledger event) instead of silently reusing this bootstrap behavior. + For a cross-epic send, the receiver ledger's idempotent `message.received` records durable acceptance of the sender's act before transport is attempted. It does not claim successful thread injection: delivery success, retries, and the terminal alarm remain in the sender epic's delivery projection. Byte-equivalent rebuilds for the A2 exchange and delivery projections are deferred to the measured-projections milestone (M5). A2 keeps those tables derivable from the communication ledger but does not expose their rebuild operation. From e1516b77dcb0e170ce4973523a63c071da07950d Mon Sep 17 00:00:00 2001 From: Jackson Miller Date: Sun, 16 Aug 2026 23:18:15 -0400 Subject: [PATCH 04/12] feat: warn before A2 epic reassignment --- .../src/j5/a2a/EnvelopeFormatter.test.ts | 18 ++- apps/server/src/j5/a2a/EnvelopeFormatter.ts | 11 ++ .../src/j5/a2a/EpicBootstrapService.test.ts | 118 +++++++++++++++++- .../server/src/j5/a2a/EpicBootstrapService.ts | 49 +++++++- apps/server/src/j5/a2a/README.md | 4 +- apps/server/src/j5/a2a/contracts.ts | 8 ++ apps/server/src/j5/a2a/envelopes.v1.json | 5 +- apps/server/src/j5/a2a/mcp/handlers.test.ts | 1 + 8 files changed, 202 insertions(+), 12 deletions(-) diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts index 18137eec416c..abffeca531b9 100644 --- a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts @@ -5,6 +5,7 @@ import { A2A_JOIN_TOOL_DESCRIPTION, A2A_LIST_TOOL_DESCRIPTION, A2A_SEND_TOOL_DESCRIPTION, + formatEpicSwitchWarning, formatHumanEnvelope, formatPeerEnvelope, } from "./EnvelopeFormatter.ts"; @@ -18,7 +19,7 @@ it("renders the versioned peer envelope with exact reply semantics", () => { message: "Please verify the worker.", }); - assert.equal(A2A_ENVELOPE_VERSION, 1); + assert.equal(A2A_ENVELOPE_VERSION, 2); assert.include(rendered, "agent:sender"); assert.include(rendered, "epic:origin"); assert.include(rendered, "Please verify the worker."); @@ -27,6 +28,20 @@ it("renders the versioned peer envelope with exact reply semantics", () => { assert.notInclude(rendered, "{{"); }); +it("renders epic-switch warnings with the abandoned exchange and peer", () => { + const rendered = formatEpicSwitchWarning({ + epicId: EpicId.make("epic:previous"), + exchangeId: ExchangeId.make("exchange:abandoned"), + peerId: ParticipantId.make("agent:waiting-peer"), + }); + + assert.include(rendered, "epic:previous"); + assert.include(rendered, "exchange:abandoned"); + assert.include(rendered, "agent:waiting-peer"); + assert.include(rendered, "not cancelled or transferred"); + assert.notInclude(rendered, "{{"); +}); + it("tells agents that human-origin exchanges require an explicit tool reply", () => { const rendered = formatHumanEnvelope({ senderId: ParticipantId.make("human:global"), @@ -39,4 +54,5 @@ it("tells agents that human-origin exchanges require an explicit tool reply", () assert.include(A2A_SEND_TOOL_DESCRIPTION, "returns after the sender ledger commit"); assert.include(A2A_LIST_TOOL_DESCRIPTION, "reachable J5 A2A participants"); assert.include(A2A_JOIN_TOOL_DESCRIPTION, "authenticated thread"); + assert.include(A2A_JOIN_TOOL_DESCRIPTION, "open exchange ID and peer"); }); diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.ts index 2e79f86f38db..b4f8e9db03a0 100644 --- a/apps/server/src/j5/a2a/EnvelopeFormatter.ts +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.ts @@ -24,6 +24,17 @@ const deliveryInstruction = (input: { exchangeId: input.exchangeId, }); +export const formatEpicSwitchWarning = (input: { + readonly epicId: EpicId; + readonly exchangeId: ExchangeId; + readonly peerId: ParticipantId; +}): string => + render(config.epicSwitchWarning, { + epicId: input.epicId, + exchangeId: input.exchangeId, + peerId: input.peerId, + }); + export const formatPeerEnvelope = (input: { readonly senderId: ParticipantId; readonly originEpicId: EpicId; diff --git a/apps/server/src/j5/a2a/EpicBootstrapService.test.ts b/apps/server/src/j5/a2a/EpicBootstrapService.test.ts index 89315fa1a327..b1cfce210835 100644 --- a/apps/server/src/j5/a2a/EpicBootstrapService.test.ts +++ b/apps/server/src/j5/a2a/EpicBootstrapService.test.ts @@ -9,14 +9,14 @@ import * as NodeSqliteClient from "../../persistence/NodeSqliteClient.ts"; import { A2AEpicBootstrap, layer as bootstrapLayer } from "./EpicBootstrapService.ts"; import { A2ALedger, layer as ledgerLayer } from "./LedgerService.ts"; import { runJ5A2AMigrations } from "./Migrations.ts"; -import { CommCommandId, EpicId, ParticipantId } from "./contracts.ts"; +import { CommCommandId, EpicId, ExchangeId, LedgerMessageId, ParticipantId } from "./contracts.ts"; const timestamp = "2026-08-16T12:00:00.000Z"; const threadId = ThreadId.make("thread:bootstrap"); const database = NodeSqliteClient.layerMemory(); const ledger = ledgerLayer.pipe(Layer.provide(database)); -const bootstrap = bootstrapLayer.pipe(Layer.provide(ledger)); +const bootstrap = bootstrapLayer.pipe(Layer.provide(ledger), Layer.provide(database)); const testLayer = Layer.mergeAll(database, ledger, bootstrap); it.effect("creates, reuses, and explicitly changes the caller's selected epic", () => @@ -32,6 +32,7 @@ it.effect("creates, reuses, and explicitly changes the caller's selected epic", assert.equal(rejoined.state, "selected"); assert.equal(rejoined.epicId, created.epicId); assert.equal(rejoined.participantId, created.participantId); + assert.deepStrictEqual(rejoined.openExchangeWarnings, []); const firstJoinEvents = yield* sql<{ readonly count: number }>` SELECT COUNT(*) AS count @@ -42,6 +43,25 @@ it.effect("creates, reuses, and explicitly changes the caller's selected epic", `; assert.equal(firstJoinEvents[0]?.count, 1, "idempotent rejoin does not append ledger junk"); + const exchangeId = ExchangeId.make("exchange:bootstrap:open"); + const peerId = ParticipantId.make("agent:bootstrap:peer"); + yield* ledgerService.appendEvents({ + commandId: CommCommandId.make("command:bootstrap:open-exchange"), + epicId: created.epicId, + acceptedAt: timestamp, + events: [ + { + kind: "exchange.opened", + sender: created.participantId, + receiver: peerId, + exchangeId, + correlationId: null, + payload: { intent: "Keep this obligation visible across reassignment", urgency: null }, + createdAt: timestamp, + }, + ], + }); + const selectedEpicId = EpicId.make("epic:bootstrap:selected"); const selected = yield* service.joinEpic({ senderThreadId: threadId, @@ -50,6 +70,14 @@ it.effect("creates, reuses, and explicitly changes the caller's selected epic", }); assert.equal(selected.state, "created"); assert.deepStrictEqual(selected.previousEpicIds, [created.epicId]); + assert.lengthOf(selected.openExchangeWarnings, 1); + assert.deepInclude(selected.openExchangeWarnings[0]!, { + epicId: created.epicId, + exchangeId, + peerId, + }); + assert.include(selected.openExchangeWarnings[0]!.message, exchangeId); + assert.include(selected.openExchangeWarnings[0]!.message, peerId); assert.deepStrictEqual(yield* ledgerService.listMembership(created.epicId), []); assert.equal( (yield* ledgerService.listMembership(selectedEpicId))[0]?.participant.kind, @@ -82,6 +110,7 @@ it.effect("creates, reuses, and explicitly changes the caller's selected epic", }); assert.equal(selectedBack.state, "joined"); assert.deepStrictEqual(selectedBack.previousEpicIds, [selectedEpicId]); + assert.deepStrictEqual(selectedBack.openExchangeWarnings, []); assert.equal((yield* ledgerService.listMembership(created.epicId)).length, 1); assert.deepStrictEqual(yield* ledgerService.listMembership(selectedEpicId), []); @@ -126,7 +155,10 @@ it.effect("derives one command id for concurrent attempts to join the same epic" }), ), }); - const serviceLayer = bootstrapLayer.pipe(Layer.provide(mockedLedger)); + const serviceLayer = bootstrapLayer.pipe( + Layer.provide(mockedLedger), + Layer.provide(NodeSqliteClient.layerMemory()), + ); const results = yield* Effect.all( [ @@ -161,10 +193,11 @@ it.effect("requires explicit selection when legacy membership is ambiguous", () id: ParticipantId.make("agent:bootstrap:ambiguous"), threadId, }; - for (const [index, epicId] of [ + const previousEpicIds = [ EpicId.make("epic:bootstrap:ambiguous:a"), EpicId.make("epic:bootstrap:ambiguous:b"), - ].entries()) { + ]; + for (const [index, epicId] of previousEpicIds.entries()) { yield* ledgerService.createEpic({ epic: { id: epicId, name: `Ambiguous ${index}`, createdAt: timestamp }, }); @@ -191,5 +224,80 @@ it.effect("requires explicit selection when legacy membership is ambiguous", () ); assert.equal(error._tag, "A2AEpicSelectionRequiredError"); assert.include(error.message, "Retry join_epic with one explicit epic_id"); + + const senderExchangeId = ExchangeId.make("exchange:bootstrap:sender-direction"); + const receiverExchangeId = ExchangeId.make("exchange:bootstrap:receiver-direction"); + const closedExchangeId = ExchangeId.make("exchange:bootstrap:closed"); + const senderPeerId = ParticipantId.make("agent:bootstrap:sender-peer"); + const receiverPeerId = ParticipantId.make("agent:bootstrap:receiver-peer"); + const closedPeerId = ParticipantId.make("agent:bootstrap:closed-peer"); + yield* ledgerService.appendEvents({ + commandId: CommCommandId.make("command:bootstrap:warning:sender"), + epicId: previousEpicIds[0]!, + acceptedAt: timestamp, + events: [ + { + kind: "exchange.opened", + sender: participant.id, + receiver: senderPeerId, + exchangeId: senderExchangeId, + correlationId: null, + payload: { intent: "Sender waits for its peer", urgency: null }, + createdAt: timestamp, + }, + ], + }); + yield* ledgerService.appendEvents({ + commandId: CommCommandId.make("command:bootstrap:warning:receiver"), + epicId: previousEpicIds[1]!, + acceptedAt: timestamp, + events: [ + { + kind: "exchange.opened", + sender: receiverPeerId, + receiver: participant.id, + exchangeId: receiverExchangeId, + correlationId: null, + payload: { intent: "Mover owes its peer", urgency: null }, + createdAt: timestamp, + }, + { + kind: "exchange.opened", + sender: participant.id, + receiver: closedPeerId, + exchangeId: closedExchangeId, + correlationId: null, + payload: { intent: "Already closed", urgency: null }, + createdAt: timestamp, + }, + { + kind: "exchange.closed", + sender: closedPeerId, + receiver: participant.id, + exchangeId: closedExchangeId, + correlationId: null, + payload: { replyMessageId: LedgerMessageId.make("message:bootstrap:closed") }, + createdAt: timestamp, + }, + ], + }); + + const selected = yield* (yield* A2AEpicBootstrap).joinEpic({ + senderThreadId: threadId, + epicId: EpicId.make("epic:bootstrap:ambiguity-resolved"), + acceptedAt: timestamp, + }); + assert.deepStrictEqual(selected.previousEpicIds, previousEpicIds); + assert.deepStrictEqual( + selected.openExchangeWarnings.map(({ epicId, exchangeId, peerId }) => ({ + epicId, + exchangeId, + peerId, + })), + [ + { epicId: previousEpicIds[0], exchangeId: senderExchangeId, peerId: senderPeerId }, + { epicId: previousEpicIds[1], exchangeId: receiverExchangeId, peerId: receiverPeerId }, + ], + ); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/j5/a2a/EpicBootstrapService.ts b/apps/server/src/j5/a2a/EpicBootstrapService.ts index fd4c0ffcabbc..57ce4203dfb7 100644 --- a/apps/server/src/j5/a2a/EpicBootstrapService.ts +++ b/apps/server/src/j5/a2a/EpicBootstrapService.ts @@ -3,15 +3,19 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; import { CommCommandId, EpicId, + ExchangeId, type JoinEpicInput, type JoinEpicResult, type Membership, ParticipantId, } from "./contracts.ts"; +import { formatEpicSwitchWarning } from "./EnvelopeFormatter.ts"; import { A2ALedger, type A2ALedgerError } from "./LedgerService.ts"; export class A2AEpicSelectionRequiredError extends Schema.TaggedErrorClass()( @@ -23,7 +27,7 @@ export class A2AEpicSelectionRequiredError extends Schema.TaggedErrorClass Effect.Effect; @@ -50,10 +54,20 @@ const membershipCommandId = ( `command:j5:a2a:bootstrap:${operation}:${stablePart(threadId)}:${stablePart(epicId)}:${stablePart(incarnation)}`, ); -export const layer: Layer.Layer = Layer.effect( +interface OpenExchangeRow { + readonly epic_id: string; + readonly exchange_id: string; + readonly sender_id: string; + readonly receiver_id: string; +} + +type A2AEpicBootstrapLayer = Layer.Layer; + +export const layer: A2AEpicBootstrapLayer = Layer.effect( A2AEpicBootstrap, Effect.gen(function* () { const ledger = yield* A2ALedger; + const sql = yield* SqlClient.SqlClient; const membershipsForThread = Effect.fn("j5.a2a.bootstrap.membershipsForThread")(function* ( threadId: ThreadId, @@ -84,6 +98,7 @@ export const layer: Layer.Layer = Layer.effe participantId: existing[0].participant.id, state: "selected", previousEpicIds: [], + openExchangeWarnings: [], }; } @@ -107,6 +122,35 @@ export const layer: Layer.Layer = Layer.effe } const previous = existing.filter((membership) => membership.epicId !== targetEpicId); + const previousParticipantByEpic = new Map( + previous.map((membership) => [membership.epicId, membership.participant.id] as const), + ); + const openExchangeWarnings = + previous.length === 0 + ? [] + : (yield* sql` + SELECT epic_id, exchange_id, sender_id, receiver_id + FROM j5_a2a_exchange + WHERE status = 'open' + ORDER BY epic_id, exchange_id + `).flatMap((row) => { + const leavingParticipantId = previousParticipantByEpic.get(row.epic_id as EpicId); + if ( + leavingParticipantId === undefined || + (row.sender_id !== leavingParticipantId && + row.receiver_id !== leavingParticipantId) + ) { + return []; + } + const warning = { + epicId: EpicId.make(row.epic_id), + exchangeId: ExchangeId.make(row.exchange_id), + peerId: ParticipantId.make( + row.sender_id === leavingParticipantId ? row.receiver_id : row.sender_id, + ), + }; + return [{ ...warning, message: formatEpicSwitchWarning(warning) }]; + }); for (const membership of previous) { yield* ledger.appendEvents({ commandId: membershipCommandId( @@ -170,6 +214,7 @@ export const layer: Layer.Layer = Layer.effe participantId, state: created ? "created" : targetMembership === undefined ? "joined" : "selected", previousEpicIds: previous.map((membership) => membership.epicId), + openExchangeWarnings, }; }); diff --git a/apps/server/src/j5/a2a/README.md b/apps/server/src/j5/a2a/README.md index 6b2cbff0e526..8455fb692a08 100644 --- a/apps/server/src/j5/a2a/README.md +++ b/apps/server/src/j5/a2a/README.md @@ -1,12 +1,12 @@ # J5 A2A runtime configuration -`envelopes.v1.json` is the human-owned wording source for every injected A2A envelope and the two MCP tool descriptions. Templates use literal `{{name}}` placeholders; `EnvelopeFormatter.ts` is the only renderer. Change wording in the JSON file, keep every referenced placeholder, and bump `version` when the rendered contract changes. +`envelopes.v1.json` is the human-owned wording source for every injected A2A envelope and the three MCP tool descriptions. Templates use literal `{{name}}` placeholders; `EnvelopeFormatter.ts` is the only renderer. Change wording in the JSON file, keep every referenced placeholder, and bump `version` when the rendered contract changes. `delivery-config.v1.json` owns the retry backoff and alarm threshold. Attempts reuse one upstream command/message id pair derived from the durable ledger message id. Increasing an attempt never rotates those ids: a permanently rejected upstream receipt must become a visible alarm rather than risk a second injection. Agent-side epic bootstrap is deliberately explicit through the authenticated `join_epic` MCP tool. `send_message` and `list_participants` never auto-join. Human/UI epic creation, selection, and management belong to the item-4 surface milestone and are not part of A2. -An explicit `join_epic` reassignment takes effect immediately: it does not transfer or cancel pending deliveries and open exchanges in the previous epic. A delivery whose recipient has left follows the normal retry-to-alarm path, while an open exchange remains durable in its original ledger. Before item 4 exposes human-managed reassignment, it must define and test an explicit disposition (for example, block, cancel by ledger event, or transfer by ledger event) instead of silently reusing this bootstrap behavior. +An explicit `join_epic` reassignment takes effect immediately: it does not transfer or cancel pending deliveries and open exchanges in the previous epic. The response warns with each open exchange ID and peer in the epics being left. A delivery whose recipient has left follows the normal retry-to-alarm path, while an open exchange remains durable in its original ledger. Before item 4 exposes human-managed reassignment, it must define and test an explicit disposition (for example, block, cancel by ledger event, or transfer by ledger event) instead of silently reusing this bootstrap behavior. That choice must also reconcile with typed silence: `peer left epic` is a candidate reason, while explicit cancellation requires a ledger event. For a cross-epic send, the receiver ledger's idempotent `message.received` records durable acceptance of the sender's act before transport is attempted. It does not claim successful thread injection: delivery success, retries, and the terminal alarm remain in the sender epic's delivery projection. diff --git a/apps/server/src/j5/a2a/contracts.ts b/apps/server/src/j5/a2a/contracts.ts index e53d5d21c85a..12939f5d759e 100644 --- a/apps/server/src/j5/a2a/contracts.ts +++ b/apps/server/src/j5/a2a/contracts.ts @@ -240,6 +240,14 @@ export const JoinEpicResult = Schema.Struct({ participantId: ParticipantId, state: Schema.Literals(["created", "joined", "selected"]), previousEpicIds: Schema.Array(EpicId), + openExchangeWarnings: Schema.Array( + Schema.Struct({ + epicId: EpicId, + exchangeId: ExchangeId, + peerId: ParticipantId, + message: Schema.String.check(Schema.isNonEmpty()), + }), + ), }); export type JoinEpicResult = typeof JoinEpicResult.Type; diff --git a/apps/server/src/j5/a2a/envelopes.v1.json b/apps/server/src/j5/a2a/envelopes.v1.json index f2ee6875c858..7284461abbef 100644 --- a/apps/server/src/j5/a2a/envelopes.v1.json +++ b/apps/server/src/j5/a2a/envelopes.v1.json @@ -1,11 +1,12 @@ { - "version": 1, + "version": 2, "peerMessage": "[J5 A2A message from {{senderId}} in {{originEpicId}}]\n\n{{message}}\n\n{{exchangeInstruction}}", "humanMessage": "[Message from the human]\n\n{{message}}\n\nThe human is not watching this chat. They see only what you send back on this exchange.\n\n{{exchangeInstruction}}", "silenceNotice": "[J5 system notice: {{noticeType}}]\n\n{{message}}\n\nThis is a platform-authored delivery signal, not a peer reply.", "replyInstruction": "Reply once with send_message(to=\"{{senderId}}\", exchange_id=\"{{exchangeId}}\", message=\"...\") to close the exchange. Follow-ups from the asker carrying this id join the same exchange.", "oneShotInstruction": "No reply is required. Use send_message without exchange_id only if a new message is needed.", + "epicSwitchWarning": "Warning: leaving {{epicId}} strands open exchange {{exchangeId}} with peer {{peerId}} in that epic. The exchange remains durable and is not cancelled or transferred.", "sendToolDescription": "Durably send one J5 A2A message. client_request_id makes retries of the same logical call idempotent. expect_reply=true opens or joins one sender-to-receiver exchange and requires intent; a reply is send_message carrying exchange_id, and one reply closes that exchange. urgency is required only when opening an exchange to the human. The call returns after the sender ledger commit; delivery continues asynchronously.", "listToolDescription": "List reachable J5 A2A participants and the capabilities accepted by each row. Use this before send_message instead of discovering participant state through failures.", - "joinToolDescription": "Select this authenticated thread's J5 epic membership. With no epic_id, return the sole current membership or create a minimal epic. With an explicit epic_id, create or select it and leave prior epic memberships. The caller thread always comes from the authenticated MCP scope." + "joinToolDescription": "Select this authenticated thread's J5 epic membership. With no epic_id, return the sole current membership or create a minimal epic. With an explicit epic_id, create or select it and leave prior epic memberships. On a switch, openExchangeWarnings names every open exchange ID and peer in the epics being left; act on those warnings before relying on the new membership. The caller thread always comes from the authenticated MCP scope." } diff --git a/apps/server/src/j5/a2a/mcp/handlers.test.ts b/apps/server/src/j5/a2a/mcp/handlers.test.ts index 7bc7d677a810..c45c99bedd31 100644 --- a/apps/server/src/j5/a2a/mcp/handlers.test.ts +++ b/apps/server/src/j5/a2a/mcp/handlers.test.ts @@ -56,6 +56,7 @@ it.effect("derives send idempotency and epic bootstrap identity from authenticat participantId, state: "selected" as const, previousEpicIds: [], + openExchangeWarnings: [], }), ), }), From 89b94b2c74b8800a109a7dc988a2180a0da25522 Mon Sep 17 00:00:00 2001 From: Jackson Miller Date: Mon, 17 Aug 2026 16:09:42 -0400 Subject: [PATCH 05/12] test: cover A2 real delivery seam --- .../a2a/DeliveryTransport.integration.test.ts | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 apps/server/src/j5/a2a/DeliveryTransport.integration.test.ts diff --git a/apps/server/src/j5/a2a/DeliveryTransport.integration.test.ts b/apps/server/src/j5/a2a/DeliveryTransport.integration.test.ts new file mode 100644 index 000000000000..816ed57fea5f --- /dev/null +++ b/apps/server/src/j5/a2a/DeliveryTransport.integration.test.ts @@ -0,0 +1,188 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + type ModelSelection, + type OrchestrationV2TurnItem, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { layer as mcpSessionRegistryTestLayer } from "../../mcp/McpSessionRegistry.testkit.ts"; +import { OrchestratorV2 } from "../../orchestration-v2/Orchestrator.ts"; +import type { ProviderAdapterV2Shape } from "../../orchestration-v2/ProviderAdapter.ts"; +import { OrchestrationV2LayerLive } from "../../orchestration-v2/runtimeLayer.ts"; +import { CodexProviderCapabilitiesV2 } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import { ThreadManagementService } from "../../orchestration-v2/ThreadManagementService.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; +import { ProviderInstanceRegistry } from "../../provider/Services/ProviderInstanceRegistry.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { + A2ADeliveryTransport, + deliveryMessageId, + live as deliveryTransportLayer, +} from "./DeliveryTransport.ts"; +import { formatPeerEnvelope } from "./EnvelopeFormatter.ts"; +import { A2ALedger, layer as ledgerLayer } from "./LedgerService.ts"; +import { CommCommandId, EpicId, ExchangeId, LedgerMessageId, ParticipantId } from "./contracts.ts"; + +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-j5-a2a-delivery-transport-", +}); + +const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", +} satisfies ModelSelection; + +const vcsDriverRegistryTestLayer = VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(serverConfigLayer), + Layer.provide(NodeServices.layer), +); + +const checkpointStoreTestLayer = CheckpointStore.layer.pipe( + Layer.provide(vcsDriverRegistryTestLayer), +); + +const driver = ProviderDriverKind.make("codex"); +const orchestrationAdapter = { + instanceId: modelSelection.instanceId, + driver, + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: () => Effect.die("provider sessions are not used by the A2 delivery seam test"), +} as ProviderAdapterV2Shape; +const providerInstance = { + instanceId: modelSelection.instanceId, + driverKind: driver, + continuationIdentity: { + driverKind: driver, + continuationKey: "codex:j5-a2a-delivery-test", + }, + displayName: "Codex A2 delivery test", + enabled: true, + snapshot: {} as ProviderInstance["snapshot"], + orchestrationAdapter, + textGeneration: {} as ProviderInstance["textGeneration"], +} satisfies ProviderInstance; + +const providerInstanceRegistryTestLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(instanceId === providerInstance.instanceId ? providerInstance : undefined), + listInstances: Effect.succeed([providerInstance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, +}); + +const orchestrationTestLayer = OrchestrationV2LayerLive.pipe( + Layer.provide(mcpSessionRegistryTestLayer), + Layer.provide(checkpointStoreTestLayer), + Layer.provide(serverConfigLayer), + Layer.provide(ServerSettingsService.layerTest()), + Layer.provide(providerInstanceRegistryTestLayer), + Layer.provide(NodeServices.layer), + Layer.provideMerge(SqlitePersistenceMemory), +); + +const testLayer = Layer.merge(ledgerLayer, deliveryTransportLayer).pipe( + Layer.provideMerge(orchestrationTestLayer), +); + +it.effect("delivers a stable peer envelope through real thread management exactly once", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const threads = yield* ThreadManagementService; + const ledger = yield* A2ALedger; + const transport = yield* A2ADeliveryTransport; + const threadId = ThreadId.make("thread:j5-a2a-delivery-target"); + const projectId = ProjectId.make("project:j5-a2a-delivery-target"); + const epicId = EpicId.make("epic:j5-a2a-delivery"); + const senderId = ParticipantId.make("agent:j5-a2a-delivery-sender"); + const receiverId = ParticipantId.make("agent:j5-a2a-delivery-receiver"); + const exchangeId = ExchangeId.make("exchange:j5-a2a-delivery"); + const messageId = LedgerMessageId.make("message:j5-a2a-delivery"); + const createdAt = "2026-08-17T12:00:00.000Z"; + const message = "Reply through the real delivery seam."; + + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:j5-a2a-delivery-create-thread"), + threadId, + projectId, + title: "J5 A2A delivery target", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: "/tmp/j5-a2a-delivery-target", + }); + yield* ledger.createEpic({ + epic: { id: epicId, name: "J5 A2A delivery", createdAt }, + }); + yield* ledger.appendEvents({ + commandId: CommCommandId.make("command:j5-a2a-delivery-join-target"), + epicId, + acceptedAt: createdAt, + events: [ + { + kind: "participant.joined", + sender: null, + receiver: receiverId, + exchangeId: null, + correlationId: null, + payload: { + participant: { kind: "agent", id: receiverId, threadId }, + }, + createdAt, + }, + ], + }); + + const delivery = { + originEpicId: epicId, + receiverEpicId: epicId, + messageId, + senderId, + receiverId, + exchangeId, + message, + }; + yield* transport.deliverAgent(delivery); + yield* transport.deliverAgent(delivery); + + const projection = yield* threads.getThreadProjection(threadId); + const upstreamMessageId = deliveryMessageId(messageId); + const deliveredMessages = projection.messages.filter( + (candidate) => candidate.id === upstreamMessageId, + ); + assert.lengthOf(deliveredMessages, 1); + assert.equal( + deliveredMessages[0]?.text, + formatPeerEnvelope({ senderId, originEpicId: epicId, exchangeId, message }), + ); + assert.lengthOf(projection.runs, 1); + assert.equal( + projection.turnItems.find( + ( + candidate, + ): candidate is Extract => + candidate.type === "user_message" && candidate.messageId === upstreamMessageId, + )?.inputIntent, + "turn_start", + ); + }).pipe(Effect.provide(testLayer)), +); From cf6798fe38be2e2ef06d081615fa16cba26fd549 Mon Sep 17 00:00:00 2001 From: Jackson Miller Date: Tue, 18 Aug 2026 01:07:25 -0400 Subject: [PATCH 06/12] fix: steer A2 delivery into active turns --- .../a2a/DeliveryTransport.integration.test.ts | 422 ++++++++++++++---- apps/server/src/j5/a2a/DeliveryTransport.ts | 7 +- 2 files changed, 345 insertions(+), 84 deletions(-) diff --git a/apps/server/src/j5/a2a/DeliveryTransport.integration.test.ts b/apps/server/src/j5/a2a/DeliveryTransport.integration.test.ts index 816ed57fea5f..80a857092137 100644 --- a/apps/server/src/j5/a2a/DeliveryTransport.integration.test.ts +++ b/apps/server/src/j5/a2a/DeliveryTransport.integration.test.ts @@ -2,25 +2,47 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { CommandId, + MessageId, type ModelSelection, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, type OrchestrationV2TurnItem, ProjectId, ProviderDriverKind, ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, ThreadId, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import { ServerConfig } from "../../config.ts"; import { layer as mcpSessionRegistryTestLayer } from "../../mcp/McpSessionRegistry.testkit.ts"; +import { OrchestrationEffectWorkerV2 } from "../../orchestration-v2/EffectWorker.ts"; +import { EventSinkV2 } from "../../orchestration-v2/EventSink.ts"; import { OrchestratorV2 } from "../../orchestration-v2/Orchestrator.ts"; -import type { ProviderAdapterV2Shape } from "../../orchestration-v2/ProviderAdapter.ts"; -import { OrchestrationV2LayerLive } from "../../orchestration-v2/runtimeLayer.ts"; +import type { + ProviderAdapterV2Event, + ProviderAdapterV2Shape, + ProviderAdapterV2SteerInput, +} from "../../orchestration-v2/ProviderAdapter.ts"; +import { + OrchestrationV2EventSinkLayerLive, + OrchestrationV2LayerLive, +} from "../../orchestration-v2/runtimeLayer.ts"; import { CodexProviderCapabilitiesV2 } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; -import { ThreadManagementService } from "../../orchestration-v2/ThreadManagementService.ts"; +import { + latestSteerableRun, + ThreadManagementService, + type ThreadManagementSendMode, +} from "../../orchestration-v2/ThreadManagementService.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; import { ProviderInstanceRegistry } from "../../provider/Services/ProviderInstanceRegistry.ts"; @@ -56,85 +78,206 @@ const checkpointStoreTestLayer = CheckpointStore.layer.pipe( ); const driver = ProviderDriverKind.make("codex"); -const orchestrationAdapter = { + +interface DeliveryInvocation { + readonly messageId: MessageId; + readonly mode: ThreadManagementSendMode; +} + +interface DeliveryHarness { + readonly deliveryInvocations: Ref.Ref>; + readonly steerInputs: Ref.Ref>; +} + +const makeOrchestrationAdapter = ( + steerInputs: Ref.Ref>, +): ProviderAdapterV2Shape => ({ instanceId: modelSelection.instanceId, driver, getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), - openSession: () => Effect.die("provider sessions are not used by the A2 delivery seam test"), -} as ProviderAdapterV2Shape; -const providerInstance = { - instanceId: modelSelection.instanceId, - driverKind: driver, - continuationIdentity: { - driverKind: driver, - continuationKey: "codex:j5-a2a-delivery-test", - }, - displayName: "Codex A2 delivery test", - enabled: true, - snapshot: {} as ProviderInstance["snapshot"], - orchestrationAdapter, - textGeneration: {} as ProviderInstance["textGeneration"], -} satisfies ProviderInstance; - -const providerInstanceRegistryTestLayer = Layer.succeed(ProviderInstanceRegistry, { - getInstance: (instanceId) => - Effect.succeed(instanceId === providerInstance.instanceId ? providerInstance : undefined), - listInstances: Effect.succeed([providerInstance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.never, + openSession: (sessionInput) => + Effect.gen(function* () { + const events = yield* PubSub.unbounded(); + const now = yield* DateTime.now; + const providerSession: OrchestrationV2ProviderSession = { + id: sessionInput.providerSessionId, + driver, + providerInstanceId: modelSelection.instanceId, + status: "ready", + cwd: sessionInput.runtimePolicy.cwd ?? process.cwd(), + model: sessionInput.modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }; + + return { + instanceId: modelSelection.instanceId, + driver, + providerSessionId: sessionInput.providerSessionId, + providerSession, + events: Stream.fromPubSub(events), + ensureThread: (input) => + Effect.gen(function* () { + const createdAt = yield* DateTime.now; + return { + id: ProviderThreadId.make(`provider-thread:${input.threadId}`), + driver, + providerInstanceId: modelSelection.instanceId, + providerSessionId: sessionInput.providerSessionId, + appThreadId: input.threadId, + ownerNodeId: null, + nativeThreadRef: { + driver, + nativeId: `native-thread:${input.threadId}`, + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt, + updatedAt: createdAt, + } satisfies OrchestrationV2ProviderThread; + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (input) => + Effect.gen(function* () { + const startedAt = yield* DateTime.now; + yield* PubSub.publish(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${input.attemptId}`), + providerThreadId: input.providerThread.id, + nodeId: input.rootNodeId, + runAttemptId: input.attemptId, + nativeTurnRef: { + driver, + nativeId: `native-turn:${input.attemptId}`, + strength: "strong", + }, + ordinal: input.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }, + }); + }), + steerTurn: (input) => Ref.update(steerInputs, (existing) => [...existing, input]), + interruptTurn: () => Effect.die("interruptTurn is unused by the A2 delivery seam test"), + respondToRuntimeRequest: () => + Effect.die("respondToRuntimeRequest is unused by the A2 delivery seam test"), + readThreadSnapshot: () => + Effect.die("readThreadSnapshot is unused by the A2 delivery seam test"), + rollbackThread: () => Effect.die("rollbackThread is unused by the A2 delivery seam test"), + forkThread: () => Effect.die("forkThread is unused by the A2 delivery seam test"), + }; + }), }); -const orchestrationTestLayer = OrchestrationV2LayerLive.pipe( - Layer.provide(mcpSessionRegistryTestLayer), - Layer.provide(checkpointStoreTestLayer), - Layer.provide(serverConfigLayer), - Layer.provide(ServerSettingsService.layerTest()), - Layer.provide(providerInstanceRegistryTestLayer), - Layer.provide(NodeServices.layer), - Layer.provideMerge(SqlitePersistenceMemory), -); +const makeTestLayer = (harness: DeliveryHarness) => { + const orchestrationAdapter = makeOrchestrationAdapter(harness.steerInputs); + const providerInstance = { + instanceId: modelSelection.instanceId, + driverKind: driver, + continuationIdentity: { + driverKind: driver, + continuationKey: "codex:j5-a2a-delivery-test", + }, + displayName: "Codex A2 delivery test", + enabled: true, + snapshot: {} as ProviderInstance["snapshot"], + orchestrationAdapter, + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const providerInstanceRegistryTestLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(instanceId === providerInstance.instanceId ? providerInstance : undefined), + listInstances: Effect.succeed([providerInstance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, + }); + const orchestrationTestLayer = Layer.merge( + OrchestrationV2LayerLive, + OrchestrationV2EventSinkLayerLive, + ).pipe( + Layer.provide(mcpSessionRegistryTestLayer), + Layer.provide(checkpointStoreTestLayer), + Layer.provide(serverConfigLayer), + Layer.provide(ServerSettingsService.layerTest()), + Layer.provide(providerInstanceRegistryTestLayer), + Layer.provide(NodeServices.layer), + Layer.provideMerge(SqlitePersistenceMemory), + ); + const recordingThreadManagement = Layer.effect( + ThreadManagementService, + Effect.gen(function* () { + const threads = yield* ThreadManagementService; + return ThreadManagementService.of({ + ...threads, + sendToThread: (input) => + Ref.update(harness.deliveryInvocations, (existing) => [ + ...existing, + { messageId: input.messageId, mode: input.mode }, + ]).pipe(Effect.andThen(threads.sendToThread(input))), + }); + }), + ); + const recordedDeliveryTransport = deliveryTransportLayer.pipe( + Layer.provide(recordingThreadManagement), + ); -const testLayer = Layer.merge(ledgerLayer, deliveryTransportLayer).pipe( - Layer.provideMerge(orchestrationTestLayer), -); + return Layer.merge(ledgerLayer, recordedDeliveryTransport).pipe( + Layer.provideMerge(orchestrationTestLayer), + ); +}; + +const makeHarness = Effect.gen(function* () { + return { + deliveryInvocations: yield* Ref.make>([]), + steerInputs: yield* Ref.make>([]), + } satisfies DeliveryHarness; +}); -it.effect("delivers a stable peer envelope through real thread management exactly once", () => +const seedTarget = (suffix: string) => Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; - const threads = yield* ThreadManagementService; const ledger = yield* A2ALedger; - const transport = yield* A2ADeliveryTransport; - const threadId = ThreadId.make("thread:j5-a2a-delivery-target"); - const projectId = ProjectId.make("project:j5-a2a-delivery-target"); - const epicId = EpicId.make("epic:j5-a2a-delivery"); - const senderId = ParticipantId.make("agent:j5-a2a-delivery-sender"); - const receiverId = ParticipantId.make("agent:j5-a2a-delivery-receiver"); - const exchangeId = ExchangeId.make("exchange:j5-a2a-delivery"); - const messageId = LedgerMessageId.make("message:j5-a2a-delivery"); + const threadId = ThreadId.make(`thread:j5-a2a-delivery-${suffix}`); + const projectId = ProjectId.make(`project:j5-a2a-delivery-${suffix}`); + const epicId = EpicId.make(`epic:j5-a2a-delivery-${suffix}`); + const senderId = ParticipantId.make(`agent:j5-a2a-delivery-${suffix}-sender`); + const receiverId = ParticipantId.make(`agent:j5-a2a-delivery-${suffix}-receiver`); + const exchangeId = ExchangeId.make(`exchange:j5-a2a-delivery-${suffix}`); + const messageId = LedgerMessageId.make(`message:j5-a2a-delivery-${suffix}`); const createdAt = "2026-08-17T12:00:00.000Z"; - const message = "Reply through the real delivery seam."; + const message = `Reply through the real ${suffix} delivery seam.`; yield* orchestrator.dispatch({ type: "thread.create", createdBy: "user", creationSource: "web", - commandId: CommandId.make("command:j5-a2a-delivery-create-thread"), + commandId: CommandId.make(`command:j5-a2a-delivery-${suffix}-create-thread`), threadId, projectId, - title: "J5 A2A delivery target", + title: `J5 A2A ${suffix} delivery target`, modelSelection, runtimeMode: "full-access", interactionMode: "default", branch: null, - worktreePath: "/tmp/j5-a2a-delivery-target", + worktreePath: `/tmp/j5-a2a-delivery-${suffix}`, }); yield* ledger.createEpic({ - epic: { id: epicId, name: "J5 A2A delivery", createdAt }, + epic: { id: epicId, name: `J5 A2A ${suffix} delivery`, createdAt }, }); yield* ledger.appendEvents({ - commandId: CommCommandId.make("command:j5-a2a-delivery-join-target"), + commandId: CommCommandId.make(`command:j5-a2a-delivery-${suffix}-join-target`), epicId, acceptedAt: createdAt, events: [ @@ -152,37 +295,150 @@ it.effect("delivers a stable peer envelope through real thread management exactl ], }); - const delivery = { - originEpicId: epicId, - receiverEpicId: epicId, - messageId, + return { + threadId, + projectId, + epicId, senderId, receiverId, exchangeId, + messageId, message, + delivery: { + originEpicId: epicId, + receiverEpicId: epicId, + messageId, + senderId, + receiverId, + exchangeId, + message, + }, }; - yield* transport.deliverAgent(delivery); - yield* transport.deliverAgent(delivery); - - const projection = yield* threads.getThreadProjection(threadId); - const upstreamMessageId = deliveryMessageId(messageId); - const deliveredMessages = projection.messages.filter( - (candidate) => candidate.id === upstreamMessageId, - ); - assert.lengthOf(deliveredMessages, 1); - assert.equal( - deliveredMessages[0]?.text, - formatPeerEnvelope({ senderId, originEpicId: epicId, exchangeId, message }), - ); - assert.lengthOf(projection.runs, 1); - assert.equal( - projection.turnItems.find( - ( - candidate, - ): candidate is Extract => - candidate.type === "user_message" && candidate.messageId === upstreamMessageId, - )?.inputIntent, - "turn_start", - ); - }).pipe(Effect.provide(testLayer)), + }); + +it.effect("starts an idle recipient immediately without the implicit auto mode", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const threads = yield* ThreadManagementService; + const transport = yield* A2ADeliveryTransport; + const target = yield* seedTarget("idle"); + + yield* transport.deliverAgent(target.delivery); + yield* transport.deliverAgent(target.delivery); + + const projection = yield* threads.getThreadProjection(target.threadId); + const upstreamMessageId = deliveryMessageId(target.messageId); + const deliveredMessages = projection.messages.filter( + (candidate) => candidate.id === upstreamMessageId, + ); + assert.lengthOf(deliveredMessages, 1); + assert.equal( + deliveredMessages[0]?.text, + formatPeerEnvelope({ + senderId: target.senderId, + originEpicId: target.epicId, + exchangeId: target.exchangeId, + message: target.message, + }), + ); + assert.lengthOf(projection.runs, 1); + assert.equal( + projection.turnItems.find( + ( + candidate, + ): candidate is Extract => + candidate.type === "user_message" && candidate.messageId === upstreamMessageId, + )?.inputIntent, + "turn_start", + ); + assert.deepStrictEqual( + (yield* Ref.get(harness.deliveryInvocations)) + .filter((invocation) => invocation.messageId === upstreamMessageId) + .map((invocation) => invocation.mode), + ["queue", "queue"], + ); + }).pipe(Effect.provide(makeTestLayer(harness))); + }), +); + +it.effect("steers a busy recipient inside its active turn without queueing a later run", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const eventSink = yield* EventSinkV2; + const worker = yield* OrchestrationEffectWorkerV2; + const threads = yield* ThreadManagementService; + const transport = yield* A2ADeliveryTransport; + const target = yield* seedTarget("busy"); + const active = yield* threads.sendToThread({ + projectId: target.projectId, + commandId: CommandId.make("command:j5-a2a-delivery-busy-start"), + threadId: target.threadId, + messageId: MessageId.make("message:j5-a2a-delivery-busy-start"), + text: "Stay active until the peer message arrives.", + attachments: [], + mode: "auto", + createdBy: "user", + creationSource: "web", + }); + assert.equal(active.delivery, "started"); + + const runningEvent = yield* eventSink.stream({ threadId: target.threadId }).pipe( + Stream.filter( + (stored) => + stored.event.type === "provider-turn.updated" && + stored.event.payload.status === "running", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + assert.isTrue(yield* worker.runOnce); + yield* Fiber.join(runningEvent); + const busyProjection = yield* threads.getThreadProjection(target.threadId); + assert.equal(latestSteerableRun(busyProjection)?.id, active.run.id); + + yield* transport.deliverAgent(target.delivery); + yield* transport.deliverAgent(target.delivery); + assert.isTrue(yield* worker.runOnce); + + const upstreamMessageId = deliveryMessageId(target.messageId); + const projection = yield* threads.getThreadProjection(target.threadId); + const deliveredMessages = projection.messages.filter( + (candidate) => candidate.id === upstreamMessageId, + ); + assert.lengthOf(deliveredMessages, 1); + assert.equal(deliveredMessages[0]?.runId, active.run.id); + assert.equal( + deliveredMessages[0]?.text, + formatPeerEnvelope({ + senderId: target.senderId, + originEpicId: target.epicId, + exchangeId: target.exchangeId, + message: target.message, + }), + ); + assert.lengthOf(projection.runs, 1); + assert.equal( + projection.turnItems.find( + ( + candidate, + ): candidate is Extract => + candidate.type === "user_message" && candidate.messageId === upstreamMessageId, + )?.inputIntent, + "steer", + ); + assert.deepStrictEqual( + (yield* Ref.get(harness.deliveryInvocations)) + .filter((invocation) => invocation.messageId === upstreamMessageId) + .map((invocation) => invocation.mode), + ["steer", "steer"], + ); + const steerInputs = yield* Ref.get(harness.steerInputs); + assert.lengthOf(steerInputs, 1); + assert.equal(steerInputs[0]?.runId, active.run.id); + assert.equal(steerInputs[0]?.message.text, deliveredMessages[0]?.text); + assert.isFalse(yield* worker.runOnce); + }).pipe(Effect.provide(makeTestLayer(harness))); + }), ); diff --git a/apps/server/src/j5/a2a/DeliveryTransport.ts b/apps/server/src/j5/a2a/DeliveryTransport.ts index deea698e1221..f2d9b1e2e074 100644 --- a/apps/server/src/j5/a2a/DeliveryTransport.ts +++ b/apps/server/src/j5/a2a/DeliveryTransport.ts @@ -118,6 +118,11 @@ export const live: Layer.Layer< }); } const target = yield* threads.getThreadProjection(participant.threadId); + // Interject peer traffic into a busy agent's active turn so blocked-on-peer + // work resumes before silence classification. Queue mode starts immediately + // when idle without reintroducing ThreadManagement's implicit auto branch. + const mode = + ThreadManagement.latestSteerableRun(target) === undefined ? "queue" : "steer"; const envelope = input.senderId === GLOBAL_HUMAN_PARTICIPANT_ID ? formatHumanEnvelope({ @@ -138,7 +143,7 @@ export const live: Layer.Layer< messageId: deliveryMessageId(input.messageId), text: envelope, attachments: [], - mode: "auto", + mode, createdBy: "agent", creationSource: "mcp", }); From 640c0151091f121f652dcb3e71ff6434c5ed821f Mon Sep 17 00:00:00 2001 From: Jackson Miller Date: Tue, 18 Aug 2026 01:36:10 -0400 Subject: [PATCH 07/12] fix: address A2 review threads --- .../a2a/DeliveryTransport.integration.test.ts | 53 ++++++++++++- apps/server/src/j5/a2a/DeliveryTransport.ts | 2 +- apps/server/src/j5/a2a/DeliveryWorker.test.ts | 36 +++++++++ apps/server/src/j5/a2a/DeliveryWorker.ts | 7 +- .../src/j5/a2a/EnvelopeFormatter.test.ts | 13 ++++ apps/server/src/j5/a2a/EnvelopeFormatter.ts | 5 +- .../src/j5/a2a/EpicBootstrapService.test.ts | 43 ++++++++--- .../server/src/j5/a2a/EpicBootstrapService.ts | 2 +- apps/server/src/j5/a2a/LedgerService.test.ts | 77 +++++++++++++++++++ apps/server/src/j5/a2a/LedgerService.ts | 12 ++- apps/server/src/j5/a2a/Migrations.test.ts | 10 +++ apps/server/src/j5/a2a/SendService.test.ts | 12 +++ apps/server/src/j5/a2a/SendService.ts | 2 +- .../j5/a2a/migrations/002_SendDeliverReply.ts | 8 ++ 14 files changed, 257 insertions(+), 25 deletions(-) diff --git a/apps/server/src/j5/a2a/DeliveryTransport.integration.test.ts b/apps/server/src/j5/a2a/DeliveryTransport.integration.test.ts index 80a857092137..ebc778738032 100644 --- a/apps/server/src/j5/a2a/DeliveryTransport.integration.test.ts +++ b/apps/server/src/j5/a2a/DeliveryTransport.integration.test.ts @@ -54,9 +54,16 @@ import { deliveryMessageId, live as deliveryTransportLayer, } from "./DeliveryTransport.ts"; -import { formatPeerEnvelope } from "./EnvelopeFormatter.ts"; +import { formatHumanEnvelope, formatPeerEnvelope } from "./EnvelopeFormatter.ts"; import { A2ALedger, layer as ledgerLayer } from "./LedgerService.ts"; -import { CommCommandId, EpicId, ExchangeId, LedgerMessageId, ParticipantId } from "./contracts.ts"; +import { + CommCommandId, + EpicId, + ExchangeId, + GLOBAL_HUMAN_PARTICIPANT_ID, + LedgerMessageId, + ParticipantId, +} from "./contracts.ts"; const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-j5-a2a-delivery-transport-", @@ -82,6 +89,7 @@ const driver = ProviderDriverKind.make("codex"); interface DeliveryInvocation { readonly messageId: MessageId; readonly mode: ThreadManagementSendMode; + readonly createdBy: "user" | "agent" | "system"; } interface DeliveryHarness { @@ -224,7 +232,7 @@ const makeTestLayer = (harness: DeliveryHarness) => { sendToThread: (input) => Ref.update(harness.deliveryInvocations, (existing) => [ ...existing, - { messageId: input.messageId, mode: input.mode }, + { messageId: input.messageId, mode: input.mode, createdBy: input.createdBy }, ]).pipe(Effect.andThen(threads.sendToThread(input))), }); }), @@ -442,3 +450,42 @@ it.effect("steers a busy recipient inside its active turn without queueing a lat }).pipe(Effect.provide(makeTestLayer(harness))); }), ); + +it.effect("attributes human-origin delivery to the user actor", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const threads = yield* ThreadManagementService; + const transport = yield* A2ADeliveryTransport; + const target = yield* seedTarget("human-origin"); + const humanMessageId = LedgerMessageId.make("message:j5-a2a-delivery-human-origin"); + const message = "Human-authored request delivered through A2A."; + + yield* transport.deliverAgent({ + ...target.delivery, + messageId: humanMessageId, + senderId: GLOBAL_HUMAN_PARTICIPANT_ID, + message, + }); + + const upstreamMessageId = deliveryMessageId(humanMessageId); + const projection = yield* threads.getThreadProjection(target.threadId); + const delivered = projection.messages.find((candidate) => candidate.id === upstreamMessageId); + assert.equal(delivered?.createdBy, "user"); + assert.equal( + delivered?.text, + formatHumanEnvelope({ + senderId: GLOBAL_HUMAN_PARTICIPANT_ID, + exchangeId: target.exchangeId, + message, + }), + ); + assert.deepStrictEqual( + (yield* Ref.get(harness.deliveryInvocations)).filter( + (invocation) => invocation.messageId === upstreamMessageId, + ), + [{ messageId: upstreamMessageId, mode: "queue", createdBy: "user" }], + ); + }).pipe(Effect.provide(makeTestLayer(harness))); + }), +); diff --git a/apps/server/src/j5/a2a/DeliveryTransport.ts b/apps/server/src/j5/a2a/DeliveryTransport.ts index f2d9b1e2e074..88f957b18904 100644 --- a/apps/server/src/j5/a2a/DeliveryTransport.ts +++ b/apps/server/src/j5/a2a/DeliveryTransport.ts @@ -144,7 +144,7 @@ export const live: Layer.Layer< text: envelope, attachments: [], mode, - createdBy: "agent", + createdBy: input.senderId === GLOBAL_HUMAN_PARTICIPANT_ID ? "user" : "agent", creationSource: "mcp", }); }).pipe( diff --git a/apps/server/src/j5/a2a/DeliveryWorker.test.ts b/apps/server/src/j5/a2a/DeliveryWorker.test.ts index 36d5b7abbbfc..843572905d4c 100644 --- a/apps/server/src/j5/a2a/DeliveryWorker.test.ts +++ b/apps/server/src/j5/a2a/DeliveryWorker.test.ts @@ -226,6 +226,42 @@ it.effect("negative control: poisoned retry ids are detected as a double injecti }), ); +it.effect("serializes manual runOnce calls against a concurrent drain", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const firstEntered = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const transport: A2ADeliveryTransportShape = { + deliverAgent: () => + Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.flatMap((call) => + call === 1 + ? Deferred.succeed(firstEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirst)), + ) + : Effect.void, + ), + ), + deliverHuman: () => Effect.void, + }; + + yield* Effect.gen(function* () { + yield* seedSend(false); + const worker = yield* A2ADeliveryWorker; + const runOnceFiber = yield* worker.runOnce.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstEntered); + const drainFiber = yield* worker.drain.pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + assert.equal(yield* Ref.get(calls), 1, "drain cannot claim the in-flight delivery"); + + yield* Deferred.succeed(releaseFirst, undefined); + assert.equal((yield* Fiber.join(runOnceFiber))?.state, "delivered"); + assert.deepStrictEqual(yield* Fiber.join(drainFiber), []); + assert.equal(yield* Ref.get(calls), 1); + }).pipe(Effect.provide(makeTestLayer(transport))); + }), +); + it.effect("cross-epic half-write recovery records exactly one receiver entry", () => Effect.gen(function* () { const result = yield* crashWindowScenario(false, true); diff --git a/apps/server/src/j5/a2a/DeliveryWorker.ts b/apps/server/src/j5/a2a/DeliveryWorker.ts index 5bcaa4cba4ae..b9ee5910f96c 100644 --- a/apps/server/src/j5/a2a/DeliveryWorker.ts +++ b/apps/server/src/j5/a2a/DeliveryWorker.ts @@ -328,14 +328,13 @@ const makeLayer = (daemon: boolean) => yield* PubSub.publish(milestones, milestone); return milestone; }); - const runOnce: A2ADeliveryWorkerShape["runOnce"] = runOnceRaw.pipe( - Effect.mapError(workerError("run one delivery")), - ); + const runOnceEffect = runOnceRaw.pipe(Effect.mapError(workerError("run one delivery"))); + const runOnce: A2ADeliveryWorkerShape["runOnce"] = drainPermit.withPermit(runOnceEffect); const drainEffect = Effect.fn("j5.a2a.delivery.drain")(function* () { const completed: Array = []; while (true) { - const milestone = yield* runOnce; + const milestone = yield* runOnceEffect; if (milestone === null) return completed; completed.push(milestone); } diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts index abffeca531b9..89e5fde45b20 100644 --- a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts @@ -42,6 +42,19 @@ it("renders epic-switch warnings with the abandoned exchange and peer", () => { assert.notInclude(rendered, "{{"); }); +it("does not interpret caller text as an envelope template", () => { + const message = "Preserve this literal token: {{exchangeInstruction}}"; + const rendered = formatPeerEnvelope({ + senderId: ParticipantId.make("agent:sender"), + originEpicId: EpicId.make("epic:origin"), + exchangeId: ExchangeId.make("exchange:one"), + message, + }); + + assert.include(rendered, message); + assert.equal(rendered.match(/send_message\(/g)?.length, 1); +}); + it("tells agents that human-origin exchanges require an explicit tool reply", () => { const rendered = formatHumanEnvelope({ senderId: ParticipantId.make("human:global"), diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.ts index b4f8e9db03a0..debd094a2733 100644 --- a/apps/server/src/j5/a2a/EnvelopeFormatter.ts +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.ts @@ -8,10 +8,7 @@ export const A2A_LIST_TOOL_DESCRIPTION = config.listToolDescription; export const A2A_JOIN_TOOL_DESCRIPTION = config.joinToolDescription; const render = (template: string, values: Readonly>): string => - Object.entries(values).reduce( - (output, [name, value]) => output.replaceAll(`{{${name}}}`, value), - template, - ); + template.replace(/\{\{([^{}]+)\}\}/g, (placeholder, name: string) => values[name] ?? placeholder); const deliveryInstruction = (input: { readonly senderId: ParticipantId; diff --git a/apps/server/src/j5/a2a/EpicBootstrapService.test.ts b/apps/server/src/j5/a2a/EpicBootstrapService.test.ts index b1cfce210835..698193ace6fd 100644 --- a/apps/server/src/j5/a2a/EpicBootstrapService.test.ts +++ b/apps/server/src/j5/a2a/EpicBootstrapService.test.ts @@ -188,16 +188,18 @@ it.effect("requires explicit selection when legacy membership is ambiguous", () Effect.gen(function* () { yield* runJ5A2AMigrations(); const ledgerService = yield* A2ALedger; - const participant = { - kind: "agent" as const, - id: ParticipantId.make("agent:bootstrap:ambiguous"), - threadId, - }; + const sql = yield* SqlClient.SqlClient; const previousEpicIds = [ EpicId.make("epic:bootstrap:ambiguous:a"), EpicId.make("epic:bootstrap:ambiguous:b"), ]; + const participants = previousEpicIds.map((_, index) => ({ + kind: "agent" as const, + id: ParticipantId.make(`agent:bootstrap:ambiguous:${index}`), + threadId, + })); for (const [index, epicId] of previousEpicIds.entries()) { + const participant = participants[index]!; yield* ledgerService.createEpic({ epic: { id: epicId, name: `Ambiguous ${index}`, createdAt: timestamp }, }); @@ -238,7 +240,7 @@ it.effect("requires explicit selection when legacy membership is ambiguous", () events: [ { kind: "exchange.opened", - sender: participant.id, + sender: participants[0]!.id, receiver: senderPeerId, exchangeId: senderExchangeId, correlationId: null, @@ -255,7 +257,7 @@ it.effect("requires explicit selection when legacy membership is ambiguous", () { kind: "exchange.opened", sender: receiverPeerId, - receiver: participant.id, + receiver: participants[1]!.id, exchangeId: receiverExchangeId, correlationId: null, payload: { intent: "Mover owes its peer", urgency: null }, @@ -263,7 +265,7 @@ it.effect("requires explicit selection when legacy membership is ambiguous", () }, { kind: "exchange.opened", - sender: participant.id, + sender: participants[1]!.id, receiver: closedPeerId, exchangeId: closedExchangeId, correlationId: null, @@ -273,7 +275,7 @@ it.effect("requires explicit selection when legacy membership is ambiguous", () { kind: "exchange.closed", sender: closedPeerId, - receiver: participant.id, + receiver: participants[1]!.id, exchangeId: closedExchangeId, correlationId: null, payload: { replyMessageId: LedgerMessageId.make("message:bootstrap:closed") }, @@ -299,5 +301,28 @@ it.effect("requires explicit selection when legacy membership is ambiguous", () { epicId: previousEpicIds[1], exchangeId: receiverExchangeId, peerId: receiverPeerId }, ], ); + const leftEvents = yield* sql<{ + readonly epic_id: string; + readonly receiver: string; + readonly payload: string; + }>` + SELECT epic_id, receiver, payload + FROM j5_a2a_comm_event + WHERE kind = 'participant.left' + AND epic_id IN (${previousEpicIds[0]!}, ${previousEpicIds[1]!}) + ORDER BY epic_id + `; + assert.deepStrictEqual( + leftEvents.map((row) => ({ + epicId: row.epic_id, + receiver: row.receiver, + participantId: (JSON.parse(row.payload) as { participant: { id: string } }).participant.id, + })), + previousEpicIds.map((epicId, index) => ({ + epicId, + receiver: participants[index]!.id, + participantId: participants[index]!.id, + })), + ); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/j5/a2a/EpicBootstrapService.ts b/apps/server/src/j5/a2a/EpicBootstrapService.ts index 57ce4203dfb7..ca8a3d42c6f0 100644 --- a/apps/server/src/j5/a2a/EpicBootstrapService.ts +++ b/apps/server/src/j5/a2a/EpicBootstrapService.ts @@ -165,7 +165,7 @@ export const layer: A2AEpicBootstrapLayer = Layer.effect( { kind: "participant.left", sender: null, - receiver: participantId, + receiver: membership.participant.id, exchangeId: null, correlationId: null, payload: { participant: membership.participant }, diff --git a/apps/server/src/j5/a2a/LedgerService.test.ts b/apps/server/src/j5/a2a/LedgerService.test.ts index 553a23205919..8afa37c17d26 100644 --- a/apps/server/src/j5/a2a/LedgerService.test.ts +++ b/apps/server/src/j5/a2a/LedgerService.test.ts @@ -450,3 +450,80 @@ it.effect("enforces one message.received correlation per receiver epic", () => assert.isTrue(retried.committed, "the rolled-back command id remains reusable"); }).pipe(Effect.provide(memoryLedgerLayer())), ); + +it.effect("rejects delivery transitions without a projected message row", () => + Effect.gen(function* () { + yield* runJ5A2AMigrations(); + const ledger = yield* A2ALedger; + const sql = yield* SqlClient.SqlClient; + const epicId = EpicId.make("epic:missing-delivery-projection"); + yield* ledger.createEpic({ + epic: { id: epicId, name: "Missing delivery projection", createdAt: timestamp }, + }); + const transitions: ReadonlyArray<{ readonly name: string; readonly event: CommEvent }> = [ + { + name: "delivered", + event: { + kind: "message.delivered", + sender: ParticipantId.make("agent:delivery:sender"), + receiver: ParticipantId.make("agent:delivery:receiver"), + exchangeId: null, + correlationId: CorrelationId.make("correlation:missing-delivered"), + payload: { + messageId: LedgerMessageId.make("message:missing-delivered"), + attempt: 1, + channel: "agent", + }, + createdAt: timestamp, + }, + }, + { + name: "delivery-failed", + event: { + kind: "message.delivery_failed", + sender: ParticipantId.make("agent:delivery:sender"), + receiver: ParticipantId.make("agent:delivery:receiver"), + exchangeId: null, + correlationId: CorrelationId.make("correlation:missing-delivery-failed"), + payload: { + messageId: LedgerMessageId.make("message:missing-delivery-failed"), + attempt: 1, + error: "forced missing projection", + nextAttemptAt: timestamp, + alarmed: false, + }, + createdAt: timestamp, + }, + }, + ]; + + for (const [index, transition] of transitions.entries()) { + const commandId = CommCommandId.make(`command:missing-delivery:${transition.name}`); + const error = yield* Effect.flip( + ledger.appendEvents({ + commandId, + epicId, + acceptedAt: timestamp, + events: [transition.event], + }), + ); + assert.isTrue(isA2AStorageError(error)); + + const retried = yield* ledger.appendEvents({ + commandId, + epicId, + acceptedAt: timestamp, + events: [messageEvent(index + 100)], + }); + assert.isTrue(retried.committed, "the failed projection rolls back its command receipt"); + } + + const transitionRows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM j5_a2a_comm_event + WHERE epic_id = ${epicId} + AND kind IN ('message.delivered', 'message.delivery_failed') + `; + assert.equal(transitionRows[0]?.count, 0); + }).pipe(Effect.provide(memoryLedgerLayer())), +); diff --git a/apps/server/src/j5/a2a/LedgerService.ts b/apps/server/src/j5/a2a/LedgerService.ts index 6eccdd303cc2..4b7a242b67a8 100644 --- a/apps/server/src/j5/a2a/LedgerService.ts +++ b/apps/server/src/j5/a2a/LedgerService.ts @@ -365,7 +365,7 @@ export const layer: Layer.Layer = Layer.e } case "message.delivered": { const payload = yield* decodeMessageDelivered(event.payload); - yield* sql` + const rows = yield* sql<{ readonly message_id: string }>` UPDATE j5_a2a_delivery SET status = 'delivered', @@ -375,12 +375,16 @@ export const layer: Layer.Layer = Layer.e delivered_seq = ${event.seq}, updated_at = ${event.createdAt} WHERE epic_id = ${event.epicId} AND message_id = ${payload.messageId} + RETURNING message_id `; + if (rows[0] === undefined) { + return yield* new A2AStorageError({ operation: "project delivered message" }); + } return; } case "message.delivery_failed": { const payload = yield* decodeMessageDeliveryFailed(event.payload); - yield* sql` + const rows = yield* sql<{ readonly message_id: string }>` UPDATE j5_a2a_delivery SET status = ${payload.alarmed ? "alarmed" : "retry_scheduled"}, @@ -389,7 +393,11 @@ export const layer: Layer.Layer = Layer.e next_attempt_at = ${payload.nextAttemptAt}, updated_at = ${event.createdAt} WHERE epic_id = ${event.epicId} AND message_id = ${payload.messageId} + RETURNING message_id `; + if (rows[0] === undefined) { + return yield* new A2AStorageError({ operation: "project failed message delivery" }); + } return; } case "message.received": diff --git a/apps/server/src/j5/a2a/Migrations.test.ts b/apps/server/src/j5/a2a/Migrations.test.ts index 26a79f62a2ff..e0a128a9a536 100644 --- a/apps/server/src/j5/a2a/Migrations.test.ts +++ b/apps/server/src/j5/a2a/Migrations.test.ts @@ -68,7 +68,9 @@ it.effect("creates the exact namespaced ledger schema and receiver correlation c 'j5_a2a_comm_event_received_correlation_idx', 'j5_a2a_comm_event_command_idx', 'j5_a2a_exchange_open_pair_idx', + 'j5_a2a_exchange_id_idx', 'j5_a2a_delivery_drain_idx', + 'j5_a2a_delivery_message_sender_idx', 'j5_a2a_delivery_one_reply_idx' ) ORDER BY name @@ -113,10 +115,18 @@ it.effect("creates the exact namespaced ledger schema and receiver correlation c indexesByName.get("j5_a2a_exchange_open_pair_idx") ?? "", "WHERE status = 'open'", ); + assert.include( + indexesByName.get("j5_a2a_exchange_id_idx") ?? "", + "ON j5_a2a_exchange(exchange_id)", + ); assert.include( indexesByName.get("j5_a2a_delivery_drain_idx") ?? "", "ON j5_a2a_delivery(status, next_attempt_at, sent_seq)", ); + assert.include( + indexesByName.get("j5_a2a_delivery_message_sender_idx") ?? "", + "ON j5_a2a_delivery(message_id, sender_id)", + ); assert.include( indexesByName.get("j5_a2a_delivery_one_reply_idx") ?? "", "WHERE exchange_id IS NOT NULL AND exchange_role = 'reply'", diff --git a/apps/server/src/j5/a2a/SendService.test.ts b/apps/server/src/j5/a2a/SendService.test.ts index e71936b4a3e1..076fa96d5de8 100644 --- a/apps/server/src/j5/a2a/SendService.test.ts +++ b/apps/server/src/j5/a2a/SendService.test.ts @@ -89,6 +89,18 @@ it.effect("opens once per sender-receiver pair, joins follow-ups, and one reply acceptedAt: timestamp, }); assert.equal(reply.exchangeState, "closed"); + assert.deepStrictEqual( + yield* service.send({ + commandId: CommCommandId.make("command:exchange:reply"), + senderThreadId: receiver.threadId, + to: sender.id, + message: "Verified.", + exchangeId: first.exchangeId!, + acceptedAt: timestamp, + }), + reply, + "the same-epic reply command replays its original durable sequence", + ); assert.deepStrictEqual( yield* service.send({ commandId: CommCommandId.make("command:exchange:first"), diff --git a/apps/server/src/j5/a2a/SendService.ts b/apps/server/src/j5/a2a/SendService.ts index ffab6122e0ee..b0dbe74e53b0 100644 --- a/apps/server/src/j5/a2a/SendService.ts +++ b/apps/server/src/j5/a2a/SendService.ts @@ -465,7 +465,7 @@ export const layer: Layer.Layer Date: Tue, 18 Aug 2026 21:02:50 -0400 Subject: [PATCH 08/12] fix: address A2 review feedback --- .../src/j5/a2a/EnvelopeFormatter.test.ts | 23 +- .../src/j5/a2a/EpicBootstrapService.test.ts | 241 ++++++------ .../server/src/j5/a2a/EpicBootstrapService.ts | 45 ++- apps/server/src/j5/a2a/README.md | 4 +- apps/server/src/j5/a2a/SendService.ts | 2 +- apps/server/src/j5/a2a/envelopes.v1.json | 14 +- apps/server/src/j5/a2a/mcp/tools.ts | 6 +- docs/j5/a2a-live-proof.md | 348 ++++++++++++++++++ 8 files changed, 538 insertions(+), 145 deletions(-) create mode 100644 docs/j5/a2a-live-proof.md diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts index 89e5fde45b20..c37be3b3149f 100644 --- a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts @@ -8,6 +8,7 @@ import { formatEpicSwitchWarning, formatHumanEnvelope, formatPeerEnvelope, + formatSilenceNoticeEnvelope, } from "./EnvelopeFormatter.ts"; import { EpicId, ExchangeId, ParticipantId } from "./contracts.ts"; @@ -19,7 +20,9 @@ it("renders the versioned peer envelope with exact reply semantics", () => { message: "Please verify the worker.", }); - assert.equal(A2A_ENVELOPE_VERSION, 2); + assert.equal(A2A_ENVELOPE_VERSION, 3); + assert.include(rendered, "Cross-agent message"); + assert.notMatch(rendered, /\b(?:J5|A2A)\b/); assert.include(rendered, "agent:sender"); assert.include(rendered, "epic:origin"); assert.include(rendered, "Please verify the worker."); @@ -28,6 +31,17 @@ it("renders the versioned peer envelope with exact reply semantics", () => { assert.notInclude(rendered, "{{"); }); +it("labels platform-authored silence without internal product branding", () => { + const rendered = formatSilenceNoticeEnvelope({ + noticeType: "peer unavailable", + message: "No reply was delivered.", + }); + + assert.include(rendered, "Cross-agent messaging system notice: peer unavailable"); + assert.include(rendered, "platform-authored delivery signal"); + assert.notMatch(rendered, /\b(?:J5|A2A)\b/); +}); + it("renders epic-switch warnings with the abandoned exchange and peer", () => { const rendered = formatEpicSwitchWarning({ epicId: EpicId.make("epic:previous"), @@ -65,7 +79,12 @@ it("tells agents that human-origin exchanges require an explicit tool reply", () assert.include(rendered, "The human is not watching this chat"); assert.include(rendered, 'exchange_id="exchange:human"'); assert.include(A2A_SEND_TOOL_DESCRIPTION, "returns after the sender ledger commit"); - assert.include(A2A_LIST_TOOL_DESCRIPTION, "reachable J5 A2A participants"); + assert.include(A2A_SEND_TOOL_DESCRIPTION, "cross-agent message"); + assert.include(A2A_LIST_TOOL_DESCRIPTION, "cross-agent messaging participants"); assert.include(A2A_JOIN_TOOL_DESCRIPTION, "authenticated thread"); assert.include(A2A_JOIN_TOOL_DESCRIPTION, "open exchange ID and peer"); + assert.notMatch( + [A2A_SEND_TOOL_DESCRIPTION, A2A_LIST_TOOL_DESCRIPTION, A2A_JOIN_TOOL_DESCRIPTION].join("\n"), + /\b(?:J5|A2A)\b/, + ); }); diff --git a/apps/server/src/j5/a2a/EpicBootstrapService.test.ts b/apps/server/src/j5/a2a/EpicBootstrapService.test.ts index 698193ace6fd..a721fbe087a3 100644 --- a/apps/server/src/j5/a2a/EpicBootstrapService.test.ts +++ b/apps/server/src/j5/a2a/EpicBootstrapService.test.ts @@ -19,7 +19,7 @@ const ledger = ledgerLayer.pipe(Layer.provide(database)); const bootstrap = bootstrapLayer.pipe(Layer.provide(ledger), Layer.provide(database)); const testLayer = Layer.mergeAll(database, ledger, bootstrap); -it.effect("creates, reuses, and explicitly changes the caller's selected epic", () => +it.effect("upgrades the auto-created default epic and warns about open exchanges", () => Effect.gen(function* () { yield* runJ5A2AMigrations(); const service = yield* A2AEpicBootstrap; @@ -43,20 +43,51 @@ it.effect("creates, reuses, and explicitly changes the caller's selected epic", `; assert.equal(firstJoinEvents[0]?.count, 1, "idempotent rejoin does not append ledger junk"); - const exchangeId = ExchangeId.make("exchange:bootstrap:open"); - const peerId = ParticipantId.make("agent:bootstrap:peer"); + const senderExchangeId = ExchangeId.make("exchange:bootstrap:open:a-sender"); + const receiverExchangeId = ExchangeId.make("exchange:bootstrap:open:b-receiver"); + const closedExchangeId = ExchangeId.make("exchange:bootstrap:closed"); + const senderPeerId = ParticipantId.make("agent:bootstrap:sender-peer"); + const receiverPeerId = ParticipantId.make("agent:bootstrap:receiver-peer"); + const closedPeerId = ParticipantId.make("agent:bootstrap:closed-peer"); yield* ledgerService.appendEvents({ - commandId: CommCommandId.make("command:bootstrap:open-exchange"), + commandId: CommCommandId.make("command:bootstrap:open-exchanges"), epicId: created.epicId, acceptedAt: timestamp, events: [ { kind: "exchange.opened", sender: created.participantId, - receiver: peerId, - exchangeId, + receiver: senderPeerId, + exchangeId: senderExchangeId, + correlationId: null, + payload: { intent: "The default participant waits for its peer", urgency: null }, + createdAt: timestamp, + }, + { + kind: "exchange.opened", + sender: receiverPeerId, + receiver: created.participantId, + exchangeId: receiverExchangeId, + correlationId: null, + payload: { intent: "The default participant owes its peer", urgency: null }, + createdAt: timestamp, + }, + { + kind: "exchange.opened", + sender: created.participantId, + receiver: closedPeerId, + exchangeId: closedExchangeId, + correlationId: null, + payload: { intent: "This exchange is already closed", urgency: null }, + createdAt: timestamp, + }, + { + kind: "exchange.closed", + sender: closedPeerId, + receiver: created.participantId, + exchangeId: closedExchangeId, correlationId: null, - payload: { intent: "Keep this obligation visible across reassignment", urgency: null }, + payload: { replyMessageId: LedgerMessageId.make("message:bootstrap:closed") }, createdAt: timestamp, }, ], @@ -70,14 +101,22 @@ it.effect("creates, reuses, and explicitly changes the caller's selected epic", }); assert.equal(selected.state, "created"); assert.deepStrictEqual(selected.previousEpicIds, [created.epicId]); - assert.lengthOf(selected.openExchangeWarnings, 1); - assert.deepInclude(selected.openExchangeWarnings[0]!, { - epicId: created.epicId, - exchangeId, - peerId, - }); - assert.include(selected.openExchangeWarnings[0]!.message, exchangeId); - assert.include(selected.openExchangeWarnings[0]!.message, peerId); + assert.deepStrictEqual( + selected.openExchangeWarnings.map(({ epicId, exchangeId, peerId }) => ({ + epicId, + exchangeId, + peerId, + })), + [ + { epicId: created.epicId, exchangeId: senderExchangeId, peerId: senderPeerId }, + { epicId: created.epicId, exchangeId: receiverExchangeId, peerId: receiverPeerId }, + ], + ); + for (const warning of selected.openExchangeWarnings) { + assert.include(warning.message, warning.exchangeId); + assert.include(warning.message, warning.peerId); + assert.include(warning.message, "auto-created default epic"); + } assert.deepStrictEqual(yield* ledgerService.listMembership(created.epicId), []); assert.equal( (yield* ledgerService.listMembership(selectedEpicId))[0]?.participant.kind, @@ -102,30 +141,59 @@ it.effect("creates, reuses, and explicitly changes the caller's selected epic", { kind: "participant.joined", count: 2 }, { kind: "participant.left", count: 1 }, ]); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("rejects switching away from an explicit epic without writing ledger state", () => + Effect.gen(function* () { + yield* runJ5A2AMigrations(); + const service = yield* A2AEpicBootstrap; + const ledgerService = yield* A2ALedger; + const sql = yield* SqlClient.SqlClient; + const currentEpicId = EpicId.make("epic:bootstrap:explicit-current"); + const requestedEpicId = EpicId.make("epic:bootstrap:explicit-requested"); - const selectedBack = yield* service.joinEpic({ + const joined = yield* service.joinEpic({ senderThreadId: threadId, - epicId: created.epicId, + epicId: currentEpicId, + acceptedAt: timestamp, + }); + assert.equal(joined.state, "created"); + assert.deepStrictEqual(joined.previousEpicIds, []); + + const rejoined = yield* service.joinEpic({ + senderThreadId: threadId, + epicId: currentEpicId, acceptedAt: timestamp, }); - assert.equal(selectedBack.state, "joined"); - assert.deepStrictEqual(selectedBack.previousEpicIds, [selectedEpicId]); - assert.deepStrictEqual(selectedBack.openExchangeWarnings, []); - assert.equal((yield* ledgerService.listMembership(created.epicId)).length, 1); - assert.deepStrictEqual(yield* ledgerService.listMembership(selectedEpicId), []); + assert.equal(rejoined.state, "selected"); + + const error = yield* Effect.flip( + service.joinEpic({ + senderThreadId: threadId, + epicId: requestedEpicId, + acceptedAt: timestamp, + }), + ); + assert.equal(error._tag, "A2AEpicReassignmentPendingError"); + assert.include(error.message, currentEpicId); + assert.include(error.message, requestedEpicId); + assert.include(error.message, "reassignment awaits product definition"); + assert.include(error.message, "join_epic"); + assert.include(error.message, "list_participants"); - const finalEventCounts = yield* sql<{ readonly kind: string; readonly count: number }>` + assert.deepStrictEqual( + (yield* ledgerService.listEpics()).map((epic) => epic.id), + [currentEpicId], + ); + assert.equal((yield* ledgerService.listMembership(currentEpicId)).length, 1); + const eventCounts = yield* sql<{ readonly kind: string; readonly count: number }>` SELECT kind, COUNT(*) AS count FROM j5_a2a_comm_event - WHERE receiver = ${created.participantId} - AND kind IN ('participant.joined', 'participant.left') GROUP BY kind ORDER BY kind `; - assert.deepStrictEqual(finalEventCounts, [ - { kind: "participant.joined", count: 3 }, - { kind: "participant.left", count: 2 }, - ]); + assert.deepStrictEqual(eventCounts, [{ kind: "participant.joined", count: 1 }]); }).pipe(Effect.provide(testLayer)), ); @@ -184,11 +252,10 @@ it.effect("derives one command id for concurrent attempts to join the same epic" }), ); -it.effect("requires explicit selection when legacy membership is ambiguous", () => +it.effect("reports legacy multi-epic membership as blocked product work", () => Effect.gen(function* () { yield* runJ5A2AMigrations(); const ledgerService = yield* A2ALedger; - const sql = yield* SqlClient.SqlClient; const previousEpicIds = [ EpicId.make("epic:bootstrap:ambiguous:a"), EpicId.make("epic:bootstrap:ambiguous:b"), @@ -225,104 +292,26 @@ it.effect("requires explicit selection when legacy membership is ambiguous", () (yield* A2AEpicBootstrap).joinEpic({ senderThreadId: threadId, acceptedAt: timestamp }), ); assert.equal(error._tag, "A2AEpicSelectionRequiredError"); - assert.include(error.message, "Retry join_epic with one explicit epic_id"); + assert.include(error.message, previousEpicIds[0]!); + assert.include(error.message, previousEpicIds[1]!); + assert.include(error.message, "reassignment, which awaits product definition"); - const senderExchangeId = ExchangeId.make("exchange:bootstrap:sender-direction"); - const receiverExchangeId = ExchangeId.make("exchange:bootstrap:receiver-direction"); - const closedExchangeId = ExchangeId.make("exchange:bootstrap:closed"); - const senderPeerId = ParticipantId.make("agent:bootstrap:sender-peer"); - const receiverPeerId = ParticipantId.make("agent:bootstrap:receiver-peer"); - const closedPeerId = ParticipantId.make("agent:bootstrap:closed-peer"); - yield* ledgerService.appendEvents({ - commandId: CommCommandId.make("command:bootstrap:warning:sender"), - epicId: previousEpicIds[0]!, - acceptedAt: timestamp, - events: [ - { - kind: "exchange.opened", - sender: participants[0]!.id, - receiver: senderPeerId, - exchangeId: senderExchangeId, - correlationId: null, - payload: { intent: "Sender waits for its peer", urgency: null }, - createdAt: timestamp, - }, - ], - }); - yield* ledgerService.appendEvents({ - commandId: CommCommandId.make("command:bootstrap:warning:receiver"), - epicId: previousEpicIds[1]!, - acceptedAt: timestamp, - events: [ - { - kind: "exchange.opened", - sender: receiverPeerId, - receiver: participants[1]!.id, - exchangeId: receiverExchangeId, - correlationId: null, - payload: { intent: "Mover owes its peer", urgency: null }, - createdAt: timestamp, - }, - { - kind: "exchange.opened", - sender: participants[1]!.id, - receiver: closedPeerId, - exchangeId: closedExchangeId, - correlationId: null, - payload: { intent: "Already closed", urgency: null }, - createdAt: timestamp, - }, - { - kind: "exchange.closed", - sender: closedPeerId, - receiver: participants[1]!.id, - exchangeId: closedExchangeId, - correlationId: null, - payload: { replyMessageId: LedgerMessageId.make("message:bootstrap:closed") }, - createdAt: timestamp, - }, - ], - }); - - const selected = yield* (yield* A2AEpicBootstrap).joinEpic({ - senderThreadId: threadId, - epicId: EpicId.make("epic:bootstrap:ambiguity-resolved"), - acceptedAt: timestamp, - }); - assert.deepStrictEqual(selected.previousEpicIds, previousEpicIds); - assert.deepStrictEqual( - selected.openExchangeWarnings.map(({ epicId, exchangeId, peerId }) => ({ - epicId, - exchangeId, - peerId, - })), - [ - { epicId: previousEpicIds[0], exchangeId: senderExchangeId, peerId: senderPeerId }, - { epicId: previousEpicIds[1], exchangeId: receiverExchangeId, peerId: receiverPeerId }, - ], + const explicitError = yield* Effect.flip( + (yield* A2AEpicBootstrap).joinEpic({ + senderThreadId: threadId, + epicId: previousEpicIds[0]!, + acceptedAt: timestamp, + }), ); - const leftEvents = yield* sql<{ - readonly epic_id: string; - readonly receiver: string; - readonly payload: string; - }>` - SELECT epic_id, receiver, payload - FROM j5_a2a_comm_event - WHERE kind = 'participant.left' - AND epic_id IN (${previousEpicIds[0]!}, ${previousEpicIds[1]!}) - ORDER BY epic_id - `; + assert.equal(explicitError._tag, "A2AEpicReassignmentPendingError"); + assert.include(explicitError.message, previousEpicIds[0]!); + assert.include(explicitError.message, previousEpicIds[1]!); + assert.include(explicitError.message, "join_epic cannot choose among these legacy memberships"); assert.deepStrictEqual( - leftEvents.map((row) => ({ - epicId: row.epic_id, - receiver: row.receiver, - participantId: (JSON.parse(row.payload) as { participant: { id: string } }).participant.id, - })), - previousEpicIds.map((epicId, index) => ({ - epicId, - receiver: participants[index]!.id, - participantId: participants[index]!.id, - })), + yield* Effect.forEach(previousEpicIds, (epicId) => ledgerService.listMembership(epicId)), + participants.map((participant, index) => [ + { epicId: previousEpicIds[index]!, participant, joinedSeq: 1, updatedSeq: 1 }, + ]), ); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/j5/a2a/EpicBootstrapService.ts b/apps/server/src/j5/a2a/EpicBootstrapService.ts index ca8a3d42c6f0..6475a127a3dd 100644 --- a/apps/server/src/j5/a2a/EpicBootstrapService.ts +++ b/apps/server/src/j5/a2a/EpicBootstrapService.ts @@ -23,11 +23,32 @@ export class A2AEpicSelectionRequiredError extends Schema.TaggedErrorClass()( + "A2AEpicReassignmentPendingError", + { + currentEpicIds: Schema.Array(Schema.String), + blockingEpicIds: Schema.Array(Schema.String), + requestedEpicId: Schema.String, + }, +) { + override get message(): string { + const nextCommand = + this.currentEpicIds.length === 1 + ? `Continue with the current membership by calling join_epic(epic_id="${this.currentEpicIds[0]}") and then list_participants.` + : "join_epic cannot choose among these legacy memberships; ask the human to resolve them after the epic-management workflow ships."; + return `This thread belongs to ${this.currentEpicIds.join(", ")}; membership in ${this.blockingEpicIds.join(", ")} blocks joining ${this.requestedEpicId}. Cross-epic reassignment awaits product definition; only an explicit upgrade from an auto-created per-thread default epic is supported today. ${nextCommand}`; + } +} + +export type A2AEpicBootstrapError = + | A2ALedgerError + | A2AEpicSelectionRequiredError + | A2AEpicReassignmentPendingError + | SqlError; export interface A2AEpicBootstrapShape { readonly joinEpic: (input: JoinEpicInput) => Effect.Effect; @@ -41,6 +62,9 @@ const stablePart = (value: string) => encodeURIComponent(value); const defaultEpicId = (threadId: ThreadId) => EpicId.make(`epic:j5:a2a:${stablePart(threadId)}`); +const isAutoCreatedDefaultMembership = (membership: Membership, threadId: ThreadId) => + membership.epicId === defaultEpicId(threadId); + const defaultParticipantId = (threadId: ThreadId) => ParticipantId.make(`agent:j5:a2a:${stablePart(threadId)}`); @@ -104,6 +128,17 @@ export const layer: A2AEpicBootstrapLayer = Layer.effect( const targetEpicId = input.epicId ?? defaultEpicId(input.senderThreadId); const targetMembership = existing.find((membership) => membership.epicId === targetEpicId); + const previous = existing.filter((membership) => membership.epicId !== targetEpicId); + const blockingMemberships = previous.filter( + (membership) => !isAutoCreatedDefaultMembership(membership, input.senderThreadId), + ); + if (blockingMemberships.length > 0) { + return yield* new A2AEpicReassignmentPendingError({ + currentEpicIds: existing.map((membership) => membership.epicId), + blockingEpicIds: blockingMemberships.map((membership) => membership.epicId), + requestedEpicId: targetEpicId, + }); + } const participantId = targetMembership?.participant.id ?? existing[0]?.participant.id ?? @@ -115,13 +150,15 @@ export const layer: A2AEpicBootstrapLayer = Layer.effect( yield* ledger.createEpic({ epic: { id: targetEpicId, - name: `J5 epic ${targetEpicId}`, + name: + input.epicId === undefined + ? `Auto-created cross-agent messaging epic for ${input.senderThreadId}` + : `Cross-agent messaging epic ${targetEpicId}`, createdAt: input.acceptedAt, }, }); } - const previous = existing.filter((membership) => membership.epicId !== targetEpicId); const previousParticipantByEpic = new Map( previous.map((membership) => [membership.epicId, membership.participant.id] as const), ); diff --git a/apps/server/src/j5/a2a/README.md b/apps/server/src/j5/a2a/README.md index 8455fb692a08..843e3c7ed0d9 100644 --- a/apps/server/src/j5/a2a/README.md +++ b/apps/server/src/j5/a2a/README.md @@ -4,9 +4,9 @@ `delivery-config.v1.json` owns the retry backoff and alarm threshold. Attempts reuse one upstream command/message id pair derived from the durable ledger message id. Increasing an attempt never rotates those ids: a permanently rejected upstream receipt must become a visible alarm rather than risk a second injection. -Agent-side epic bootstrap is deliberately explicit through the authenticated `join_epic` MCP tool. `send_message` and `list_participants` never auto-join. Human/UI epic creation, selection, and management belong to the item-4 surface milestone and are not part of A2. +Agent-side epic bootstrap is deliberately explicit through the authenticated `join_epic` MCP tool. `send_message` and `list_participants` never auto-join. With no membership, `join_epic` either creates the requested first epic or creates a deterministic per-thread default. Rejoining the same epic is idempotent. An explicit epic may replace only that auto-created default; the leave is recorded with `participant.left`, and `openExchangeWarnings` reports any open exchange ID and peer left in the default epic. -An explicit `join_epic` reassignment takes effect immediately: it does not transfer or cancel pending deliveries and open exchanges in the previous epic. The response warns with each open exchange ID and peer in the epics being left. A delivery whose recipient has left follows the normal retry-to-alarm path, while an open exchange remains durable in its original ledger. Before item 4 exposes human-managed reassignment, it must define and test an explicit disposition (for example, block, cancel by ledger event, or transfer by ledger event) instead of silently reusing this bootstrap behavior. That choice must also reconcile with typed silence: `peer left epic` is a candidate reason, while explicit cancellation requires a ledger event. +All other cross-epic reassignment is blocked. Human/UI epic creation, selection, and management belong wholly to the future item-4 epic-definition surface and are not part of A2. That work must choose and test an explicit disposition for existing deliveries and exchanges—for example, block, cancel by ledger event, or transfer by ledger event—before any human/UI reassignment ships. The chosen behavior must also reconcile with typed silence: `peer left epic` is a candidate reason, while explicit cancellation requires a ledger event. For a cross-epic send, the receiver ledger's idempotent `message.received` records durable acceptance of the sender's act before transport is attempted. It does not claim successful thread injection: delivery success, retries, and the terminal alarm remain in the sender epic's delivery projection. diff --git a/apps/server/src/j5/a2a/SendService.ts b/apps/server/src/j5/a2a/SendService.ts index b0dbe74e53b0..0d1de004ef6f 100644 --- a/apps/server/src/j5/a2a/SendService.ts +++ b/apps/server/src/j5/a2a/SendService.ts @@ -28,7 +28,7 @@ export class A2ASenderNotJoinedError extends Schema.TaggedErrorClass` before running them. + +```bash +set -euo pipefail +umask 077 + +REPO_ROOT="$(git rev-parse --show-toplevel)" +PROOF_HEAD="" +PROOF_PARENT="$(mktemp -d /tmp/j5-a2-source.XXXXXX)" +PROOF_SOURCE="$PROOF_PARENT/source" +PROOF_BASE="$(mktemp -d /tmp/j5-a2-live.XXXXXX)" +PROOF_WORKSPACE="$(mktemp -d /tmp/j5-a2-workspace.XXXXXX)" +PROOF_PORT=17659 +PROOF_SESSION="j5-a2-live-$PROOF_PORT" +PWCLI="${CODEX_HOME:-$HOME/.codex}/skills/playwright/scripts/playwright_cli.sh" + +git -C "$REPO_ROOT" worktree add --detach "$PROOF_SOURCE" "$PROOF_HEAD" +test "$(git -C "$PROOF_SOURCE" rev-parse HEAD)" = "$PROOF_HEAD" +test -z "$(git -C "$PROOF_SOURCE" status --short)" +git -C "$PROOF_WORKSPACE" init + +NODE_VERSION="$(tr -d '[:space:]' < "$PROOF_SOURCE/.nvmrc")" +fnm exec --using="$NODE_VERSION" node --version +( + cd "$PROOF_SOURCE" + fnm exec --using="$NODE_VERSION" pnpm install --frozen-lockfile + fnm exec --using="$NODE_VERSION" pnpm exec vp run build +) + +if lsof -nP -iTCP:"$PROOF_PORT" -sTCP:LISTEN >/dev/null; then + echo "Choose an unused PROOF_PORT; do not stop the existing listener." >&2 + exit 1 +fi +``` + +This intentionally builds with a fresh `node_modules` in the detached worktree. Do not reuse a +dependency directory from another checkout. + +## 2. Start the watch-free server and hold the stability gate + +The production `vp run start` path is watch-free. Keep the raw log private: startup output can contain +a pairing credential. + +```bash +SERVER_LOG="$PROOF_BASE/server.raw.log" +( + cd "$PROOF_SOURCE" + env -u VITE_HTTP_URL -u VITE_WS_URL \ + T3CODE_HOME="$PROOF_BASE" \ + T3CODE_PORT="$PROOF_PORT" \ + T3CODE_HOST=127.0.0.1 \ + T3CODE_NO_BROWSER=true \ + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD=false \ + fnm exec --using="$NODE_VERSION" pnpm exec vp run start +) >"$SERVER_LOG" 2>&1 & +SERVER_PID=$! +echo "Captured server PID $SERVER_PID" + +until grep -Fq "Listening on http://127.0.0.1:$PROOF_PORT" "$SERVER_LOG"; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "The proof server exited before listening. Inspect the private log locally." >&2 + exit 1 + fi + sleep 1 +done + +STABILITY_LINE=$(( $(wc -l < "$SERVER_LOG") + 1 )) +sleep 90 +kill -0 "$SERVER_PID" +if tail -n +"$STABILITY_LINE" "$SERVER_LOG" | grep -Eq \ + 'Restarting|shutdown reconciliation|terminalizedRuns'; then + echo "The server changed lifecycle state during the 90-second gate; stop this proof." >&2 + exit 1 +fi +echo "Watch-free server passed the 90-second no-restart gate." +``` + +Do not continue if the PID exits, the listener changes, or a restart/shutdown marker appears. + +## 3. Pair Playwright once + +The pairing command prints a one-time credential. Capture it without displaying it, open it once, and +delete both raw pairing outputs immediately after the browser redirects away from `/pair`. + +```bash +PAIR_RAW="$PROOF_BASE/pair.raw.log" +BROWSER_RAW="$PROOF_BASE/pair-browser.raw.log" +( + cd "$PROOF_SOURCE" + fnm exec --using="$NODE_VERSION" node apps/server/src/bin.ts pair --base-dir "$PROOF_BASE" +) >"$PAIR_RAW" 2>&1 +PAIR_URL="$(sed -n 's/^Pairing URL: //p' "$PAIR_RAW" | tail -n 1)" +test -n "$PAIR_URL" + +pw() { + ( + cd "$PROOF_BASE" + PLAYWRIGHT_CLI_SESSION="$PROOF_SESSION" "$PWCLI" "$@" + ) +} + +pw open "$PAIR_URL" --headed >"$BROWSER_RAW" 2>&1 +unset PAIR_URL +rm -f "$PAIR_RAW" "$BROWSER_RAW" +pw snapshot +``` + +Take screenshots only after pairing has redirected to the authenticated app. Browser snapshots and +screenshots must not contain the pairing URL or any credential. Use `pw snapshot` after each +navigation before using element references; references are not stable across page changes. + +## 4. Create the two real provider threads + +In the authenticated UI: + +1. Add only `PROOF_WORKSPACE` as a project. Do not add the source worktree or a personal project. +2. Create one stock Codex thread and one stock Claude Code thread. Record the two thread IDs and the + participant IDs returned by `join_epic`. +3. Generate unique, non-secret values for this run: + + ```bash + NONCE="$(date -u +%Y%m%dT%H%M%SZ)" + EPIC_ID="epic:a2-live:$NONCE" + ASK_TOKEN="A2-LIVE-ASK-$NONCE" + REPLY_TOKEN="A2-LIVE-REPLY-$NONCE" + OBSERVED_TOKEN="A2-LIVE-OBSERVED-$NONCE" + ASK_CLIENT_ID="a2-live-ask-$NONCE" + REPLY_CLIENT_ID="a2-live-reply-$NONCE" + printf '%s\n' "$EPIC_ID" "$ASK_TOKEN" "$REPLY_TOKEN" "$OBSERVED_TOKEN" \ + "$ASK_CLIENT_ID" "$REPLY_CLIENT_ID" + ``` + +The values are evidence markers, not credentials. Substitute them into the prompts below. + +Send this initial prompt to Codex: + +```text +This is an isolated cross-agent messaging live proof. Do not edit files or run shell commands. Call join_epic exactly once with epic_id="". Report the returned epicId and participantId, then stop. Later, if a cross-agent message contains , do not call send_message again; print and stop. +``` + +Send this initial prompt to Claude: + +```text +This is an isolated cross-agent messaging live proof. Do not edit files or run shell commands. Call join_epic exactly once with epic_id="". Report the returned epicId and participantId, then stop. Later, when a cross-agent message contains , call send_message exactly once to the sender named in the envelope, using the envelope exchange_id, message="", and client_request_id="". Do not set expect_reply, intent, or urgency. Report the reply messageId and stop. +``` + +Wait for both completed `join_epic` tool receipts. Then send Codex: + +```text +Call list_participants. Select the only agent participant whose participantId differs from your own. Call send_message exactly once with to=, message="", expect_reply=true, intent="Prove live Codex-to-Claude ask/reply delivery", and client_request_id="". Do not send anything else. Report messageId, exchangeId, exchangeState, and durableAtSeq. +``` + +Wait on actual turn completion and tool receipts, not a fixed delay. Confirm in the UI that: + +- Codex reports one ask with a message ID and open exchange ID. +- Claude receives exactly one rendered cross-agent envelope containing the ask token and that exchange + ID, then sends exactly one reply with the reply token. +- Codex receives exactly one rendered reply envelope, prints the observed token, and does not call + `send_message` again. + +Capture screenshots of these authenticated results only. Treat an extra message, duplicate tool call, +missing receipt, provider restart, or file/shell action as a failed proof. + +## 5. Verify durable state + +Run read-only queries while the server is still alive. This helper uses the repository's SQLite +inspection command against only the disposable T3 home. + +```bash +query() { + fnm exec --using="$NODE_VERSION" node \ + "$PROOF_SOURCE/apps/server/scripts/t3-sqlite-state.ts" query \ + --base-dir "$PROOF_BASE" --sql "$1" +} +``` + +The ledger must contain exactly eight ordered events: + +```bash +query " + SELECT seq, kind, sender, receiver, exchange_id, + json_extract(payload, '$.text') AS message_text, + json_extract(payload, '$.attempt') AS attempt + FROM j5_a2a_comm_event + WHERE epic_id = '$EPIC_ID' + ORDER BY seq +" +``` + +The expected kinds, in order, are: + +1. `participant.joined` +2. `participant.joined` +3. `exchange.opened` +4. `message.sent` with the ask token +5. `message.delivered` with attempt `1` +6. `message.sent` with the reply token +7. `exchange.closed` +8. `message.delivered` with attempt `1` + +Check the closed exchange and both successful deliveries: + +```bash +query " + SELECT exchange_id, status, sender_id, receiver_id, opened_seq, closed_seq + FROM j5_a2a_exchange + WHERE epic_id = '$EPIC_ID' +" + +query " + SELECT message_id, exchange_role, status, attempts, last_error, sent_seq, delivered_seq + FROM j5_a2a_delivery + WHERE epic_id = '$EPIC_ID' + ORDER BY sent_seq +" +``` + +Expect one `closed` exchange with `opened_seq=3` and `closed_seq=7`. Expect two `delivered` rows, +each with `attempts=1` and `last_error` null; their `delivered_seq` values must be `5` and `8`. + +Prove that there are no alarms and both delivery ledger receipts exist: + +```bash +query " + SELECT COUNT(*) AS alarm_count + FROM j5_a2a_comm_event + WHERE epic_id = '$EPIC_ID' AND kind = 'message.delivery_alarm' +" + +query " + SELECT command_id, result_seq + FROM j5_a2a_comm_command_receipt + WHERE epic_id = '$EPIC_ID' + AND command_id LIKE 'command:j5:a2a:delivered:%' + ORDER BY result_seq +" +``` + +Expect `alarm_count=0` and exactly two delivery receipts at result sequences `5` and `8`. + +Prove the two accepted upstream dispatch receipts: + +```bash +query " + SELECT command_id, aggregate_id, status, command_type, result_sequence + FROM orchestration_command_receipts + WHERE command_id LIKE 'command:j5:a2a:delivery:%' + AND (command_id LIKE '%$ASK_CLIENT_ID%' OR command_id LIKE '%$REPLY_CLIENT_ID%') + ORDER BY result_sequence +" +``` + +Expect exactly two rows with `status='accepted'` and `command_type='message.dispatch'`. + +Finally, prove the durable injected messages contain both rendered envelopes: + +```bash +query " + SELECT message_id, thread_id, + json_extract(payload_json, '$.creationSource') AS creation_source, + json_extract(payload_json, '$.text') AS text + FROM orchestration_v2_projection_messages + WHERE json_extract(payload_json, '$.creationSource') = 'mcp' + AND ( + json_extract(payload_json, '$.text') LIKE '%$ASK_TOKEN%' + OR json_extract(payload_json, '$.text') LIKE '%$REPLY_TOKEN%' + ) + ORDER BY created_at +" +``` + +Expect exactly two rows with `creation_source='mcp'`. Each must start with `[Cross-agent message`, +contain the corresponding evidence token, and contain the concrete exchange ID used by the reply. + +Confirm that the joined agent threads ran on the two intended real providers: + +```bash +query " + WITH joined AS ( + SELECT DISTINCT json_extract(payload, '$.participant.threadId') AS thread_id + FROM j5_a2a_comm_event + WHERE epic_id = '$EPIC_ID' AND kind = 'participant.joined' + ) + SELECT DISTINCT runs.thread_id, runs.provider + FROM orchestration_v2_projection_runs AS runs + JOIN joined ON joined.thread_id = runs.thread_id + ORDER BY runs.provider +" +``` + +Expect one Codex thread and one Claude Code (`claudeAgent`) thread. Save query output and +post-pairing screenshots under `PROOF_BASE`; do not save raw credentials. + +## 6. Clean up without touching other processes + +Close the named Playwright session, then stop only the captured server PID: + +```bash +pw close +kill "$SERVER_PID" +wait "$SERVER_PID" || true + +if lsof -nP -iTCP:"$PROOF_PORT" -sTCP:LISTEN >/dev/null; then + echo "The selected port is still open; investigate without killing by pattern." >&2 +fi + +rm -f "$SERVER_LOG" + +git -C "$REPO_ROOT" worktree remove "$PROOF_SOURCE" +rmdir "$PROOF_PARENT" +``` + +Keep the exact `PROOF_BASE` and `PROOF_WORKSPACE` paths with the proof record until the evidence is +reviewed. A human may then move those exact disposable directories to Trash. Do not use a recursive +deletion command with an unset variable, glob, home directory, repository root, or workspace root. + +The proof report must name the tested commit, Node version, providers, thread/participant IDs, +message/exchange IDs, ordered event sequence, delivery attempts, alarm count, and captured evidence +paths. It must not include pairing URLs, tokens, cookies, or provider credentials. From a82913bbe095f0e5ef1b4c13364a40b31de56d73 Mon Sep 17 00:00:00 2001 From: Jackson Miller Date: Tue, 18 Aug 2026 21:12:34 -0400 Subject: [PATCH 09/12] fix: remove unprovisioned epic join surface --- FORK.md | 2 +- .../src/j5/a2a/EnvelopeFormatter.test.ts | 20 +- apps/server/src/j5/a2a/EnvelopeFormatter.ts | 12 - .../src/j5/a2a/EpicBootstrapService.test.ts | 317 ---------------- .../server/src/j5/a2a/EpicBootstrapService.ts | 260 ------------- apps/server/src/j5/a2a/README.md | 6 +- apps/server/src/j5/a2a/SendService.test.ts | 33 ++ apps/server/src/j5/a2a/SendService.ts | 2 +- apps/server/src/j5/a2a/contracts.ts | 23 -- apps/server/src/j5/a2a/envelopes.v1.json | 6 +- apps/server/src/j5/a2a/mcp/handlers.test.ts | 40 +- apps/server/src/j5/a2a/mcp/handlers.ts | 12 - apps/server/src/j5/a2a/mcp/tools.ts | 30 +- apps/server/src/j5/a2a/runtimeLayer.ts | 3 +- .../toolkits/worktree/registration.test.ts | 1 - docs/j5/a2a-live-proof.md | 348 ------------------ 16 files changed, 55 insertions(+), 1060 deletions(-) delete mode 100644 apps/server/src/j5/a2a/EpicBootstrapService.test.ts delete mode 100644 apps/server/src/j5/a2a/EpicBootstrapService.ts delete mode 100644 docs/j5/a2a-live-proof.md diff --git a/FORK.md b/FORK.md index e593bfe20401..17891100fd68 100644 --- a/FORK.md +++ b/FORK.md @@ -42,7 +42,7 @@ Against upstream pin `993407dd9e57f1edf2f5681d70140bfefeca93cc`, the complete A2 1. A1's independent migration lane: `apps/server/src/persistence/Layers/Sqlite.ts:10` imports `runJ5A2AMigrations`, and `:42` runs it after upstream migrations. Introduced by `a064a87ac40ea2d2d936ba72008c95edeb8bbc2b` and merged in `521c50aa9bb6b4c7f55bc10a772822ec31129f2d`. 2. The one shared authenticated J5 MCP/runtime seam: `apps/server/src/mcp/McpHttpServer.ts:31` imports `J5McpIntegrationLive`, and `:247-248` append its sole entry to `layer`. Registration and runtime composition stay in `apps/server/src/j5/a2a/mcp/registration.ts`; A6 extends the J5-owned toolkit without another protected-file registration. 3. The internal delivery-dedup contract proof: `apps/server/src/orchestration-v2/runtimeLayer.test.ts:188-234`, test `replays an internal thread send without injecting a second message`. -4. The authenticated shared-toolkit integration proof: `apps/server/src/mcp/toolkits/worktree/registration.test.ts:15,70,83-90,128-132`, within test `production mcp layer lists worktree tools over http`. +4. The authenticated shared-toolkit integration proof: `apps/server/src/mcp/toolkits/worktree/registration.test.ts:15,70,83-90,128-131`, within test `production mcp layer lists worktree tools over http`. These are per-instance Director/Jackson-authorized exceptions, not standing category permission. The earlier fork rebrand is separately complete at `0c0de1acefea00a34f9529bb97be32ff5056cfcc`; its rebase-critical boundary is recorded in `BRANDING.md:1-5,24-35`. The supporting fork setup plan (`artifacts/fork-setup-plan/index.md:8-12`) and its six T1-T6 ticket artifacts are internal project records and are not present in this repository. diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts index c37be3b3149f..a77b2a444f01 100644 --- a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts @@ -2,10 +2,8 @@ import { assert, it } from "@effect/vitest"; import { A2A_ENVELOPE_VERSION, - A2A_JOIN_TOOL_DESCRIPTION, A2A_LIST_TOOL_DESCRIPTION, A2A_SEND_TOOL_DESCRIPTION, - formatEpicSwitchWarning, formatHumanEnvelope, formatPeerEnvelope, formatSilenceNoticeEnvelope, @@ -42,20 +40,6 @@ it("labels platform-authored silence without internal product branding", () => { assert.notMatch(rendered, /\b(?:J5|A2A)\b/); }); -it("renders epic-switch warnings with the abandoned exchange and peer", () => { - const rendered = formatEpicSwitchWarning({ - epicId: EpicId.make("epic:previous"), - exchangeId: ExchangeId.make("exchange:abandoned"), - peerId: ParticipantId.make("agent:waiting-peer"), - }); - - assert.include(rendered, "epic:previous"); - assert.include(rendered, "exchange:abandoned"); - assert.include(rendered, "agent:waiting-peer"); - assert.include(rendered, "not cancelled or transferred"); - assert.notInclude(rendered, "{{"); -}); - it("does not interpret caller text as an envelope template", () => { const message = "Preserve this literal token: {{exchangeInstruction}}"; const rendered = formatPeerEnvelope({ @@ -81,10 +65,8 @@ it("tells agents that human-origin exchanges require an explicit tool reply", () assert.include(A2A_SEND_TOOL_DESCRIPTION, "returns after the sender ledger commit"); assert.include(A2A_SEND_TOOL_DESCRIPTION, "cross-agent message"); assert.include(A2A_LIST_TOOL_DESCRIPTION, "cross-agent messaging participants"); - assert.include(A2A_JOIN_TOOL_DESCRIPTION, "authenticated thread"); - assert.include(A2A_JOIN_TOOL_DESCRIPTION, "open exchange ID and peer"); assert.notMatch( - [A2A_SEND_TOOL_DESCRIPTION, A2A_LIST_TOOL_DESCRIPTION, A2A_JOIN_TOOL_DESCRIPTION].join("\n"), + [A2A_SEND_TOOL_DESCRIPTION, A2A_LIST_TOOL_DESCRIPTION].join("\n"), /\b(?:J5|A2A)\b/, ); }); diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.ts index debd094a2733..079f0f076259 100644 --- a/apps/server/src/j5/a2a/EnvelopeFormatter.ts +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.ts @@ -5,7 +5,6 @@ import type { EpicId, ExchangeId, ParticipantId } from "./contracts.ts"; export const A2A_ENVELOPE_VERSION = config.version; export const A2A_SEND_TOOL_DESCRIPTION = config.sendToolDescription; export const A2A_LIST_TOOL_DESCRIPTION = config.listToolDescription; -export const A2A_JOIN_TOOL_DESCRIPTION = config.joinToolDescription; const render = (template: string, values: Readonly>): string => template.replace(/\{\{([^{}]+)\}\}/g, (placeholder, name: string) => values[name] ?? placeholder); @@ -21,17 +20,6 @@ const deliveryInstruction = (input: { exchangeId: input.exchangeId, }); -export const formatEpicSwitchWarning = (input: { - readonly epicId: EpicId; - readonly exchangeId: ExchangeId; - readonly peerId: ParticipantId; -}): string => - render(config.epicSwitchWarning, { - epicId: input.epicId, - exchangeId: input.exchangeId, - peerId: input.peerId, - }); - export const formatPeerEnvelope = (input: { readonly senderId: ParticipantId; readonly originEpicId: EpicId; diff --git a/apps/server/src/j5/a2a/EpicBootstrapService.test.ts b/apps/server/src/j5/a2a/EpicBootstrapService.test.ts deleted file mode 100644 index a721fbe087a3..000000000000 --- a/apps/server/src/j5/a2a/EpicBootstrapService.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { ThreadId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -import * as NodeSqliteClient from "../../persistence/NodeSqliteClient.ts"; -import { A2AEpicBootstrap, layer as bootstrapLayer } from "./EpicBootstrapService.ts"; -import { A2ALedger, layer as ledgerLayer } from "./LedgerService.ts"; -import { runJ5A2AMigrations } from "./Migrations.ts"; -import { CommCommandId, EpicId, ExchangeId, LedgerMessageId, ParticipantId } from "./contracts.ts"; - -const timestamp = "2026-08-16T12:00:00.000Z"; -const threadId = ThreadId.make("thread:bootstrap"); - -const database = NodeSqliteClient.layerMemory(); -const ledger = ledgerLayer.pipe(Layer.provide(database)); -const bootstrap = bootstrapLayer.pipe(Layer.provide(ledger), Layer.provide(database)); -const testLayer = Layer.mergeAll(database, ledger, bootstrap); - -it.effect("upgrades the auto-created default epic and warns about open exchanges", () => - Effect.gen(function* () { - yield* runJ5A2AMigrations(); - const service = yield* A2AEpicBootstrap; - const ledgerService = yield* A2ALedger; - const sql = yield* SqlClient.SqlClient; - - const created = yield* service.joinEpic({ senderThreadId: threadId, acceptedAt: timestamp }); - assert.equal(created.state, "created"); - const rejoined = yield* service.joinEpic({ senderThreadId: threadId, acceptedAt: timestamp }); - assert.equal(rejoined.state, "selected"); - assert.equal(rejoined.epicId, created.epicId); - assert.equal(rejoined.participantId, created.participantId); - assert.deepStrictEqual(rejoined.openExchangeWarnings, []); - - const firstJoinEvents = yield* sql<{ readonly count: number }>` - SELECT COUNT(*) AS count - FROM j5_a2a_comm_event - WHERE epic_id = ${created.epicId} - AND kind = 'participant.joined' - AND receiver = ${created.participantId} - `; - assert.equal(firstJoinEvents[0]?.count, 1, "idempotent rejoin does not append ledger junk"); - - const senderExchangeId = ExchangeId.make("exchange:bootstrap:open:a-sender"); - const receiverExchangeId = ExchangeId.make("exchange:bootstrap:open:b-receiver"); - const closedExchangeId = ExchangeId.make("exchange:bootstrap:closed"); - const senderPeerId = ParticipantId.make("agent:bootstrap:sender-peer"); - const receiverPeerId = ParticipantId.make("agent:bootstrap:receiver-peer"); - const closedPeerId = ParticipantId.make("agent:bootstrap:closed-peer"); - yield* ledgerService.appendEvents({ - commandId: CommCommandId.make("command:bootstrap:open-exchanges"), - epicId: created.epicId, - acceptedAt: timestamp, - events: [ - { - kind: "exchange.opened", - sender: created.participantId, - receiver: senderPeerId, - exchangeId: senderExchangeId, - correlationId: null, - payload: { intent: "The default participant waits for its peer", urgency: null }, - createdAt: timestamp, - }, - { - kind: "exchange.opened", - sender: receiverPeerId, - receiver: created.participantId, - exchangeId: receiverExchangeId, - correlationId: null, - payload: { intent: "The default participant owes its peer", urgency: null }, - createdAt: timestamp, - }, - { - kind: "exchange.opened", - sender: created.participantId, - receiver: closedPeerId, - exchangeId: closedExchangeId, - correlationId: null, - payload: { intent: "This exchange is already closed", urgency: null }, - createdAt: timestamp, - }, - { - kind: "exchange.closed", - sender: closedPeerId, - receiver: created.participantId, - exchangeId: closedExchangeId, - correlationId: null, - payload: { replyMessageId: LedgerMessageId.make("message:bootstrap:closed") }, - createdAt: timestamp, - }, - ], - }); - - const selectedEpicId = EpicId.make("epic:bootstrap:selected"); - const selected = yield* service.joinEpic({ - senderThreadId: threadId, - epicId: selectedEpicId, - acceptedAt: timestamp, - }); - assert.equal(selected.state, "created"); - assert.deepStrictEqual(selected.previousEpicIds, [created.epicId]); - assert.deepStrictEqual( - selected.openExchangeWarnings.map(({ epicId, exchangeId, peerId }) => ({ - epicId, - exchangeId, - peerId, - })), - [ - { epicId: created.epicId, exchangeId: senderExchangeId, peerId: senderPeerId }, - { epicId: created.epicId, exchangeId: receiverExchangeId, peerId: receiverPeerId }, - ], - ); - for (const warning of selected.openExchangeWarnings) { - assert.include(warning.message, warning.exchangeId); - assert.include(warning.message, warning.peerId); - assert.include(warning.message, "auto-created default epic"); - } - assert.deepStrictEqual(yield* ledgerService.listMembership(created.epicId), []); - assert.equal( - (yield* ledgerService.listMembership(selectedEpicId))[0]?.participant.kind, - "agent", - ); - - const selectedAgain = yield* service.joinEpic({ - senderThreadId: threadId, - epicId: selectedEpicId, - acceptedAt: timestamp, - }); - assert.equal(selectedAgain.state, "selected"); - const eventCounts = yield* sql<{ readonly kind: string; readonly count: number }>` - SELECT kind, COUNT(*) AS count - FROM j5_a2a_comm_event - WHERE receiver = ${created.participantId} - AND kind IN ('participant.joined', 'participant.left') - GROUP BY kind - ORDER BY kind - `; - assert.deepStrictEqual(eventCounts, [ - { kind: "participant.joined", count: 2 }, - { kind: "participant.left", count: 1 }, - ]); - }).pipe(Effect.provide(testLayer)), -); - -it.effect("rejects switching away from an explicit epic without writing ledger state", () => - Effect.gen(function* () { - yield* runJ5A2AMigrations(); - const service = yield* A2AEpicBootstrap; - const ledgerService = yield* A2ALedger; - const sql = yield* SqlClient.SqlClient; - const currentEpicId = EpicId.make("epic:bootstrap:explicit-current"); - const requestedEpicId = EpicId.make("epic:bootstrap:explicit-requested"); - - const joined = yield* service.joinEpic({ - senderThreadId: threadId, - epicId: currentEpicId, - acceptedAt: timestamp, - }); - assert.equal(joined.state, "created"); - assert.deepStrictEqual(joined.previousEpicIds, []); - - const rejoined = yield* service.joinEpic({ - senderThreadId: threadId, - epicId: currentEpicId, - acceptedAt: timestamp, - }); - assert.equal(rejoined.state, "selected"); - - const error = yield* Effect.flip( - service.joinEpic({ - senderThreadId: threadId, - epicId: requestedEpicId, - acceptedAt: timestamp, - }), - ); - assert.equal(error._tag, "A2AEpicReassignmentPendingError"); - assert.include(error.message, currentEpicId); - assert.include(error.message, requestedEpicId); - assert.include(error.message, "reassignment awaits product definition"); - assert.include(error.message, "join_epic"); - assert.include(error.message, "list_participants"); - - assert.deepStrictEqual( - (yield* ledgerService.listEpics()).map((epic) => epic.id), - [currentEpicId], - ); - assert.equal((yield* ledgerService.listMembership(currentEpicId)).length, 1); - const eventCounts = yield* sql<{ readonly kind: string; readonly count: number }>` - SELECT kind, COUNT(*) AS count - FROM j5_a2a_comm_event - GROUP BY kind - ORDER BY kind - `; - assert.deepStrictEqual(eventCounts, [{ kind: "participant.joined", count: 1 }]); - }).pipe(Effect.provide(testLayer)), -); - -it.effect("derives one command id for concurrent attempts to join the same epic", () => - Effect.gen(function* () { - const commandIds = yield* Ref.make>([]); - const mockedLedger = Layer.mock(A2ALedger)({ - listEpics: () => Effect.succeed([]), - listMembership: () => Effect.succeed([]), - createEpic: ({ epic }) => Effect.succeed(epic), - appendEvents: (command) => - Ref.update(commandIds, (ids) => [...ids, command.commandId]).pipe( - Effect.as({ - receipt: { - commandId: command.commandId, - epicId: command.epicId, - commandType: "comm.append" as const, - acceptedAt: command.acceptedAt, - resultSeq: command.events.length, - }, - events: command.events.map((event, index) => ({ - ...event, - epicId: command.epicId, - seq: index + 1, - })), - committed: true, - }), - ), - }); - const serviceLayer = bootstrapLayer.pipe( - Layer.provide(mockedLedger), - Layer.provide(NodeSqliteClient.layerMemory()), - ); - - const results = yield* Effect.all( - [ - A2AEpicBootstrap.pipe( - Effect.flatMap((service) => - service.joinEpic({ senderThreadId: threadId, acceptedAt: timestamp }), - ), - ), - A2AEpicBootstrap.pipe( - Effect.flatMap((service) => - service.joinEpic({ senderThreadId: threadId, acceptedAt: timestamp }), - ), - ), - ], - { concurrency: "unbounded" }, - ).pipe(Effect.provide(serviceLayer)); - - assert.equal(results[0].epicId, results[1].epicId); - assert.equal(results[0].participantId, results[1].participantId); - const captured = yield* Ref.get(commandIds); - assert.lengthOf(captured, 2); - assert.equal(captured[0], captured[1]); - }), -); - -it.effect("reports legacy multi-epic membership as blocked product work", () => - Effect.gen(function* () { - yield* runJ5A2AMigrations(); - const ledgerService = yield* A2ALedger; - const previousEpicIds = [ - EpicId.make("epic:bootstrap:ambiguous:a"), - EpicId.make("epic:bootstrap:ambiguous:b"), - ]; - const participants = previousEpicIds.map((_, index) => ({ - kind: "agent" as const, - id: ParticipantId.make(`agent:bootstrap:ambiguous:${index}`), - threadId, - })); - for (const [index, epicId] of previousEpicIds.entries()) { - const participant = participants[index]!; - yield* ledgerService.createEpic({ - epic: { id: epicId, name: `Ambiguous ${index}`, createdAt: timestamp }, - }); - yield* ledgerService.appendEvents({ - commandId: CommCommandId.make(`command:bootstrap:ambiguous:${index}`), - epicId, - acceptedAt: timestamp, - events: [ - { - kind: "participant.joined", - sender: null, - receiver: participant.id, - exchangeId: null, - correlationId: null, - payload: { participant }, - createdAt: timestamp, - }, - ], - }); - } - - const error = yield* Effect.flip( - (yield* A2AEpicBootstrap).joinEpic({ senderThreadId: threadId, acceptedAt: timestamp }), - ); - assert.equal(error._tag, "A2AEpicSelectionRequiredError"); - assert.include(error.message, previousEpicIds[0]!); - assert.include(error.message, previousEpicIds[1]!); - assert.include(error.message, "reassignment, which awaits product definition"); - - const explicitError = yield* Effect.flip( - (yield* A2AEpicBootstrap).joinEpic({ - senderThreadId: threadId, - epicId: previousEpicIds[0]!, - acceptedAt: timestamp, - }), - ); - assert.equal(explicitError._tag, "A2AEpicReassignmentPendingError"); - assert.include(explicitError.message, previousEpicIds[0]!); - assert.include(explicitError.message, previousEpicIds[1]!); - assert.include(explicitError.message, "join_epic cannot choose among these legacy memberships"); - assert.deepStrictEqual( - yield* Effect.forEach(previousEpicIds, (epicId) => ledgerService.listMembership(epicId)), - participants.map((participant, index) => [ - { epicId: previousEpicIds[index]!, participant, joinedSeq: 1, updatedSeq: 1 }, - ]), - ); - }).pipe(Effect.provide(testLayer)), -); diff --git a/apps/server/src/j5/a2a/EpicBootstrapService.ts b/apps/server/src/j5/a2a/EpicBootstrapService.ts deleted file mode 100644 index 6475a127a3dd..000000000000 --- a/apps/server/src/j5/a2a/EpicBootstrapService.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { ThreadId } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import type { SqlError } from "effect/unstable/sql/SqlError"; - -import { - CommCommandId, - EpicId, - ExchangeId, - type JoinEpicInput, - type JoinEpicResult, - type Membership, - ParticipantId, -} from "./contracts.ts"; -import { formatEpicSwitchWarning } from "./EnvelopeFormatter.ts"; -import { A2ALedger, type A2ALedgerError } from "./LedgerService.ts"; - -export class A2AEpicSelectionRequiredError extends Schema.TaggedErrorClass()( - "A2AEpicSelectionRequiredError", - { epicIds: Schema.Array(Schema.String) }, -) { - override get message(): string { - return `This thread already belongs to multiple epics (${this.epicIds.join(", ")}). Selecting one would require cross-epic reassignment, which awaits product definition. Ask the human to resolve this membership after that workflow ships.`; - } -} - -export class A2AEpicReassignmentPendingError extends Schema.TaggedErrorClass()( - "A2AEpicReassignmentPendingError", - { - currentEpicIds: Schema.Array(Schema.String), - blockingEpicIds: Schema.Array(Schema.String), - requestedEpicId: Schema.String, - }, -) { - override get message(): string { - const nextCommand = - this.currentEpicIds.length === 1 - ? `Continue with the current membership by calling join_epic(epic_id="${this.currentEpicIds[0]}") and then list_participants.` - : "join_epic cannot choose among these legacy memberships; ask the human to resolve them after the epic-management workflow ships."; - return `This thread belongs to ${this.currentEpicIds.join(", ")}; membership in ${this.blockingEpicIds.join(", ")} blocks joining ${this.requestedEpicId}. Cross-epic reassignment awaits product definition; only an explicit upgrade from an auto-created per-thread default epic is supported today. ${nextCommand}`; - } -} - -export type A2AEpicBootstrapError = - | A2ALedgerError - | A2AEpicSelectionRequiredError - | A2AEpicReassignmentPendingError - | SqlError; - -export interface A2AEpicBootstrapShape { - readonly joinEpic: (input: JoinEpicInput) => Effect.Effect; -} - -export class A2AEpicBootstrap extends Context.Service()( - "t3/j5/a2a/EpicBootstrapService/A2AEpicBootstrap", -) {} - -const stablePart = (value: string) => encodeURIComponent(value); - -const defaultEpicId = (threadId: ThreadId) => EpicId.make(`epic:j5:a2a:${stablePart(threadId)}`); - -const isAutoCreatedDefaultMembership = (membership: Membership, threadId: ThreadId) => - membership.epicId === defaultEpicId(threadId); - -const defaultParticipantId = (threadId: ThreadId) => - ParticipantId.make(`agent:j5:a2a:${stablePart(threadId)}`); - -const membershipCommandId = ( - operation: "join" | "leave", - threadId: ThreadId, - epicId: EpicId, - incarnation: string, -) => - CommCommandId.make( - `command:j5:a2a:bootstrap:${operation}:${stablePart(threadId)}:${stablePart(epicId)}:${stablePart(incarnation)}`, - ); - -interface OpenExchangeRow { - readonly epic_id: string; - readonly exchange_id: string; - readonly sender_id: string; - readonly receiver_id: string; -} - -type A2AEpicBootstrapLayer = Layer.Layer; - -export const layer: A2AEpicBootstrapLayer = Layer.effect( - A2AEpicBootstrap, - Effect.gen(function* () { - const ledger = yield* A2ALedger; - const sql = yield* SqlClient.SqlClient; - - const membershipsForThread = Effect.fn("j5.a2a.bootstrap.membershipsForThread")(function* ( - threadId: ThreadId, - ) { - const epics = yield* ledger.listEpics(); - const memberships = yield* Effect.forEach(epics, (epic) => ledger.listMembership(epic.id), { - concurrency: 1, - }); - return memberships - .flat() - .filter( - (membership): membership is Membership & { participant: { kind: "agent" } } => - membership.participant.kind === "agent" && membership.participant.threadId === threadId, - ); - }); - - const joinEpic: A2AEpicBootstrapShape["joinEpic"] = (input) => - Effect.gen(function* () { - const existing = yield* membershipsForThread(input.senderThreadId); - if (input.epicId === undefined && existing.length > 1) { - return yield* new A2AEpicSelectionRequiredError({ - epicIds: existing.map((membership) => membership.epicId), - }); - } - if (input.epicId === undefined && existing[0] !== undefined) { - return { - epicId: existing[0].epicId, - participantId: existing[0].participant.id, - state: "selected", - previousEpicIds: [], - openExchangeWarnings: [], - }; - } - - const targetEpicId = input.epicId ?? defaultEpicId(input.senderThreadId); - const targetMembership = existing.find((membership) => membership.epicId === targetEpicId); - const previous = existing.filter((membership) => membership.epicId !== targetEpicId); - const blockingMemberships = previous.filter( - (membership) => !isAutoCreatedDefaultMembership(membership, input.senderThreadId), - ); - if (blockingMemberships.length > 0) { - return yield* new A2AEpicReassignmentPendingError({ - currentEpicIds: existing.map((membership) => membership.epicId), - blockingEpicIds: blockingMemberships.map((membership) => membership.epicId), - requestedEpicId: targetEpicId, - }); - } - const participantId = - targetMembership?.participant.id ?? - existing[0]?.participant.id ?? - defaultParticipantId(input.senderThreadId); - const currentEpics = yield* ledger.listEpics(); - const targetEpic = currentEpics.find((epic) => epic.id === targetEpicId); - const created = targetEpic === undefined; - if (created) { - yield* ledger.createEpic({ - epic: { - id: targetEpicId, - name: - input.epicId === undefined - ? `Auto-created cross-agent messaging epic for ${input.senderThreadId}` - : `Cross-agent messaging epic ${targetEpicId}`, - createdAt: input.acceptedAt, - }, - }); - } - - const previousParticipantByEpic = new Map( - previous.map((membership) => [membership.epicId, membership.participant.id] as const), - ); - const openExchangeWarnings = - previous.length === 0 - ? [] - : (yield* sql` - SELECT epic_id, exchange_id, sender_id, receiver_id - FROM j5_a2a_exchange - WHERE status = 'open' - ORDER BY epic_id, exchange_id - `).flatMap((row) => { - const leavingParticipantId = previousParticipantByEpic.get(row.epic_id as EpicId); - if ( - leavingParticipantId === undefined || - (row.sender_id !== leavingParticipantId && - row.receiver_id !== leavingParticipantId) - ) { - return []; - } - const warning = { - epicId: EpicId.make(row.epic_id), - exchangeId: ExchangeId.make(row.exchange_id), - peerId: ParticipantId.make( - row.sender_id === leavingParticipantId ? row.receiver_id : row.sender_id, - ), - }; - return [{ ...warning, message: formatEpicSwitchWarning(warning) }]; - }); - for (const membership of previous) { - yield* ledger.appendEvents({ - commandId: membershipCommandId( - "leave", - input.senderThreadId, - membership.epicId, - String(membership.joinedSeq), - ), - epicId: membership.epicId, - acceptedAt: input.acceptedAt, - events: [ - { - kind: "participant.left", - sender: null, - receiver: membership.participant.id, - exchangeId: null, - correlationId: null, - payload: { participant: membership.participant }, - createdAt: input.acceptedAt, - }, - ], - }); - } - - if (targetMembership === undefined) { - const sourceIncarnation = - existing - .map((membership) => `${membership.epicId}:${membership.joinedSeq}`) - .sort() - .join(",") || "initial"; - const participant = { - kind: "agent" as const, - id: participantId, - threadId: input.senderThreadId, - }; - yield* ledger.appendEvents({ - commandId: membershipCommandId( - "join", - input.senderThreadId, - targetEpicId, - sourceIncarnation, - ), - epicId: targetEpicId, - acceptedAt: input.acceptedAt, - events: [ - { - kind: "participant.joined", - sender: null, - receiver: participantId, - exchangeId: null, - correlationId: null, - payload: { participant }, - createdAt: input.acceptedAt, - }, - ], - }); - } - - return { - epicId: targetEpicId, - participantId, - state: created ? "created" : targetMembership === undefined ? "joined" : "selected", - previousEpicIds: previous.map((membership) => membership.epicId), - openExchangeWarnings, - }; - }); - - return A2AEpicBootstrap.of({ joinEpic }); - }), -); diff --git a/apps/server/src/j5/a2a/README.md b/apps/server/src/j5/a2a/README.md index 843e3c7ed0d9..eb6dcf56950c 100644 --- a/apps/server/src/j5/a2a/README.md +++ b/apps/server/src/j5/a2a/README.md @@ -1,12 +1,12 @@ # J5 A2A runtime configuration -`envelopes.v1.json` is the human-owned wording source for every injected A2A envelope and the three MCP tool descriptions. Templates use literal `{{name}}` placeholders; `EnvelopeFormatter.ts` is the only renderer. Change wording in the JSON file, keep every referenced placeholder, and bump `version` when the rendered contract changes. +`envelopes.v1.json` is the human-owned wording source for every injected A2A envelope and the two MCP tool descriptions. Templates use literal `{{name}}` placeholders; `EnvelopeFormatter.ts` is the only renderer. Change wording in the JSON file, keep every referenced placeholder, and bump `version` when the rendered contract changes. `delivery-config.v1.json` owns the retry backoff and alarm threshold. Attempts reuse one upstream command/message id pair derived from the durable ledger message id. Increasing an attempt never rotates those ids: a permanently rejected upstream receipt must become a visible alarm rather than risk a second injection. -Agent-side epic bootstrap is deliberately explicit through the authenticated `join_epic` MCP tool. `send_message` and `list_participants` never auto-join. With no membership, `join_epic` either creates the requested first epic or creates a deterministic per-thread default. Rejoining the same epic is idempotent. An explicit epic may replace only that auto-created default; the leave is recorded with `participant.left`, and `openExchangeWarnings` reports any open exchange ID and peer left in the default epic. +Production membership provisioning is intentionally absent from A2. Native threads without an internally registered home epic are not A2A participants. The authenticated MCP toolkit exposes only `send_message` and `list_participants`; both fail closed when the caller has no provisioned epic membership. It does not expose an agent-invocable join, default epic creation, or epic movement. The membership schema, projection, and lifecycle events remain as the durable foundation for provisioning outside the agent tool surface. -All other cross-epic reassignment is blocked. Human/UI epic creation, selection, and management belong wholly to the future item-4 epic-definition surface and are not part of A2. That work must choose and test an explicit disposition for existing deliveries and exchanges—for example, block, cancel by ledger event, or transfer by ledger event—before any human/UI reassignment ships. The chosen behavior must also reconcile with typed silence: `peer left epic` is a candidate reason, while explicit cancellation requires a ledger event. +The named coordinated **home-epic registrar + A6 creation integrations** follow-up owns internal creation-time registration and its product integrations: users create and choose epics, and a spawned agent inherits its spawner's home epic. That follow-up also owns the executable live-proof runbook and a fresh real Codex-to-Claude proof before A3 is staffed. This PR does not infer those creation-time seams early or advertise a tool that production cannot provision truthfully. For a cross-epic send, the receiver ledger's idempotent `message.received` records durable acceptance of the sender's act before transport is attempted. It does not claim successful thread injection: delivery success, retries, and the terminal alarm remain in the sender epic's delivery projection. diff --git a/apps/server/src/j5/a2a/SendService.test.ts b/apps/server/src/j5/a2a/SendService.test.ts index 076fa96d5de8..ce6a39625d9e 100644 --- a/apps/server/src/j5/a2a/SendService.test.ts +++ b/apps/server/src/j5/a2a/SendService.test.ts @@ -275,6 +275,39 @@ it.effect("rolls back the send receipt when its projection write fails", () => }).pipe(Effect.provide(testLayer)), ); +it.effect("fails closed when a native thread has no provisioned epic membership", () => + Effect.gen(function* () { + yield* runJ5A2AMigrations(); + const service = yield* A2ASendService; + const sql = yield* SqlClient.SqlClient; + const nativeThreadId = ThreadId.make("thread:native-without-home-epic"); + + const listError = yield* Effect.flip(service.listParticipants(nativeThreadId)); + assert.equal(listError._tag, "A2ASenderNotJoinedError"); + assert.include(listError.message, "no provisioned epic membership"); + assert.include(listError.message, "Ask the user to create an epic"); + assert.include(listError.message, "list_participants"); + + const sendError = yield* Effect.flip( + service.send({ + commandId: CommCommandId.make("command:native-without-home-epic"), + senderThreadId: nativeThreadId, + to: receiver.id, + message: "This must fail without provisioning.", + acceptedAt: timestamp, + }), + ); + assert.equal(sendError._tag, "A2ASenderNotJoinedError"); + + const state = yield* sql<{ readonly epics: number; readonly events: number }>` + SELECT + (SELECT COUNT(*) FROM j5_a2a_epic) AS epics, + (SELECT COUNT(*) FROM j5_a2a_comm_event) AS events + `; + assert.deepStrictEqual(state, [{ epics: 0, events: 0 }]); + }).pipe(Effect.provide(testLayer)), +); + it.effect("lists membership-derived participant capabilities", () => Effect.gen(function* () { const epicId = yield* setupSameEpic(); diff --git a/apps/server/src/j5/a2a/SendService.ts b/apps/server/src/j5/a2a/SendService.ts index 0d1de004ef6f..496ea0f7a7b8 100644 --- a/apps/server/src/j5/a2a/SendService.ts +++ b/apps/server/src/j5/a2a/SendService.ts @@ -28,7 +28,7 @@ export class A2ASenderNotJoinedError extends Schema.TaggedErrorClass +it.effect("derives send idempotency and sender identity from authenticated scope", () => Effect.gen(function* () { + assert.deepStrictEqual(Object.keys(J5Toolkit.tools).sort(), [ + "list_participants", + "send_message", + ]); const sends = yield* Ref.make>([]); - const bootstrapThreads = yield* Ref.make>([]); - const epicId = EpicId.make("epic:j5:mcp-handler"); const participantId = ParticipantId.make("agent:j5:mcp-handler"); const sendService = Layer.succeed( A2ASendService, @@ -46,24 +47,8 @@ it.effect("derives send idempotency and epic bootstrap identity from authenticat listParticipants: () => Effect.succeed([]), }), ); - const bootstrapService = Layer.succeed( - A2AEpicBootstrap, - A2AEpicBootstrap.of({ - joinEpic: (input) => - Ref.update(bootstrapThreads, (threads) => [...threads, input.senderThreadId]).pipe( - Effect.as({ - epicId: input.epicId ?? epicId, - participantId, - state: "selected" as const, - previousEpicIds: [], - openExchangeWarnings: [], - }), - ), - }), - ); const dependencies = Layer.mergeAll( sendService, - bootstrapService, Layer.mock(A2ADeliveryWorker)({ notify: Effect.void }), NodeServices.layer, ); @@ -71,9 +56,9 @@ it.effect("derives send idempotency and epic bootstrap identity from authenticat yield* Effect.gen(function* () { const toolkit = yield* J5Toolkit; - const call = (name: "send_message" | "join_epic", args: Record) => + const call = (args: J5SendMessageInput) => toolkit - .handle(name, args) + .handle("send_message", args) .pipe( Stream.unwrap, Stream.run(Sink.last()), @@ -85,15 +70,12 @@ it.effect("derives send idempotency and epic bootstrap identity from authenticat message: "Idempotent MCP send", client_request_id: "logical-send-1", }; - yield* call("send_message", sendArguments); - yield* call("send_message", sendArguments); + yield* call(sendArguments); + yield* call(sendArguments); const captured = yield* Ref.get(sends); assert.lengthOf(captured, 2); assert.equal(captured[0]?.commandId, captured[1]?.commandId); assert.equal(captured[0]?.senderThreadId, invocation.threadId); - - yield* call("join_epic", { epic_id: epicId }); - assert.deepStrictEqual(yield* Ref.get(bootstrapThreads), [invocation.threadId]); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/server/src/j5/a2a/mcp/handlers.ts b/apps/server/src/j5/a2a/mcp/handlers.ts index 5aca682c0090..cfbc2dbedf72 100644 --- a/apps/server/src/j5/a2a/mcp/handlers.ts +++ b/apps/server/src/j5/a2a/mcp/handlers.ts @@ -4,7 +4,6 @@ import * as Effect from "effect/Effect"; import { McpInvocationContext } from "../../../mcp/McpInvocationContext.ts"; import { A2ADeliveryWorker } from "../DeliveryWorker.ts"; -import { A2AEpicBootstrap } from "../EpicBootstrapService.ts"; import { A2ASendService } from "../SendService.ts"; import { CommCommandId } from "../contracts.ts"; import { J5Toolkit, type J5McpFailure } from "./tools.ts"; @@ -59,17 +58,6 @@ const handlers = { const service = yield* A2ASendService; return { participants: yield* service.listParticipants(scope.threadId) }; }).pipe(Effect.mapError(failure)), - join_epic: (input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext; - const service = yield* A2AEpicBootstrap; - const acceptedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); - return yield* service.joinEpic({ - senderThreadId: scope.threadId, - ...(input.epic_id === undefined ? {} : { epicId: input.epic_id }), - acceptedAt, - }); - }).pipe(Effect.mapError(failure)), } satisfies Parameters[0]; export const J5ToolkitHandlersLive = J5Toolkit.toLayer(handlers); diff --git a/apps/server/src/j5/a2a/mcp/tools.ts b/apps/server/src/j5/a2a/mcp/tools.ts index 818035e5185e..2ea7d7877d80 100644 --- a/apps/server/src/j5/a2a/mcp/tools.ts +++ b/apps/server/src/j5/a2a/mcp/tools.ts @@ -3,18 +3,11 @@ import * as Schema from "effect/Schema"; import * as Crypto from "effect/Crypto"; import * as McpInvocationContext from "../../../mcp/McpInvocationContext.ts"; -import { - A2A_JOIN_TOOL_DESCRIPTION, - A2A_LIST_TOOL_DESCRIPTION, - A2A_SEND_TOOL_DESCRIPTION, -} from "../EnvelopeFormatter.ts"; +import { A2A_LIST_TOOL_DESCRIPTION, A2A_SEND_TOOL_DESCRIPTION } from "../EnvelopeFormatter.ts"; import { A2ADeliveryWorker } from "../DeliveryWorker.ts"; -import { A2AEpicBootstrap } from "../EpicBootstrapService.ts"; import { A2ASendService } from "../SendService.ts"; import { - EpicId, ExchangeId, - JoinEpicResult, ParticipantDirectoryRow, ParticipantId, SendMessageResult, @@ -42,15 +35,10 @@ export const J5ListParticipantsResult = Schema.Struct({ participants: Schema.Array(ParticipantDirectoryRow), }); -export const J5JoinEpicInput = Schema.Struct({ - epic_id: Schema.optional(EpicId), -}); - const dependencies = [ McpInvocationContext.McpInvocationContext, A2ASendService, A2ADeliveryWorker, - A2AEpicBootstrap, Crypto.Crypto, ]; @@ -81,19 +69,5 @@ export const J5ListParticipantsTool = Tool.make("list_participants", { .annotate(Tool.Idempotent, true) .annotate(Tool.OpenWorld, false); -export const J5JoinEpicTool = Tool.make("join_epic", { - description: A2A_JOIN_TOOL_DESCRIPTION, - parameters: J5JoinEpicInput, - success: JoinEpicResult, - failure: J5McpFailure, - failureMode: "return", - dependencies, -}) - .annotate(Tool.Title, "Join a cross-agent messaging epic") - .annotate(Tool.Readonly, false) - .annotate(Tool.Destructive, true) - .annotate(Tool.Idempotent, true) - .annotate(Tool.OpenWorld, false); - /** Shared J5 toolkit bootstrap. Later J5 milestones append their tools here. */ -export const J5Toolkit = Toolkit.make(J5SendMessageTool, J5ListParticipantsTool, J5JoinEpicTool); +export const J5Toolkit = Toolkit.make(J5SendMessageTool, J5ListParticipantsTool); diff --git a/apps/server/src/j5/a2a/runtimeLayer.ts b/apps/server/src/j5/a2a/runtimeLayer.ts index b988e860d776..a4105d9dd0cf 100644 --- a/apps/server/src/j5/a2a/runtimeLayer.ts +++ b/apps/server/src/j5/a2a/runtimeLayer.ts @@ -2,7 +2,6 @@ import * as Layer from "effect/Layer"; import { layer as deliveryWorkerLayer } from "./DeliveryWorker.ts"; import { live as deliveryTransportLayer } from "./DeliveryTransport.ts"; -import { layer as epicBootstrapLayer } from "./EpicBootstrapService.ts"; import { layer as ledgerLayer } from "./LedgerService.ts"; import { layer as sendServiceLayer } from "./SendService.ts"; @@ -18,7 +17,7 @@ export const makeJ5A2ARuntimeLayer = ( Layer.provideMerge(deliveryTransportProvided), ); - return Layer.mergeAll(sendServiceLayer, epicBootstrapLayer, deliveryWorkerProvided).pipe( + return Layer.mergeAll(sendServiceLayer, deliveryWorkerProvided).pipe( Layer.provideMerge(ledgerProvided), ); }; diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 6e8418f2c8fd..5725f070fb95 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -129,7 +129,6 @@ it.effect("production mcp layer lists worktree tools over http", () => // that later J5 milestones extend inside the fork-owned toolkit. expect(toolNames).toContain("send_message"); expect(toolNames).toContain("list_participants"); - expect(toolNames).toContain("join_epic"); // The handoff tool mutates thread state, reaches the network (origin // fetch), and runs project setup scripts, so its MCP hints must not diff --git a/docs/j5/a2a-live-proof.md b/docs/j5/a2a-live-proof.md deleted file mode 100644 index 46154b95a984..000000000000 --- a/docs/j5/a2a-live-proof.md +++ /dev/null @@ -1,348 +0,0 @@ -# Run the cross-agent messaging live proof - -This is a human-run release proof for the cross-agent `join_epic`, `send_message`, and reply loop. -It is not a CI test, and it is not an instruction for an agent to start a server or browser. A human -must approve and supervise the real Codex and Claude provider turns. - -The procedure was completed successfully at -`e1516b77dcb0e170ce4973523a63c071da07950d`. The commands and assertions below record that -workflow. The runbook itself has not been rerun end to end at every later commit, so record the exact -reviewed head used by each new proof. - -## Safety boundaries - -- Use a detached, clean worktree at one reviewed commit, with its own dependency installation. -- Put the T3 home, proof workspace, browser artifacts, logs, and screenshots under fresh `/tmp` - directories. Never point the proof at the installed application's live T3 home or copy its settings, - provider secrets, `.codex`, or `.claude` directories. -- Use `fnm`, the Node version in `.nvmrc`, pnpm, and the repository-local `vp`. Do not use Bun or a - globally installed `vp`. -- Never print, paste into a report, or commit a pairing URL, token, cookie, raw server log, or provider - credential. Pairing URLs are credentials. -- Never kill by process name, path, or pattern. Capture the server PID when it starts and stop only - that PID. If the selected port is occupied, choose another port; do not kill its owner. -- Keep provider prompts free of credentials and repository data. Stop if either provider tries to edit - files or run shell commands. - -## 1. Prepare detached source and disposable state - -Run these commands in Bash from the repository that contains the reviewed commit. Replace -`` before running them. - -```bash -set -euo pipefail -umask 077 - -REPO_ROOT="$(git rev-parse --show-toplevel)" -PROOF_HEAD="" -PROOF_PARENT="$(mktemp -d /tmp/j5-a2-source.XXXXXX)" -PROOF_SOURCE="$PROOF_PARENT/source" -PROOF_BASE="$(mktemp -d /tmp/j5-a2-live.XXXXXX)" -PROOF_WORKSPACE="$(mktemp -d /tmp/j5-a2-workspace.XXXXXX)" -PROOF_PORT=17659 -PROOF_SESSION="j5-a2-live-$PROOF_PORT" -PWCLI="${CODEX_HOME:-$HOME/.codex}/skills/playwright/scripts/playwright_cli.sh" - -git -C "$REPO_ROOT" worktree add --detach "$PROOF_SOURCE" "$PROOF_HEAD" -test "$(git -C "$PROOF_SOURCE" rev-parse HEAD)" = "$PROOF_HEAD" -test -z "$(git -C "$PROOF_SOURCE" status --short)" -git -C "$PROOF_WORKSPACE" init - -NODE_VERSION="$(tr -d '[:space:]' < "$PROOF_SOURCE/.nvmrc")" -fnm exec --using="$NODE_VERSION" node --version -( - cd "$PROOF_SOURCE" - fnm exec --using="$NODE_VERSION" pnpm install --frozen-lockfile - fnm exec --using="$NODE_VERSION" pnpm exec vp run build -) - -if lsof -nP -iTCP:"$PROOF_PORT" -sTCP:LISTEN >/dev/null; then - echo "Choose an unused PROOF_PORT; do not stop the existing listener." >&2 - exit 1 -fi -``` - -This intentionally builds with a fresh `node_modules` in the detached worktree. Do not reuse a -dependency directory from another checkout. - -## 2. Start the watch-free server and hold the stability gate - -The production `vp run start` path is watch-free. Keep the raw log private: startup output can contain -a pairing credential. - -```bash -SERVER_LOG="$PROOF_BASE/server.raw.log" -( - cd "$PROOF_SOURCE" - env -u VITE_HTTP_URL -u VITE_WS_URL \ - T3CODE_HOME="$PROOF_BASE" \ - T3CODE_PORT="$PROOF_PORT" \ - T3CODE_HOST=127.0.0.1 \ - T3CODE_NO_BROWSER=true \ - T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD=false \ - fnm exec --using="$NODE_VERSION" pnpm exec vp run start -) >"$SERVER_LOG" 2>&1 & -SERVER_PID=$! -echo "Captured server PID $SERVER_PID" - -until grep -Fq "Listening on http://127.0.0.1:$PROOF_PORT" "$SERVER_LOG"; do - if ! kill -0 "$SERVER_PID" 2>/dev/null; then - echo "The proof server exited before listening. Inspect the private log locally." >&2 - exit 1 - fi - sleep 1 -done - -STABILITY_LINE=$(( $(wc -l < "$SERVER_LOG") + 1 )) -sleep 90 -kill -0 "$SERVER_PID" -if tail -n +"$STABILITY_LINE" "$SERVER_LOG" | grep -Eq \ - 'Restarting|shutdown reconciliation|terminalizedRuns'; then - echo "The server changed lifecycle state during the 90-second gate; stop this proof." >&2 - exit 1 -fi -echo "Watch-free server passed the 90-second no-restart gate." -``` - -Do not continue if the PID exits, the listener changes, or a restart/shutdown marker appears. - -## 3. Pair Playwright once - -The pairing command prints a one-time credential. Capture it without displaying it, open it once, and -delete both raw pairing outputs immediately after the browser redirects away from `/pair`. - -```bash -PAIR_RAW="$PROOF_BASE/pair.raw.log" -BROWSER_RAW="$PROOF_BASE/pair-browser.raw.log" -( - cd "$PROOF_SOURCE" - fnm exec --using="$NODE_VERSION" node apps/server/src/bin.ts pair --base-dir "$PROOF_BASE" -) >"$PAIR_RAW" 2>&1 -PAIR_URL="$(sed -n 's/^Pairing URL: //p' "$PAIR_RAW" | tail -n 1)" -test -n "$PAIR_URL" - -pw() { - ( - cd "$PROOF_BASE" - PLAYWRIGHT_CLI_SESSION="$PROOF_SESSION" "$PWCLI" "$@" - ) -} - -pw open "$PAIR_URL" --headed >"$BROWSER_RAW" 2>&1 -unset PAIR_URL -rm -f "$PAIR_RAW" "$BROWSER_RAW" -pw snapshot -``` - -Take screenshots only after pairing has redirected to the authenticated app. Browser snapshots and -screenshots must not contain the pairing URL or any credential. Use `pw snapshot` after each -navigation before using element references; references are not stable across page changes. - -## 4. Create the two real provider threads - -In the authenticated UI: - -1. Add only `PROOF_WORKSPACE` as a project. Do not add the source worktree or a personal project. -2. Create one stock Codex thread and one stock Claude Code thread. Record the two thread IDs and the - participant IDs returned by `join_epic`. -3. Generate unique, non-secret values for this run: - - ```bash - NONCE="$(date -u +%Y%m%dT%H%M%SZ)" - EPIC_ID="epic:a2-live:$NONCE" - ASK_TOKEN="A2-LIVE-ASK-$NONCE" - REPLY_TOKEN="A2-LIVE-REPLY-$NONCE" - OBSERVED_TOKEN="A2-LIVE-OBSERVED-$NONCE" - ASK_CLIENT_ID="a2-live-ask-$NONCE" - REPLY_CLIENT_ID="a2-live-reply-$NONCE" - printf '%s\n' "$EPIC_ID" "$ASK_TOKEN" "$REPLY_TOKEN" "$OBSERVED_TOKEN" \ - "$ASK_CLIENT_ID" "$REPLY_CLIENT_ID" - ``` - -The values are evidence markers, not credentials. Substitute them into the prompts below. - -Send this initial prompt to Codex: - -```text -This is an isolated cross-agent messaging live proof. Do not edit files or run shell commands. Call join_epic exactly once with epic_id="". Report the returned epicId and participantId, then stop. Later, if a cross-agent message contains , do not call send_message again; print and stop. -``` - -Send this initial prompt to Claude: - -```text -This is an isolated cross-agent messaging live proof. Do not edit files or run shell commands. Call join_epic exactly once with epic_id="". Report the returned epicId and participantId, then stop. Later, when a cross-agent message contains , call send_message exactly once to the sender named in the envelope, using the envelope exchange_id, message="", and client_request_id="". Do not set expect_reply, intent, or urgency. Report the reply messageId and stop. -``` - -Wait for both completed `join_epic` tool receipts. Then send Codex: - -```text -Call list_participants. Select the only agent participant whose participantId differs from your own. Call send_message exactly once with to=, message="", expect_reply=true, intent="Prove live Codex-to-Claude ask/reply delivery", and client_request_id="". Do not send anything else. Report messageId, exchangeId, exchangeState, and durableAtSeq. -``` - -Wait on actual turn completion and tool receipts, not a fixed delay. Confirm in the UI that: - -- Codex reports one ask with a message ID and open exchange ID. -- Claude receives exactly one rendered cross-agent envelope containing the ask token and that exchange - ID, then sends exactly one reply with the reply token. -- Codex receives exactly one rendered reply envelope, prints the observed token, and does not call - `send_message` again. - -Capture screenshots of these authenticated results only. Treat an extra message, duplicate tool call, -missing receipt, provider restart, or file/shell action as a failed proof. - -## 5. Verify durable state - -Run read-only queries while the server is still alive. This helper uses the repository's SQLite -inspection command against only the disposable T3 home. - -```bash -query() { - fnm exec --using="$NODE_VERSION" node \ - "$PROOF_SOURCE/apps/server/scripts/t3-sqlite-state.ts" query \ - --base-dir "$PROOF_BASE" --sql "$1" -} -``` - -The ledger must contain exactly eight ordered events: - -```bash -query " - SELECT seq, kind, sender, receiver, exchange_id, - json_extract(payload, '$.text') AS message_text, - json_extract(payload, '$.attempt') AS attempt - FROM j5_a2a_comm_event - WHERE epic_id = '$EPIC_ID' - ORDER BY seq -" -``` - -The expected kinds, in order, are: - -1. `participant.joined` -2. `participant.joined` -3. `exchange.opened` -4. `message.sent` with the ask token -5. `message.delivered` with attempt `1` -6. `message.sent` with the reply token -7. `exchange.closed` -8. `message.delivered` with attempt `1` - -Check the closed exchange and both successful deliveries: - -```bash -query " - SELECT exchange_id, status, sender_id, receiver_id, opened_seq, closed_seq - FROM j5_a2a_exchange - WHERE epic_id = '$EPIC_ID' -" - -query " - SELECT message_id, exchange_role, status, attempts, last_error, sent_seq, delivered_seq - FROM j5_a2a_delivery - WHERE epic_id = '$EPIC_ID' - ORDER BY sent_seq -" -``` - -Expect one `closed` exchange with `opened_seq=3` and `closed_seq=7`. Expect two `delivered` rows, -each with `attempts=1` and `last_error` null; their `delivered_seq` values must be `5` and `8`. - -Prove that there are no alarms and both delivery ledger receipts exist: - -```bash -query " - SELECT COUNT(*) AS alarm_count - FROM j5_a2a_comm_event - WHERE epic_id = '$EPIC_ID' AND kind = 'message.delivery_alarm' -" - -query " - SELECT command_id, result_seq - FROM j5_a2a_comm_command_receipt - WHERE epic_id = '$EPIC_ID' - AND command_id LIKE 'command:j5:a2a:delivered:%' - ORDER BY result_seq -" -``` - -Expect `alarm_count=0` and exactly two delivery receipts at result sequences `5` and `8`. - -Prove the two accepted upstream dispatch receipts: - -```bash -query " - SELECT command_id, aggregate_id, status, command_type, result_sequence - FROM orchestration_command_receipts - WHERE command_id LIKE 'command:j5:a2a:delivery:%' - AND (command_id LIKE '%$ASK_CLIENT_ID%' OR command_id LIKE '%$REPLY_CLIENT_ID%') - ORDER BY result_sequence -" -``` - -Expect exactly two rows with `status='accepted'` and `command_type='message.dispatch'`. - -Finally, prove the durable injected messages contain both rendered envelopes: - -```bash -query " - SELECT message_id, thread_id, - json_extract(payload_json, '$.creationSource') AS creation_source, - json_extract(payload_json, '$.text') AS text - FROM orchestration_v2_projection_messages - WHERE json_extract(payload_json, '$.creationSource') = 'mcp' - AND ( - json_extract(payload_json, '$.text') LIKE '%$ASK_TOKEN%' - OR json_extract(payload_json, '$.text') LIKE '%$REPLY_TOKEN%' - ) - ORDER BY created_at -" -``` - -Expect exactly two rows with `creation_source='mcp'`. Each must start with `[Cross-agent message`, -contain the corresponding evidence token, and contain the concrete exchange ID used by the reply. - -Confirm that the joined agent threads ran on the two intended real providers: - -```bash -query " - WITH joined AS ( - SELECT DISTINCT json_extract(payload, '$.participant.threadId') AS thread_id - FROM j5_a2a_comm_event - WHERE epic_id = '$EPIC_ID' AND kind = 'participant.joined' - ) - SELECT DISTINCT runs.thread_id, runs.provider - FROM orchestration_v2_projection_runs AS runs - JOIN joined ON joined.thread_id = runs.thread_id - ORDER BY runs.provider -" -``` - -Expect one Codex thread and one Claude Code (`claudeAgent`) thread. Save query output and -post-pairing screenshots under `PROOF_BASE`; do not save raw credentials. - -## 6. Clean up without touching other processes - -Close the named Playwright session, then stop only the captured server PID: - -```bash -pw close -kill "$SERVER_PID" -wait "$SERVER_PID" || true - -if lsof -nP -iTCP:"$PROOF_PORT" -sTCP:LISTEN >/dev/null; then - echo "The selected port is still open; investigate without killing by pattern." >&2 -fi - -rm -f "$SERVER_LOG" - -git -C "$REPO_ROOT" worktree remove "$PROOF_SOURCE" -rmdir "$PROOF_PARENT" -``` - -Keep the exact `PROOF_BASE` and `PROOF_WORKSPACE` paths with the proof record until the evidence is -reviewed. A human may then move those exact disposable directories to Trash. Do not use a recursive -deletion command with an unset variable, glob, home directory, repository root, or workspace root. - -The proof report must name the tested commit, Node version, providers, thread/participant IDs, -message/exchange IDs, ordered event sequence, delivery attempts, alarm count, and captured evidence -paths. It must not include pairing URLs, tokens, cookies, or provider credentials. From d23fb228fdb25d4c52be819d03b82ba113e07c3b Mon Sep 17 00:00:00 2001 From: Jackson Miller Date: Tue, 18 Aug 2026 21:19:57 -0400 Subject: [PATCH 10/12] fix: make no-home guidance truthful --- apps/server/src/j5/a2a/EnvelopeFormatter.test.ts | 9 ++++++++- apps/server/src/j5/a2a/SendService.test.ts | 10 +++++++--- apps/server/src/j5/a2a/SendService.ts | 2 +- apps/server/src/j5/a2a/envelopes.v1.json | 6 +++--- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts index a77b2a444f01..1be2d60b4b1c 100644 --- a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts @@ -18,7 +18,7 @@ it("renders the versioned peer envelope with exact reply semantics", () => { message: "Please verify the worker.", }); - assert.equal(A2A_ENVELOPE_VERSION, 3); + assert.equal(A2A_ENVELOPE_VERSION, 4); assert.include(rendered, "Cross-agent message"); assert.notMatch(rendered, /\b(?:J5|A2A)\b/); assert.include(rendered, "agent:sender"); @@ -65,6 +65,13 @@ it("tells agents that human-origin exchanges require an explicit tool reply", () assert.include(A2A_SEND_TOOL_DESCRIPTION, "returns after the sender ledger commit"); assert.include(A2A_SEND_TOOL_DESCRIPTION, "cross-agent message"); assert.include(A2A_LIST_TOOL_DESCRIPTION, "cross-agent messaging participants"); + for (const description of [A2A_SEND_TOOL_DESCRIPTION, A2A_LIST_TOOL_DESCRIPTION]) { + assert.include(description, "native thread without a registered home epic"); + assert.include(description, "wrapper-spawned agent"); + assert.include(description, "controlled test seeding"); + assert.include(description, "home-epic registrar + A6 creation integrations follow-up"); + assert.notMatch(description, /ask the user|product workflow|list_participants again/i); + } assert.notMatch( [A2A_SEND_TOOL_DESCRIPTION, A2A_LIST_TOOL_DESCRIPTION].join("\n"), /\b(?:J5|A2A)\b/, diff --git a/apps/server/src/j5/a2a/SendService.test.ts b/apps/server/src/j5/a2a/SendService.test.ts index ce6a39625d9e..160a9bb84dbe 100644 --- a/apps/server/src/j5/a2a/SendService.test.ts +++ b/apps/server/src/j5/a2a/SendService.test.ts @@ -284,9 +284,13 @@ it.effect("fails closed when a native thread has no provisioned epic membership" const listError = yield* Effect.flip(service.listParticipants(nativeThreadId)); assert.equal(listError._tag, "A2ASenderNotJoinedError"); - assert.include(listError.message, "no provisioned epic membership"); - assert.include(listError.message, "Ask the user to create an epic"); - assert.include(listError.message, "list_participants"); + assert.include(listError.message, "native thread"); + assert.include(listError.message, "no registered home epic"); + assert.include(listError.message, "wrapper-spawned agent"); + assert.include(listError.message, "controlled test seeding"); + assert.include(listError.message, "home-epic registrar + A6 creation integrations follow-up"); + assert.include(listError.message, "Stop this messaging attempt"); + assert.notMatch(listError.message, /ask the user|product workflow|list_participants again/i); const sendError = yield* Effect.flip( service.send({ diff --git a/apps/server/src/j5/a2a/SendService.ts b/apps/server/src/j5/a2a/SendService.ts index 496ea0f7a7b8..6d8af8ff7beb 100644 --- a/apps/server/src/j5/a2a/SendService.ts +++ b/apps/server/src/j5/a2a/SendService.ts @@ -28,7 +28,7 @@ export class A2ASenderNotJoinedError extends Schema.TaggedErrorClass Date: Tue, 18 Aug 2026 21:27:20 -0400 Subject: [PATCH 11/12] test: guard human envelope branding --- apps/server/src/j5/a2a/EnvelopeFormatter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts index 1be2d60b4b1c..6975d81ed714 100644 --- a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts @@ -73,7 +73,7 @@ it("tells agents that human-origin exchanges require an explicit tool reply", () assert.notMatch(description, /ask the user|product workflow|list_participants again/i); } assert.notMatch( - [A2A_SEND_TOOL_DESCRIPTION, A2A_LIST_TOOL_DESCRIPTION].join("\n"), + [rendered, A2A_SEND_TOOL_DESCRIPTION, A2A_LIST_TOOL_DESCRIPTION].join("\n"), /\b(?:J5|A2A)\b/, ); }); From 3214047076988b00de890153c2c100da22a497b3 Mon Sep 17 00:00:00 2001 From: Jackson Miller Date: Tue, 18 Aug 2026 21:34:14 -0400 Subject: [PATCH 12/12] fix: clarify message recipients --- apps/server/src/j5/a2a/EnvelopeFormatter.test.ts | 6 +++--- apps/server/src/j5/a2a/envelopes.v1.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts index 6975d81ed714..bce699fa52ef 100644 --- a/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts +++ b/apps/server/src/j5/a2a/EnvelopeFormatter.test.ts @@ -18,7 +18,7 @@ it("renders the versioned peer envelope with exact reply semantics", () => { message: "Please verify the worker.", }); - assert.equal(A2A_ENVELOPE_VERSION, 4); + assert.equal(A2A_ENVELOPE_VERSION, 5); assert.include(rendered, "Cross-agent message"); assert.notMatch(rendered, /\b(?:J5|A2A)\b/); assert.include(rendered, "agent:sender"); @@ -63,8 +63,8 @@ it("tells agents that human-origin exchanges require an explicit tool reply", () assert.include(rendered, "The human is not watching this chat"); assert.include(rendered, 'exchange_id="exchange:human"'); assert.include(A2A_SEND_TOOL_DESCRIPTION, "returns after the sender ledger commit"); - assert.include(A2A_SEND_TOOL_DESCRIPTION, "cross-agent message"); - assert.include(A2A_LIST_TOOL_DESCRIPTION, "cross-agent messaging participants"); + assert.include(A2A_SEND_TOOL_DESCRIPTION, "Durably send one message"); + assert.include(A2A_LIST_TOOL_DESCRIPTION, "List reachable message recipients"); for (const description of [A2A_SEND_TOOL_DESCRIPTION, A2A_LIST_TOOL_DESCRIPTION]) { assert.include(description, "native thread without a registered home epic"); assert.include(description, "wrapper-spawned agent"); diff --git a/apps/server/src/j5/a2a/envelopes.v1.json b/apps/server/src/j5/a2a/envelopes.v1.json index 523941852f8e..706c64b4b9bf 100644 --- a/apps/server/src/j5/a2a/envelopes.v1.json +++ b/apps/server/src/j5/a2a/envelopes.v1.json @@ -1,10 +1,10 @@ { - "version": 4, + "version": 5, "peerMessage": "[Cross-agent message from {{senderId}} in epic {{originEpicId}}]\n\n{{message}}\n\n{{exchangeInstruction}}", "humanMessage": "[Message from the human]\n\n{{message}}\n\nThe human is not watching this chat. They see only what you send back on this exchange.\n\n{{exchangeInstruction}}", "silenceNotice": "[Cross-agent messaging system notice: {{noticeType}}]\n\n{{message}}\n\nThis is a platform-authored delivery signal, not a peer reply.", "replyInstruction": "Reply once with send_message(to=\"{{senderId}}\", exchange_id=\"{{exchangeId}}\", message=\"...\") to close the exchange. Follow-ups from the asker carrying this id join the same exchange.", "oneShotInstruction": "No reply is required. Use send_message without exchange_id only if a new message is needed.", - "sendToolDescription": "Durably send one cross-agent message. client_request_id makes retries of the same logical call idempotent. expect_reply=true opens or joins one sender-to-receiver exchange and requires intent; a reply is send_message carrying exchange_id, and one reply closes that exchange. urgency is required only when opening an exchange to the human. The call returns after the sender ledger commit; delivery continues asynchronously. This tool is unavailable to a native thread without a registered home epic. Participation currently requires a wrapper-spawned agent that already has a home epic or controlled test seeding; native user-created home provisioning is deferred to the home-epic registrar + A6 creation integrations follow-up.", - "listToolDescription": "List reachable cross-agent messaging participants and the capabilities accepted by each row. Use this before send_message instead of discovering participant state through failures. This tool is unavailable to a native thread without a registered home epic. Participation currently requires a wrapper-spawned agent that already has a home epic or controlled test seeding; native user-created home provisioning is deferred to the home-epic registrar + A6 creation integrations follow-up." + "sendToolDescription": "Durably send one message. client_request_id makes retries of the same logical call idempotent. expect_reply=true opens or joins one sender-to-receiver exchange and requires intent; a reply is send_message carrying exchange_id, and one reply closes that exchange. urgency is required only when opening an exchange to the human. The call returns after the sender ledger commit; delivery continues asynchronously. This tool is unavailable to a native thread without a registered home epic. Participation currently requires a wrapper-spawned agent that already has a home epic or controlled test seeding; native user-created home provisioning is deferred to the home-epic registrar + A6 creation integrations follow-up.", + "listToolDescription": "List reachable message recipients and the capabilities accepted by each row. Use this before send_message instead of discovering participant state through failures. This tool is unavailable to a native thread without a registered home epic. Participation currently requires a wrapper-spawned agent that already has a home epic or controlled test seeding; native user-created home provisioning is deferred to the home-epic registrar + A6 creation integrations follow-up." }