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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/server/src/j5/a2a/ClientReadsHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ it("maps A4 resolver failures without treating a bad person selection as a serve
assert.deepStrictEqual(await missing.json(), {
error: "A2AParticipantNotFoundError",
message:
"Participant human:client-reads:missing is not currently reachable. Call list_participants and choose a row with canReceiveMessage=true.",
"Participant human:client-reads:missing is not currently reachable. Call list_participants and choose an agent row with canReceiveMessage=true, or a human row with canOpenExchange=true to open an ask.",
});
const absentLocalOperator = await count();
assert.equal(absentLocalOperator.status, 404);
Expand Down
170 changes: 95 additions & 75 deletions apps/server/src/j5/a2a/DeliveryWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,88 +575,108 @@ it.effect("delivers to the human through the idempotent inbox-data transport", (
}),
);

it.effect("treats a follow-up delivered after the human answered as a successful no-op", () =>
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 inbox = humanInboxLayer.pipe(Layer.provide(ledger), Layer.provide(database));
const transport = deliveryTransportLive.pipe(
Layer.provide(database),
Layer.provide(Layer.mock(ThreadManagementService)({})),
);
const worker = deliveryWorkerLayerWithHooks(false).pipe(
Layer.provide(ledger),
Layer.provide(database),
Layer.provide(transport),
Layer.provide(
Layer.succeed(
A2ADeliveryHooks,
A2ADeliveryHooks.of({ afterTransportSuccess: () => Effect.void }),
it.effect(
"treats a historical follow-up delivered after the human answered as a successful no-op",
() =>
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 inbox = humanInboxLayer.pipe(Layer.provide(ledger), Layer.provide(database));
const transport = deliveryTransportLive.pipe(
Layer.provide(database),
Layer.provide(Layer.mock(ThreadManagementService)({})),
);
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, inbox, transport, worker);

yield* Effect.gen(function* () {
yield* runJ5A2AMigrations();
const ledgerService = yield* A2ALedger;
const sendService = yield* A2ASendService;
const deliveryWorker = yield* A2ADeliveryWorker;
const sql = yield* SqlClient.SqlClient;
const squadronId = SquadronId.make("squadron:delivery:human-followup-race");
yield* ledgerService.createSquadron({
squadron: { id: squadronId, name: "Human follow-up race", createdAt: timestamp },
});
yield* join(squadronId, sender, "human-followup-race-sender");
yield* sql`
);
const layer = Layer.mergeAll(database, ledger, send, inbox, transport, worker);

yield* Effect.gen(function* () {
yield* runJ5A2AMigrations();
const ledgerService = yield* A2ALedger;
const sendService = yield* A2ASendService;
const deliveryWorker = yield* A2ADeliveryWorker;
const sql = yield* SqlClient.SqlClient;
const squadronId = SquadronId.make("squadron:delivery:human-followup-race");
yield* ledgerService.createSquadron({
squadron: { id: squadronId, name: "Human follow-up race", createdAt: timestamp },
});
yield* join(squadronId, sender, "human-followup-race-sender");
yield* sql`
INSERT INTO j5_a2a_human_person (person_id, is_local_operator, created_at)
VALUES (${person.id}, 1, ${timestamp})
`;
const opened = yield* sendService.send({
commandId: CommCommandId.make("command:delivery:human-followup-race:open"),
senderThreadId: sender.threadId,
to: person.id,
message: "Initial question",
expectReply: true,
intent: "Reproduce answer racing a follow-up",
urgency: "blocking",
acceptedAt: timestamp,
});
assert.equal((yield* deliveryWorker.runOnce)?.state, "delivered");

const followup = yield* sendService.send({
commandId: CommCommandId.make("command:delivery:human-followup-race:followup"),
senderThreadId: sender.threadId,
to: person.id,
message: "Pending follow-up",
exchangeId: opened.exchangeId!,
acceptedAt: "2026-08-16T12:00:01.000Z",
});
yield* (yield* A2AHumanInbox).answer({
commandId: CommCommandId.make("command:delivery:human-followup-race:answer"),
personId: person.id,
exchangeId: opened.exchangeId!,
message: "Answered before follow-up transport",
acceptedAt: "2026-08-16T12:00:02.000Z",
});

const milestone = yield* deliveryWorker.runOnce;
assert.equal(milestone?.messageId, followup.messageId);
assert.equal(milestone?.state, "delivered");
const rows = yield* sql<{
readonly attempts: number;
readonly last_error: string | null;
readonly status: string;
}>`
const opened = yield* sendService.send({
commandId: CommCommandId.make("command:delivery:human-followup-race:open"),
senderThreadId: sender.threadId,
to: person.id,
message: "Initial question",
expectReply: true,
intent: "Reproduce answer racing a follow-up",
urgency: "blocking",
acceptedAt: timestamp,
});
assert.equal((yield* deliveryWorker.runOnce)?.state, "delivered");

const followupMessageId = LedgerMessageId.make(
"message:delivery:human-followup-race:legacy-followup",
);
yield* ledgerService.append({
commandId: CommCommandId.make("command:delivery:human-followup-race:legacy-followup"),
squadronId,
acceptedAt: "2026-08-16T12:00:01.000Z",
event: {
kind: "message.sent",
sender: sender.id,
receiver: person.id,
exchangeId: opened.exchangeId!,
correlationId: CorrelationId.make(
"correlation:delivery:human-followup-race:legacy-followup",
),
payload: {
messageId: followupMessageId,
text: "Pending historical follow-up",
originSquadronId: squadronId,
receiverSquadronId: squadronId,
exchangeRole: "followup",
envelopeChannel: "peer",
},
createdAt: "2026-08-16T12:00:01.000Z",
},
});
yield* (yield* A2AHumanInbox).answer({
commandId: CommCommandId.make("command:delivery:human-followup-race:answer"),
personId: person.id,
exchangeId: opened.exchangeId!,
message: "Answered before follow-up transport",
acceptedAt: "2026-08-16T12:00:02.000Z",
});

const milestone = yield* deliveryWorker.runOnce;
assert.equal(milestone?.messageId, followupMessageId);
assert.equal(milestone?.state, "delivered");
const rows = yield* sql<{
readonly attempts: number;
readonly last_error: string | null;
readonly status: string;
}>`
SELECT status, attempts, last_error
FROM j5_a2a_delivery
WHERE message_id = ${followup.messageId}
WHERE message_id = ${followupMessageId}
`;
assert.deepStrictEqual(rows, [{ status: "delivered", attempts: 1, last_error: null }]);
assert.deepStrictEqual(yield* deliveryWorker.listAlarms, []);
}).pipe(Effect.provide(layer));
}),
assert.deepStrictEqual(rows, [{ status: "delivered", attempts: 1, last_error: null }]);
assert.deepStrictEqual(yield* deliveryWorker.listAlarms, []);
}).pipe(Effect.provide(layer));
}),
);

it.effect("receipts a human lifecycle notice without creating a second actionable inbox row", () =>
Expand Down
44 changes: 41 additions & 3 deletions apps/server/src/j5/a2a/EnvelopeFormatter.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";

import {
A2A_CLEAR_OWN_ASK_TOOL_DESCRIPTION,
Expand All @@ -13,6 +16,31 @@ import {
} from "./EnvelopeFormatter.ts";
import { SquadronId, ExchangeId, ParticipantId } from "./contracts.ts";

const documentedSendToolContract = new URL(
"../../../../../docs/j5/product/a2a/agent-tools.md",
import.meta.url,
);

const readDocumentedSendToolDescription = Effect.fn("readDocumentedSendToolDescription")(
function* () {
const fileSystem = yield* FileSystem.FileSystem;
const document = yield* fileSystem.readFileString(
decodeURIComponent(documentedSendToolContract.pathname),
);
const description = document.match(
/## `send_message`[\s\S]*?\*\*Description \(contract(?:, [^)]*)?\):\*\* "([\s\S]*?)"\n\n\| Input/,
)?.[1];
if (description === undefined) {
return yield* Effect.die("send_message contract description is missing from agent-tools.md");
}
return description
.split("\n")
.map((line) => line.trim())
.join(" ");
},
Effect.provide(NodeServices.layer),
);

it("renders the versioned peer envelope with exact reply semantics", () => {
const rendered = formatPeerEnvelope({
senderId: ParticipantId.make("agent:sender"),
Expand All @@ -21,7 +49,7 @@ it("renders the versioned peer envelope with exact reply semantics", () => {
message: "Please verify the worker.",
});

assert.equal(A2A_ENVELOPE_VERSION, 13);
assert.equal(A2A_ENVELOPE_VERSION, 15);
assert.include(rendered, "Cross-agent message");
assert.notMatch(rendered, /\b(?:J5|A2A)\b/);
assert.include(rendered, "agent:sender");
Expand All @@ -32,6 +60,12 @@ it("renders the versioned peer envelope with exact reply semantics", () => {
assert.notInclude(rendered, "{{");
});

it.effect("keeps the send_message runtime description byte-equal to its documented contract", () =>
Effect.gen(function* () {
assert.equal(yield* readDocumentedSendToolDescription(), A2A_SEND_TOOL_DESCRIPTION);
}),
);

it("renders reply closures without another reply instruction for either channel", () => {
const peerMessage = "Peer reply bytes\n stay exact. ";
const humanMessage = " Human reply bytes\nremain exact. ";
Expand Down Expand Up @@ -93,7 +127,11 @@ it("tells agents that human-origin exchanges require an explicit tool reply", ()
assert.include(rendered, 'exchange_id="exchange:human"');
assert.equal(
A2A_SEND_TOOL_DESCRIPTION,
"Send one durable message to another agent or the human. Three uses: a plain send when you don't need a reply; an ask — set expect_reply=true with a one-line intent, opening an exchange the receiver owes a reply to; a reply — include the exchange_id from the ask you are answering, which closes that exchange. Use this tool only for participants already returned by list_participants; when creating a Peer Agent, put any reply expectation in spawn_agent's brief instead of sending a follow-up ask. Set urgency only when asking the human. Returns once the message is committed; delivery continues asynchronously — carry on with your work, and the reply arrives later as an incoming message. Reuse client_request_id to retry the same send safely. This tool is unavailable to a native thread without a registered home squadron.",
"Send one durable message. To another agent, three uses: a **plain send** when you don't need a reply; an **ask** — set expect_reply=true with a one-line intent, opening an exchange the receiver owes a reply to; a **reply** — include the exchange_id from the ask you are answering, which closes that exchange. To the human, only an ask or a reply: a plain send to a person is refused — if nobody needs to act, say it in your own thread instead. Set urgency only when asking the human. Use this tool only for participants already returned by list_participants; when creating a Peer Agent, put any reply expectation in spawn_agent's brief instead of sending a follow-up ask. Returns once the message is committed; delivery continues asynchronously — carry on with your work, and the reply arrives later as an incoming message. A caller without a registered home is refused. Reuse client_request_id to retry the same send safely.",
);
assert.include(
A2A_SEND_TOOL_DESCRIPTION,
"To the human, only an ask or a reply: a plain send to a person is refused",
);
assert.include(A2A_CLEAR_OWN_ASK_TOOL_DESCRIPTION, "Withdraw an ask you sent");
assert.include(A2A_CLEAR_OWN_ASK_TOOL_DESCRIPTION, "sender-cleared");
Expand All @@ -110,7 +148,7 @@ it("tells agents that human-origin exchanges require an explicit tool reply", ()
]) {
assert.include(A2A_LIST_TOOL_DESCRIPTION, clause);
}
assert.include(A2A_SEND_TOOL_DESCRIPTION, "native thread without a registered home squadron");
assert.include(A2A_SEND_TOOL_DESCRIPTION, "A caller without a registered home is refused.");
for (const description of [A2A_SEND_TOOL_DESCRIPTION, A2A_LIST_TOOL_DESCRIPTION]) {
assert.notInclude(description, "wrapper-spawned");
}
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/j5/a2a/HumanInboxService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,8 @@ it.effect(
yield* send.send({
commandId: collidingCommandId,
senderThreadId: senderCleared.agent.threadId,
to: firstPerson.id,
message: "An unrelated send reserved this command id.",
to: second.agent.id,
message: "An unrelated agent send reserved this command id.",
acceptedAt: "2026-08-23T13:41:30.000Z",
});
const collidingClear = yield* Effect.flip(
Expand Down
Loading
Loading