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
5 changes: 4 additions & 1 deletion apps/web/src/hooks/use-event-bus-init.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,10 @@ describe("useEventBusInit — SSE ownership", () => {
isAssistantActive: true,
}),
);
const event = { type: "avatar_updated" } as AssistantEvent;
const event: AssistantEvent = {
type: "avatar_updated",
avatarPath: "/tmp/avatar.png",
};
activeOnEvent!(event);
expect(handler).toHaveBeenCalledWith(event);
});
Expand Down
166 changes: 155 additions & 11 deletions apps/web/src/lib/streaming/event-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1797,21 +1797,165 @@ describe("parseAssistantEvent", () => {
}
});

describe("identity_changed", () => {
test("parses to a typed invalidation signal regardless of payload", () => {
// Arbitrary payload — handler treats this as an invalidation-only signal
// and refetches identity from the canonical endpoint.
// ---------------------------------------------------------------------
// identity_changed (schema-validated)
// ---------------------------------------------------------------------

test("parses identity_changed with all required fields", () => {
const event = parseAssistantEvent({
type: "identity_changed",
name: "ApolloBot",
role: "sidekick",
personality: "cosmic",
emoji: "🦖",
home: "kubernetes",
});
expect(event).toEqual({
type: "identity_changed",
name: "ApolloBot",
role: "sidekick",
personality: "cosmic",
emoji: "🦖",
home: "kubernetes",
});
});

test("returns unknown identity_changed when a required field is missing", () => {
const data = {
type: "identity_changed",
name: "ApolloBot",
role: "sidekick",
personality: "cosmic",
// emoji omitted
home: "kubernetes",
};
expect(parseAssistantEvent(data)).toEqual({
type: "unknown",
rawType: "identity_changed",
data,
});
});

test("returns unknown identity_changed when a field has wrong type", () => {
const data = {
type: "identity_changed",
name: 42,
role: "sidekick",
personality: "cosmic",
emoji: "🦖",
home: "kubernetes",
};
expect(parseAssistantEvent(data)).toEqual({
type: "unknown",
rawType: "identity_changed",
data,
});
});

// ---------------------------------------------------------------------
// avatar_updated (schema-validated)
// ---------------------------------------------------------------------

test("parses avatar_updated with avatarPath", () => {
const event = parseAssistantEvent({
type: "avatar_updated",
avatarPath: "/home/.vellum/avatar.png",
});
expect(event).toEqual({
type: "avatar_updated",
avatarPath: "/home/.vellum/avatar.png",
});
});

test("returns unknown avatar_updated when avatarPath is missing", () => {
const data = { type: "avatar_updated" };
expect(parseAssistantEvent(data)).toEqual({
type: "unknown",
rawType: "avatar_updated",
data,
});
});

// ---------------------------------------------------------------------
// conversation_list_invalidated (schema-validated)
// ---------------------------------------------------------------------

test("parses conversation_list_invalidated with each valid reason", () => {
for (const reason of [
"created",
"renamed",
"deleted",
"reordered",
"seen_changed",
] as const) {
const event = parseAssistantEvent({
type: "identity_changed",
name: "Pax",
role: "assistant",
type: "conversation_list_invalidated",
reason,
});
expect(event.type).toBe("identity_changed");
expect(event).toEqual({ type: "conversation_list_invalidated", reason });
}
});

test("returns unknown conversation_list_invalidated when reason is missing", () => {
const data = { type: "conversation_list_invalidated" };
expect(parseAssistantEvent(data)).toEqual({
type: "unknown",
rawType: "conversation_list_invalidated",
data,
});
});

test("returns unknown conversation_list_invalidated when reason is not a recognized enum value", () => {
const data = {
type: "conversation_list_invalidated",
reason: "evicted",
};
expect(parseAssistantEvent(data)).toEqual({
type: "unknown",
rawType: "conversation_list_invalidated",
data,
});
});

// ---------------------------------------------------------------------
// conversation_title_updated (schema-validated)
// ---------------------------------------------------------------------

test("parses conversation_title_updated with conversationId and title", () => {
const event = parseAssistantEvent({
type: "conversation_title_updated",
conversationId: "conv-1",
title: "New Title",
});
expect(event).toEqual({
type: "conversation_title_updated",
conversationId: "conv-1",
title: "New Title",
});
});

test("empty payload still produces IdentityChangedEvent (not UnknownEvent)", () => {
const event = parseAssistantEvent({ type: "identity_changed" });
expect(event.type).toBe("identity_changed");
test("returns unknown conversation_title_updated when conversationId is missing", () => {
const data = {
type: "conversation_title_updated",
title: "Orphan title",
};
expect(parseAssistantEvent(data)).toEqual({
type: "unknown",
rawType: "conversation_title_updated",
data,
});
});

test("returns unknown conversation_title_updated when title is missing", () => {
const data = {
type: "conversation_title_updated",
conversationId: "conv-1",
};
expect(parseAssistantEvent(data)).toEqual({
type: "unknown",
rawType: "conversation_title_updated",
data,
conversationId: "conv-1",
});
});
});
Expand Down
12 changes: 0 additions & 12 deletions apps/web/src/lib/streaming/event-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ import {
} from "@/lib/streaming/parse-subagent-events";
import {
parseSyncChanged,
parseIdentityChanged,
parseAvatarUpdated,
parseConversationTitleUpdated,
parseConversationListInvalidated,
parseNotificationIntent,
parseDiskPressureStatusChanged,
parseDocumentEditorUpdate,
Expand Down Expand Up @@ -184,14 +180,6 @@ function parseLegacyEvent(data: Record<string, unknown>): AssistantEvent {
// --- Resource invalidation / push signals ---
case "sync_changed":
return parseSyncChanged(data);
case "identity_changed":
return parseIdentityChanged();
case "avatar_updated":
return parseAvatarUpdated();
case "conversation_title_updated":
return parseConversationTitleUpdated(data);
case "conversation_list_invalidated":
return parseConversationListInvalidated(data);
case "notification_intent":
return parseNotificationIntent(data);
case "disk_pressure_status_changed":
Expand Down
45 changes: 7 additions & 38 deletions apps/web/src/lib/streaming/parse-resource-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ import type {
DiskPressureBlockedCapability,
DiskPressureStatus,
} from "@/assistant/types";
import type {
AssistantEvent,
ConversationListInvalidatedReason,
} from "@/types/event-types";
import type { AssistantEvent } from "@/types/event-types";
import type { SyncInvalidationTag } from "@/lib/sync/types";
import { unknownEvent } from "@/lib/streaming/parse-helpers";

Expand All @@ -40,40 +37,12 @@ export function parseSyncChanged(
};
}

export function parseIdentityChanged(): AssistantEvent {
return { type: "identity_changed" };
}

export function parseAvatarUpdated(): AssistantEvent {
return { type: "avatar_updated" };
}

export function parseConversationTitleUpdated(
data: Record<string, unknown>,
): AssistantEvent {
const conversationId =
typeof data.conversationId === "string" ? data.conversationId : "";
const title = typeof data.title === "string" ? data.title : "";
if (!conversationId) {
return unknownEvent("conversation_title_updated", data);
}
return { type: "conversation_title_updated", conversationId, title };
}

export function parseConversationListInvalidated(
data: Record<string, unknown>,
): AssistantEvent {
const rawReason = typeof data.reason === "string" ? data.reason : "";
const reason: ConversationListInvalidatedReason =
rawReason === "created" ||
rawReason === "renamed" ||
rawReason === "deleted" ||
rawReason === "reordered" ||
rawReason === "seen_changed"
? rawReason
: "created";
return { type: "conversation_list_invalidated", reason };
}
// `identity_changed`, `avatar_updated`, `conversation_title_updated`, and
// `conversation_list_invalidated` are now schema-validated via canonical
// schemas in `@vellumai/assistant-api`. The legacy parser functions
// previously here are gone — `event-parser.ts` no longer dispatches
// these cases; `parseAssistantEvent` resolves them through
// `AssistantEventSchema` before reaching the legacy switch.
Comment on lines +40 to +45
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.


export function parseNotificationIntent(
data: Record<string, unknown>,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/stores/event-bus-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from "@/stores/event-bus-store";

function avatarEvent(): AssistantEvent {
return { type: "avatar_updated" } as AssistantEvent;
return { type: "avatar_updated", avatarPath: "/tmp/avatar.png" };
}

beforeEach(() => {
Expand Down
52 changes: 0 additions & 52 deletions apps/web/src/types/event-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,42 +317,6 @@ export interface UnknownEvent {
// Conversation lifecycle events
// ---------------------------------------------------------------------------

/**
* Reasons the server may invalidate a client's conversation list — mirrors
* `ConversationListInvalidatedReason` on the daemon.
*/
export type ConversationListInvalidatedReason =
| "created"
| "renamed"
| "deleted"
| "reordered"
| "seen_changed";

/**
* Server push notifying clients that their sidebar conversation list is
* stale and should be refetched (e.g. after a create/rename/delete/reorder
* from another client). Global to the assistant — not scoped to a single
* conversationId.
*/
export interface ConversationListInvalidatedEvent {
type: "conversation_list_invalidated";
reason: ConversationListInvalidatedReason;
conversationId?: string;
}

/**
* Server push notifying clients that a single conversation's title has
* changed. Emitted on auto-title generation (agent loop, first turn),
* auto-title regeneration (after 3 turns), and explicit renames. Clients
* should update the matching conversation's title in-place rather than
* refetching the whole list.
*/
export interface ConversationTitleUpdatedEvent {
type: "conversation_title_updated";
conversationId: string;
title: string;
}

/**
* Server push asking the client to display a native notification. Mirrors
* the daemon's `NotificationIntent` message (see
Expand All @@ -377,18 +341,6 @@ export interface NotificationIntentEvent {
conversationId?: string;
}

/** Cache-invalidation signal: refetch identity from the canonical endpoint. */
export interface IdentityChangedEvent {
type: "identity_changed";
conversationId?: string;
}

/** Broadcast by the daemon when avatar files change on disk. */
export interface AvatarUpdatedEvent {
type: "avatar_updated";
conversationId?: string;
}

/**
* Emitted by the daemon when the inference profile is auto-routed for the
* current turn (e.g. tool-based routing selects a different model profile).
Expand Down Expand Up @@ -495,14 +447,10 @@ export type AssistantEvent =
| UISurfaceCompleteEvent
| ToolResultEvent
| ToolProgressEvent
| ConversationListInvalidatedEvent
| ConversationTitleUpdatedEvent
| NotificationIntentEvent
| UsageUpdateEvent
| AssistantActivityStateEvent
| NavigateSettingsEvent
| IdentityChangedEvent
| AvatarUpdatedEvent
| ConversationErrorEvent
| DiskPressureStatusChangedEvent
| AssistantSyncChangedEvent
Expand Down
26 changes: 26 additions & 0 deletions assistant/src/api/events/avatar-updated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* `avatar_updated` SSE event.
*
* Emitted after the avatar image has been regenerated and saved to disk.
* Clients bust their avatar cache; the `avatarPath` is the absolute path
* to the new image file, available to clients that read it directly.
*
* Global event (no `conversationId`): the avatar is per-user, not
* per-conversation, and the daemon fans this out across every active
* client of the user.
*
* 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 AvatarUpdatedEventSchema = z
.object({
type: z.literal("avatar_updated"),
/** Absolute path to the updated avatar image file. */
avatarPath: z.string(),
})
.strict();

export type AvatarUpdatedEvent = z.infer<typeof AvatarUpdatedEventSchema>;
Loading
Loading