Skip to content
Merged
3 changes: 2 additions & 1 deletion apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -2397,7 +2397,8 @@ select:disabled,

.insights-grid > .insights-panel {
border: 0;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-bottom: 1px solid
color-mix(in srgb, var(--foreground) 10%, transparent);
}

.insights-grid > .insights-panel:last-child {
Expand Down
9 changes: 3 additions & 6 deletions apps/web/src/shell/context-menu/items.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";

import { spyOnReactUiToast } from "../../../test/react-ui-toast-mock";
import type { ContextMenuEntry } from "@corbits/context-menu";

const toastMock = mock(() => undefined);
const actualReactUi = await import("@corbits/react-ui");
mock.module("@corbits/react-ui", () => ({
...actualReactUi,
toast: toastMock,
}));
const toastMock = spyOnReactUiToast();

import { shellContextMenuFor } from "./items";
import type { ShellContextMenuActions } from "./items";
Expand Down
9 changes: 3 additions & 6 deletions apps/web/test/canvas-column.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,12 @@
// Mention action already used (CL-6019).

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

import { spyOnReactUiToast } from "./react-ui-toast-mock";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";

const toastMock = mock((_message: string) => undefined);
const actualReactUi = await import("@corbits/react-ui");
mock.module("@corbits/react-ui", () => ({
...actualReactUi,
toast: toastMock,
}));
const toastMock = spyOnReactUiToast();

let ensureProfileDmResult: Promise<
{ kind: "ready"; workbenchId: string } | { kind: "error"; message: string }
Expand Down
6 changes: 5 additions & 1 deletion apps/web/test/login-routing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ const user: SessionUser = {
* `LoginForm` submission end to end. */
let capturedHandleSignedIn: ((user: SessionUser) => void) | null = null;

function TestRoot({ initialSession }: { readonly initialSession: SessionState }) {
function TestRoot({
initialSession,
}: {
readonly initialSession: SessionState;
}) {
const [path, setPath] = useState(window.location.pathname);
useEffect(() => {
const onPopState = () => setPath(window.location.pathname);
Expand Down
46 changes: 46 additions & 0 deletions apps/web/test/react-ui-toast-mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Observing `@corbits/react-ui`'s `toast` from a test means `mock.module`,
// which rewrites the module registry for the whole process rather than for
// the calling file — and bun offers no way to take that back. A stub
// installed by one test file is therefore still installed when every later
// file loads, and `toast-single-system.test.tsx` renders the real toaster
// and asserts on the DOM: under a plain stub it observes nothing and fails
// for reasons that have nothing to do with toasts.
//
// So the spy DELEGATES rather than replaces. Callers get the call record
// they assert on, and any file that renders a real `<Toaster />` still sees
// real toasts, whichever order bun happens to load the suites in.

import { mock } from "bun:test";
import { toast as sonnerToast } from "sonner";

const actualReactUi = await import("@corbits/react-ui");
const realToast = actualReactUi.toast;

type ToastFn = typeof actualReactUi.toast;

/**
* Installs a delegating spy over `toast` and returns it. Call once at module
* scope; `mockClear()` it between tests the way any other spy is cleared.
*/
export function spyOnReactUiToast(): ReturnType<typeof mock<ToastFn>> {
const spy = mock(((...args: Parameters<ToastFn>) =>
realToast(...args)) as ToastFn);
// `toast` carries its own variants (`toast.error` and friends); the spy
// stands in for the whole callable, so it must carry them too.
Object.assign(spy, realToast);
mock.module("@corbits/react-ui", () => ({
...actualReactUi,
toast: spy,
}));
return spy;
}

/**
* Empties sonner's toast store. The store is global and outlives any one
* `<Toaster />` mount or test file, so a suite that counts rendered toasts
* starts here. `@corbits/react-ui`'s `toast` is a raise-only wrapper with no
* dismiss of its own, so the clear goes to sonner directly.
*/
export function clearToasts(): void {
sonnerToast.dismiss();
}
11 changes: 4 additions & 7 deletions apps/web/test/routine-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,13 @@
// workbench in scope, this workbench's existing Myra workbench; never a
// newly minted one.

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

import { spyOnReactUiToast } from "./react-ui-toast-mock";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";

const toastMock = mock((_message: string) => undefined);
const actualReactUi = await import("@corbits/react-ui");
mock.module("@corbits/react-ui", () => ({
...actualReactUi,
toast: toastMock,
}));
const toastMock = spyOnReactUiToast();

const { BenchProvider } = await import("../src/bench-context");
const { NavigationProvider } = await import("../src/navigation");
Expand Down
11 changes: 10 additions & 1 deletion apps/web/test/toast-single-system.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
// the house styling, and clears itself.

import { toast, Toaster } from "@corbits/react-ui";
import { afterEach, describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";

import { BenchProvider } from "../src/bench-context";
import { NavigationProvider } from "../src/navigation";
import { NewWorkbenchPickerRoute } from "../src/pages/new-workbench-picker";
import { clearToasts } from "./react-ui-toast-mock";
import { TestQueryProvider } from "./test-query-provider";

const realFetch = globalThis.fetch;
Expand Down Expand Up @@ -116,6 +117,14 @@ async function renderPickerWithToaster(): Promise<void> {
}

describe("the one toast system (CL-6372)", () => {
// The store outlives this file too: a sibling suite that raised a toast
// before bun loaded this one leaves it queued, and it would render into
// the first `<Toaster />` mounted here. Start every test from an empty
// surface so the count below is this test's own toasts and nothing else.
beforeEach(() => {
clearToasts();
});

test("a failed workbench create fires exactly one toast", async () => {
stubFailingCreate();
await renderPickerWithToaster();
Expand Down
11 changes: 8 additions & 3 deletions packages/chat/src/platform-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { createAgentLifecycle } from "@corbits/agent-lifecycle";
import {
createCryptoProviderCache,
domainOf,
findFoldedRunById,
launchFoldedRun,
mintFoldedRun,
readDefinitionProjection,
Expand Down Expand Up @@ -64,7 +63,6 @@ import type {
import type { InferencePreference } from "@intx/agent";
import { formatRunAddress } from "@intx/types";
import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash";
import { type } from "arktype";
import {
AgentUnreachableError,
type ChatWorkbenchEvent,
Expand Down Expand Up @@ -818,6 +816,13 @@ export function createHubChatPlatform(
return live?.run.definitionId ?? undefined;
},

async resolveDefinitionAssetId(definitionId): Promise<string | undefined> {
const row = await deps.db.query.workflowDefinition.findFirst({
where: eq(workflowDefinition.id, definitionId),
});
return row?.assetId ?? undefined;
},

async refreshAgentInstanceFromDefinition(
tenantId,
_workbenchId,
Expand Down Expand Up @@ -855,7 +860,7 @@ export function createHubChatPlatform(
// The stable id names the room's participant; the run it resolves
// to is whichever one is alive right now, which is a different
// run (and a different address) after every relaunch.
const { binding, run } = await requireLive(input.workbenchId);
const { binding } = await requireLive(input.workbenchId);
const liveAddress = binding.liveAddress;

// Wake before send: a sleeping instance (the lifecycle package's
Expand Down
11 changes: 11 additions & 0 deletions packages/chat/src/platform-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ export interface WorkbenchLauncher {
*/
resolveDefinitionIdByAddress(address: string): Promise<string | undefined>;

/**
* Resolves a definition id to the workflow asset it projects over —
* the agent's stable identity. A code-sourced deploy projects a fresh
* `workflow_definition` row per frozen wire projection, so one agent
* accumulates many definition ids over its life while its asset stays
* the same; anything asking "is this the same agent?" compares assets,
* never rows. Returns undefined for a definition this tenant has no
* row for.
*/
resolveDefinitionAssetId(definitionId: string): Promise<string | undefined>;

/**
* Recomputes an already-invited instance's folded launch body from
* its definition's CURRENT asset content, and persists it so the
Expand Down
28 changes: 27 additions & 1 deletion packages/chat/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,11 @@ const MoveWorkbenchBody = type({
* Matches forward, by the `chat/definitionId` every agent chat has
* carried in its settings since this landed, and falls back to
* `matchesLegacyAgentChat` for a chat minted before that key existed.
* The comparison is on the definition's ASSET, not the row id: a
* code-sourced deploy projects a new `workflow_definition` row per
* frozen wire projection, so the id a chat recorded at creation and the
* id the picker offers later are routinely different rows over the one
* asset that IS the agent.
* More than one match (duplicates this same gap already let through)
* resolves to the oldest by its workbench-tenancy `createdAt` — the
* original conversation, not whichever the caller happens to hit first —
Expand All @@ -887,12 +892,14 @@ export async function findExistingAgentChat(
definitionId: string,
): Promise<WorkbenchSettingsRow | undefined> {
const chats = await deps.store.listWorkbenchSettings(tenantId, "chat");
const assetId = await deps.platform.resolveDefinitionAssetId(definitionId);
const matches: { row: WorkbenchSettingsRow; createdAt: Date }[] = [];
for (const row of chats) {
const storedDefinitionId = row.settings["chat/definitionId"];
const isMatch =
storedDefinitionId !== undefined
? storedDefinitionId === definitionId
? typeof storedDefinitionId === "string" &&
(await sameAgent(deps, storedDefinitionId, definitionId, assetId))
: await matchesLegacyAgentChat(deps, row, definitionId);
if (!isMatch) continue;
const link = await deps.tenancy.getWorkbenchTenancy(row.workbenchId);
Expand All @@ -903,6 +910,25 @@ export async function findExistingAgentChat(
return matches[0]?.row;
}

/**
* Whether two definition ids name the same agent: the same row, or two
* rows projected over the same workflow asset. An unresolvable asset (a
* definition row that no longer exists) never matches by asset, so a
* stale recorded id falls back to plain id equality alone.
*/
async function sameAgent(
deps: Pick<CreateChatRoutesDeps, "platform">,
storedDefinitionId: string,
definitionId: string,
assetId: string | undefined,
): Promise<boolean> {
if (storedDefinitionId === definitionId) return true;
if (assetId === undefined) return false;
const storedAssetId =
await deps.platform.resolveDefinitionAssetId(storedDefinitionId);
return storedAssetId === assetId;
}

/**
* A chat minted before `chat/definitionId` was recorded at creation
* carries no forward marker naming its agent — the only way back to its
Expand Down
32 changes: 32 additions & 0 deletions packages/chat/test/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,38 @@ describe("POST /workbenches — reuseExisting: true reopens the land-hop's chat,
expect(chats).toHaveLength(1);
});

test("reuses the chat when the agent's definition was re-projected under a new id over the same asset", async () => {
const deps = buildDeps({
platform: fakePlatform({
invitable: [
{ id: "wfd_echo_v1", name: "Echo" },
{ id: "wfd_echo_v2", name: "Echo" },
],
resolveDefinitionAssetId: async (definitionId: string) =>
definitionId.startsWith("wfd_echo") ? "ast_echo" : undefined,
}),
});
const app = mountAs(createChatRoutes(deps), "prn_alice");

const first = await createWorkbench(app, {
kind: "chat",
definitionId: "wfd_echo_v1",
reuseExisting: true,
});
expect(first.response.status).toBe(201);

const second = await createWorkbench(app, {
kind: "chat",
definitionId: "wfd_echo_v2",
reuseExisting: true,
});

expect(second.response.status).toBe(200);
expect(second.body.id).toBe(first.body.id);
const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat");
expect(chats).toHaveLength(1);
});

test("a new agent chat records its definitionId for future dedup", async () => {
const deps = buildDeps({
platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }),
Expand Down
9 changes: 9 additions & 0 deletions packages/chat/test/test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export function fakePlatform(
workbenchId: string,
blobId: string,
) => Promise<string | Uint8Array>;
resolveDefinitionAssetId?: (
definitionId: string,
) => Promise<string | undefined>;
resolveDefinitionIdByAddress?: (
address: string,
) => Promise<string | undefined>;
Expand Down Expand Up @@ -138,6 +141,12 @@ export function fakePlatform(
async listInvitableDefinitions() {
return opts.invitable ?? [];
},
async resolveDefinitionAssetId(definitionId: string) {
if (opts.resolveDefinitionAssetId !== undefined) {
return opts.resolveDefinitionAssetId(definitionId);
}
return undefined;
},
async resolveDefinitionIdByAddress(address) {
if (opts.resolveDefinitionIdByAddress !== undefined) {
return opts.resolveDefinitionIdByAddress(address);
Expand Down
15 changes: 12 additions & 3 deletions packages/cli/test/seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ function deps(overrides: Partial<SeedDeps> & Pick<SeedDeps, "api">): SeedDeps {
const { log } = collector();
return {
config: CONFIG,
pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }),
pushWorkflow: async () => ({
outcome: "pushed" as const,
commitSha: "a".repeat(40),
}),
publishToolRegistry: async () => undefined,
log,
...overrides,
Expand Down Expand Up @@ -279,7 +282,10 @@ describe("runSeed", () => {
await runSeed(
deps({
api: fakeAPI(handler),
pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }),
pushWorkflow: async () => ({
outcome: "pushed" as const,
commitSha: "a".repeat(40),
}),
log,
sleep: async () => {},
runStartTimeoutMs: 3,
Expand Down Expand Up @@ -472,7 +478,10 @@ describe("runSeed", () => {
await runSeed(
deps({
api: fakeAPI(handler),
pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }),
pushWorkflow: async () => ({
outcome: "pushed" as const,
commitSha: "a".repeat(40),
}),
log,
sleep: async () => {},
runStartTimeoutMs: 3,
Expand Down
Loading
Loading