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
38 changes: 33 additions & 5 deletions assistant/src/__tests__/tool-preview-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,16 +314,30 @@ describe("tool preview lifecycle", () => {
input: { path: "/test" },
});

// Verify ordering
const eventTypes = collector.events.map((e) => e.type);
// Verify ordering of the legacy tool events. The streaming
// architecture (PR 1 of the streaming-message-architecture plan)
// interleaves additive `message_open` / `block_open` events here,
// so filter to the legacy tool events the test originally covered.
const STREAMING_LIFECYCLE_TYPES = new Set([
"message_open",
"block_open",
"block_close",
"message_close",
]);
const eventTypes = collector.events
.map((e) => e.type)
.filter((t) => !STREAMING_LIFECYCLE_TYPES.has(t));
expect(eventTypes).toEqual([
"tool_use_preview_start",
"tool_input_delta",
"tool_use_start",
]);

// Verify all events carry the same toolUseId
// Verify all tool-scoped events carry the same toolUseId. The new
// streaming lifecycle events are not tool-scoped (they identify
// messages and blocks instead) so they are excluded from the check.
for (const event of collector.events) {
if (STREAMING_LIFECYCLE_TYPES.has(event.type)) continue;
expect((event as any).toolUseId).toBe(toolUseId);
}
});
Expand Down Expand Up @@ -397,16 +411,30 @@ describe("tool preview lifecycle", () => {
isError: false,
});

const eventTypes = collector.events.map((e) => e.type);
// Filter out the additive streaming lifecycle events emitted by the
// streaming-message-architecture plan (PR 1) so the legacy ordering
// assertion still holds.
const STREAMING_LIFECYCLE_TYPES = new Set([
"message_open",
"block_open",
"block_close",
"message_close",
]);
const eventTypes = collector.events
.map((e) => e.type)
.filter((t) => !STREAMING_LIFECYCLE_TYPES.has(t));
expect(eventTypes).toEqual([
"tool_use_preview_start",
"tool_input_delta",
"tool_use_start",
"tool_result",
]);

// Verify toolUseId consistency across all events
// Verify toolUseId consistency across tool-scoped events. The new
// streaming lifecycle events identify messages / blocks instead and
// are excluded.
for (const event of collector.events) {
if (STREAMING_LIFECYCLE_TYPES.has(event.type)) continue;
expect((event as any).toolUseId).toBe(toolUseId);
}

Expand Down
8 changes: 8 additions & 0 deletions assistant/src/api/events/assistant-text-delta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export const AssistantTextDeltaEventSchema = z
type: z.literal("assistant_text_delta"),
text: z.string(),
messageId: z.string().optional(),
/** 0-based content-block index within the parent `messageId`. Optional
* for backwards compatibility with synthetic deltas that don't bind
* to a block. */
blockIndex: z.number().optional(),
/** Monotonically increasing per-conversation sequence number for
* idempotent client replay. Optional during the streaming-architecture
* rollout — daemons that pre-date PR 1 of the plan omit it. */
seq: z.number().optional(),
conversationId: z.string().optional(),
})
.strict();
Expand Down
27 changes: 27 additions & 0 deletions assistant/src/api/events/block-close.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* `block_close` SSE event.
*
* Emitted when a content block within an assistant message ends — the
* peer of `block_open`. Text blocks close when the next non-text content
* starts (or when the turn ends); tool_use blocks close when their
* matching `tool_result` arrives. Clients should treat `(messageId,
* blockIndex)` as the block identity for idempotent application.
*
* Canonical wire-contract source. Daemon code imports the type directly
* from this file; external consumers import via `@vellumai/assistant-api`.
*/

import { z } from "zod";

export const BlockCloseEventSchema = z
.object({
type: z.literal("block_close"),
messageId: z.string(),
blockIndex: z.number(),
/** Monotonically increasing per-conversation sequence number. */
seq: z.number(),
conversationId: z.string().optional(),
})
.strict();

export type BlockCloseEvent = z.infer<typeof BlockCloseEventSchema>;
41 changes: 41 additions & 0 deletions assistant/src/api/events/block-open.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* `block_open` SSE event.
*
* Emitted when a new content block within an assistant message starts —
* paired with `block_close` at the block's end. Carries the message and
* block coordinates that every block-scoped event (`assistant_text_delta`,
* `tool_use_start`, `tool_input_delta`, `tool_result`) stamps in this turn.
*
* Block kinds today:
* - `text` — a streamed text block opened on the first text delta
* emitted after the previous block closed.
* - `tool_use` — a tool invocation; opened immediately before the
* matching `tool_use_start` event and closed when the
* corresponding `tool_result` arrives.
*
* `blockIndex` is 0-based and monotonically increases within a single
* message; it never repeats across blocks in the same `messageId`.
*
* Canonical wire-contract source. Daemon code imports the type directly
* from this file; external consumers import via `@vellumai/assistant-api`.
*/

import { z } from "zod";

export const BlockOpenEventSchema = z
.object({
type: z.literal("block_open"),
messageId: z.string(),
blockIndex: z.number(),
blockType: z.enum(["text", "tool_use"]),
/** Tool name when `blockType` is `tool_use`; omitted otherwise. */
toolName: z.string().optional(),
/** Tool-use id when `blockType` is `tool_use`; omitted otherwise. */
toolUseId: z.string().optional(),
/** Monotonically increasing per-conversation sequence number. */
seq: z.number(),
conversationId: z.string().optional(),
})
.strict();

export type BlockOpenEvent = z.infer<typeof BlockOpenEventSchema>;
25 changes: 25 additions & 0 deletions assistant/src/api/events/message-close.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* `message_close` SSE event.
*
* Emitted at the end of an assistant turn — the peer of `message_open`,
* carrying the same `messageId`. Marks the turn done in the new
* streaming architecture; the legacy `message_complete` event continues
* to fire alongside it during the rollout for backward compatibility.
*
* Canonical wire-contract source. Daemon code imports the type directly
* from this file; external consumers import via `@vellumai/assistant-api`.
*/

import { z } from "zod";

export const MessageCloseEventSchema = z
.object({
type: z.literal("message_close"),
messageId: z.string(),
/** Monotonically increasing per-conversation sequence number. */
seq: z.number(),
conversationId: z.string().optional(),
})
.strict();

export type MessageCloseEvent = z.infer<typeof MessageCloseEventSchema>;
32 changes: 32 additions & 0 deletions assistant/src/api/events/message-open.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* `message_open` SSE event.
*
* Emitted by the daemon on the first content emission of an assistant
* turn — before the first `assistant_text_delta` or `tool_use_start` —
* to declare a stable `messageId` (UUIDv7) for the message that the rest
* of the turn's events will stamp on their `messageId` field. Paired with
* `message_close` at end-of-turn. Clients should anchor a bubble at
* `message_open` instead of inferring identity from the first delta.
*
* Additive alongside the legacy `assistant_text_delta` + `message_complete`
* pair during the streaming-architecture rollout; new clients prefer the
* `message_open` / `block_open` / `block_close` / `message_close` shape.
*
* Canonical wire-contract source. Daemon code imports the type directly
* from this file; external consumers import via `@vellumai/assistant-api`.
*/

import { z } from "zod";

export const MessageOpenEventSchema = z
.object({
type: z.literal("message_open"),
messageId: z.string(),
role: z.enum(["assistant"]),
/** Monotonically increasing per-conversation sequence number. */
seq: z.number(),
conversationId: z.string().optional(),
})
.strict();

export type MessageOpenEvent = z.infer<typeof MessageOpenEventSchema>;
5 changes: 5 additions & 0 deletions assistant/src/api/events/tool-use-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export const ToolUseStartEventSchema = z
input: z.record(z.string(), z.unknown()),
toolUseId: z.string().optional(),
messageId: z.string().optional(),
/** 0-based content-block index within the parent `messageId`. */
blockIndex: z.number().optional(),
/** Monotonically increasing per-conversation sequence number for
* idempotent client replay. */
seq: z.number().optional(),
conversationId: z.string().optional(),
})
.strict();
Expand Down
24 changes: 24 additions & 0 deletions assistant/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import { z } from "zod";

import { AssistantTextDeltaEventSchema } from "./events/assistant-text-delta.js";
import { AssistantTurnStartEventSchema } from "./events/assistant-turn-start.js";
import { BlockCloseEventSchema } from "./events/block-close.js";
import { BlockOpenEventSchema } from "./events/block-open.js";
import { DocumentCommentCreatedEventSchema } from "./events/document-comment-created.js";
import { DocumentCommentDeletedEventSchema } from "./events/document-comment-deleted.js";
import { DocumentCommentReopenedEventSchema } from "./events/document-comment-reopened.js";
import { DocumentCommentResolvedEventSchema } from "./events/document-comment-resolved.js";
import { GenerationCancelledEventSchema } from "./events/generation-cancelled.js";
import { GenerationHandoffEventSchema } from "./events/generation-handoff.js";
import { MessageCloseEventSchema } from "./events/message-close.js";
import { MessageCompleteEventSchema } from "./events/message-complete.js";
import { MessageOpenEventSchema } from "./events/message-open.js";
import { OpenUrlEventSchema } from "./events/open-url.js";
import { RelationshipStateUpdatedEventSchema } from "./events/relationship-state-updated.js";
import { ToolUseStartEventSchema } from "./events/tool-use-start.js";
Expand All @@ -26,6 +30,14 @@ export {
type AssistantTurnStartEvent,
AssistantTurnStartEventSchema,
} from "./events/assistant-turn-start.js";
export {
type BlockCloseEvent,
BlockCloseEventSchema,
} from "./events/block-close.js";
export {
type BlockOpenEvent,
BlockOpenEventSchema,
} from "./events/block-open.js";
export {
type DocumentCommentCreatedEvent,
DocumentCommentCreatedEventSchema,
Expand All @@ -50,10 +62,18 @@ export {
type GenerationHandoffEvent,
GenerationHandoffEventSchema,
} from "./events/generation-handoff.js";
export {
type MessageCloseEvent,
MessageCloseEventSchema,
} from "./events/message-close.js";
export {
type MessageCompleteEvent,
MessageCompleteEventSchema,
} from "./events/message-complete.js";
export {
type MessageOpenEvent,
MessageOpenEventSchema,
} from "./events/message-open.js";
export { type OpenUrlEvent, OpenUrlEventSchema } from "./events/open-url.js";
export {
type RelationshipStateUpdatedEvent,
Expand Down Expand Up @@ -108,13 +128,17 @@ export {
export const AssistantEventSchema = z.discriminatedUnion("type", [
AssistantTextDeltaEventSchema,
AssistantTurnStartEventSchema,
BlockCloseEventSchema,
BlockOpenEventSchema,
DocumentCommentCreatedEventSchema,
DocumentCommentDeletedEventSchema,
DocumentCommentReopenedEventSchema,
DocumentCommentResolvedEventSchema,
GenerationCancelledEventSchema,
GenerationHandoffEventSchema,
MessageCloseEventSchema,
MessageCompleteEventSchema,
MessageOpenEventSchema,
OpenUrlEventSchema,
RelationshipStateUpdatedEventSchema,
ToolUseStartEventSchema,
Expand Down
Loading
Loading