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: 19 additions & 0 deletions e2e_tests/tests_omni_light/fixtures/modules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"version": "1.0.0",
"includes": [{ "path": "modules_browser_only.json" }],
"modules": [
{
"id": "external-test-hello-world-route",
"type": "Routes",
"path": "@/modules/external-module-test/helloWorldRouteDefinition/create",
"dependencies": {}
},
{
"id": "routing-main",
"collisionStrategy": "merge",
"dependencies": {
"module:routes": ["external-test-hello-world-route"]
}
}
]
}
53 changes: 0 additions & 53 deletions e2e_tests/tests_omni_light/scripts/extend-light-modules-json.mjs

This file was deleted.

8 changes: 4 additions & 4 deletions e2e_tests/tests_omni_light/scripts/run-frontend.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# Builds and starts the Vite preview server for e2e light tests.
#
# Uses frontend/omni browser-only manifest and extends it at runtime with
# external test module entries.
# Uses frontend/omni browser-only manifest composed with the test fixtures
# modules.json (which includes modules_browser_only.json and adds the external
# test module entries via the composition system).

set -euo pipefail

Expand All @@ -16,8 +17,7 @@ cd "$FRONTEND_DIR"
rm -rf src/modules/external-module-test
cp -R "$LIGHT_TESTS_DIR/fixtures/external-module-test" src/modules/external-module-test

rm -f public/modules.json
node "$LIGHT_TESTS_DIR/scripts/extend-light-modules-json.mjs"
cp "$LIGHT_TESTS_DIR/fixtures/modules.json" public/modules.json

pnpm install
pnpm build
Expand Down
22 changes: 22 additions & 0 deletions e2e_tests/tests_omni_light/src/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,26 @@ test.describe("Chat", () => {
await chatPage.sendMessage("world");
await chatPage.assertLastResponse("world");
});

test("Each assistant message keeps the model that generated it", async ({
page,
}) => {
const chatPage = new ChatPage(page);

await chatPage.navigateTo();
await chatPage.selectModel("gpt-4o");

await chatPage.sendMessage("first message");
await chatPage.assertLastResponse("first message");
await chatPage.waitForIdle();

await chatPage.selectModel("gpt-4o-mini");

await chatPage.sendMessage("second message");
await chatPage.assertLastResponse("second message");
await chatPage.waitForIdle();

await chatPage.assertAssistantMessageModelName(0, "gpt-4o");
await chatPage.assertAssistantMessageModelName(1, "gpt-4o-mini");
});
});
38 changes: 36 additions & 2 deletions e2e_tests/tests_omni_light/src/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,28 +44,62 @@ export class ChatPage {
hasText: /Select model|gpt-/i,
})
.first();
await expect(modelButton).toBeEnabled({ timeout: 10000 });
await modelButton.waitFor({ state: "visible", timeout: 10000 });
await modelButton.click();
// Click the first option in the popover/command list
const firstOption = this.page.locator('[role="option"]').first();
await firstOption.waitFor({ state: "visible", timeout: 5000 });
await firstOption.click();
}

async selectModel(modelName: string): Promise<void> {
const modelButton = this.page
.locator("button")
.filter({ hasText: /Select model|gpt-/i })
.first();
await modelButton.waitFor({ state: "visible", timeout: 10000 });
await modelButton.click();
const option = this.page
.locator('[role="option"]')
.filter({ hasText: modelName })
.first();
await option.waitFor({ state: "visible", timeout: 5000 });
await option.click();
}

async sendMessage(message: string): Promise<void> {
const input = this.page.getByRole("textbox", {
name: "Type a message...",
});
await expect(input).toBeEnabled({ timeout: 5000 });
await input.fill(message);
await this.page.getByRole("button", { name: "Send" }).click();
}

async waitForIdle(): Promise<void> {
const input = this.page.getByRole("textbox", {
name: "Type a message...",
});
await input
.and(this.page.locator(":enabled"))
.waitFor({ timeout: 10000 });
}

async assertLastResponse(content: string): Promise<void> {
await expect(
this.page.locator(".bg-muted.rounded-2xl").last(),
).toContainText(content, { timeout: 5000 });
}

async assertAssistantMessageModelName(
index: number,
modelName: string,
): Promise<void> {
// Each assistant message is in a flex container with justify-start.
// The model-name label is the first <p> inside the message content area.
await expect(
this.page.locator("div.flex.justify-start .flex-1 > p").nth(index),
).toHaveText(modelName, { timeout: 5000 });
}
}

export class Sidebar {
Expand Down
2 changes: 1 addition & 1 deletion frontend/omni/docs/architecture/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ A manifest file has the following top-level shape:
2. The root manifest's own `modules` are applied last and always win.

**`collisionStrategy`** on a module entry controls what happens when that module's `id` already exists from an earlier include:
- `"merge"` *(default)* — deep-merges `config`; incoming wins on shared keys. `dependencies` is shallow-merged; incoming wins on key collision.
- `"merge"` *(default)* — deep-merges `config`; incoming wins on shared keys. `dependencies` is merged per key: string values follow the same "incoming wins" rule; **array values are unioned** (base items first, then new incoming items; duplicates dropped).
- `"replace"` — incoming entry fully replaces the existing one.
- `"drop"` — removes the existing entry; does not add the incoming one either.

Expand Down
64 changes: 63 additions & 1 deletion frontend/omni/src/core/module-system/manifestJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ describe("resolveManifest — collisionStrategy", () => {
expect(svc?.config).toEqual({ nested: { a: 1, b: 2, c: 3 } });
});

it("merge: merges dependencies records, incoming wins on key collision", async () => {
it("merge: merges dependencies records, incoming wins on string key collision", async () => {
mockFetch({
"/modules.json": manifest(
[
Expand Down Expand Up @@ -190,6 +190,68 @@ describe("resolveManifest — collisionStrategy", () => {
});
});

it("merge: unions array dependencies, base items first then new incoming items", async () => {
mockFetch({
"/modules.json": manifest(
[
entry("router", {
dependencies: {
"module:routes": ["extra-route"],
},
}),
],
{ includes: [{ path: "/base.json" }] },
),
"/base.json": manifest([
entry("router", {
dependencies: {
"module:routes": ["chat-route", "fallback-route"],
},
}),
]),
});

const result = await resolveManifest("/modules.json");
const router = result.modules.find((m) => m.id === "router");

expect(router?.dependencies?.["module:routes"]).toEqual([
"chat-route",
"fallback-route",
"extra-route",
]);
});

it("merge: array union deduplicates items already present in the base", async () => {
mockFetch({
"/modules.json": manifest(
[
entry("router", {
dependencies: {
"module:routes": ["chat-route", "extra-route"],
},
}),
],
{ includes: [{ path: "/base.json" }] },
),
"/base.json": manifest([
entry("router", {
dependencies: {
"module:routes": ["chat-route", "fallback-route"],
},
}),
]),
});

const result = await resolveManifest("/modules.json");
const router = result.modules.find((m) => m.id === "router");

expect(router?.dependencies?.["module:routes"]).toEqual([
"chat-route",
"fallback-route",
"extra-route",
]);
});

it("replace: incoming entry fully replaces the included one", async () => {
mockFetch({
"/modules.json": manifest(
Expand Down
28 changes: 24 additions & 4 deletions frontend/omni/src/core/module-system/manifestJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export interface ManifestEntry {
* includes that overwrite earlier ones).
*
* - `merge` (default): deep-merge config; incoming wins on shared keys.
* For dependency keys whose values are arrays in both base and incoming,
* the arrays are unioned (base items first, then new incoming items;
* duplicates are dropped). For string dependency values, incoming wins.
* - `replace`: incoming entry fully replaces the existing one.
* - `drop`: remove the existing entry; do not add this entry either.
*
Expand Down Expand Up @@ -117,6 +120,23 @@ function applyModules(
return result;
}

function mergeDependencies(
base: Record<string, string | string[]>,
incoming: Record<string, string | string[]>,
): Record<string, string | string[]> {
const result: Record<string, string | string[]> = { ...base };
for (const [key, val] of Object.entries(incoming)) {
const existing = result[key];
if (Array.isArray(val) && Array.isArray(existing)) {
const seen = new Set(existing);
result[key] = [...existing, ...val.filter((v) => !seen.has(v))];
} else {
result[key] = val;
}
}
return result;
}

function deepMergeEntries(
base: ManifestEntry,
incoming: ManifestEntry,
Expand All @@ -127,10 +147,10 @@ function deepMergeEntries(
base.dependencies !== undefined ||
incoming.dependencies !== undefined
) {
merged.dependencies = {
...(base.dependencies ?? {}),
...(incoming.dependencies ?? {}),
};
merged.dependencies = mergeDependencies(
base.dependencies ?? {},
incoming.dependencies ?? {},
);
}

if (base.config !== undefined || incoming.config !== undefined) {
Expand Down
4 changes: 2 additions & 2 deletions frontend/omni/src/modules/chat/ChatComponent.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const providerGroups = $derived(
})),
);

let messages = $state<UIMessage[]>([]);
let messages = $state<UIMessage<{ modelName?: string }>[]>([]);
let chatStatus = $state<"ready" | "submitted" | "streaming">("ready");

const isIdle = $derived(
Expand All @@ -106,6 +106,7 @@ async function handleSend(text: string) {
id: assistantMessageId,
role: "assistant",
parts: [{ type: "text", text: "" }],
metadata: { modelName: selectedModelData.modelName },
},
];
chatStatus = "submitted";
Expand Down Expand Up @@ -172,7 +173,6 @@ function makeMessageId(): string {
status={chatStatus}
{modelsLoading}
hasModels={availableModels.length > 0}
selectedModelName={selectedModelData?.modelName}
/>
{#if messages.length === 0 && availableModels.length > 0}
<ChatSuggestions onselect={handleSend} />
Expand Down
6 changes: 2 additions & 4 deletions frontend/omni/src/modules/chat/ChatConversationArea.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,11 @@ let {
status,
modelsLoading,
hasModels,
selectedModelName,
}: {
messages: UIMessage[];
messages: UIMessage<{ modelName?: string }>[];
status: string;
modelsLoading: boolean;
hasModels: boolean;
selectedModelName: string | undefined;
} = $props();

let conversationEl = $state<HTMLElement | null>(null);
Expand Down Expand Up @@ -72,7 +70,7 @@ $effect(() => {
{/if}

{#each messages as message (message.id)}
<ChatMessageItem {message} {selectedModelName} />
<ChatMessageItem {message} />
{/each}

<!-- Streaming indicator -->
Expand Down
6 changes: 2 additions & 4 deletions frontend/omni/src/modules/chat/ChatMessageItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ import * as Collapsible from "$lib/shadcnui/components/ui/collapsible/index.js";

let {
message,
selectedModelName,
}: {
message: UIMessage;
selectedModelName: string | undefined;
message: UIMessage<{ modelName?: string }>;
} = $props();

function renderMarkdown(text: string): string {
Expand Down Expand Up @@ -51,7 +49,7 @@ function renderMarkdown(text: string): string {
>
{message.role === "user"
? "You"
: (selectedModelName ?? "Assistant")}
: (message.metadata?.modelName ?? "Assistant")}
</p>
<div
class="flex flex-col gap-2 {message.role === 'user'
Expand Down
Loading