@mandujs/cli 0.15.2 → 0.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/cli",
3
- "version": "0.15.2",
3
+ "version": "0.15.4",
4
4
  "description": "Agent-Native Fullstack Framework - 에이전트가 코딩해도 아키텍처가 무너지지 않는 개발 OS",
5
5
  "type": "module",
6
6
  "main": "./src/main.ts",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@mandujs/core": "^0.13.1",
35
+ "@mandujs/core": "^0.13.2",
36
36
  "cfonts": "^3.3.0"
37
37
  },
38
38
  "engines": {
@@ -8,6 +8,54 @@ import {
8
8
  type LockfileValidationResult,
9
9
  } from "@mandujs/core";
10
10
 
11
+ /**
12
+ * Lockfile command templates for consistent messaging
13
+ */
14
+ export const LOCKFILE_COMMANDS = {
15
+ update: "mandu lock",
16
+ diff: "mandu lock --diff",
17
+ safeDev: "mandu lock && mandu dev --watch",
18
+ } as const;
19
+
20
+ /**
21
+ * Formatted lockfile guidance lines with alternative commands
22
+ */
23
+ export const LOCKFILE_GUIDE_LINES = {
24
+ update: `${LOCKFILE_COMMANDS.update} (or bunx mandu lock)`,
25
+ diff: `${LOCKFILE_COMMANDS.diff} (or bunx mandu lock --diff)`,
26
+ safeDev: `${LOCKFILE_COMMANDS.safeDev} (or bun run dev:safe)`,
27
+ } as const;
28
+
29
+ /**
30
+ * Returns formatted lockfile guidance lines for display
31
+ *
32
+ * @returns Array of guidance messages with Korean labels
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * const lines = getLockfileGuidanceLines();
37
+ * lines.forEach(line => console.log(` ↳ ${line}`));
38
+ * // Output:
39
+ * // ↳ lock 갱신: mandu lock (or bunx mandu lock)
40
+ * // ↳ 변경 확인: mandu lock --diff (or bunx mandu lock --diff)
41
+ * // ↳ 안정 실행: mandu lock && mandu dev --watch (or bun run dev:safe)
42
+ * ```
43
+ */
44
+ export function getLockfileGuidanceLines(): string[] {
45
+ return [
46
+ `lock 갱신: ${LOCKFILE_GUIDE_LINES.update}`,
47
+ `변경 확인: ${LOCKFILE_GUIDE_LINES.diff}`,
48
+ `안정 실행: ${LOCKFILE_GUIDE_LINES.safeDev}`,
49
+ ];
50
+ }
51
+
52
+ /**
53
+ * Validates runtime lockfile against current config
54
+ *
55
+ * @param config - Mandu configuration object
56
+ * @param rootDir - Project root directory
57
+ * @returns Validation result with lockfile, action, and bypass status
58
+ */
11
59
  export async function validateRuntimeLockfile(config: Record<string, unknown>, rootDir: string) {
12
60
  const lockfile = await readLockfile(rootDir);
13
61
 
@@ -30,17 +78,22 @@ export async function validateRuntimeLockfile(config: Record<string, unknown>, r
30
78
  return { lockfile, lockResult, action, bypassed };
31
79
  }
32
80
 
81
+ /**
82
+ * Handles blocked server start due to lockfile mismatch
83
+ *
84
+ * Exits process with error code 1 if action is "block"
85
+ *
86
+ * @param action - Policy action from lockfile validation
87
+ * @param lockResult - Validation result with details
88
+ */
33
89
  export function handleBlockedLockfile(action: "pass" | "warn" | "error" | "block", lockResult: LockfileValidationResult | null): void {
34
90
  if (action !== "block") return;
35
91
 
92
+ const guidance = getLockfileGuidanceLines();
36
93
  console.error("🛑 서버 시작 차단: Lockfile 불일치");
37
- console.error(" 설정이 변경되었습니다. 의도한 변경이라면 아래 중 하나를 실행하세요:");
38
- console.error(" $ mandu lock");
39
- console.error(" $ bunx mandu lock");
40
- console.error("");
41
- console.error(" 변경 사항 확인:");
42
- console.error(" $ mandu lock --diff");
43
- console.error(" $ bunx mandu lock --diff");
94
+ console.error(" 설정이 변경되었습니다. 의도한 변경이라면 아래를 실행하세요:");
95
+ console.error(` ${guidance[0]}`);
96
+ console.error(` ${guidance[1]}`);
44
97
  if (lockResult) {
45
98
  console.error("");
46
99
  console.error(formatValidationResult(lockResult));
@@ -48,6 +101,14 @@ export function handleBlockedLockfile(action: "pass" | "warn" | "error" | "block
48
101
  process.exit(1);
49
102
  }
50
103
 
104
+ /**
105
+ * Prints runtime lockfile validation status
106
+ *
107
+ * @param action - Policy action from lockfile validation
108
+ * @param bypassed - Whether validation was bypassed
109
+ * @param lockfile - Lockfile data (null if not found)
110
+ * @param lockResult - Validation result with hash and validity
111
+ */
51
112
  export function printRuntimeLockfileStatus(
52
113
  action: "pass" | "warn" | "error" | "block",
53
114
  bypassed: boolean,
@@ -56,11 +117,12 @@ export function printRuntimeLockfileStatus(
56
117
  ): void {
57
118
  if (action === "warn") {
58
119
  console.log(`⚠️ ${formatPolicyAction(action, bypassed)}`);
59
- console.log(` ↳ lock 갱신: mandu lock (or bunx mandu lock)`);
60
- console.log(` ↳ 변경 확인: mandu lock --diff (or bunx mandu lock --diff)`);
120
+ for (const line of getLockfileGuidanceLines()) {
121
+ console.log(` ↳ ${line}`);
122
+ }
61
123
  } else if (lockfile && lockResult?.valid) {
62
124
  console.log(`🔒 설정 무결성 확인됨 (${lockResult.currentHash?.slice(0, 8)})`);
63
125
  } else if (!lockfile) {
64
- console.log(`💡 Lockfile 없음 - 'mandu lock' 또는 'bunx mandu lock'으로 생성 권장`);
126
+ console.log(`💡 Lockfile 없음 - '${LOCKFILE_COMMANDS.update}'으로 생성 권장`);
65
127
  }
66
128
  }
@@ -1,4 +1,7 @@
1
- import { subscribeWithSnapshot } from "@/server/application/chat-store";
1
+ import {
2
+ planResumeFrom,
3
+ subscribeWithSnapshot,
4
+ } from "@/server/application/chat-store";
2
5
  import type { ChatMessage, ChatStreamEvent } from "@/shared/contracts/chat";
3
6
  import { createSSEConnection } from "@mandujs/core";
4
7
  import { createRateLimiter } from "@mandujs/core/runtime/server.ts";
@@ -6,6 +9,17 @@ import { createRateLimiter } from "@mandujs/core/runtime/server.ts";
6
9
  // Rate limiter: 1분당 5개 연결로 제한 (SSE는 장시간 유지되므로 보수적으로 설정)
7
10
  const limiter = createRateLimiter({ max: 5, windowMs: 60000 });
8
11
 
12
+ function getLastEventId(request: Request): string | null {
13
+ const fromHeader = request.headers.get("last-event-id");
14
+ if (fromHeader && fromHeader.trim()) return fromHeader.trim();
15
+
16
+ const url = new URL(request.url);
17
+ const fromQuery = url.searchParams.get("lastEventId");
18
+ if (fromQuery && fromQuery.trim()) return fromQuery.trim();
19
+
20
+ return null;
21
+ }
22
+
9
23
  export function GET(request: Request): Response {
10
24
  // Rate limiting 체크
11
25
  const decision = limiter.check(request, "chat-stream");
@@ -14,22 +28,46 @@ export function GET(request: Request): Response {
14
28
  }
15
29
 
16
30
  const sse = createSSEConnection(request.signal);
31
+ const lastEventId = getLastEventId(request);
17
32
 
18
- const subscription = subscribeWithSnapshot((message: ChatMessage) => {
19
- const event: ChatStreamEvent = {
20
- type: "message",
21
- data: message,
22
- };
23
- sse.send(event);
24
- });
33
+ const subscribeToLiveMessages = () => {
34
+ const subscription = subscribeWithSnapshot((event) => {
35
+ const streamEvent: ChatStreamEvent = {
36
+ type: "message",
37
+ data: event.message,
38
+ };
39
+ sse.send(streamEvent, { id: event.eventId });
40
+ });
25
41
 
26
- const snapshot: ChatStreamEvent = {
27
- type: "snapshot",
28
- data: subscription.snapshot,
42
+ return subscription;
29
43
  };
30
- sse.send(snapshot);
31
44
 
32
- const unsubscribe = subscription.commit();
45
+ const resume = planResumeFrom(lastEventId);
46
+
47
+ if (resume.mode === "catch-up") {
48
+ for (const event of resume.events) {
49
+ const streamEvent: ChatStreamEvent = {
50
+ type: "message",
51
+ data: event.message,
52
+ };
53
+ sse.send(streamEvent, { id: event.eventId });
54
+ }
55
+
56
+ const liveSubscription = subscribeToLiveMessages();
57
+ const unsubscribe = liveSubscription.commit();
58
+ sse.onClose(() => unsubscribe());
59
+ } else {
60
+ const snapshotSubscription = subscribeToLiveMessages();
61
+
62
+ const snapshot: ChatStreamEvent = {
63
+ type: "snapshot",
64
+ data: snapshotSubscription.snapshot,
65
+ };
66
+ sse.send(snapshot);
67
+
68
+ const unsubscribe = snapshotSubscription.commit();
69
+ sse.onClose(() => unsubscribe());
70
+ }
33
71
 
34
72
  const interval = setInterval(() => {
35
73
  const heartbeat: ChatStreamEvent = {
@@ -41,7 +79,6 @@ export function GET(request: Request): Response {
41
79
 
42
80
  sse.onClose(() => {
43
81
  clearInterval(interval);
44
- unsubscribe();
45
82
  });
46
83
 
47
84
  return sse.response;
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  ChatHistoryResponse,
3
+ ChatMessage,
3
4
  ChatMessagePayload,
4
5
  ChatMessageResponse,
5
6
  ChatStreamEvent,
@@ -24,7 +25,9 @@ interface ChatStreamOptions {
24
25
  onConnectionStateChange?: (state: ChatStreamConnectionState) => void;
25
26
  }
26
27
 
27
- const DEFAULT_STREAM_OPTIONS: Required<Omit<ChatStreamOptions, "eventSourceFactory" | "onConnectionStateChange">> = {
28
+ type ReconnectOptions = Required<Omit<ChatStreamOptions, "eventSourceFactory" | "onConnectionStateChange">>;
29
+
30
+ const DEFAULT_STREAM_OPTIONS: ReconnectOptions = {
28
31
  maxRetries: 8,
29
32
  baseDelayMs: 500,
30
33
  maxDelayMs: 10_000,
@@ -55,18 +58,36 @@ export async function fetchChatHistory(): Promise<ChatHistoryResponse> {
55
58
  return response.json() as Promise<ChatHistoryResponse>;
56
59
  }
57
60
 
58
- function toReconnectDelayMs(attempt: number, options: Required<Omit<ChatStreamOptions, "eventSourceFactory" | "onConnectionStateChange">>): number {
61
+ export function mergeChatMessages(base: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] {
62
+ const merged = new Map<string, ChatMessage>();
63
+
64
+ for (const message of base) {
65
+ merged.set(message.id, message);
66
+ }
67
+
68
+ for (const message of incoming) {
69
+ merged.set(message.id, message);
70
+ }
71
+
72
+ return [...merged.values()].sort((a, b) => {
73
+ const byTime = a.createdAt.localeCompare(b.createdAt);
74
+ if (byTime !== 0) return byTime;
75
+ return a.id.localeCompare(b.id);
76
+ });
77
+ }
78
+
79
+ function toReconnectDelayMs(attempt: number, options: ReconnectOptions): number {
59
80
  const exponentialDelay = Math.min(options.maxDelayMs, options.baseDelayMs * 2 ** attempt);
60
81
  const jitterRange = exponentialDelay * options.jitterRatio;
61
82
  const jitter = (options.random() * 2 - 1) * jitterRange;
62
- return Math.max(0, Math.round(exponentialDelay + jitter));
83
+ return Math.max(0, Math.min(options.maxDelayMs, Math.round(exponentialDelay + jitter)));
63
84
  }
64
85
 
65
86
  export function openChatStream(
66
87
  onEvent: (event: ChatStreamEvent) => void,
67
88
  streamOptions: ChatStreamOptions = {},
68
89
  ): () => void {
69
- const options: Required<Omit<ChatStreamOptions, "eventSourceFactory" | "onConnectionStateChange">> = {
90
+ const options: ReconnectOptions = {
70
91
  maxRetries: streamOptions.maxRetries ?? DEFAULT_STREAM_OPTIONS.maxRetries,
71
92
  baseDelayMs: streamOptions.baseDelayMs ?? DEFAULT_STREAM_OPTIONS.baseDelayMs,
72
93
  maxDelayMs: streamOptions.maxDelayMs ?? DEFAULT_STREAM_OPTIONS.maxDelayMs,
@@ -79,6 +100,7 @@ export function openChatStream(
79
100
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
80
101
  let reconnectAttempts = 0;
81
102
  let isDisposed = false;
103
+ let lastEventId: string | null = null;
82
104
 
83
105
  const setConnectionState = (state: ChatStreamConnectionState) => {
84
106
  streamOptions.onConnectionStateChange?.(state);
@@ -124,6 +146,11 @@ export function openChatStream(
124
146
  }, delayMs);
125
147
  };
126
148
 
149
+ const toStreamUrl = () => {
150
+ if (!lastEventId) return `${API_BASE}/stream`;
151
+ return `${API_BASE}/stream?lastEventId=${encodeURIComponent(lastEventId)}`;
152
+ };
153
+
127
154
  const connect = () => {
128
155
  if (isDisposed) {
129
156
  return;
@@ -131,7 +158,7 @@ export function openChatStream(
131
158
 
132
159
  setConnectionState("connecting");
133
160
  closeSource();
134
- const currentSource = createSource(`${API_BASE}/stream`);
161
+ const currentSource = createSource(toStreamUrl());
135
162
  source = currentSource;
136
163
 
137
164
  currentSource.onopen = () => {
@@ -148,6 +175,11 @@ export function openChatStream(
148
175
  return;
149
176
  }
150
177
 
178
+ const maybeLastEventId = (event as MessageEvent).lastEventId;
179
+ if (typeof maybeLastEventId === "string" && maybeLastEventId.trim().length > 0) {
180
+ lastEventId = maybeLastEventId.trim();
181
+ }
182
+
151
183
  try {
152
184
  const parsed = JSON.parse(event.data) as ChatStreamEvent;
153
185
  onEvent(parsed);
@@ -3,11 +3,11 @@
3
3
  import { useCallback, useEffect, useMemo, useState } from "react";
4
4
  import type { ChatMessage } from "@/shared/contracts/chat";
5
5
  import {
6
- fetchChatHistory,
6
+ mergeChatMessages,
7
7
  openChatStream,
8
8
  sendChatMessage,
9
9
  type ChatStreamConnectionState,
10
- } from "./chat-api";
10
+ } from "@/client/features/chat/chat-api";
11
11
 
12
12
  export function useRealtimeChat() {
13
13
  const [messages, setMessages] = useState<ChatMessage[]>([]);
@@ -17,14 +17,6 @@ export function useRealtimeChat() {
17
17
  useEffect(() => {
18
18
  let mounted = true;
19
19
 
20
- fetchChatHistory()
21
- .then((res) => {
22
- if (mounted) setMessages(res.messages);
23
- })
24
- .catch(() => {
25
- // starter template keeps errors simple
26
- });
27
-
28
20
  const close = openChatStream((event) => {
29
21
  if (!mounted) return;
30
22
 
@@ -33,7 +25,7 @@ export function useRealtimeChat() {
33
25
  }
34
26
 
35
27
  if (event.type === "message" && !Array.isArray(event.data)) {
36
- setMessages((prev) => [...prev, event.data]);
28
+ setMessages((prev) => mergeChatMessages(prev, [event.data]));
37
29
  }
38
30
  }, {
39
31
  onConnectionStateChange: (state) => {
@@ -1,16 +1,29 @@
1
1
  import type { ChatMessage } from "@/shared/contracts/chat";
2
2
 
3
- type ChatListener = (message: ChatMessage) => void;
3
+ type ChatListener = (event: ChatMessageEvent) => void;
4
+
5
+ export interface ChatMessageEvent {
6
+ eventId: string;
7
+ message: ChatMessage;
8
+ }
4
9
 
5
10
  type SubscriptionSnapshot = {
6
11
  snapshot: ChatMessage[];
7
12
  commit: () => () => void;
8
13
  };
9
14
 
15
+ export interface ResumePlan {
16
+ mode: "catch-up" | "snapshot";
17
+ events: ChatMessageEvent[];
18
+ }
19
+
10
20
  const listeners = new Set<ChatListener>();
11
21
  const messages: ChatMessage[] = [];
12
22
  const MAX_HISTORY_MESSAGES = 200;
23
+ const MAX_CATCH_UP_EVENTS = 500;
13
24
  let storeVersion = 0;
25
+ let streamEventSeq = 0;
26
+ const catchUpEvents: ChatMessageEvent[] = [];
14
27
  let testHookBeforeSubscribeCommit: (() => void) | undefined;
15
28
 
16
29
  function createMessage(role: ChatMessage["role"], text: string): ChatMessage {
@@ -22,6 +35,25 @@ function createMessage(role: ChatMessage["role"], text: string): ChatMessage {
22
35
  };
23
36
  }
24
37
 
38
+ function createEventId(nextSeq: number): string {
39
+ return `msg-${nextSeq}`;
40
+ }
41
+
42
+ function parseEventSeq(eventId: string | null | undefined): number | null {
43
+ if (!eventId) return null;
44
+ const match = /^msg-(\d+)$/.exec(eventId);
45
+ if (!match) return null;
46
+ const parsed = Number.parseInt(match[1]!, 10);
47
+ return Number.isFinite(parsed) ? parsed : null;
48
+ }
49
+
50
+ function pushCatchUpEvent(event: ChatMessageEvent): void {
51
+ catchUpEvents.push(event);
52
+ if (catchUpEvents.length > MAX_CATCH_UP_EVENTS) {
53
+ catchUpEvents.splice(0, catchUpEvents.length - MAX_CATCH_UP_EVENTS);
54
+ }
55
+ }
56
+
25
57
  export function getMessages(): ChatMessage[] {
26
58
  return [...messages];
27
59
  }
@@ -35,10 +67,18 @@ export function appendMessage(role: ChatMessage["role"], text: string): ChatMess
35
67
  }
36
68
 
37
69
  storeVersion += 1;
70
+ streamEventSeq += 1;
71
+
72
+ const event: ChatMessageEvent = {
73
+ eventId: createEventId(streamEventSeq),
74
+ message,
75
+ };
76
+
77
+ pushCatchUpEvent(event);
38
78
 
39
79
  for (const listener of listeners) {
40
80
  try {
41
- listener(message);
81
+ listener(event);
42
82
  } catch {
43
83
  // Ignore listener errors so one broken subscriber does not stop fan-out.
44
84
  }
@@ -74,10 +114,40 @@ export function subscribeWithSnapshot(listener: ChatListener): SubscriptionSnaps
74
114
  }
75
115
  }
76
116
 
117
+ export function planResumeFrom(lastEventId: string | null | undefined): ResumePlan {
118
+ const parsedSeq = parseEventSeq(lastEventId);
119
+ if (parsedSeq === null) {
120
+ return { mode: "snapshot", events: [] };
121
+ }
122
+
123
+ if (parsedSeq === streamEventSeq) {
124
+ return { mode: "catch-up", events: [] };
125
+ }
126
+
127
+ const firstAvailable = catchUpEvents[0];
128
+ if (!firstAvailable) {
129
+ return { mode: "snapshot", events: [] };
130
+ }
131
+
132
+ const firstSeq = parseEventSeq(firstAvailable.eventId);
133
+ if (firstSeq === null || parsedSeq < firstSeq - 1 || parsedSeq > streamEventSeq) {
134
+ return { mode: "snapshot", events: [] };
135
+ }
136
+
137
+ const events = catchUpEvents.filter((event) => {
138
+ const seq = parseEventSeq(event.eventId);
139
+ return seq !== null && seq > parsedSeq;
140
+ });
141
+
142
+ return { mode: "catch-up", events };
143
+ }
144
+
77
145
  export function __resetChatStoreForTests(): void {
78
146
  messages.length = 0;
79
147
  listeners.clear();
80
148
  storeVersion = 0;
149
+ streamEventSeq = 0;
150
+ catchUpEvents.length = 0;
81
151
  testHookBeforeSubscribeCommit = undefined;
82
152
  }
83
153
 
@@ -85,4 +155,4 @@ export function __setSubscribeCommitHookForTests(hook?: () => void): void {
85
155
  testHookBeforeSubscribeCommit = hook;
86
156
  }
87
157
 
88
- export { MAX_HISTORY_MESSAGES };
158
+ export { MAX_HISTORY_MESSAGES, MAX_CATCH_UP_EVENTS };
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { openChatStream } from "@/client/features/chat/chat-api";
2
+ import { mergeChatMessages, openChatStream } from "@/client/features/chat/chat-api";
3
3
  import type { ChatStreamEvent } from "@/shared/contracts/chat";
4
4
 
5
- type MessageEventLike = { data: string };
5
+ type MessageEventLike = { data: string; lastEventId?: string };
6
6
 
7
7
  class FakeEventSource {
8
8
  onopen: ((event: Event) => void) | null = null;
@@ -16,8 +16,8 @@ class FakeEventSource {
16
16
  this.onopen?.(new Event("open"));
17
17
  }
18
18
 
19
- emitMessage(data: string) {
20
- this.onmessage?.({ data });
19
+ emitMessage(data: string, lastEventId?: string) {
20
+ this.onmessage?.({ data, lastEventId });
21
21
  }
22
22
 
23
23
  emitError() {
@@ -66,6 +66,32 @@ describe("openChatStream", () => {
66
66
  stop();
67
67
  });
68
68
 
69
+ it("adds lastEventId cursor on reconnect for resumable SSE", async () => {
70
+ const sources: FakeEventSource[] = [];
71
+
72
+ const stop = openChatStream(() => {}, {
73
+ baseDelayMs: 5,
74
+ maxDelayMs: 5,
75
+ jitterRatio: 0,
76
+ maxRetries: 1,
77
+ eventSourceFactory: (url) => {
78
+ const source = new FakeEventSource(url);
79
+ sources.push(source);
80
+ return source as unknown as EventSource;
81
+ },
82
+ });
83
+
84
+ expect(sources[0]?.url).toBe("/api/chat/stream");
85
+
86
+ sources[0]?.emitMessage(JSON.stringify({ type: "message" }), "msg-42");
87
+ sources[0]?.emitError();
88
+
89
+ await sleep(8);
90
+ expect(sources[1]?.url).toBe("/api/chat/stream?lastEventId=msg-42");
91
+
92
+ stop();
93
+ });
94
+
69
95
  it("notifies connection state changes and terminal failure", async () => {
70
96
  const states: Array<"connecting" | "connected" | "reconnecting" | "failed" | "closed"> = [];
71
97
  const sources: FakeEventSource[] = [];
@@ -149,3 +175,14 @@ describe("openChatStream", () => {
149
175
  stop();
150
176
  });
151
177
  });
178
+
179
+ describe("mergeChatMessages", () => {
180
+ it("idempotently merges duplicate messages by id", () => {
181
+ const a = { id: "1", role: "user", text: "hello", createdAt: "2026-02-13T00:00:00.000Z" } as const;
182
+ const b = { id: "2", role: "assistant", text: "world", createdAt: "2026-02-13T00:00:01.000Z" } as const;
183
+
184
+ const merged = mergeChatMessages([a, b], [b]);
185
+ expect(merged).toHaveLength(2);
186
+ expect(merged.map((m) => m.id)).toEqual(["1", "2"]);
187
+ });
188
+ });
@@ -133,6 +133,57 @@ describe("realtime chat starter template", () => {
133
133
  }
134
134
  });
135
135
 
136
+ it("supports catch-up resume from lastEventId without snapshot", async () => {
137
+ appendMessage("user", "m1");
138
+ appendMessage("assistant", "m2");
139
+
140
+ const request = createTestRequest("http://localhost:3000/api/chat/stream?lastEventId=msg-1");
141
+ const response = getStream(request);
142
+
143
+ const reader = response.body?.getReader();
144
+ expect(reader).toBeDefined();
145
+
146
+ const firstChunk = await reader!.read();
147
+ expect(firstChunk.done).toBe(false);
148
+
149
+ const firstText = new TextDecoder().decode(firstChunk.value);
150
+ expect(firstText).toContain("id: msg-2");
151
+ expect(firstText).toContain('"type":"message"');
152
+ expect(firstText).toContain('"text":"m2"');
153
+ expect(firstText).not.toContain('"type":"snapshot"');
154
+
155
+ appendMessage("user", "m3-live");
156
+
157
+ const secondChunk = await reader!.read();
158
+ expect(secondChunk.done).toBe(false);
159
+
160
+ const secondText = new TextDecoder().decode(secondChunk.value);
161
+ expect(secondText).toContain("id: msg-3");
162
+ expect(secondText).toContain('"type":"message"');
163
+ expect(secondText).toContain('"text":"m3-live"');
164
+
165
+ await reader!.cancel();
166
+ });
167
+
168
+ it("falls back to snapshot when lastEventId is out of catch-up range", async () => {
169
+ appendMessage("user", "hello");
170
+
171
+ const request = createTestRequest("http://localhost:3000/api/chat/stream?lastEventId=msg-9999");
172
+ const response = getStream(request);
173
+
174
+ const reader = response.body?.getReader();
175
+ expect(reader).toBeDefined();
176
+
177
+ const chunk = await reader!.read();
178
+ expect(chunk.done).toBe(false);
179
+
180
+ const text = new TextDecoder().decode(chunk.value);
181
+ expect(text).toContain('"type":"snapshot"');
182
+ expect(text).toContain('"text":"hello"');
183
+
184
+ await reader!.cancel();
185
+ });
186
+
136
187
  it("includes essential ARIA attributes in chat UI", async () => {
137
188
  const source = await Bun.file(
138
189
  new URL("../src/client/features/chat/realtime-chat-starter.client.tsx", import.meta.url),
@@ -22,8 +22,8 @@ describe("chat-store concurrency", () => {
22
22
  });
23
23
 
24
24
  const seen: string[] = [];
25
- const subscription = subscribeWithSnapshot((message) => {
26
- seen.push(message.text);
25
+ const subscription = subscribeWithSnapshot((event) => {
26
+ seen.push(event.message.text);
27
27
  });
28
28
 
29
29
  expect(subscription.snapshot.some((message) => message.text === "racing-message")).toBe(true);