fix(runtime): 运行恢复整合 - 合并 PR #253 本地任务恢复改动

This commit is contained in:
ddCat
2026-08-18 20:27:06 +08:00
13 changed files with 736 additions and 67 deletions

View File

@@ -10,7 +10,18 @@ import { createClientId } from "@/lib/client-id";
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { useCanvasAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/canvas/use-canvas-agent-store";
import {
canvasAgentConnectionStatusText,
canvasAgentConnectionStartingPatch,
canvasAgentTransientDisconnectPatch,
useCanvasAgentStore,
type AgentAttachment,
type AgentChatItem,
type AgentEventLog,
type AgentPanelTab,
type AgentPendingToolCall,
type AgentThreadSummary,
} from "@/stores/canvas/use-canvas-agent-store";
import { previewCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools";
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
@@ -236,7 +247,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
}
errorLoggedRef.current = true;
connectedRef.current = false;
clearAgentSession({ activity: wasConnected ? "正在重连" : "连接失败", connected: false, connectError: text });
setAgentState(canvasAgentTransientDisconnectPatch(wasConnected ? "正在重连" : "连接失败", text));
await waitForCanvasRuntimeReconnect(controller.signal);
}
}
@@ -402,28 +413,15 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
if (connected) syncState(clientIdRef.current, restored);
};
const toggleAgentConnection = async () => {
const toggleAgentConnection = () => {
if (enabled) {
connectionControllerRef.current?.abort();
clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" });
pendingToolRef.current = null;
setAgentState({ enabled: false, connected: false, activity: "离线", connectError: "", waiting: false, sending: false, pendingTool: null });
return;
}
const controller = new AbortController();
connectionControllerRef.current = controller;
setAgentState({ connected: false, activity: "连接中", connectError: "", activeTab: "setup" });
try {
await prepareCanvasRuntimeConnection(useLocalRuntimeStore, controller.signal);
if (controller.signal.aborted) return;
errorLoggedRef.current = false;
setAgentState({ enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" });
} catch (error) {
if (controller.signal.aborted) return;
const text = error instanceof Error ? error.message : "本机 Runtime 连接失败";
setAgentState({ connectError: text });
if (!headless) message.warning(text);
} finally {
if (connectionControllerRef.current === controller) connectionControllerRef.current = null;
}
errorLoggedRef.current = false;
setAgentState(canvasAgentConnectionStartingPatch());
};
useEffect(() => {
@@ -432,21 +430,6 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
void toggleAgentConnection();
}, [autoConnect, connected, enabled]);
function clearAgentSession(patch: Parameters<typeof setAgentState>[0] = {}) {
setAgentState({
messages: [],
threads: [],
activeThreadId: "",
workspacePath: "",
loadingThreads: false,
waiting: false,
sending: false,
pendingTool: null,
...patch,
});
pendingToolRef.current = null;
}
const startNewThread = async () => {
const projectId = snapshotRef.current.projectId;
if (!connected || !projectId) return;
@@ -786,8 +769,8 @@ function AgentConnectView({
connectError: string;
onToggleEnabled: () => void;
}) {
const statusText = connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接";
const statusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
const statusText = canvasAgentConnectionStatusText({ enabled, connected, activity, connectError });
const statusColor = statusText === "连接失败" ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
return (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
<div className="space-y-4">

View File

@@ -7,6 +7,7 @@ import { isLocalDreaminaBackgroundTask, localDreaminaTaskId, projectLocalDreamin
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { grokImagePromptLimitError } from "@/lib/grok-image-prompt-limit";
import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import { useLocalDreaminaModelStore } from "@/stores/use-local-dreamina-model-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
@@ -46,6 +47,7 @@ export type GenerationTaskDependencies = {
runLocal: (input: LocalDreaminaGenerationInput, signal?: AbortSignal, onTaskUpdate?: (task: LocalDreaminaGenerationTask) => void) => ReturnType<typeof runLocalDreaminaGenerationTask>;
createId: () => string;
now: () => string;
ensureLocalDreaminaReady?: (signal?: AbortSignal) => Promise<unknown>;
};
const defaultDependencies: GenerationTaskDependencies = {
@@ -54,6 +56,7 @@ const defaultDependencies: GenerationTaskDependencies = {
runLocal: (input, signal, onTaskUpdate) => runLocalDreaminaGenerationTask(input, { onTaskUpdate }, signal),
createId: () => crypto.randomUUID(),
now: () => new Date().toISOString(),
ensureLocalDreaminaReady: (signal) => useLocalDreaminaModelStore.getState().ensureReady(signal),
};
type PreparedGenerationReferences = {
@@ -88,6 +91,8 @@ export async function runBackendGenerationTask(
throwIfAborted(signal);
assertClientPromptLimit(mode, prompt, config, metadata);
if (isLocalDreaminaModel(config.model)) {
await dependencies.ensureLocalDreaminaReady?.(signal);
throwIfAborted(signal);
return await runLocalDreaminaGeneration(
{ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, mask, signal, metadata, onTaskUpdate, localIdempotencyKey, localResumeOnly, clientOperationId, retryOf, attemptGroupId },
dependencies,
@@ -104,6 +109,8 @@ export async function runBackendGenerationTaskBatch(options: BackendGenerationTa
assertClientPromptLimit(options.mode, options.prompt, options.config, options.metadata);
if (options.retryContextsByBatchIndex && options.retryContextsByBatchIndex.length !== count) throw new Error("生成重试批次任务数量不匹配");
if (isLocalDreaminaModel(options.config.model)) {
await dependencies.ensureLocalDreaminaReady?.(options.signal);
throwIfAborted(options.signal);
return Promise.allSettled(
Array.from({ length: count }, (_, batchIndex) => {
const retryContext = options.retryContextsByBatchIndex?.[batchIndex];
@@ -191,7 +198,8 @@ async function runLocalDreaminaGeneration(options: BackendGenerationTaskOptions,
},
);
const completedAt = dependencies.now();
options.onTaskUpdate?.({ ...task, status: "succeeded", progress: 100, stage: "local_cli_succeeded", resultJson: JSON.stringify(result), completedAt, updatedAt: completedAt });
latestPublicTask = { ...latestPublicTask, status: "succeeded", progress: 100, stage: "local_cli_succeeded", resultJson: JSON.stringify(result), completedAt, updatedAt: completedAt };
options.onTaskUpdate?.(latestPublicTask);
return result;
} catch (error) {
const completedAt = dependencies.now();
@@ -200,7 +208,7 @@ async function runLocalDreaminaGeneration(options: BackendGenerationTaskOptions,
const localErrorCode = error instanceof LocalDreaminaGenerationClientError ? error.code : undefined;
if (!(cancelled && isLocalDreaminaBackgroundTask(latestPublicTask))) {
options.onTaskUpdate?.({
...task,
...latestPublicTask,
status: cancelled ? "cancelled" : "failed",
stage: cancelled ? "local_cli_cancelled" : "local_cli_failed",
completedAt,

View File

@@ -35,10 +35,50 @@ type CanvasAgentStore = {
clearEventLogs: () => void;
};
const CANVAS_AGENT_ENABLED_STORAGE_KEY = "canvas-agent-enabled";
type CanvasAgentPreferenceStorage = {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
};
export function readCanvasAgentEnabledPreference(storage: Pick<CanvasAgentPreferenceStorage, "getItem"> | undefined = browserPreferenceStorage()) {
try {
return storage?.getItem(CANVAS_AGENT_ENABLED_STORAGE_KEY) === "true";
} catch {
return false;
}
}
export function writeCanvasAgentEnabledPreference(enabled: boolean, storage: Pick<CanvasAgentPreferenceStorage, "setItem"> | undefined = browserPreferenceStorage()) {
try {
storage?.setItem(CANVAS_AGENT_ENABLED_STORAGE_KEY, String(enabled));
} catch {
// 浏览器隐私模式可能拒绝本地偏好写入;连接本身仍可继续。
}
}
export function canvasAgentConnectionStartingPatch() {
return { enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" as const };
}
export function canvasAgentTransientDisconnectPatch(activity: string, connectError: string) {
return { enabled: true, connected: false, activity, connectError, waiting: false, sending: false };
}
export function canvasAgentConnectionStatusText({ enabled, connected, activity, connectError }: Pick<CanvasAgentStore, "enabled" | "connected" | "activity" | "connectError">) {
if (enabled && !connected && activity === "正在重连") return activity;
return connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接";
}
function browserPreferenceStorage(): CanvasAgentPreferenceStorage | undefined {
return typeof window === "undefined" ? undefined : window.localStorage;
}
export const useCanvasAgentStore = create<CanvasAgentStore>((set) => ({
width: typeof window === "undefined" ? 440 : Number(localStorage.getItem("canvas-agent-panel-width")) || 440,
connected: false,
enabled: false,
enabled: readCanvasAgentEnabledPreference(),
prompt: "",
attachments: [],
sending: false,
@@ -54,7 +94,10 @@ export const useCanvasAgentStore = create<CanvasAgentStore>((set) => ({
activity: "就绪",
connectError: "",
pendingTool: null,
setAgentState: (patch) => set(patch),
setAgentState: (patch) => {
if (typeof patch.enabled === "boolean") writeCanvasAgentEnabledPreference(patch.enabled);
set(patch);
},
addMessage: (item) => set((state) => ({ messages: [...state.messages.slice(-120), item] })),
addEventLog: (item) => set((state) => ({ eventLogs: [...state.eventLogs.slice(-160), item] })),
clearEventLogs: () => set({ eventLogs: [] }),

View File

@@ -4,31 +4,103 @@ import { create } from "zustand";
import { getDreaminaModelCatalogSnapshotWithSessionRecovery, type DreaminaLocalModel } from "@/services/local-dreamina-model-catalog";
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
type State = { state: "idle" | "loading" | "ready" | "error"; models: DreaminaLocalModel[]; cacheScope?: string; sync(signal?: AbortSignal): Promise<void> };
type CatalogSnapshot = Awaited<ReturnType<typeof getDreaminaModelCatalogSnapshotWithSessionRecovery>>;
type RuntimeCatalogState = { connection: string; modules: Array<{ id: string; scopes: string[] }> };
type Dependencies = {
getRuntimeState(): RuntimeCatalogState;
getClient(): Parameters<typeof getDreaminaModelCatalogSnapshotWithSessionRecovery>[0];
loadSnapshot(client: Parameters<typeof getDreaminaModelCatalogSnapshotWithSessionRecovery>[0], signal: AbortSignal): Promise<CatalogSnapshot>;
requestTimeoutMs?: number;
};
type State = {
state: "idle" | "loading" | "ready" | "error";
models: DreaminaLocalModel[];
cacheScope?: string;
sync(signal?: AbortSignal): Promise<void>;
ensureReady(signal?: AbortSignal): Promise<DreaminaLocalModel[]>;
};
export const dreaminaModelCacheScopeKey = ({ accountBinding, sessionEpoch }: { accountBinding: string; sessionEpoch: string | number }) => `${accountBinding}:${sessionEpoch}`;
export const useLocalDreaminaModelStore = create<State>((set, get) => ({
state: "idle",
models: [],
cacheScope: undefined,
async sync(signal) {
const runtime = useLocalRuntimeStore.getState();
const available = runtime.connection === "connected" && runtime.modules.some((module) => module.id === "dreamina" && module.scopes.includes("dreamina:models"));
if (!available) {
set({ state: "idle", models: [], cacheScope: undefined });
return;
}
const client = getLocalRuntimeSessionClient();
try {
const snapshot = await getDreaminaModelCatalogSnapshotWithSessionRecovery(client, signal);
const cacheScope = dreaminaModelCacheScopeKey(snapshot);
set({ state: "ready", models: snapshot.models, cacheScope });
} catch {
if (!signal?.aborted) set({ state: "error", models: [], cacheScope: undefined });
}
},
}));
const DEFAULT_CATALOG_TIMEOUT_MS = 8_000;
export function createLocalDreaminaModelStore(dependencies: Dependencies) {
let requestRevision = 0;
let activeRequest: { revision: number; controller: AbortController; promise: Promise<DreaminaLocalModel[]> } | null = null;
return create<State>((set, get) => {
const load = (force: boolean, signal?: AbortSignal) => {
const runtime = dependencies.getRuntimeState();
const available = runtime.connection === "connected" && runtime.modules.some((module) => module.id === "dreamina" && module.scopes.includes("dreamina:models"));
if (!available) {
requestRevision++;
activeRequest?.controller.abort();
activeRequest = null;
set({ state: "idle", models: [], cacheScope: undefined });
return Promise.reject(new Error("即梦本机模型目录尚未就绪"));
}
const current = get();
if (!force && current.state === "ready" && current.models.length) return Promise.resolve(current.models);
if (force && activeRequest) {
requestRevision++;
activeRequest.controller.abort();
activeRequest = null;
}
if (!activeRequest) {
set({ state: "loading" });
const revision = ++requestRevision;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.max(1, dependencies.requestTimeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS));
const promise = dependencies
.loadSnapshot(dependencies.getClient(), controller.signal)
.then((snapshot) => {
if (revision === requestRevision) set({ state: "ready", models: snapshot.models, cacheScope: dreaminaModelCacheScopeKey(snapshot) });
return snapshot.models;
})
.catch((error) => {
if (revision === requestRevision) set({ state: "error", models: [], cacheScope: undefined });
throw error;
})
.finally(() => {
clearTimeout(timer);
if (activeRequest?.revision === revision) activeRequest = null;
});
activeRequest = { revision, controller, promise };
}
return waitForCatalog(activeRequest.promise, signal);
};
return {
state: "idle",
models: [],
cacheScope: undefined,
async sync(signal) {
try {
await load(true, signal);
} catch {
// 页面引导读取失败只投影 store 状态;生成入口的 ensureReady 会明确失败。
}
},
ensureReady(signal) {
return load(false, signal);
},
};
});
}
function waitForCatalog<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
return new Promise<T>((resolve, reject) => {
const abort = () => reject(new DOMException("Aborted", "AbortError"));
signal.addEventListener("abort", abort, { once: true });
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
});
}
export const useLocalDreaminaModelStore = createLocalDreaminaModelStore({
getRuntimeState: () => useLocalRuntimeStore.getState(),
getClient: getLocalRuntimeSessionClient,
loadSnapshot: (client, signal) => getDreaminaModelCatalogSnapshotWithSessionRecovery(client, signal),
});
export function useLocalDreaminaModelBootstrap() {
const connection = useLocalRuntimeStore((state) => state.connection);

View File

@@ -98,6 +98,73 @@ test("Canvas Agent UI store contains no Runtime endpoint, bearer, or session fie
expect("session" in state).toBe(false);
});
test("Canvas Agent reconnect intent persists while transient disconnects preserve the active conversation", async () => {
const module = await import("../src/stores/canvas/use-canvas-agent-store").catch(() => ({}));
const connectionStartingPatch = (
module as {
canvasAgentConnectionStartingPatch?: () => Record<string, unknown>;
}
).canvasAgentConnectionStartingPatch;
const transientDisconnectPatch = (
module as {
canvasAgentTransientDisconnectPatch?: (activity: string, connectError: string) => Record<string, unknown>;
}
).canvasAgentTransientDisconnectPatch;
const writeEnabled = (
module as {
writeCanvasAgentEnabledPreference?: (enabled: boolean, storage: { setItem(key: string, value: string): void }) => void;
}
).writeCanvasAgentEnabledPreference;
const readEnabled = (
module as {
readCanvasAgentEnabledPreference?: (storage: { getItem(key: string): string | null }) => boolean;
}
).readCanvasAgentEnabledPreference;
expect(typeof connectionStartingPatch).toBe("function");
expect(typeof transientDisconnectPatch).toBe("function");
expect(typeof writeEnabled).toBe("function");
expect(typeof readEnabled).toBe("function");
if (!connectionStartingPatch || !transientDisconnectPatch || !writeEnabled || !readEnabled) return;
const durable = {
messages: [{ id: "message-1", role: "assistant", text: "kept" }],
threads: [{ id: "thread-1", preview: "kept" }],
activeThreadId: "thread-1",
workspacePath: "D:/workspace",
pendingTool: { requestId: "tool-1", name: "canvas_get_state" },
};
const starting = { ...durable, ...connectionStartingPatch() };
expect(starting).toMatchObject({ enabled: true, connected: false, activity: "连接中", ...durable });
const reconnecting = { ...starting, ...transientDisconnectPatch("正在重连", "连接已断开") };
expect(reconnecting).toMatchObject({ enabled: true, connected: false, activity: "正在重连", connectError: "连接已断开", ...durable });
const values = new Map<string, string>();
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
};
writeEnabled(true, storage);
expect(readEnabled(storage)).toBe(true);
writeEnabled(false, storage);
expect(readEnabled(storage)).toBe(false);
});
test("Canvas Agent status shows reconnecting instead of a stale connection failure", async () => {
const module = await import("../src/stores/canvas/use-canvas-agent-store").catch(() => ({}));
const statusText = (
module as {
canvasAgentConnectionStatusText?: (state: { enabled: boolean; connected: boolean; activity: string; connectError: string }) => string;
}
).canvasAgentConnectionStatusText;
expect(typeof statusText).toBe("function");
if (!statusText) return;
expect(statusText({ enabled: true, connected: false, activity: "正在重连", connectError: "连接已断开" })).toBe("正在重连");
expect(statusText({ enabled: true, connected: false, activity: "连接失败", connectError: "首次连接失败" })).toBe("连接失败");
});
test("Canvas connection reuses the shared Runtime store and requires the canvas module", async () => {
const module = await import("../src/lib/canvas/local-runtime-connection");
const prepare = (

View File

@@ -335,6 +335,140 @@ test("shared Create and Canvas task projection exposes queued, submitted, genera
expect(distinctUpdates[4]?.resultJson).toContain('"mode":"video"');
});
test("the first local Dreamina submission waits for catalog readiness before invoking the paid Runtime path", async () => {
const order: string[] = [];
let release!: () => void;
const readyGate = new Promise<void>((resolve) => {
release = resolve;
});
const pending = runBackendGenerationTask(
{
mode: "video",
prompt: "first visit",
config: { ...defaultConfig, model: "local:dreamina-cli:seedance2.0mini", videoSeconds: "4", vquality: "720" },
},
{
createTask: async () => {
throw new Error("must not create backend task");
},
waitTask: async () => {
throw new Error("must not wait backend task");
},
ensureLocalDreaminaReady: async () => {
order.push("catalog-start");
await readyGate;
order.push("catalog-ready");
},
runLocal: async () => {
order.push("paid-runtime");
return { mode: "video", video: { dataUrl: "data:video/mp4;base64,AAAA", mimeType: "video/mp4", bytes: 3 } };
},
createId: () => "dreamina-first-ready-0001",
now: () => "2026-08-18T00:00:00.000Z",
},
);
await Promise.resolve();
expect(order).toEqual(["catalog-start"]);
release();
await pending;
expect(order).toEqual(["catalog-start", "catalog-ready", "paid-runtime"]);
});
test("the first local Dreamina batch shares one catalog readiness gate before any paid Runtime call", async () => {
const order: string[] = [];
let release!: () => void;
const readyGate = new Promise<void>((resolve) => {
release = resolve;
});
const pending = runBackendGenerationTaskBatch(
{
mode: "image",
prompt: "first batch",
config: { ...defaultConfig, model: "local:dreamina-cli:5.0Pro", count: "1" },
count: 3,
},
{
createTask: async () => {
throw new Error("must not create backend task");
},
waitTask: async () => {
throw new Error("must not wait backend task");
},
ensureLocalDreaminaReady: async () => {
order.push("catalog-start");
await readyGate;
order.push("catalog-ready");
},
runLocal: async () => {
order.push("paid-runtime");
return { mode: "image", images: [{ dataUrl: "data:image/png;base64,AAAA", mimeType: "image/png", bytes: 3 }] };
},
createId: () => `dreamina-first-batch-${order.length}`,
now: () => "2026-08-18T00:00:00.000Z",
},
);
await Promise.resolve();
expect(order).toEqual(["catalog-start"]);
release();
const settled = await pending;
expect(settled.every((entry) => entry.status === "fulfilled")).toBe(true);
expect(order).toEqual(["catalog-start", "catalog-ready", "paid-runtime", "paid-runtime", "paid-runtime"]);
});
test("local Dreamina terminal projection keeps the latest receipt, observation, and durable outputs", async () => {
const updates: GenerationTask[] = [];
const timestamp = "2026-08-18T00:00:00.000Z";
await runBackendGenerationTask(
{
mode: "video",
prompt: "late result",
config: { ...defaultConfig, model: "local:dreamina-cli:seedance2.0mini", videoSeconds: "4", vquality: "720" },
onTaskUpdate: (task) => updates.push(task),
},
{
createTask: async () => {
throw new Error("must not create backend task");
},
waitTask: async () => {
throw new Error("must not wait backend task");
},
runLocal: async (input, _signal, onTaskUpdate) => {
onTaskUpdate?.({
id: input.idempotencyKey!,
provider: "dreamina-cli",
mode: "video",
operation: "text2video",
model: "seedance2.0mini",
status: "succeeded",
stage: "succeeded",
progress: 100,
receiptRecorded: true,
officialStatus: "completed",
providerObservation: { source: "query_result", status: "completed", observedAt: timestamp },
outputs: [{ outputIndex: 0, mediaType: "video", materializedAssetId: "asset-late-0001" }],
result: { mode: "video", video: { dataUrl: "data:video/mp4;base64,AAAA", mimeType: "video/mp4", bytes: 3 } },
createdAt: timestamp,
updatedAt: timestamp,
});
return { mode: "video", video: { dataUrl: "data:video/mp4;base64,AAAA", mimeType: "video/mp4", bytes: 3 } };
},
createId: () => "dreamina-latest-context-0001",
now: () => timestamp,
},
);
expect(updates.at(-1)).toMatchObject({
status: "succeeded",
stage: "local_cli_succeeded",
receiptRecorded: true,
officialStatus: "completed",
outputs: [{ outputIndex: 0, mediaType: "video", materializedAssetId: "asset-late-0001" }],
});
expect(updates.at(-1)?.resultJson).toContain('"mode":"video"');
});
test("an explicitly cancelled Dreamina task stays cancelled instead of being projected as failed", async () => {
const updates: Array<{ status: string; stage?: string }> = [];
const timestamp = "2026-08-12T00:00:00.000Z";

View File

@@ -213,6 +213,125 @@ test("effective config projects an asynchronously arriving Dreamina catalog with
expect(defaultConfig.channels.some((channel) => channel.id === "local:dreamina-cli")).toBe(false);
});
test("Dreamina catalog readiness is loading immediately and shares one in-flight bootstrap", async () => {
const module = await import("../src/stores/use-local-dreamina-model-store").catch(() => ({}));
const createStore = (
module as {
createLocalDreaminaModelStore?: (dependencies: Record<string, unknown>) => {
getState(): {
state: string;
models: Array<{ id: string }>;
ensureReady(signal?: AbortSignal): Promise<Array<{ id: string }>>;
};
};
}
).createLocalDreaminaModelStore;
expect(typeof createStore).toBe("function");
if (!createStore) return;
let requests = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const store = createStore({
getRuntimeState: () => ({ connection: "connected", modules: [{ id: "dreamina", scopes: ["dreamina:models"] }] }),
getClient: () => ({ request: async () => catalogResponse() }),
loadSnapshot: async () => {
requests += 1;
await gate;
return {
accountBinding: "a".repeat(64),
sessionEpoch: 3,
models: [
{
provider: "dreamina-cli",
id: "seedance2.0mini",
displayName: "seedance2.0mini",
modality: "video",
operations: ["text-to-video"],
adapterSupported: true,
accountEntitlement: "unknown",
currentlyObservedAvailable: "unknown",
settings: { aliases: [], aspects: ["16:9"], maxReferenceImages: 9, minDuration: 4, maxDuration: 15, tiers: ["720p"] },
source: "runtime-execution-contract",
},
],
};
},
});
const first = store.getState().ensureReady();
const second = store.getState().ensureReady();
expect(store.getState().state).toBe("loading");
expect(requests).toBe(1);
release();
const [firstModels, secondModels] = await Promise.all([first, second]);
expect(firstModels.map((item) => item.id)).toEqual(["seedance2.0mini"]);
expect(secondModels).toEqual(firstModels);
expect(store.getState()).toMatchObject({ state: "ready", cacheScope: `${"a".repeat(64)}:3` });
expect(requests).toBe(1);
});
test("Dreamina catalog timeout releases a hung shared request so generation can recover without reload", async () => {
const { createLocalDreaminaModelStore } = await import("../src/stores/use-local-dreamina-model-store");
let requests = 0;
const store = createLocalDreaminaModelStore({
getRuntimeState: () => ({ connection: "connected", modules: [{ id: "dreamina", scopes: ["dreamina:models"] }] }),
getClient: () => ({ request: async () => catalogResponse() }),
requestTimeoutMs: 10,
loadSnapshot: async (_client, signal) => {
requests += 1;
if (requests > 1) return catalogSnapshot("seedance-recovered", 4);
if (!signal) return await new Promise<ReturnType<typeof catalogSnapshot>>(() => undefined);
return await new Promise<ReturnType<typeof catalogSnapshot>>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
});
},
});
const caller = new AbortController();
const first = store.getState().ensureReady(caller.signal);
caller.abort();
await expect(first).rejects.toMatchObject({ name: "AbortError" });
await Bun.sleep(20);
const recovered = await Promise.race([store.getState().ensureReady(), Bun.sleep(40).then(() => "stuck" as const)]);
expect(recovered).not.toBe("stuck");
expect(recovered).toMatchObject([{ id: "seedance-recovered" }]);
expect(store.getState()).toMatchObject({ state: "ready", cacheScope: `${"a".repeat(64)}:4` });
expect(requests).toBe(2);
});
test("Dreamina catalog forced resync ignores a late result from the previous Runtime scope", async () => {
const { createLocalDreaminaModelStore } = await import("../src/stores/use-local-dreamina-model-store");
let requests = 0;
let releaseOld!: (snapshot: ReturnType<typeof catalogSnapshot>) => void;
const oldRequest = new Promise<ReturnType<typeof catalogSnapshot>>((resolve) => {
releaseOld = resolve;
});
const store = createLocalDreaminaModelStore({
getRuntimeState: () => ({ connection: "connected", modules: [{ id: "dreamina", scopes: ["dreamina:models"] }] }),
getClient: () => ({ request: async () => catalogResponse() }),
requestTimeoutMs: 100,
loadSnapshot: async () => {
requests += 1;
return requests === 1 ? oldRequest : catalogSnapshot("seedance-current", 5);
},
});
const oldSync = store.getState().sync();
const currentSync = store.getState().sync();
const currentResult = await Promise.race([currentSync.then(() => "ready" as const), Bun.sleep(40).then(() => "stuck" as const)]);
expect(currentResult).toBe("ready");
expect(requests).toBe(2);
expect(store.getState()).toMatchObject({ state: "ready", cacheScope: `${"a".repeat(64)}:5`, models: [{ id: "seedance-current" }] });
releaseOld(catalogSnapshot("seedance-stale", 3));
await oldSync;
expect(store.getState()).toMatchObject({ state: "ready", cacheScope: `${"a".repeat(64)}:5`, models: [{ id: "seedance-current" }] });
});
test("effective config removes custom channels when administrators disable them", async () => {
const { createModelChannel, effectiveConfigForCustomChannels, normalizeConfigSnapshot } = await import("../src/stores/use-config-store");
const config = normalizeConfigSnapshot({
@@ -264,3 +383,24 @@ function catalogResponse() {
{ status: 200, headers: { "content-type": "application/json" } },
);
}
function catalogSnapshot(id: string, sessionEpoch: number) {
return {
accountBinding: "a".repeat(64),
sessionEpoch,
models: [
{
provider: "dreamina-cli" as const,
id,
displayName: id,
modality: "video" as const,
operations: ["text-to-video" as const],
adapterSupported: true,
accountEntitlement: "unknown" as const,
currentlyObservedAvailable: "unknown" as const,
settings: { aliases: [], aspects: ["16:9"], maxReferenceImages: 9, minDuration: 4, maxDuration: 15, tiers: ["720p"] },
source: "runtime-execution-contract" as const,
},
],
};
}