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
19 changes: 15 additions & 4 deletions assistant/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4616,8 +4616,10 @@ paths:
properties:
id:
type: string
description: Assistant-minted internal conversation id. The authoritative identifier for the conversation.
conversationKey:
type: string
description: Echo of the optional external key supplied by the client (or the value the daemon minted when omitted).
conversationType:
type: string
created:
Expand All @@ -4636,14 +4638,15 @@ paths:
type: object
properties:
conversationKey:
description:
Optional external key. Echoed back in the response. Non-vellum channels (Telegram, WhatsApp) use this to
scope to a logical channel thread; vellum-web clients can omit it and rely on the assistant-minted
`id`.
type: string
description: Idempotency key for the conversation
conversationType:
description: Only standard conversations are created by this endpoint
type: string
const: standard
required:
- conversationKey
additionalProperties: false
/v1/conversations/{conversationId}/slack-channel/resolve:
post:
Expand Down Expand Up @@ -7651,12 +7654,20 @@ paths:
"200":
description: Successful response
parameters:
- name: conversationId
in: query
required: false
schema:
type: string
description: Scope to a single conversation by its assistant-minted internal id. 404s if no such conversation exists.
- name: conversationKey
in: query
required: false
schema:
type: string
description: Scope to a single conversation
description:
Scope to a single conversation by an external key (non-vellum channels) or the web idempotency key.
Materializes a row on first use. Ignored when conversationId is also provided.
/v1/events/emit:
post:
operationId: events_emit_post
Expand Down
109 changes: 109 additions & 0 deletions assistant/src/__tests__/conversation-routes-disk-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,115 @@ describe("macOS browser backend fallback (no extension, no cdp-inspect)", () =>
});
});

describe("POST /v1/messages — body.conversationId direct id lookup", () => {
// The handler accepts two scope inputs with distinct semantics:
//
// - `body.conversationId` is the assistant-minted internal id and is
// looked up directly. A missing row is a 404 — clients must obtain
// the id from a prior daemon response.
// - `body.conversationKey` is an external key (non-vellum channels /
// web idempotency); resolved via the conversation_keys table and
// materialised on first use.
//
// When both are sent, `conversationId` wins and `conversationKey` is
// ignored. (Don't combine — fetch by one and then the other.)

async function sendMessage(
body: Record<string, unknown>,
successStatus = 202,
): Promise<Response> {
return callHandler(
(args) =>
handleSendMessage(args, {
sendMessageDeps: {
getOrCreateConversation: async (conversationId: string) =>
getOrCreateFakeConversation(conversationId),
assistantEventHub: new AssistantEventHub(),
resolveAttachments: () => [],
},
}),
new Request("http://localhost/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-vellum-principal-type": authContext.principalType,
},
body: JSON.stringify(body),
}),
undefined,
successStatus,
);
}

test("body.conversationId=<existing-id> scopes the send to that conversation", async () => {
// Pre-materialise a conversation via the key path, then send a message
// by its assistant-minted internal id.
const externalKey = `pre-materialised-${crypto.randomUUID()}`;
const seeded = getOrCreateConversationMapping(externalKey);

const response = await sendMessage({
conversationId: seeded.conversationId,
content: "Direct id lookup — should reuse the existing conversation.",
sourceChannel: "vellum",
interface: "macos",
});

expect(response.status).toBe(202);
const body = (await response.json()) as {
accepted: boolean;
conversationId: string;
};
expect(body.accepted).toBe(true);
expect(body.conversationId).toBe(seeded.conversationId);

// No new external-key row should be materialised under the internal id.
expect(getConversationByKey(seeded.conversationId)).toBeNull();
});

test("body.conversationId=<non-existent-id> returns 404", async () => {
const response = await sendMessage(
{
conversationId: `does-not-exist-${crypto.randomUUID()}`,
content: "Should 404 — unknown internal id.",
sourceChannel: "vellum",
interface: "macos",
},
404,
);
expect(response.status).toBe(404);
const body = (await response.json()) as {
error?: { code?: string; message?: string };
};
expect(body.error?.code).toBe("NOT_FOUND");
expect(body.error?.message).toMatch(/not found/i);
});

test("body.conversationId is honored and body.conversationKey is ignored when both are sent", async () => {
// Seed a conversation for the id we'll send. Also seed a separate
// conversation under a key the client will pass alongside — but the
// handler must scope to the id, NOT the key.
const idSeed = getOrCreateConversationMapping(
`id-honored-${crypto.randomUUID()}`,
);
const keyValue = `key-ignored-${crypto.randomUUID()}`;
const keySeed = getOrCreateConversationMapping(keyValue);
expect(idSeed.conversationId).not.toBe(keySeed.conversationId);

const response = await sendMessage({
conversationId: idSeed.conversationId,
conversationKey: keyValue,
content: "Both fields sent — id should win.",
sourceChannel: "vellum",
interface: "macos",
});

expect(response.status).toBe(202);
const body = (await response.json()) as { conversationId: string };
expect(body.conversationId).toBe(idSeed.conversationId);
expect(body.conversationId).not.toBe(keySeed.conversationId);
});
});

describe("conversationKey send path disk-view regression", () => {
test("first send on a fresh conversationKey creates disk-view dir and writes user+assistant records", async () => {
const conversationKey = `fresh-conv-key-${crypto.randomUUID()}`;
Expand Down
154 changes: 154 additions & 0 deletions assistant/src/__tests__/runtime-events-sse-bilingual.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* `GET /v1/events` (`handleSubscribeAssistantEvents`) — bilingual scope
* resolution. Two query params are accepted, with distinct semantics:
*
* - `?conversationId=<internal-id>` — looks up the conversation row
* directly by its assistant-minted id. 404 if not found. Does NOT
* materialise a new row.
* - `?conversationKey=<external-key>` — resolves via the
* `conversation_keys` table; materialises on first use. Ignored when
* `conversationId` is also supplied.
*
* Companion to `runtime-events-sse.test.ts`, which exercises the broader
* `?conversationKey=` happy/error path.
*/

import { beforeEach, describe, expect, mock, test } from "bun:test";

mock.module("../util/logger.js", () => ({
getLogger: () =>
new Proxy({} as Record<string, unknown>, {
get: () => () => {},
}),
}));

mock.module("../config/loader.js", () => ({
getConfig: () => ({
ui: {},
model: "test",
provider: "test",
memory: { enabled: false },
rateLimit: { maxRequestsPerMinute: 0 },
secretDetection: { enabled: false },
}),
}));

import { getOrCreateConversation } from "../memory/conversation-key-store.js";
import { getDb } from "../memory/db-connection.js";
import { initializeDb } from "../memory/db-init.js";
import { buildAssistantEvent } from "../runtime/assistant-event.js";
import { AssistantEventHub } from "../runtime/assistant-event-hub.js";
import {
BadRequestError,
NotFoundError,
} from "../runtime/routes/errors.js";
import { handleSubscribeAssistantEvents } from "../runtime/routes/events-routes.js";

initializeDb();

describe("GET /v1/events — bilingual scope query params", () => {
beforeEach(() => {
const db = getDb();
db.run("DELETE FROM conversation_keys");
db.run("DELETE FROM conversations");
});

test("?conversationId=<existing-id> scopes the stream to that conversation", async () => {
// Materialise a conversation via the key path, then subscribe to it
// directly by its internal id.
const { conversationId } = getOrCreateConversation("sse-id-scope-source");

const ac = new AbortController();
const testHub = new AssistantEventHub();

const stream = handleSubscribeAssistantEvents(
{
queryParams: { conversationId },
abortSignal: ac.signal,
},
{ hub: testHub },
);

const reader = stream.getReader();
// Consume the initial heartbeat.
const heartbeat = await reader.read();
expect(new TextDecoder().decode(heartbeat.value)).toBe(": heartbeat\n\n");

// Publish an event scoped to that conversation — should be delivered.
await testHub.publish(buildAssistantEvent({ type: "pong" }, conversationId));

const { value, done } = await reader.read();
ac.abort();

expect(done).toBe(false);
const frame = new TextDecoder().decode(value);
expect(frame).toContain("event: assistant_event");
expect(frame).toContain(`"conversationId":"${conversationId}"`);
});

test("?conversationId=<non-existent-id> throws NotFoundError", () => {
expect(() =>
handleSubscribeAssistantEvents({
queryParams: { conversationId: "does-not-exist" },
abortSignal: new AbortController().signal,
}),
).toThrow(NotFoundError);
});

test("?conversationId is honored and ?conversationKey is ignored when both are present", async () => {
// Materialise two distinct conversations: one we'll subscribe to by id,
// one we'll publish to via the ignored key.
const { conversationId: idConv } = getOrCreateConversation("sse-id-wins");
const { conversationId: keyConv } = getOrCreateConversation(
"sse-key-ignored",
);
expect(idConv).not.toBe(keyConv);

const ac = new AbortController();
const testHub = new AssistantEventHub();

const stream = handleSubscribeAssistantEvents(
{
queryParams: {
conversationId: idConv,
conversationKey: "sse-key-ignored",
},
abortSignal: ac.signal,
},
{ hub: testHub },
);
const reader = stream.getReader();
await reader.read(); // heartbeat

// Publish on the "key" conversation — should NOT be delivered (filter
// is locked to idConv because conversationId wins).
await testHub.publish(buildAssistantEvent({ type: "pong" }, keyConv));
// Publish on the "id" conversation — should be delivered.
await testHub.publish(buildAssistantEvent({ type: "pong" }, idConv));

const { value } = await reader.read();
ac.abort();
const frame = new TextDecoder().decode(value);

expect(frame).toContain(`"conversationId":"${idConv}"`);
expect(frame).not.toContain(`"conversationId":"${keyConv}"`);
});

test("empty conversationId is rejected with BadRequestError", () => {
expect(() =>
handleSubscribeAssistantEvents({
queryParams: { conversationId: "" },
abortSignal: new AbortController().signal,
}),
).toThrow(BadRequestError);
});

test("empty conversationKey is still rejected (legacy parity)", () => {
expect(() =>
handleSubscribeAssistantEvents({
queryParams: { conversationKey: "" },
abortSignal: new AbortController().signal,
}),
).toThrow(BadRequestError);
});
});
24 changes: 21 additions & 3 deletions assistant/src/runtime/routes/conversation-management-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ function handleCreateConversation({ body = {}, headers }: RouteHandlerArgs) {
},
"Created conversation via POST",
);
// `id` is the assistant-minted internal `conversations.id` — the
// authoritative identifier for this conversation. `conversationKey`
// echoes the optional external key supplied by the client (or the
// UUID we minted) and is the identifier non-vellum channel adapters
// (Telegram, WhatsApp, etc.) use to scope to a logical channel
// thread. Vellum-web clients can ignore `conversationKey` and use
// `id` directly.
Comment on lines +109 to +115
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete this comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Queued for the next assistant-touching PR — tracked at notes/pending-followups.md with the exact patch ready to apply.

return {
id: result.conversationId,
conversationKey,
Expand Down Expand Up @@ -428,15 +435,26 @@ export const ROUTES: RouteDefinition[] = [
requestBody: z.object({
conversationKey: z
.string()
.describe("Idempotency key for the conversation"),
.optional()
.describe(
"Optional external key. Echoed back in the response. Non-vellum channels (Telegram, WhatsApp) use this to scope to a logical channel thread; vellum-web clients can omit it and rely on the assistant-minted `id`.",
),
conversationType: z
.literal("standard")
.optional()
.describe("Only standard conversations are created by this endpoint"),
}),
responseBody: z.object({
id: z.string(),
conversationKey: z.string(),
id: z
.string()
.describe(
"Assistant-minted internal conversation id. The authoritative identifier for the conversation.",
),
conversationKey: z
.string()
.describe(
"Echo of the optional external key supplied by the client (or the value the daemon minted when omitted).",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the parenthesis

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Queued for the next assistant-touching PR — tracked at notes/pending-followups.md with the exact patch ready to apply.

),
conversationType: z.string(),
created: z.boolean(),
}),
Expand Down
Loading
Loading