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
32 changes: 17 additions & 15 deletions src/cli/commands/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import { warnIfRedacted } from "../redaction-notice.js";
import type { DigestResult } from "../../lib/messages.js";
import { printErrorLine, printJson, printJsonLine, printLine } from "../../lib/stdout.js";
import { normalizeChannelName } from "../../lib/channel-names.js";
import { parseMessageReference } from "../../lib/message-reference.js";
import {
messageChannel,
parseMessageReference,
resolveMessageReference,
} from "../../lib/message-reference.js";
import {
discloseEmptyResult,
FROM_ALIAS_HELP,
Expand Down Expand Up @@ -129,20 +133,19 @@ export function registerMessagingCommands(program: Command): void {
);
}

const parent = ref.kind === "id"
? await getStore().getMessageById(ref.id)
: await getStore().getMessageByUuid(ref.uuid);
const expectedChannel = channel ? normalizeChannelName(channel) : undefined;
const parent = await resolveMessageReference(getStore(), ref, {
channel: expectedChannel,
session_id: session,
});
if (!parent) {
emitCliError(`Message ${String(opts.replyTo)} not found.`, opts);
}
if (!parent.uuid) {
emitCliError("Parent message has no immutable UUID; refusing to write a numeric-only reply.", opts);
}

const parentChannel =
parent.channel ||
(parent.session_id?.startsWith("channel:") ? parent.session_id.slice(8) : undefined);
const expectedChannel = channel ? normalizeChannelName(channel) : undefined;
const parentChannel = messageChannel(parent);
if (expectedChannel && expectedChannel !== parentChannel) {
emitCliError(
`Expected parent channel ${expectedChannel} does not match resolved channel ${parentChannel ?? "(direct message)"}.`,
Expand Down Expand Up @@ -621,9 +624,11 @@ used for — auditing a sender or a channel, which is an ABSENCE claim.
);
}

const original = ref.kind === "id"
? await getStore().getMessageById(ref.id)
: await getStore().getMessageByUuid(ref.uuid);
const expectedChannel = opts.channel ? normalizeChannelName(opts.channel) : undefined;
const original = await resolveMessageReference(getStore(), ref, {
channel: expectedChannel,
session_id: opts.session,
});
if (!original) {
emitCliError(`Message ${String(opts.to)} not found.`, opts);
}
Expand All @@ -639,10 +644,7 @@ used for — auditing a sender or a channel, which is an ABSENCE claim.
if (!content.trim()) {
emitCliError("Reply content cannot be empty.", opts);
}
const channel =
original.channel ||
(original.session_id?.startsWith("channel:") ? original.session_id.slice(6) : undefined);
const expectedChannel = opts.channel ? normalizeChannelName(opts.channel) : undefined;
const channel = messageChannel(original);
if (expectedChannel && expectedChannel !== channel) {
emitCliError(
`Expected parent channel ${expectedChannel} does not match resolved channel ${channel ?? "(direct message)"}.`,
Expand Down
183 changes: 183 additions & 0 deletions src/cli/reply-reference-cloud-compat.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { STORE_SELECTING_KEYS } from "../lib/store/isolated-test-env.js";

const CLI = ["bun", "run", "./src/cli/index.tsx"];
const PARENT = {
id: 695033,
uuid: "5307e936-efb7-4eeb-b7e2-0fe354b7ac35",
session_id: "channel:git-publishing",
from_agent: "alice",
to_agent: "git-publishing",
channel: "git-publishing",
content: "synthetic parent",
priority: "normal",
blocking: false,
reply_to: null,
created_at: "2026-08-10T10:00:00.000Z",
};
const COLLIDING_NUMERIC_ROW = {
...PARENT,
uuid: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
session_id: "channel:mementos",
from_agent: "other",
to_agent: "mementos",
channel: "mementos",
content: "synthetic collision",
};

function cloudChildEnv(url: string): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !STORE_SELECTING_KEYS.includes(key)) env[key] = value;
}
env.HASNA_CONVERSATIONS_API_URL = url;
env.HASNA_CONVERSATIONS_API_KEY = ["fixture", "not", "a", "credential"].join("-");
env.CONVERSATIONS_AGENT_ID = "bob";
env.FORCE_COLOR = "0";
return env;
}

async function runCli(args: string[], env: Record<string, string>) {
const proc = Bun.spawn({
cmd: [...CLI, ...args],
cwd: process.cwd(),
env,
stdout: "pipe",
stderr: "pipe",
});
const [exitCode, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
return { exitCode, stdout, stderr };
}

describe("cloud reply reference compatibility (e2e)", () => {
let server: ReturnType<typeof Bun.serve>;
let env: Record<string, string>;
const writes: Array<Record<string, unknown>> = [];
const reads: string[] = [];

beforeAll(() => {
server = Bun.serve({
port: 0,
hostname: "127.0.0.1",
async fetch(request) {
const url = new URL(request.url);
const path = url.pathname.replace(/^\/v1/, "");
reads.push(`${request.method} ${path}${url.search}`);

if (request.method === "GET" && path === `/messages/${PARENT.id}`) {
// This is the observed numeric misresolution. Old CLI code trusted
// this row and rejected the reply as a channel mismatch.
return Response.json({ message: COLLIDING_NUMERIC_ROW });
}
if (request.method === "GET" && path === `/messages/by-uuid/${PARENT.uuid}`) {
// Older deployed servers do not have this route; their generic 404
// is indistinguishable from a genuine UUID miss by status alone.
return Response.json({ error: "Not found" }, { status: 404 });
}
if (request.method === "GET" && path === "/messages") {
const uuid = url.searchParams.get("uuid");
const channel = url.searchParams.get("channel");
const sinceId = url.searchParams.get("since_id");
if (uuid === PARENT.uuid) {
return Response.json({ messages: [PARENT] });
}
if (
channel === PARENT.channel &&
sinceId === String(PARENT.id - 1)
) {
return Response.json({ messages: [PARENT] });
}
return Response.json({ messages: [] });
}
if (request.method === "POST" && path === "/messages") {
const body = await request.json() as Record<string, unknown>;
writes.push(body);
return Response.json({
message: {
...body,
id: 695034 + writes.length,
uuid: body.uuid,
session_id: PARENT.session_id,
from_agent: body.from,
to_agent: PARENT.channel,
channel: PARENT.channel,
reply_to: PARENT.id,
created_at: "2026-08-10T10:01:00.000Z",
},
}, { status: 201 });
}
return Response.json({ error: "Not found" }, { status: 404 });
},
});
env = cloudChildEnv(`http://127.0.0.1:${server.port}`);
});

afterAll(() => {
server?.stop(true);
});

test("numeric reply resolution stays inside the supplied channel instead of trusting a colliding direct lookup", async () => {
const result = await runCli([
"send",
"--channel",
PARENT.channel,
"--reply-to",
String(PARENT.id),
"--from",
"bob",
"--json",
"synthetic numeric reply",
], env);

expect(result.exitCode, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
channel: PARENT.channel,
reply_to: PARENT.id,
});
expect(writes[0]).toMatchObject({
channel: PARENT.channel,
reply_to: PARENT.id,
reply_to_uuid: PARENT.uuid,
});
expect(reads.some((entry) => entry.startsWith(`GET /messages/${PARENT.id}`))).toBe(false);
expect(reads.some((entry) =>
entry.startsWith("GET /messages?") &&
entry.includes(`channel=${PARENT.channel}`) &&
entry.includes(`since_id=${PARENT.id - 1}`)
)).toBe(true);
});

test("the UUID returned by send resolves through the older collection filter when the dedicated route is absent", async () => {
const result = await runCli([
"send",
"--channel",
PARENT.channel,
"--reply-to",
PARENT.uuid,
"--from",
"bob",
"--json",
"synthetic UUID reply",
], env);

expect(result.exitCode, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
channel: PARENT.channel,
reply_to: PARENT.id,
});
expect(writes[1]).toMatchObject({
channel: PARENT.channel,
reply_to: PARENT.id,
reply_to_uuid: PARENT.uuid,
});
expect(reads.some((entry) => entry === `GET /messages/by-uuid/${PARENT.uuid}`)).toBe(true);
expect(reads.some((entry) =>
entry.startsWith("GET /messages?") &&
entry.includes(`uuid=${PARENT.uuid}`)
)).toBe(true);
});
});
69 changes: 69 additions & 0 deletions src/lib/message-reference.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import type { Message, ReadMessagesOptions } from "../types.js";
import { normalizeChannelName } from "./channel-names.js";

export type MessageReference =
| { kind: "id"; id: number }
| { kind: "uuid"; uuid: string };

export interface MessageReferenceScope {
channel?: string;
session_id?: string;
}

export interface MessageReferenceStore {
getMessageById(id: number): Promise<Message | null>;
getMessageByUuid(uuid: string): Promise<Message | null>;
readMessages(options: ReadMessagesOptions): Promise<Message[]>;
}

const COMPACT_UUID = /^[0-9a-f]{32}$/i;
const CANONICAL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const CHANNEL_SESSION_PREFIX = "channel:";

export function normalizeMessageUuid(value: unknown): string | null {
if (typeof value !== "string") return null;
Expand All @@ -22,3 +37,57 @@ export function parseMessageReference(value: unknown): MessageReference | null {
const uuid = normalizeMessageUuid(raw);
return uuid ? { kind: "uuid", uuid } : null;
}

/**
* Resolve a message reference inside the caller's independently supplied scope.
*
* Numeric message IDs are mutable backend-local identifiers. A direct
* `/messages/:id` read can therefore name a reachable row from the wrong
* channel when server generations or tenant-local stores disagree. A scoped
* forward read asks the collection endpoint for the exact ID inside the
* expected channel/session instead; the equality check rejects the next row
* when the requested ID is absent.
*
* UUIDs remain globally scoped and use the store's UUID compatibility path.
*/
export async function resolveMessageReference(
store: MessageReferenceStore,
reference: MessageReference,
scope: MessageReferenceScope = {},
): Promise<Message | null> {
if (reference.kind === "uuid") {
return store.getMessageByUuid(reference.uuid);
}

const channel = scope.channel ? normalizeChannelName(scope.channel) : undefined;
const sessionId = scope.session_id?.trim() || undefined;
if (!channel && !sessionId) {
return store.getMessageById(reference.id);
}

const rows = await store.readMessages({
...(channel ? { channel } : {}),
...(sessionId ? { session_id: sessionId } : {}),
since_id: reference.id - 1,
limit: 1,
order: "asc",
});
const scoped = rows.find((message) => message.id === reference.id);
if (scoped) return scoped;

// Preserve the established mismatch diagnostic when the ID exists but the
// caller supplied the wrong scope. This fallback is reached only after the
// scoped collection read found no exact row, so a colliding direct lookup
// cannot replace a valid parent that was found in the requested channel.
return store.getMessageById(reference.id);
}

/** Return the canonical channel carried directly or through a channel session. */
export function messageChannel(
message: Pick<Message, "channel" | "session_id">,
): string | undefined {
if (message.channel) return normalizeChannelName(message.channel);
if (!message.session_id?.startsWith(CHANNEL_SESSION_PREFIX)) return undefined;
const channel = message.session_id.slice(CHANNEL_SESSION_PREFIX.length);
return channel ? normalizeChannelName(channel) : undefined;
}
Loading
Loading