fix: stabilize ai design history switching
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# Task: Fix AI design history loading stall
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260819-history-load-stall-a92d
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260819-history-load-stall-a92d-history-load-stall
|
||||
- Worktree: D:\mk-history-load-stall-a92d
|
||||
- Base commit: abece81fdb178711d4dcbe1f87b987f54d71d6dd
|
||||
- Owner: codex-root
|
||||
- Status: Completed
|
||||
|
||||
## Scope
|
||||
|
||||
- Replace full-history AI Design conversation loading with a cursor-paginated latest-message flow.
|
||||
- Ensure workspace history becomes visible before generation-task reconciliation completes.
|
||||
- Make rapid conversation switching safe when an event-stream connection is still pending.
|
||||
- Update the local and Works Square adapters, shared boundary, main-process route, renderer store/canvas, and focused tests as needed.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Initial conversation reads return the newest 10 message records; scrolling upward requests older pages and preserves viewport position.
|
||||
- Do not retain or transmit an unbounded message history through the normal conversation API, mutation payloads, or snapshots.
|
||||
- Preserve the existing workspace/conversation identity checks and do not apply stale responses after a switch.
|
||||
- A pending connection attempt is treated as owned until it settles or is explicitly aborted; returning A -> B -> A must not create duplicate pending streams for A.
|
||||
- Server and desktop client contracts must remain explicit and bounded; page cursor contents are opaque to the renderer.
|
||||
|
||||
## Outcome
|
||||
|
||||
- The renderer shows a selected Conversation as soon as its Workspace response arrives; delayed or failed generation-task reconciliation can no longer keep the history view on its loading state.
|
||||
- Conversation history is cursor-paginated end to end: the initial page is the newest 10 messages, scrolling upward loads one older page at a time, de-duplicates stable message IDs, and retains viewport position.
|
||||
- Local and Works Square adapters expose the same page metadata. The Main route forwards the opaque cursor without interpreting it.
|
||||
- Rapid A → B → A switching reuses an in-flight A event-stream attempt instead of opening another non-cancellable request. Renderer disconnection while Main is still opening a relay now closes the eventual subscription, and an upstream WebSocket open is bounded by a 10-second timeout.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm test` — 183 files and 2167 tests passed.
|
||||
- `pnpm exec vitest run tests/unit/image-workspace-api.test.ts tests/unit/image-workspace-store.test.ts tests/unit/local-image-workspace.test.ts tests/unit/works-square-design-workspace.test.ts tests/unit/image-workspace-route.test.ts tests/unit/image-canvas-page.test.tsx --reporter=dot` — 154 passed. The Canvas suite still reports three pre-existing React `act(...)` warnings in its stale-auth test.
|
||||
- `pnpm run typecheck` — passed.
|
||||
- `pnpm exec eslint electron/api/routes/image-workspace.ts electron/image-workspace/local-workspace.ts electron/image-workspace/module.ts electron/image-workspace/works-square-workspace.ts shared/image-workspace.ts src/lib/image-workspace.ts src/pages/ImageCanvas/index.tsx src/stores/image-workspace.ts tests/unit/image-workspace-route.test.ts tests/unit/image-workspace-store.test.ts tests/unit/local-image-workspace.test.ts tests/unit/works-square-design-workspace.test.ts tests/unit/image-workspace-api.test.ts` — passed.
|
||||
- `git diff --check` — passed.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Main-branch promotion is pending release of the repository-wide integration lock held by task `20260819-merge-performance-package-6e5a`.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None recorded.
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
type DesignRenameWorkspaceInput,
|
||||
type DesignSubmitMessageInput,
|
||||
} from '../../../shared/image-workspace';
|
||||
import { DesignWorkspaceModuleError } from '../../image-workspace/module';
|
||||
import {
|
||||
DesignWorkspaceModuleError,
|
||||
type DesignWorkspaceEventSubscription,
|
||||
} from '../../image-workspace/module';
|
||||
import type { HostApiContext } from '../context';
|
||||
import {
|
||||
flushStreamingHeaders,
|
||||
@@ -328,19 +331,25 @@ async function relayWorkspaceEvents(
|
||||
}
|
||||
const header = req.headers['last-event-id'];
|
||||
const afterEventId = Array.isArray(header) ? header[0] : header;
|
||||
const subscription = await ctx.imageWorkspace.openWorkspaceEvents({
|
||||
workspaceId,
|
||||
conversationId,
|
||||
...(afterEventId ? { afterEventId } : {}),
|
||||
});
|
||||
let subscription: DesignWorkspaceEventSubscription | null = null;
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
subscription.close();
|
||||
subscription?.close();
|
||||
};
|
||||
res.once('close', close);
|
||||
try {
|
||||
const opened = await ctx.imageWorkspace.openWorkspaceEvents({
|
||||
workspaceId,
|
||||
conversationId,
|
||||
...(afterEventId ? { afterEventId } : {}),
|
||||
});
|
||||
if (closed) {
|
||||
opened.close();
|
||||
return;
|
||||
}
|
||||
subscription = opened;
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
@@ -456,7 +465,13 @@ export async function handleImageWorkspaceRoutes(
|
||||
&& segments[0] === 'workspaces'
|
||||
&& segments[2] === 'conversations'
|
||||
&& req.method === 'GET') {
|
||||
sendData(res, await ctx.imageWorkspace.getConversation(segments[1], segments[3]));
|
||||
const before = url.searchParams.get('before');
|
||||
sendData(
|
||||
res,
|
||||
before === null
|
||||
? await ctx.imageWorkspace.getConversation(segments[1], segments[3])
|
||||
: await ctx.imageWorkspace.getConversation(segments[1], segments[3], before),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -257,6 +257,7 @@ const LOCAL_CAPABILITIES: DesignCapabilities = {
|
||||
image: true,
|
||||
video: true,
|
||||
};
|
||||
const LOCAL_CONVERSATION_MESSAGE_PAGE_SIZE = 10;
|
||||
|
||||
type PersistedWorkspace = DesignWorkspaceSummary & {
|
||||
clientWorkspaceId: string;
|
||||
@@ -542,9 +543,13 @@ export class LocalImageWorkspace implements DesignWorkspaceModule {
|
||||
async getConversation(
|
||||
workspaceId: string,
|
||||
conversationId: string,
|
||||
before?: string,
|
||||
): Promise<DesignConversation> {
|
||||
const state = await this.load();
|
||||
return clone(this.requireConversation(state, workspaceId, conversationId));
|
||||
return this.conversationView(
|
||||
this.requireConversation(state, workspaceId, conversationId),
|
||||
before,
|
||||
);
|
||||
}
|
||||
|
||||
renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace> {
|
||||
@@ -1081,14 +1086,34 @@ export class LocalImageWorkspace implements DesignWorkspaceModule {
|
||||
});
|
||||
}
|
||||
|
||||
private conversationView(conversation: PersistedConversation): DesignConversation {
|
||||
private conversationView(
|
||||
conversation: PersistedConversation,
|
||||
before?: string,
|
||||
): DesignConversation {
|
||||
const {
|
||||
clientConversationId: _clientId,
|
||||
requestHashes: _requestHashes,
|
||||
...view
|
||||
} = conversation;
|
||||
const endIndex = before === undefined
|
||||
? conversation.messages.length
|
||||
: conversation.messages.findIndex((message) => message.id === before);
|
||||
if (endIndex < 0) {
|
||||
throw new LocalImageWorkspaceError(
|
||||
422,
|
||||
'conversation_history_cursor_invalid',
|
||||
'历史会话分页位置已失效,请重新打开会话',
|
||||
);
|
||||
}
|
||||
const startIndex = Math.max(0, endIndex - LOCAL_CONVERSATION_MESSAGE_PAGE_SIZE);
|
||||
const messages = conversation.messages.slice(startIndex, endIndex);
|
||||
return clone({
|
||||
...view,
|
||||
messages,
|
||||
messagePage: {
|
||||
hasOlder: startIndex > 0,
|
||||
nextBefore: startIndex > 0 ? messages[0]?.id ?? null : null,
|
||||
},
|
||||
latestMessagePreview: latestMessagePreview(conversation.messages),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +53,11 @@ export interface DesignWorkspaceModule {
|
||||
renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace>;
|
||||
getWorkspace(workspaceId: string): Promise<DesignWorkspace>;
|
||||
createConversation(input: DesignCreateConversationInput): Promise<DesignConversation>;
|
||||
getConversation(workspaceId: string, conversationId: string): Promise<DesignConversation>;
|
||||
getConversation(
|
||||
workspaceId: string,
|
||||
conversationId: string,
|
||||
before?: string,
|
||||
): Promise<DesignConversation>;
|
||||
submitMessage(input: DesignSubmitMessageInput): Promise<DesignConversation>;
|
||||
updateGenerationQuote(input: DesignGenerationQuoteUpdateInput): Promise<DesignGenerationQuote>;
|
||||
confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignConversation>;
|
||||
|
||||
@@ -86,6 +86,7 @@ type ServerQuote = {
|
||||
};
|
||||
|
||||
type ServerMessage = {
|
||||
message_id?: string;
|
||||
role: DesignMessage['role'];
|
||||
kind: DesignMessage['kind'];
|
||||
text: string;
|
||||
@@ -119,6 +120,10 @@ type ServerConversationSummary = {
|
||||
|
||||
type ServerConversation = ServerConversationSummary & {
|
||||
messages: ServerMessage[];
|
||||
message_page?: {
|
||||
has_older: boolean;
|
||||
next_before: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type ServerWorkspace = ServerWorkspaceSummary & {
|
||||
@@ -264,6 +269,7 @@ type AgentCommandChannel = {
|
||||
};
|
||||
|
||||
const AGENT_WEBSOCKET_OPEN = 1;
|
||||
const AGENT_WEBSOCKET_OPEN_TIMEOUT_MS = 10_000;
|
||||
const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000;
|
||||
const AGENT_COMMAND_ACK_TIMEOUT_MS = 5_000;
|
||||
const AGENT_RUN_INITIAL_POLL_INTERVAL_MS = 1_000;
|
||||
@@ -393,7 +399,8 @@ function mapConversationSummary(
|
||||
|
||||
function mapConversation(conversation: ServerConversation): DesignConversation {
|
||||
const messages = conversation.messages.map((message, index) => ({
|
||||
id: `${conversation.conversation_id}:${message.turn_revision}:${message.role}:${index}`,
|
||||
id: message.message_id
|
||||
?? `${conversation.conversation_id}:${message.turn_revision}:${message.role}:${index}`,
|
||||
role: message.role,
|
||||
kind: message.kind,
|
||||
text: message.text,
|
||||
@@ -408,6 +415,12 @@ function mapConversation(conversation: ServerConversation): DesignConversation {
|
||||
?? normalizeMessagePreview(conversation.latest_message_preview)
|
||||
?? normalizeMessagePreview(conversation.brief.summary),
|
||||
messages,
|
||||
messagePage: conversation.message_page
|
||||
? {
|
||||
hasOlder: conversation.message_page.has_older,
|
||||
nextBefore: conversation.message_page.next_before,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -935,9 +948,11 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
async getConversation(
|
||||
workspaceId: string,
|
||||
conversationId: string,
|
||||
before?: string,
|
||||
): Promise<DesignConversation> {
|
||||
const query = before ? `?before=${encodeURIComponent(before)}` : '';
|
||||
const conversation = await this.requestJson<ServerConversation>(
|
||||
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}`,
|
||||
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}${query}`,
|
||||
);
|
||||
this.rememberConversation(conversation);
|
||||
return mapConversation(conversation);
|
||||
@@ -1328,6 +1343,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
let ending = false;
|
||||
let settled = false;
|
||||
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||
let openedTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let unregister = () => undefined;
|
||||
let resolveOpened: () => void = () => undefined;
|
||||
let rejectOpened: (error: unknown) => void = () => undefined;
|
||||
@@ -1349,6 +1365,8 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
socket.onclose = null;
|
||||
if (heartbeat !== null) clearInterval(heartbeat);
|
||||
heartbeat = null;
|
||||
if (openedTimeout !== null) clearTimeout(openedTimeout);
|
||||
openedTimeout = null;
|
||||
unregister();
|
||||
if (didOpen) {
|
||||
this.unregisterAgentCommandChannel(sessionId, commandChannel);
|
||||
@@ -1450,6 +1468,19 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
input.conversationId,
|
||||
close,
|
||||
);
|
||||
openedTimeout = setTimeout(() => {
|
||||
if (ending || didOpen) return;
|
||||
beginEnd(new DesignWorkspaceModuleError(
|
||||
504,
|
||||
'DESIGN_EVENT_STREAM_TIMEOUT',
|
||||
'AI 设计任务状态连接超时,请稍后重试',
|
||||
));
|
||||
try {
|
||||
socket.close(1000, 'Timed out opening design event stream');
|
||||
} catch {
|
||||
// settle below remains authoritative when the transport cannot close.
|
||||
}
|
||||
}, AGENT_WEBSOCKET_OPEN_TIMEOUT_MS);
|
||||
await opened;
|
||||
return {
|
||||
events: queue.events,
|
||||
|
||||
@@ -105,6 +105,13 @@ export type DesignConversationSummary = {
|
||||
|
||||
export type DesignConversation = DesignConversationSummary & {
|
||||
messages: DesignMessage[];
|
||||
/** Cursor for the next older message page; absent only for legacy adapters. */
|
||||
messagePage?: DesignConversationMessagePage;
|
||||
};
|
||||
|
||||
export type DesignConversationMessagePage = {
|
||||
hasOlder: boolean;
|
||||
nextBefore: string | null;
|
||||
};
|
||||
|
||||
export type DesignAsset = {
|
||||
|
||||
@@ -160,9 +160,11 @@ export function createImageWorkspaceConversation(
|
||||
export function fetchImageWorkspaceConversation(
|
||||
workspaceId: string,
|
||||
conversationId: string,
|
||||
before?: string,
|
||||
): Promise<DesignConversation> {
|
||||
const query = before ? `?before=${encodeURIComponent(before)}` : '';
|
||||
return requestData(
|
||||
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}`,
|
||||
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/conversations/${encodeURIComponent(conversationId)}${query}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -930,6 +930,8 @@ export function ImageCanvas() {
|
||||
const conversation = useImageWorkspaceStore((state) => state.conversation);
|
||||
const tasks = useImageWorkspaceStore((state) => state.tasks);
|
||||
const pendingTurn = useImageWorkspaceStore((state) => state.pendingTurn);
|
||||
const loadingOlderMessages = useImageWorkspaceStore((state) => state.loadingOlderMessages);
|
||||
const olderMessagesError = useImageWorkspaceStore((state) => state.olderMessagesError);
|
||||
const workspaceError = useImageWorkspaceStore((state) => state.error);
|
||||
const load = useImageWorkspaceStore((state) => state.load);
|
||||
const connectTaskStream = useImageWorkspaceStore((state) => state.connectTaskStream);
|
||||
@@ -937,6 +939,7 @@ export function ImageCanvas() {
|
||||
const markTaskStreamNeedsReconciliation = useImageWorkspaceStore(
|
||||
(state) => state.markTaskStreamNeedsReconciliation,
|
||||
);
|
||||
const loadOlderMessages = useImageWorkspaceStore((state) => state.loadOlderMessages);
|
||||
const sendMessage = useImageWorkspaceStore((state) => state.sendMessage);
|
||||
const updateGenerationQuote = useImageWorkspaceStore(
|
||||
(state) => state.updateGenerationQuote,
|
||||
@@ -966,6 +969,7 @@ export function ImageCanvas() {
|
||||
const conversationEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const conversationWindowStartRef = useRef<number | null>(null);
|
||||
const conversationShouldStickRef = useRef(true);
|
||||
const olderMessagesRequestRef = useRef(false);
|
||||
const promptRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const referenceUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const promptSelectionRef = useRef({ start: 0, end: 0 });
|
||||
@@ -1111,6 +1115,7 @@ export function ImageCanvas() {
|
||||
useEffect(() => {
|
||||
conversationWindowStartRef.current = null;
|
||||
conversationShouldStickRef.current = true;
|
||||
olderMessagesRequestRef.current = false;
|
||||
setConversationWindowStart(null);
|
||||
setPrompt('');
|
||||
setReferenceAssets([]);
|
||||
@@ -1162,6 +1167,22 @@ export function ImageCanvas() {
|
||||
setConversationWindowStart(null);
|
||||
}, [conversationMessages.length]);
|
||||
|
||||
const requestOlderMessages = useCallback(() => {
|
||||
const element = conversationScrollRef.current;
|
||||
if (!element || olderMessagesRequestRef.current || !conversation?.messagePage?.hasOlder) return;
|
||||
const previousHeight = element.scrollHeight;
|
||||
olderMessagesRequestRef.current = true;
|
||||
conversationShouldStickRef.current = false;
|
||||
void loadOlderMessages().finally(() => {
|
||||
olderMessagesRequestRef.current = false;
|
||||
window.requestAnimationFrame(() => {
|
||||
const nextElement = conversationScrollRef.current;
|
||||
if (!nextElement) return;
|
||||
nextElement.scrollTop += nextElement.scrollHeight - previousHeight;
|
||||
});
|
||||
});
|
||||
}, [conversation?.messagePage?.hasOlder, loadOlderMessages]);
|
||||
|
||||
const handleConversationScroll = useCallback(() => {
|
||||
const element = conversationScrollRef.current;
|
||||
if (!element) return;
|
||||
@@ -1177,11 +1198,13 @@ export function ImageCanvas() {
|
||||
if (!nextElement) return;
|
||||
nextElement.scrollTop += nextElement.scrollHeight - previousHeight;
|
||||
});
|
||||
} else if (element.scrollTop < 96 && currentStart === 0) {
|
||||
requestOlderMessages();
|
||||
}
|
||||
conversationShouldStickRef.current = element.scrollHeight
|
||||
- element.scrollTop
|
||||
- element.clientHeight <= CANVAS_BOTTOM_THRESHOLD_PX;
|
||||
}, [conversationMessages.length]);
|
||||
}, [conversationMessages.length, requestOlderMessages]);
|
||||
|
||||
const repriceQuote = async (
|
||||
requestedQuoteId: string,
|
||||
@@ -1787,9 +1810,30 @@ export function ImageCanvas() {
|
||||
setConversationWindowStart(0);
|
||||
}}
|
||||
>
|
||||
加载更早消息({effectiveConversationWindowStart})
|
||||
显示已加载的更早消息({effectiveConversationWindowStart})
|
||||
</button>
|
||||
) : null}
|
||||
{effectiveConversationWindowStart === 0 && loadingOlderMessages ? (
|
||||
<div
|
||||
data-testid="image-workspace-loading-older-messages"
|
||||
className="mx-auto flex items-center gap-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
正在读取更早消息…
|
||||
</div>
|
||||
) : null}
|
||||
{effectiveConversationWindowStart === 0
|
||||
&& !loadingOlderMessages
|
||||
&& olderMessagesError
|
||||
&& conversation?.messagePage?.hasOlder ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mx-auto block rounded-md border border-border/70 px-3 py-1.5 text-xs text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
|
||||
onClick={requestOlderMessages}
|
||||
>
|
||||
重新加载更早消息
|
||||
</button>
|
||||
) : null}
|
||||
{windowedConversationMessages.map((message) => {
|
||||
const assistant = message.role === 'assistant';
|
||||
return (
|
||||
|
||||
@@ -65,6 +65,8 @@ type ImageWorkspaceState = {
|
||||
deletingWorkspaceId: string | null;
|
||||
tasks: DesignGenerationTask[];
|
||||
pendingTurn: PendingDesignTurn | null;
|
||||
loadingOlderMessages: boolean;
|
||||
olderMessagesError: string | null;
|
||||
taskStreamState: ImageWorkspaceTaskStreamState;
|
||||
error: string | null;
|
||||
load: () => Promise<DesignWorkspaceBootstrap | null>;
|
||||
@@ -76,6 +78,7 @@ type ImageWorkspaceState = {
|
||||
selectConversation: (conversationId: string) => Promise<void>;
|
||||
refreshWorkspace: () => Promise<DesignWorkspace | null>;
|
||||
refreshConversation: () => Promise<DesignConversation | null>;
|
||||
loadOlderMessages: () => Promise<DesignConversation | null>;
|
||||
refreshTasks: () => Promise<DesignGenerationTask[]>;
|
||||
connectTaskStream: () => void;
|
||||
markTaskStreamNeedsReconciliation: () => void;
|
||||
@@ -101,7 +104,7 @@ const CONFIRMED_TASK_RECONCILE_INTERVAL_MS = 250;
|
||||
let activeTaskEventSource: EventSource | null = null;
|
||||
let activeTaskEventWorkspaceId: string | null = null;
|
||||
let activeTaskEventConversationId: string | null = null;
|
||||
let taskStreamGeneration = 0;
|
||||
const pendingTaskEventConnections = new Map<string, symbol>();
|
||||
let taskFallbackTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const taskEventRevisions = new Map<string, number>();
|
||||
let workspaceLoadGeneration = 0;
|
||||
@@ -126,13 +129,16 @@ function taskRevisionKey(workspaceId: string, taskId: string): string {
|
||||
return `${workspaceId}:${taskId}`;
|
||||
}
|
||||
|
||||
function taskStreamKey(workspaceId: string, conversationId: string): string {
|
||||
return `${workspaceId}:${conversationId}`;
|
||||
}
|
||||
|
||||
function stopFallbackPolling(): void {
|
||||
if (taskFallbackTimer !== null) clearInterval(taskFallbackTimer);
|
||||
taskFallbackTimer = null;
|
||||
}
|
||||
|
||||
function closeTaskEventSource(): void {
|
||||
taskStreamGeneration += 1;
|
||||
stopFallbackPolling();
|
||||
if (activeTaskEventSource) {
|
||||
activeTaskEventSource.onopen = null;
|
||||
@@ -345,33 +351,35 @@ function mergePendingUserMessage(
|
||||
return { ...conversation, messages };
|
||||
}
|
||||
|
||||
function mergeUserMessagesFromCurrentConversation(
|
||||
function mergeConversationMessagePages(
|
||||
current: DesignConversation | null,
|
||||
incoming: DesignConversation,
|
||||
source: 'latest' | 'older',
|
||||
): DesignConversation {
|
||||
if (!current
|
||||
|| current.conversationId !== incoming.conversationId
|
||||
|| current.workspaceId !== incoming.workspaceId) {
|
||||
return incoming;
|
||||
}
|
||||
const missingUserMessages = current.messages.filter((message) => (
|
||||
message.role === 'user'
|
||||
&& !incoming.messages.some((candidate) => (
|
||||
candidate.role === 'user'
|
||||
&& candidate.turnRevision === message.turnRevision
|
||||
&& candidate.text === message.text
|
||||
))
|
||||
));
|
||||
if (missingUserMessages.length === 0) return incoming;
|
||||
|
||||
const messages = [...incoming.messages];
|
||||
for (const userMessage of missingUserMessages) {
|
||||
const insertionIndex = messages.findIndex(
|
||||
(message) => message.turnRevision >= userMessage.turnRevision,
|
||||
);
|
||||
messages.splice(insertionIndex < 0 ? messages.length : insertionIndex, 0, userMessage);
|
||||
const messagesById = new Map<string, DesignMessage>();
|
||||
const messageSources = source === 'older'
|
||||
? [incoming.messages, current.messages]
|
||||
: [current.messages, incoming.messages];
|
||||
for (const messages of messageSources) {
|
||||
for (const message of messages) messagesById.set(message.id, message);
|
||||
}
|
||||
return { ...incoming, messages };
|
||||
const messages = [...messagesById.values()].sort((left, right) => (
|
||||
left.turnRevision - right.turnRevision
|
||||
|| (left.role === right.role ? left.id.localeCompare(right.id) : left.role === 'user' ? -1 : 1)
|
||||
|| left.createdAt.localeCompare(right.createdAt)
|
||||
));
|
||||
return {
|
||||
...incoming,
|
||||
messages,
|
||||
messagePage: source === 'older'
|
||||
? incoming.messagePage
|
||||
: current.messagePage ?? incoming.messagePage,
|
||||
};
|
||||
}
|
||||
|
||||
function replaceGenerationQuote(
|
||||
@@ -473,13 +481,20 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
&& activeTaskEventConversationId === conversationId
|
||||
&& get().taskStreamState !== 'idle') return;
|
||||
closeTaskEventSource();
|
||||
const generation = taskStreamGeneration;
|
||||
activeTaskEventWorkspaceId = workspaceId;
|
||||
activeTaskEventConversationId = conversationId;
|
||||
set({ taskStreamState: 'connecting' });
|
||||
const connectionKey = taskStreamKey(workspaceId, conversationId);
|
||||
if (pendingTaskEventConnections.has(connectionKey)) return;
|
||||
const connectionAttempt = Symbol(connectionKey);
|
||||
pendingTaskEventConnections.set(connectionKey, connectionAttempt);
|
||||
void openImageWorkspaceTaskEvents(workspaceId, conversationId).then((source) => {
|
||||
if (generation !== taskStreamGeneration
|
||||
|| get().activeWorkspaceId !== workspaceId
|
||||
if (pendingTaskEventConnections.get(connectionKey) !== connectionAttempt) {
|
||||
source.close();
|
||||
return;
|
||||
}
|
||||
pendingTaskEventConnections.delete(connectionKey);
|
||||
if (get().activeWorkspaceId !== workspaceId
|
||||
|| get().activeConversationId !== conversationId) {
|
||||
source.close();
|
||||
return;
|
||||
@@ -518,9 +533,10 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
|| snapshot.conversation.turnRevision >= state.conversation.turnRevision
|
||||
? snapshot.conversation
|
||||
: state.conversation;
|
||||
const resolvedConversation = mergeUserMessagesFromCurrentConversation(
|
||||
const resolvedConversation = mergeConversationMessagePages(
|
||||
state.conversation,
|
||||
mergePendingUserMessage(conversation, state.pendingTurn),
|
||||
'latest',
|
||||
);
|
||||
const workspace = upsertConversation(
|
||||
state.workspace
|
||||
@@ -607,8 +623,9 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
startFallbackPolling();
|
||||
};
|
||||
}).catch(() => {
|
||||
if (generation !== taskStreamGeneration
|
||||
|| get().activeWorkspaceId !== workspaceId
|
||||
if (pendingTaskEventConnections.get(connectionKey) !== connectionAttempt) return;
|
||||
pendingTaskEventConnections.delete(connectionKey);
|
||||
if (get().activeWorkspaceId !== workspaceId
|
||||
|| get().activeConversationId !== conversationId) return;
|
||||
set({ taskStreamState: 'degraded' });
|
||||
startFallbackPolling();
|
||||
@@ -668,7 +685,10 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
return workspace;
|
||||
};
|
||||
|
||||
const applyConversation = (conversation: DesignConversation): DesignConversation => {
|
||||
const applyConversation = (
|
||||
conversation: DesignConversation,
|
||||
source: 'latest' | 'older' = 'latest',
|
||||
): DesignConversation => {
|
||||
let applied = conversation;
|
||||
set((state) => {
|
||||
if (state.conversation?.conversationId === conversation.conversationId
|
||||
@@ -678,9 +698,10 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
applied = state.conversation;
|
||||
return state;
|
||||
}
|
||||
const resolvedConversation = mergeUserMessagesFromCurrentConversation(
|
||||
const resolvedConversation = mergeConversationMessagePages(
|
||||
state.conversation,
|
||||
mergePendingUserMessage(conversation, state.pendingTurn),
|
||||
source,
|
||||
);
|
||||
applied = resolvedConversation;
|
||||
const pendingTurn = state.pendingTurn?.workspaceId === resolvedConversation.workspaceId
|
||||
@@ -693,6 +714,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
activeConversationId: resolvedConversation.conversationId,
|
||||
conversation: resolvedConversation,
|
||||
pendingTurn,
|
||||
loadingOlderMessages: false,
|
||||
olderMessagesError: null,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
@@ -711,9 +734,6 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
)
|
||||
? currentConversationId
|
||||
: workspace.conversations[0]?.conversationId ?? null;
|
||||
const tasks = await fetchImageWorkspaceTasks(workspaceId);
|
||||
if (loadGeneration !== workspaceLoadGeneration
|
||||
|| get().activeWorkspaceId !== workspaceId) return;
|
||||
set((state) => ({
|
||||
status: 'ready',
|
||||
bootstrap: upsertSummary(state.bootstrap, workspace),
|
||||
@@ -721,9 +741,17 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
activeConversationId: conversationId,
|
||||
workspace,
|
||||
conversation: null,
|
||||
tasks: sortTasks(tasks),
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
loadingOlderMessages: false,
|
||||
olderMessagesError: null,
|
||||
error: null,
|
||||
}));
|
||||
void fetchImageWorkspaceTasks(workspaceId).then((tasks) => {
|
||||
if (loadGeneration !== workspaceLoadGeneration
|
||||
|| get().activeWorkspaceId !== workspaceId) return;
|
||||
set({ tasks: sortTasks(tasks) });
|
||||
}).catch(() => undefined);
|
||||
if (!conversationId) return;
|
||||
const selectionGeneration = ++conversationSelectionGeneration;
|
||||
const conversation = await fetchImageWorkspaceConversation(workspaceId, conversationId);
|
||||
@@ -760,6 +788,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
deletingWorkspaceId: null,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
loadingOlderMessages: false,
|
||||
olderMessagesError: null,
|
||||
taskStreamState: 'idle',
|
||||
error: null,
|
||||
|
||||
@@ -861,6 +891,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
activeConversationId: conversation.conversationId,
|
||||
conversation: null,
|
||||
pendingTurn: null,
|
||||
loadingOlderMessages: false,
|
||||
olderMessagesError: null,
|
||||
taskStreamState: 'idle',
|
||||
error: null,
|
||||
});
|
||||
@@ -962,6 +994,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
creatingConversation: false,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
loadingOlderMessages: false,
|
||||
olderMessagesError: null,
|
||||
taskStreamState: 'idle',
|
||||
error: null,
|
||||
});
|
||||
@@ -990,6 +1024,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
activeConversationId: conversationId,
|
||||
conversation: null,
|
||||
pendingTurn: null,
|
||||
loadingOlderMessages: false,
|
||||
olderMessagesError: null,
|
||||
taskStreamState: 'idle',
|
||||
error: null,
|
||||
});
|
||||
@@ -1047,6 +1083,35 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
}
|
||||
},
|
||||
|
||||
loadOlderMessages: async () => {
|
||||
const workspaceId = get().activeWorkspaceId;
|
||||
const conversation = get().conversation;
|
||||
const before = conversation?.messagePage?.nextBefore;
|
||||
if (!workspaceId || !conversation || !conversation.messagePage?.hasOlder || !before) {
|
||||
return conversation ?? null;
|
||||
}
|
||||
const conversationId = conversation.conversationId;
|
||||
const selectionGeneration = conversationSelectionGeneration;
|
||||
set({ loadingOlderMessages: true, olderMessagesError: null });
|
||||
try {
|
||||
const older = await fetchImageWorkspaceConversation(workspaceId, conversationId, before);
|
||||
if (selectionGeneration !== conversationSelectionGeneration
|
||||
|| get().activeWorkspaceId !== workspaceId
|
||||
|| get().activeConversationId !== conversationId) return null;
|
||||
return applyConversation(older, 'older');
|
||||
} catch (error) {
|
||||
if (selectionGeneration === conversationSelectionGeneration
|
||||
&& get().activeWorkspaceId === workspaceId
|
||||
&& get().activeConversationId === conversationId) {
|
||||
set({
|
||||
loadingOlderMessages: false,
|
||||
olderMessagesError: handleRequestError(error),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
refreshTasks: async () => {
|
||||
const workspaceId = get().activeWorkspaceId;
|
||||
if (!workspaceId) {
|
||||
@@ -1306,6 +1371,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
generationQuoteUpdateSequence += 1;
|
||||
reconcileTaskStateOnNextConnect = false;
|
||||
stopTaskStream();
|
||||
pendingTaskEventConnections.clear();
|
||||
set({
|
||||
status: 'idle',
|
||||
bootstrap: null,
|
||||
@@ -1317,6 +1383,8 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
deletingWorkspaceId: null,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
loadingOlderMessages: false,
|
||||
olderMessagesError: null,
|
||||
taskStreamState: 'idle',
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -104,6 +104,11 @@ describe('AI design renderer API boundary', () => {
|
||||
'client-conversation-one',
|
||||
);
|
||||
await fetchImageWorkspaceConversation('workspace/one', 'conversation/one');
|
||||
await fetchImageWorkspaceConversation(
|
||||
'workspace/one',
|
||||
'conversation/one',
|
||||
'older page/+',
|
||||
);
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -121,6 +126,11 @@ describe('AI design renderer API boundary', () => {
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone',
|
||||
{},
|
||||
);
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone?before=older%20page%2F%2B',
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('sends conversation turns with the current turn revision and no provider settings', async () => {
|
||||
|
||||
@@ -51,6 +51,14 @@ function createRequest(method: string, body?: unknown): IncomingMessage {
|
||||
return request;
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((accept) => {
|
||||
resolve = accept;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
class MediaResponse extends Writable {
|
||||
statusCode = 0;
|
||||
readonly chunks: Buffer[] = [];
|
||||
@@ -224,6 +232,21 @@ describe('AI design Main route boundary', () => {
|
||||
data: { conversationId: 'conversation/one' },
|
||||
});
|
||||
|
||||
const olderConversationResponse = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('GET'),
|
||||
olderConversationResponse.res,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone?before=opaque-cursor',
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
expect(workspace.getConversation).toHaveBeenLastCalledWith(
|
||||
'workspace/one',
|
||||
'conversation/one',
|
||||
'opaque-cursor',
|
||||
);
|
||||
|
||||
const updateQuoteResponse = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('PATCH', {
|
||||
@@ -637,4 +660,33 @@ describe('AI design Main route boundary', () => {
|
||||
].join('\n'));
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('closes an event subscription that finishes opening after the renderer disconnects', async () => {
|
||||
const opening = deferred<{
|
||||
events: AsyncIterable<never>;
|
||||
close(): void;
|
||||
}>();
|
||||
const close = vi.fn();
|
||||
const openWorkspaceEvents = vi.fn().mockReturnValue(opening.promise);
|
||||
const response = new MediaResponse();
|
||||
const handled = handleImageWorkspaceRoutes(
|
||||
createRequest('GET'),
|
||||
response as unknown as ServerResponse,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/events',
|
||||
),
|
||||
{ imageWorkspace: { openWorkspaceEvents } } as unknown as HostApiContext,
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(openWorkspaceEvents).toHaveBeenCalledOnce());
|
||||
response.emit('close');
|
||||
opening.resolve({
|
||||
events: (async function* () {})(),
|
||||
close,
|
||||
});
|
||||
|
||||
await expect(handled).resolves.toBe(true);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(response.statusCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,6 +114,31 @@ function conversation(
|
||||
};
|
||||
}
|
||||
|
||||
function conversationMessages(revisions: number[]): DesignConversation['messages'] {
|
||||
return revisions.flatMap((turnRevision) => ([
|
||||
{
|
||||
id: `turn-${turnRevision}:user`,
|
||||
role: 'user' as const,
|
||||
kind: 'user' as const,
|
||||
text: `用户消息 ${turnRevision}`,
|
||||
quickReplies: [],
|
||||
generationQuote: null,
|
||||
turnRevision,
|
||||
createdAt: `2026-08-02T10:${String(turnRevision).padStart(2, '0')}:00Z`,
|
||||
},
|
||||
{
|
||||
id: `turn-${turnRevision}:assistant`,
|
||||
role: 'assistant' as const,
|
||||
kind: 'reply' as const,
|
||||
text: `助手消息 ${turnRevision}`,
|
||||
quickReplies: [],
|
||||
generationQuote: null,
|
||||
turnRevision,
|
||||
createdAt: `2026-08-02T10:${String(turnRevision).padStart(2, '0')}:00Z`,
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
function workspace(workspaceId = 'workspace-one', viewRevision = 1): DesignWorkspace {
|
||||
const { messages: _messages, ...conversationSummary } = conversation(workspaceId);
|
||||
return {
|
||||
@@ -400,6 +425,91 @@ describe('AI design task event store', () => {
|
||||
expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reuses an unresolved Conversation stream when switching A to B to A', async () => {
|
||||
const pendingA = deferred<EventSource>();
|
||||
const sourceB = new MockEventSource();
|
||||
const project = workspace();
|
||||
const { messages: _messages, ...secondSummary } = conversation(
|
||||
'workspace-one',
|
||||
0,
|
||||
'conversation-two',
|
||||
);
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue({
|
||||
...project,
|
||||
conversationCount: 2,
|
||||
conversations: [...project.conversations, secondSummary],
|
||||
});
|
||||
openImageWorkspaceTaskEventsMock
|
||||
.mockReturnValueOnce(pendingA.promise)
|
||||
.mockResolvedValueOnce(sourceB as unknown as EventSource);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock)
|
||||
.toHaveBeenCalledWith('workspace-one', 'conversation-one'));
|
||||
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
|
||||
await useImageWorkspaceStore.getState().selectConversation('conversation-one');
|
||||
|
||||
expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledTimes(2);
|
||||
expect(openImageWorkspaceTaskEventsMock).toHaveBeenLastCalledWith(
|
||||
'workspace-one',
|
||||
'conversation-two',
|
||||
);
|
||||
|
||||
const sourceA = new MockEventSource();
|
||||
pendingA.resolve(sourceA as unknown as EventSource);
|
||||
await vi.waitFor(() => expect(sourceA.onopen).not.toBeNull());
|
||||
});
|
||||
|
||||
it('shows the Conversation before a delayed task refresh settles', async () => {
|
||||
const source = new MockEventSource();
|
||||
const delayedTasks = deferred<DesignGenerationTask[]>();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
fetchImageWorkspaceTasksMock.mockReturnValueOnce(delayedTasks.promise);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
status: 'ready',
|
||||
conversation: { conversationId: 'conversation-one' },
|
||||
tasks: [],
|
||||
});
|
||||
delayedTasks.resolve([task]);
|
||||
await vi.waitFor(() => expect(useImageWorkspaceStore.getState().tasks).toEqual([task]));
|
||||
});
|
||||
|
||||
it('prepends the next older page without losing the latest messages', async () => {
|
||||
const source = new MockEventSource();
|
||||
const latest = {
|
||||
...conversation('workspace-one', 6),
|
||||
messages: conversationMessages([2, 3, 4, 5, 6]),
|
||||
messagePage: { hasOlder: true, nextBefore: 'cursor-before-turn-2' },
|
||||
};
|
||||
const older = {
|
||||
...conversation('workspace-one', 6),
|
||||
messages: conversationMessages([1]),
|
||||
messagePage: { hasOlder: false, nextBefore: null },
|
||||
};
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
fetchImageWorkspaceConversationMock
|
||||
.mockResolvedValueOnce(latest)
|
||||
.mockResolvedValueOnce(older);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await useImageWorkspaceStore.getState().loadOlderMessages();
|
||||
|
||||
expect(fetchImageWorkspaceConversationMock).toHaveBeenLastCalledWith(
|
||||
'workspace-one',
|
||||
'conversation-one',
|
||||
'cursor-before-turn-2',
|
||||
);
|
||||
expect(useImageWorkspaceStore.getState().conversation).toMatchObject({
|
||||
messagePage: { hasOlder: false, nextBefore: null },
|
||||
});
|
||||
expect(useImageWorkspaceStore.getState().conversation?.messages.map((message) => (
|
||||
message.turnRevision
|
||||
))).toEqual([1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6]);
|
||||
});
|
||||
|
||||
it('recovers a committed generation task when the Agent Run fails afterward', async () => {
|
||||
const source = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
|
||||
@@ -286,6 +286,39 @@ describe('local AI design workspace', () => {
|
||||
})).resolves.toMatchObject({ turnRevision: 2 });
|
||||
});
|
||||
|
||||
it('pages local Conversation history in ten-message windows', async () => {
|
||||
const service = createService(createTemporaryDirectory());
|
||||
const created = await service.createWorkspace({
|
||||
clientWorkspaceId: 'client-history-page',
|
||||
title: '分页历史',
|
||||
});
|
||||
const conversationId = created.conversations[0].conversationId;
|
||||
|
||||
for (let revision = 0; revision < 6; revision += 1) {
|
||||
await service.submitMessage({
|
||||
workspaceId: created.workspaceId,
|
||||
conversationId,
|
||||
clientTurnId: `history-page-turn-${revision + 1}`,
|
||||
expectedTurnRevision: revision,
|
||||
message: `第 ${revision + 1} 条设计需求`,
|
||||
});
|
||||
}
|
||||
|
||||
const latest = await service.getConversation(created.workspaceId, conversationId);
|
||||
expect(latest.messages).toHaveLength(10);
|
||||
expect(latest.messages.map((message) => message.turnRevision))
|
||||
.toEqual([2, 2, 3, 3, 4, 4, 5, 5, 6, 6]);
|
||||
expect(latest.messagePage).toMatchObject({ hasOlder: true });
|
||||
|
||||
const older = await service.getConversation(
|
||||
created.workspaceId,
|
||||
conversationId,
|
||||
latest.messagePage!.nextBefore!,
|
||||
);
|
||||
expect(older.messages.map((message) => message.turnRevision)).toEqual([1, 1]);
|
||||
expect(older.messagePage).toEqual({ hasOlder: false, nextBefore: null });
|
||||
});
|
||||
|
||||
it('deletes a project while a task is queued and makes its task state inaccessible', async () => {
|
||||
const userDataDir = createTemporaryDirectory();
|
||||
const service = createService(userDataDir);
|
||||
|
||||
@@ -464,6 +464,41 @@ describe('Works Square AI design adapter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the opaque older-history cursor through and preserves server message ids', async () => {
|
||||
const pagedConversation = {
|
||||
...serverConversation,
|
||||
messages: [{
|
||||
...serverConversation.messages[0],
|
||||
message_id: 'persisted-turn-one:assistant',
|
||||
}],
|
||||
message_page: {
|
||||
has_older: true,
|
||||
next_before: 'opaque-older-page',
|
||||
},
|
||||
};
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(pagedConversation));
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example',
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
|
||||
const result = await adapter.getConversation(
|
||||
'workspace-one',
|
||||
'conversation-one',
|
||||
'opaque-older-page',
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one?before=opaque-older-page',
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
expect(result.messages[0]?.id).toBe('persisted-turn-one:assistant');
|
||||
expect(result.messagePage).toEqual({
|
||||
hasOlder: true,
|
||||
nextBefore: 'opaque-older-page',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes a missing Brief medium to null and rejects invalid non-null values', async () => {
|
||||
const { medium: _medium, ...legacyBrief } = serverConversation.brief;
|
||||
const legacyConversation = {
|
||||
@@ -1148,6 +1183,46 @@ describe('Works Square AI design adapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('fails a WebSocket event stream that never opens instead of leaving it pending', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { sockets, webSocketFactory } = scriptedSockets([{ open: false }]);
|
||||
const fetchMock = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/conversations/conversation-one')) {
|
||||
return jsonResponse({ ...serverConversation, agent_session_id: 'session-open-timeout' });
|
||||
}
|
||||
if (url.endsWith('/stream-tickets')) {
|
||||
return jsonResponse({
|
||||
stream_url: '/api/agents/sessions/session-open-timeout/ws?ticket=ticket-open-timeout',
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
});
|
||||
const adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example',
|
||||
fetchImpl: fetchMock,
|
||||
webSocketFactory,
|
||||
});
|
||||
|
||||
try {
|
||||
const opening = adapter.openWorkspaceEvents({
|
||||
workspaceId: 'workspace-one',
|
||||
conversationId: 'conversation-one',
|
||||
});
|
||||
const openingAssertion = expect(opening).rejects.toMatchObject({
|
||||
status: 504,
|
||||
code: 'DESIGN_EVENT_STREAM_TIMEOUT',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
await openingAssertion;
|
||||
expect(sockets).toHaveLength(1);
|
||||
expect(sockets[0].readyState).toBe(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps a failed Run received from the connected WebSocket without polling', async () => {
|
||||
const { webSocketFactory } = scriptedSockets([{
|
||||
open: true,
|
||||
|
||||
Reference in New Issue
Block a user