Files
open-design/apps/web/tests/providers/project-events.test.ts
RockyandGitHub cd68c8a80a fix(daemon+web): emit conversation-created SSE event when routine run starts (#1523)
* 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.
2026-05-13 14:50:58 +08:00

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();
});
});