mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(daemon+web): emit conversation-created SSE event when routine run starts When a Routine fires in "Reuse an existing project" mode, the daemon creates a new conversation in the project and writes a queued/running assistant message to the database, but the open `ProjectView` has no way to learn that anything happened: the project events SSE stream only carries `file-changed` and `live_artifact*` events, and `ProjectView` reloads conversations only when `project.id` changes. The result is the user's own routine "Run now" appears to do nothing until they exit and re-enter the project (#1361). Fix: - Add a `conversation-created` payload type to the existing project events stream in `apps/web/src/providers/project-events.ts`. The payload carries `projectId`, `conversationId`, `title`, and `createdAt`. Mirror the existing `file-changed` listener pattern with explicit malformed-payload handling. - In `apps/daemon/src/server.ts`, after `insertConversation` runs in the routine `setRunHandler` (both reuse-an-existing-project and new-project paths), broadcast a `conversation-created` event through the existing `activeProjectEventSinks` map. The function body was already generic so it was renamed from `emitProjectLiveArtifactEvent` to `emitProjectEvent` and the two pre-existing callers updated. - In `apps/web/src/components/ProjectView.tsx`, when `handleProjectEvent` receives a `conversation-created` event whose `projectId` matches the currently-viewed project, refetch the conversation list via `listConversations`. The active conversation is intentionally NOT changed — per maintainer guidance on #1361, auto-switching is a separate UX decision left for a follow-up. - `projectEventToAgentEvent` returns null for `conversation-created` so it doesn't get routed into the live-artifact path. Tests (`apps/web/tests/providers/project-events.test.ts`): - A single `conversation-created` event reaches the consumer with the parsed payload. - Two consecutive `conversation-created` events from concurrent routine runs both reach the consumer (covers the multiple-concurrent-runs case reported in #1502). - Malformed `conversation-created` payloads are swallowed without throwing, matching the existing `file-changed` / `live_artifact` defensive behavior. Manual verification: - Built locally with `pnpm exec tools-pack mac build --to app --portable` and installed. - Created a routine in `Reuse an existing project` mode targeting an existing project. - With the project view open, clicked `Run now`. The new "Routine" conversation appeared in the project's conversation list within about a second, without exiting and re-entering the project, and the active conversation was not changed. - Clicked `Run now` twice in quick succession; both new conversations appeared in the list, covering the concurrent-runs case in #1502. - `pnpm guard` and `pnpm --filter @open-design/web typecheck` clean; full web test suite is 1016/1016 passing. Fixes #1361 * fix(#1523 review): share SSE type via contracts; guard conversation refresh against project-switch and reordering races Addresses Codex P1 + lefarcen P2 inline review on #1523 (#1361): 1. Move `ProjectConversationCreatedSsePayload` to `@open-design/contracts` (`packages/contracts/src/sse/chat.ts`) so the daemon producer and the web consumer share one type. The web provider re-exports it under the local `ProjectConversationCreatedEvent` name to keep the existing import shape stable for callers; the daemon emit site picks up the same shape via a JSDoc typedef so producer and consumer can't drift as this stream grows. (Addresses lefarcen P2 on project-events.ts:17.) 2. Guard the `conversation-created` async refresh in `ProjectView` against two distinct races: - Project-switch race: capture `project.id` at dispatch time and re-check it via a live `projectIdRef` after `listConversations` resolves; bail if the user switched projects while the request was in flight. The existing project-load effects use the same cancellation pattern. (Addresses Codex P1 on ProjectView.tsx:767.) - Concurrent-refresh re-ordering race: bump a monotonic `conversationsRefreshTokenRef` on every dispatch and capture each request's token; only the request whose captured token still equals the live ref at await-return applies its result. Two rapid `conversation-created` events (the #1502 concurrent Run-now case) can no longer drop the newest conversation when the earlier request resolves last with a stale, shorter list. (Addresses lefarcen P2 on ProjectView.tsx:767.) Both guards are documented inline with comments that point back at the review threads. The existing project-events tests (single delivery, concurrent delivery, malformed payloads) are unchanged — the new guards are defensive logic on the consumer, not new event shapes. `pnpm guard`, `pnpm --filter @open-design/web typecheck`, `pnpm --filter @open-design/daemon typecheck`, and the full web test suite (1016/1016) remain green.
346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
createProjectEventsConnection,
|
|
projectEventsUrl,
|
|
type ProjectEvent,
|
|
} from '../../src/providers/project-events';
|
|
|
|
type Listener = (evt: unknown) => void;
|
|
|
|
class MockEventSource {
|
|
static instances: MockEventSource[] = [];
|
|
url: string;
|
|
listeners: Map<string, Set<Listener>> = new Map();
|
|
closed = false;
|
|
constructor(url: string) {
|
|
this.url = url;
|
|
MockEventSource.instances.push(this);
|
|
}
|
|
addEventListener(name: string, cb: Listener): void {
|
|
if (!this.listeners.has(name)) this.listeners.set(name, new Set());
|
|
this.listeners.get(name)!.add(cb);
|
|
}
|
|
removeEventListener(name: string, cb: Listener): void {
|
|
this.listeners.get(name)?.delete(cb);
|
|
}
|
|
dispatch(name: string, evt: unknown): void {
|
|
for (const cb of this.listeners.get(name) ?? []) cb(evt);
|
|
}
|
|
close(): void {
|
|
this.closed = true;
|
|
}
|
|
// EventSource type compat
|
|
get readyState(): number { return this.closed ? 2 : 1; }
|
|
}
|
|
|
|
afterEach(() => {
|
|
MockEventSource.instances = [];
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('projectEventsUrl', () => {
|
|
it('encodes project id segment', () => {
|
|
expect(projectEventsUrl('818cf7a8-839/9'))
|
|
.toBe('/api/projects/818cf7a8-839%2F9/events');
|
|
});
|
|
});
|
|
|
|
describe('createProjectEventsConnection', () => {
|
|
it('opens an EventSource against the events URL on creation', () => {
|
|
const conn = createProjectEventsConnection(
|
|
'p1',
|
|
() => {},
|
|
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
|
);
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
expect(MockEventSource.instances[0]!.url).toBe('/api/projects/p1/events');
|
|
conn.close();
|
|
});
|
|
|
|
it('invokes onChange with parsed payload on file-changed events', () => {
|
|
const seen: ProjectEvent[] = [];
|
|
const conn = createProjectEventsConnection(
|
|
'p1',
|
|
(evt) => seen.push(evt),
|
|
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
|
);
|
|
const es = MockEventSource.instances[0]!;
|
|
es.dispatch('file-changed', {
|
|
data: JSON.stringify({ type: 'file-changed', path: 'a.html', kind: 'change' }),
|
|
});
|
|
es.dispatch('file-changed', {
|
|
data: JSON.stringify({ type: 'file-changed', path: 'b.css', kind: 'add' }),
|
|
});
|
|
expect(seen).toEqual([
|
|
{ type: 'file-changed', path: 'a.html', kind: 'change' },
|
|
{ type: 'file-changed', path: 'b.css', kind: 'add' },
|
|
]);
|
|
conn.close();
|
|
});
|
|
|
|
it('ignores malformed payloads instead of throwing', () => {
|
|
const seen: ProjectEvent[] = [];
|
|
const conn = createProjectEventsConnection(
|
|
'p1',
|
|
(evt) => seen.push(evt),
|
|
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
|
);
|
|
const es = MockEventSource.instances[0]!;
|
|
expect(() => es.dispatch('file-changed', { data: '{not-json' })).not.toThrow();
|
|
expect(seen).toEqual([]);
|
|
conn.close();
|
|
});
|
|
|
|
it('parses live_artifact events', () => {
|
|
const seen: ProjectEvent[] = [];
|
|
const conn = createProjectEventsConnection(
|
|
'p1',
|
|
(evt) => seen.push(evt),
|
|
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
|
);
|
|
const es = MockEventSource.instances[0]!;
|
|
es.dispatch('live_artifact', {
|
|
data: JSON.stringify({
|
|
type: 'live_artifact',
|
|
action: 'updated',
|
|
projectId: 'p1',
|
|
artifactId: 'artifact-1',
|
|
title: 'Status Board',
|
|
refreshStatus: 'running',
|
|
}),
|
|
});
|
|
|
|
expect(seen).toEqual([
|
|
{
|
|
type: 'live_artifact',
|
|
action: 'updated',
|
|
projectId: 'p1',
|
|
artifactId: 'artifact-1',
|
|
title: 'Status Board',
|
|
refreshStatus: 'running',
|
|
},
|
|
]);
|
|
conn.close();
|
|
});
|
|
|
|
it('parses live_artifact_refresh events', () => {
|
|
const seen: ProjectEvent[] = [];
|
|
const conn = createProjectEventsConnection(
|
|
'p1',
|
|
(evt) => seen.push(evt),
|
|
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
|
);
|
|
const es = MockEventSource.instances[0]!;
|
|
es.dispatch('live_artifact_refresh', {
|
|
data: JSON.stringify({
|
|
type: 'live_artifact_refresh',
|
|
phase: 'succeeded',
|
|
projectId: 'p1',
|
|
artifactId: 'artifact-1',
|
|
refreshId: 'refresh-000001',
|
|
title: 'Status Board',
|
|
refreshedSourceCount: 1,
|
|
}),
|
|
});
|
|
|
|
expect(seen).toEqual([
|
|
{
|
|
type: 'live_artifact_refresh',
|
|
phase: 'succeeded',
|
|
projectId: 'p1',
|
|
artifactId: 'artifact-1',
|
|
refreshId: 'refresh-000001',
|
|
title: 'Status Board',
|
|
refreshedSourceCount: 1,
|
|
},
|
|
]);
|
|
conn.close();
|
|
});
|
|
|
|
it('parses conversation-created events emitted by routine reuse runs (#1361)', () => {
|
|
const seen: ProjectEvent[] = [];
|
|
const conn = createProjectEventsConnection(
|
|
'p1',
|
|
(evt) => seen.push(evt),
|
|
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
|
);
|
|
const es = MockEventSource.instances[0]!;
|
|
es.dispatch('conversation-created', {
|
|
data: JSON.stringify({
|
|
type: 'conversation-created',
|
|
projectId: 'p1',
|
|
conversationId: 'routine-conv-aaa',
|
|
title: 'Routine · 2026-05-13',
|
|
createdAt: 1747109840000,
|
|
}),
|
|
});
|
|
|
|
expect(seen).toEqual([
|
|
{
|
|
type: 'conversation-created',
|
|
projectId: 'p1',
|
|
conversationId: 'routine-conv-aaa',
|
|
title: 'Routine · 2026-05-13',
|
|
createdAt: 1747109840000,
|
|
},
|
|
]);
|
|
conn.close();
|
|
});
|
|
|
|
// Concurrent routine runs (#1502): two Run-now clicks against the same
|
|
// reuse-an-existing-project routine each create their own conversation.
|
|
// Both events should be delivered to the open `ProjectView` so the
|
|
// conversation list surfaces all of them, not just the latest one.
|
|
it('delivers multiple concurrent conversation-created events (covers #1502)', () => {
|
|
const seen: ProjectEvent[] = [];
|
|
const conn = createProjectEventsConnection(
|
|
'p1',
|
|
(evt) => seen.push(evt),
|
|
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
|
);
|
|
const es = MockEventSource.instances[0]!;
|
|
es.dispatch('conversation-created', {
|
|
data: JSON.stringify({
|
|
type: 'conversation-created',
|
|
projectId: 'p1',
|
|
conversationId: 'routine-conv-aaa',
|
|
title: 'Routine · 8:12 AM',
|
|
createdAt: 1747109520000,
|
|
}),
|
|
});
|
|
es.dispatch('conversation-created', {
|
|
data: JSON.stringify({
|
|
type: 'conversation-created',
|
|
projectId: 'p1',
|
|
conversationId: 'routine-conv-bbb',
|
|
title: 'Routine · 8:13 AM',
|
|
createdAt: 1747109580000,
|
|
}),
|
|
});
|
|
|
|
expect(seen.map((e) => e.type === 'conversation-created' ? e.conversationId : null))
|
|
.toEqual(['routine-conv-aaa', 'routine-conv-bbb']);
|
|
conn.close();
|
|
});
|
|
|
|
it('ignores malformed conversation-created payloads instead of throwing', () => {
|
|
const seen: ProjectEvent[] = [];
|
|
const conn = createProjectEventsConnection(
|
|
'p1',
|
|
(evt) => seen.push(evt),
|
|
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
|
);
|
|
const es = MockEventSource.instances[0]!;
|
|
expect(() => es.dispatch('conversation-created', { data: '{not-json' })).not.toThrow();
|
|
expect(seen).toEqual([]);
|
|
conn.close();
|
|
});
|
|
|
|
it('reconnects with exponential backoff on error', () => {
|
|
let nextDelay = 0;
|
|
const setTimeoutFn = vi.fn((cb: () => void, ms: number) => {
|
|
nextDelay = ms;
|
|
cb();
|
|
return 0 as unknown as ReturnType<typeof setTimeout>;
|
|
});
|
|
const clearTimeoutFn = vi.fn();
|
|
const conn = createProjectEventsConnection(
|
|
'p1',
|
|
() => {},
|
|
{
|
|
EventSourceCtor: MockEventSource as unknown as typeof EventSource,
|
|
initialBackoffMs: 100,
|
|
maxBackoffMs: 800,
|
|
setTimeoutFn: setTimeoutFn as unknown as typeof setTimeout,
|
|
clearTimeoutFn: clearTimeoutFn as unknown as typeof clearTimeout,
|
|
},
|
|
);
|
|
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
MockEventSource.instances[0]!.dispatch('error', {});
|
|
expect(nextDelay).toBe(100);
|
|
expect(MockEventSource.instances).toHaveLength(2);
|
|
|
|
MockEventSource.instances[1]!.dispatch('error', {});
|
|
expect(nextDelay).toBe(200);
|
|
|
|
MockEventSource.instances[2]!.dispatch('error', {});
|
|
expect(nextDelay).toBe(400);
|
|
|
|
MockEventSource.instances[3]!.dispatch('error', {});
|
|
expect(nextDelay).toBe(800);
|
|
|
|
MockEventSource.instances[4]!.dispatch('error', {});
|
|
expect(nextDelay).toBe(800); // capped at maxBackoffMs
|
|
|
|
conn.close();
|
|
});
|
|
|
|
it('resets backoff after a ready event', () => {
|
|
let nextDelay = 0;
|
|
const setTimeoutFn = vi.fn((cb: () => void, ms: number) => {
|
|
nextDelay = ms;
|
|
cb();
|
|
return 0 as unknown as ReturnType<typeof setTimeout>;
|
|
});
|
|
const conn = createProjectEventsConnection(
|
|
'p1',
|
|
() => {},
|
|
{
|
|
EventSourceCtor: MockEventSource as unknown as typeof EventSource,
|
|
initialBackoffMs: 100,
|
|
setTimeoutFn: setTimeoutFn as unknown as typeof setTimeout,
|
|
},
|
|
);
|
|
|
|
MockEventSource.instances[0]!.dispatch('error', {});
|
|
expect(nextDelay).toBe(100);
|
|
MockEventSource.instances[1]!.dispatch('error', {});
|
|
expect(nextDelay).toBe(200);
|
|
// Ready arrives → reset
|
|
MockEventSource.instances[2]!.dispatch('ready', { data: '{}' });
|
|
MockEventSource.instances[2]!.dispatch('error', {});
|
|
expect(nextDelay).toBe(100);
|
|
|
|
conn.close();
|
|
});
|
|
|
|
it('close() prevents further reconnects and closes the active source', () => {
|
|
let scheduled: (() => void) | null = null;
|
|
const setTimeoutFn = vi.fn((cb: () => void) => {
|
|
scheduled = cb;
|
|
return 1 as unknown as ReturnType<typeof setTimeout>;
|
|
});
|
|
const clearTimeoutFn = vi.fn();
|
|
const conn = createProjectEventsConnection(
|
|
'p1',
|
|
() => {},
|
|
{
|
|
EventSourceCtor: MockEventSource as unknown as typeof EventSource,
|
|
setTimeoutFn: setTimeoutFn as unknown as typeof setTimeout,
|
|
clearTimeoutFn: clearTimeoutFn as unknown as typeof clearTimeout,
|
|
},
|
|
);
|
|
|
|
MockEventSource.instances[0]!.dispatch('error', {});
|
|
expect(scheduled).toBeTypeOf('function');
|
|
|
|
conn.close();
|
|
expect(clearTimeoutFn).toHaveBeenCalled();
|
|
// even if a stale timer fired, the connect is a no-op
|
|
(scheduled as (() => void) | null)?.();
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
});
|
|
|
|
it('returns a no-op connection when no EventSource constructor is available', () => {
|
|
const conn = createProjectEventsConnection(
|
|
'p1',
|
|
() => {},
|
|
{ EventSourceCtor: undefined },
|
|
);
|
|
expect(MockEventSource.instances).toHaveLength(0);
|
|
expect(() => conn.close()).not.toThrow();
|
|
});
|
|
});
|