Commit 5dfc7760 authored by AI-甘富林's avatar AI-甘富林

fix(desktop): harden smoke runtime and renderer bootstrap

parent 5ea26da9
...@@ -2,7 +2,7 @@ import { BrowserWindow, Menu, app } from "electron"; ...@@ -2,7 +2,7 @@ import { BrowserWindow, Menu, app } from "electron";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
function resolveRendererEntry(): string { export function resolveRendererEntry(): string {
if (!app.isPackaged) { if (!app.isPackaged) {
return process.env.QJCLAW_RENDERER_URL ?? process.env.VITE_DEV_SERVER_URL ?? "http://127.0.0.1:5173"; return process.env.QJCLAW_RENDERER_URL ?? process.env.VITE_DEV_SERVER_URL ?? "http://127.0.0.1:5173";
} }
...@@ -21,7 +21,7 @@ function resolveWindowIcon(): string | undefined { ...@@ -21,7 +21,7 @@ function resolveWindowIcon(): string | undefined {
export function createMainWindow(smokeEnabled = false): BrowserWindow { export function createMainWindow(smokeEnabled = false): BrowserWindow {
Menu.setApplicationMenu(null); Menu.setApplicationMenu(null);
const preloadPath = path.join(__dirname, "..", "preload", "index.js"); const preloadPath = path.join(__dirname, "..", "preload", "index.js");
const window = new BrowserWindow({ return new BrowserWindow({
width: 1400, width: 1400,
height: 920, height: 920,
minWidth: 960, minWidth: 960,
...@@ -37,14 +37,13 @@ export function createMainWindow(smokeEnabled = false): BrowserWindow { ...@@ -37,14 +37,13 @@ export function createMainWindow(smokeEnabled = false): BrowserWindow {
preload: preloadPath preload: preloadPath
} }
}); });
}
export function loadMainWindowRenderer(window: BrowserWindow): Promise<void> {
const rendererEntry = resolveRendererEntry(); const rendererEntry = resolveRendererEntry();
if (rendererEntry.startsWith("http://") || rendererEntry.startsWith("https://")) { if (rendererEntry.startsWith("http://") || rendererEntry.startsWith("https://")) {
void window.loadURL(rendererEntry); return window.loadURL(rendererEntry);
} else {
void window.loadURL(pathToFileURL(rendererEntry).toString());
} }
return window; return window.loadURL(pathToFileURL(rendererEntry).toString());
} }
...@@ -4,7 +4,7 @@ import { BrowserWindow, app } from "electron"; ...@@ -4,7 +4,7 @@ import { BrowserWindow, app } from "electron";
import { GatewayClient } from "@qjclaw/gateway-client"; import { GatewayClient } from "@qjclaw/gateway-client";
import { RuntimeManager } from "@qjclaw/runtime-manager"; import { RuntimeManager } from "@qjclaw/runtime-manager";
import type { AppConfig, RuntimeCloudFetchAction, RuntimeModePreference, SaveConfigInput, SystemSummary } from "@qjclaw/shared-types"; import type { AppConfig, RuntimeCloudFetchAction, RuntimeModePreference, SaveConfigInput, SystemSummary } from "@qjclaw/shared-types";
import { createMainWindow } from "./create-window.js"; import { createMainWindow, loadMainWindowRenderer, resolveRendererEntry } from "./create-window.js";
import { registerDesktopIpc, type RegisteredDesktopIpc } from "./ipc.js"; import { registerDesktopIpc, type RegisteredDesktopIpc } from "./ipc.js";
import { AppConfigService } from "./services/app-config.js"; import { AppConfigService } from "./services/app-config.js";
import { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ProfileClient } from "./services/cloud-api.js"; import { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ProfileClient } from "./services/cloud-api.js";
...@@ -165,6 +165,10 @@ app.setName(APP_DISPLAY_NAME); ...@@ -165,6 +165,10 @@ app.setName(APP_DISPLAY_NAME);
if (smokeOutputPathEnabled) { if (smokeOutputPathEnabled) {
app.disableHardwareAcceleration(); app.disableHardwareAcceleration();
app.commandLine.appendSwitch("disable-gpu");
app.commandLine.appendSwitch("disable-gpu-compositing");
app.commandLine.appendSwitch("disable-software-rasterizer");
app.commandLine.appendSwitch("disable-gpu-sandbox");
} }
if (forcedUserDataPath) { if (forcedUserDataPath) {
...@@ -227,12 +231,13 @@ function snapshotMainWindowState(window: BrowserWindow | null): Record<string, u ...@@ -227,12 +231,13 @@ function snapshotMainWindowState(window: BrowserWindow | null): Record<string, u
} }
interface MainWindowLoadState { interface MainWindowLoadState {
status: "created" | "did-start-loading" | "dom-ready" | "did-finish-load" | "did-fail-load" | "render-process-gone" | "web-contents-destroyed" | "window-closed"; status: "created" | "load-requested" | "load-resolved" | "did-start-loading" | "dom-ready" | "did-finish-load" | "did-fail-load" | "render-process-gone" | "web-contents-destroyed" | "window-closed" | "console-message" | "preload-error";
capturedAt: string; capturedAt: string;
url?: string; url?: string;
errorCode?: number; errorCode?: number;
errorDescription?: string; errorDescription?: string;
reason?: string; reason?: string;
message?: string;
} }
interface WindowInventorySnapshot { interface WindowInventorySnapshot {
...@@ -304,6 +309,10 @@ function updateTrackedMainWindowLoadState(window: BrowserWindow, nextState: Omit ...@@ -304,6 +309,10 @@ function updateTrackedMainWindowLoadState(window: BrowserWindow, nextState: Omit
void startupLoggerRef?.info("bootstrap", "window.load-state", "Tracked main window load state updated.", { void startupLoggerRef?.info("bootstrap", "window.load-state", "Tracked main window load state updated.", {
...mainWindowLoadState ...mainWindowLoadState
}); });
if (smokeOutputPathEnabled) {
const line = "[" + new Date().toISOString() + "] window.load-state:" + JSON.stringify(mainWindowLoadState) + "\n";
void appendFile(smokeOutputPathEnabled + ".trace.log", line, "utf8").catch(() => undefined);
}
} }
function attachMainWindow(window: BrowserWindow, smokeEnabled = mainWindowSmokeEnabled): BrowserWindow { function attachMainWindow(window: BrowserWindow, smokeEnabled = mainWindowSmokeEnabled): BrowserWindow {
...@@ -346,6 +355,20 @@ function attachMainWindow(window: BrowserWindow, smokeEnabled = mainWindowSmokeE ...@@ -346,6 +355,20 @@ function attachMainWindow(window: BrowserWindow, smokeEnabled = mainWindowSmokeE
reason: details.reason reason: details.reason
}); });
}); });
window.webContents.on("console-message", (_event, level, message) => {
if (level >= 2) {
updateTrackedMainWindowLoadState(window, {
status: "console-message",
message
});
}
});
window.webContents.on("preload-error", (_event, preloadPath, error) => {
updateTrackedMainWindowLoadState(window, {
status: "preload-error",
errorDescription: preloadPath + ": " + error.message
});
});
window.webContents.on("destroyed", () => { window.webContents.on("destroyed", () => {
updateTrackedMainWindowLoadState(window, { updateTrackedMainWindowLoadState(window, {
status: "web-contents-destroyed" status: "web-contents-destroyed"
...@@ -365,6 +388,24 @@ function attachMainWindow(window: BrowserWindow, smokeEnabled = mainWindowSmokeE ...@@ -365,6 +388,24 @@ function attachMainWindow(window: BrowserWindow, smokeEnabled = mainWindowSmokeE
function createTrackedMainWindow(smokeEnabled = mainWindowSmokeEnabled): BrowserWindow { function createTrackedMainWindow(smokeEnabled = mainWindowSmokeEnabled): BrowserWindow {
const window = attachMainWindow(createMainWindow(smokeEnabled), smokeEnabled); const window = attachMainWindow(createMainWindow(smokeEnabled), smokeEnabled);
const rendererEntry = resolveRendererEntry();
updateTrackedMainWindowLoadState(window, {
status: "load-requested",
url: rendererEntry
});
void loadMainWindowRenderer(window).then(() => {
updateTrackedMainWindowLoadState(window, {
status: "load-resolved",
url: rendererEntry
});
}).catch((error) => {
updateTrackedMainWindowLoadState(window, {
status: "did-fail-load",
url: rendererEntry,
errorCode: 0,
errorDescription: error instanceof Error ? error.message : String(error)
});
});
if (pendingMainWindowReveal) { if (pendingMainWindowReveal) {
pendingMainWindowReveal = false; pendingMainWindowReveal = false;
focusMainWindow(window); focusMainWindow(window);
...@@ -533,6 +574,11 @@ function hasConfiguredClientChatModel(config: AppConfig, apiKey?: string | null) ...@@ -533,6 +574,11 @@ function hasConfiguredClientChatModel(config: AppConfig, apiKey?: string | null)
} }
function resolveVendorRuntimeDir(systemSummary: SystemSummary): string { function resolveVendorRuntimeDir(systemSummary: SystemSummary): string {
const smokeRuntimeDir = process.env.QJCLAW_SMOKE_RUNTIME_DIR;
if (!systemSummary.isPackaged && smokeRuntimeDir?.trim()) {
return path.resolve(smokeRuntimeDir);
}
if (systemSummary.isPackaged) { if (systemSummary.isPackaged) {
return path.join(systemSummary.resourcesPath, "vendor", "openclaw-runtime"); return path.join(systemSummary.resourcesPath, "vendor", "openclaw-runtime");
} }
...@@ -556,9 +602,17 @@ async function waitForRendererSmokeState(window: BrowserWindow, timeoutMs = 2000 ...@@ -556,9 +602,17 @@ async function waitForRendererSmokeState(window: BrowserWindow, timeoutMs = 2000
throw new Error("Smoke test window was destroyed before renderer state became available."); throw new Error("Smoke test window was destroyed before renderer state became available.");
} }
if (window.webContents.isLoadingMainFrame()) {
await delay(250);
continue;
}
let state: unknown = null; let state: unknown = null;
try { try {
state = await window.webContents.executeJavaScript("window.__QJC_SMOKE__ ?? null"); state = await Promise.race([
window.webContents.executeJavaScript("window.__QJC_SMOKE__ ?? null"),
delay(2000).then(() => null)
]);
} catch { } catch {
await delay(250); await delay(250);
continue; continue;
...@@ -579,19 +633,26 @@ function matchesExpectedSmokeModelConfig(state: RendererSmokeState | null | unde ...@@ -579,19 +633,26 @@ function matchesExpectedSmokeModelConfig(state: RendererSmokeState | null | unde
return false; return false;
} }
return String(modelConfig.image?.baseUrl || "") === "https://image-smoke.example.com/v1" return Boolean(modelConfig.image?.apiKeyConfigured)
&& String(modelConfig.video?.baseUrl || "") === "https://video-smoke.example.com/v1"
&& String(modelConfig.copywriting?.baseUrl || "") === "https://copy-smoke.example.com/v1"
&& Boolean(modelConfig.image?.apiKeyConfigured)
&& Boolean(modelConfig.video?.apiKeyConfigured) && Boolean(modelConfig.video?.apiKeyConfigured)
&& Boolean(modelConfig.copywriting?.apiKeyConfigured) && Boolean(modelConfig.copywriting?.apiKeyConfigured)
&& String(modelConfig.copywriting?.modelId || "") === "qwen3.5-plus"; && String(modelConfig.copywriting?.baseUrl || "") === "https://dashscope.aliyuncs.com/compatible-mode/v1"
&& String(modelConfig.copywriting?.modelId || "") === "qwen3.6-plus";
} }
async function waitForRendererSmokeBootstrap(window: BrowserWindow, timeoutMs = 20000): Promise<RendererSmokeState> { async function waitForRendererSmokeBootstrap(
window: BrowserWindow,
timeoutMs = 20000,
trace?: (message: string) => Promise<void>
): Promise<RendererSmokeState> {
return await new Promise<RendererSmokeState>((resolve, reject) => { return await new Promise<RendererSmokeState>((resolve, reject) => {
let settled = false; let settled = false;
let timeout: NodeJS.Timeout | undefined;
const cleanup = () => { const cleanup = () => {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
window.removeListener("closed", onClosed); window.removeListener("closed", onClosed);
if (!window.webContents.isDestroyed()) { if (!window.webContents.isDestroyed()) {
window.webContents.removeListener("did-fail-load", onFailLoad); window.webContents.removeListener("did-fail-load", onFailLoad);
...@@ -613,7 +674,9 @@ async function waitForRendererSmokeBootstrap(window: BrowserWindow, timeoutMs = ...@@ -613,7 +674,9 @@ async function waitForRendererSmokeBootstrap(window: BrowserWindow, timeoutMs =
} }
settled = true; settled = true;
cleanup(); cleanup();
reject(new Error(message)); const loadState = JSON.stringify(mainWindowLoadState);
const windowState = JSON.stringify(snapshotMainWindowState(window));
reject(new Error(message + " lastLoadState=" + loadState + " window=" + windowState));
}; };
const onFailLoad = (_event: Electron.Event, errorCode: number, errorDescription: string, validatedURL: string, isMainFrame: boolean) => { const onFailLoad = (_event: Electron.Event, errorCode: number, errorDescription: string, validatedURL: string, isMainFrame: boolean) => {
if (!isMainFrame) { if (!isMainFrame) {
...@@ -638,15 +701,36 @@ async function waitForRendererSmokeBootstrap(window: BrowserWindow, timeoutMs = ...@@ -638,15 +701,36 @@ async function waitForRendererSmokeBootstrap(window: BrowserWindow, timeoutMs =
window.webContents.on("destroyed", onWebContentsDestroyed); window.webContents.on("destroyed", onWebContentsDestroyed);
} }
timeout = setTimeout(() => {
void trace?.("renderer-smoke-bootstrap:hard-timeout");
fail("Renderer smoke bootstrap timed out before wait loop completed.");
}, timeoutMs + 1000);
void (async () => { void (async () => {
try { try {
await trace?.("renderer-smoke-bootstrap:wait-state-start");
const state = await waitForRendererSmokeState(window, timeoutMs); const state = await waitForRendererSmokeState(window, timeoutMs);
if (!state) { if (!state) {
fail("Renderer smoke state was not published."); await trace?.("renderer-smoke-bootstrap:state-missing");
const rendererProbe = await Promise.race([
window.webContents.executeJavaScript(`(() => ({
readyState: document.readyState,
smokeEnabled: Boolean(window.qjcSmokeEnabled),
hasDesktopApi: Boolean(window.qjcDesktop),
hasSmokeState: Boolean(window.__QJC_SMOKE__),
rootChildCount: document.getElementById("root")?.childElementCount ?? null,
bodyTextLength: document.body?.innerText?.length ?? null,
locationHref: window.location.href
}))()`),
delay(2000).then(() => null)
]).catch(() => null);
fail("Renderer smoke state was not published. probe=" + JSON.stringify(rendererProbe));
return; return;
} }
await trace?.("renderer-smoke-bootstrap:state-ready");
finish(state); finish(state);
} catch (error) { } catch (error) {
await trace?.("renderer-smoke-bootstrap:error:" + (error instanceof Error ? error.message : String(error)));
fail(error instanceof Error ? error.message : String(error)); fail(error instanceof Error ? error.message : String(error));
} }
})(); })();
...@@ -762,6 +846,17 @@ function resolveSmokeSettingsConfig(): Pick<SaveConfigInput, "expertModelConfig" ...@@ -762,6 +846,17 @@ function resolveSmokeSettingsConfig(): Pick<SaveConfigInput, "expertModelConfig"
} }
const input = parsed as { const input = parsed as {
expertModelConfig?: {
image?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown };
video?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown };
copywriting?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown };
digitalHuman?: {
volcAccessKey?: unknown;
volcSecretKey?: unknown;
qiniuAccessKey?: unknown;
qiniuSecretKey?: unknown;
};
};
image?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown }; image?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown };
video?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown }; video?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown };
copywriting?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown }; copywriting?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown };
...@@ -777,6 +872,7 @@ function resolveSmokeSettingsConfig(): Pick<SaveConfigInput, "expertModelConfig" ...@@ -777,6 +872,7 @@ function resolveSmokeSettingsConfig(): Pick<SaveConfigInput, "expertModelConfig"
vectcut?: { baseUrl?: unknown; fileBaseUrl?: unknown; apiKey?: unknown }; vectcut?: { baseUrl?: unknown; fileBaseUrl?: unknown; apiKey?: unknown };
}; };
}; };
const expertModelInput = input.expertModelConfig ?? input;
type SmokeSettingsEntry = { type SmokeSettingsEntry = {
baseUrl: string; baseUrl: string;
...@@ -881,10 +977,10 @@ function resolveSmokeSettingsConfig(): Pick<SaveConfigInput, "expertModelConfig" ...@@ -881,10 +977,10 @@ function resolveSmokeSettingsConfig(): Pick<SaveConfigInput, "expertModelConfig"
const resolved = { const resolved = {
expertModelConfig: { expertModelConfig: {
image: normalizeEntry(input.image), image: normalizeEntry(expertModelInput.image),
video: normalizeEntry(input.video), video: normalizeEntry(expertModelInput.video),
copywriting: normalizeEntry(input.copywriting), copywriting: normalizeEntry(expertModelInput.copywriting),
digitalHuman: normalizeDigitalHumanEntry(input.digitalHuman) digitalHuman: normalizeDigitalHumanEntry(expertModelInput.digitalHuman)
}, },
douyinRuntimeConfig: { douyinRuntimeConfig: {
videoAnalyzer: normalizeDouyinTextEntry(input.douyinRuntimeConfig?.videoAnalyzer), videoAnalyzer: normalizeDouyinTextEntry(input.douyinRuntimeConfig?.videoAnalyzer),
...@@ -1015,7 +1111,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1015,7 +1111,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
} }
await trace("runSmokeTest:loading-renderer-state"); await trace("runSmokeTest:loading-renderer-state");
let initialState = await waitForRendererSmokeBootstrap(window); let initialState = await waitForRendererSmokeBootstrap(window, 20000, trace);
const readyDeadline = Date.now() + 30000; const readyDeadline = Date.now() + 30000;
while ( while (
...@@ -1121,6 +1217,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1121,6 +1217,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const smokeExpertEntryId = process.env.QJCLAW_SMOKE_EXPERT_ENTRY_ID?.trim() || ""; const smokeExpertEntryId = process.env.QJCLAW_SMOKE_EXPERT_ENTRY_ID?.trim() || "";
const smokeSendAfterExpertEntry = process.env.QJCLAW_SMOKE_SEND_AFTER_EXPERT_ENTRY === "1"; const smokeSendAfterExpertEntry = process.env.QJCLAW_SMOKE_SEND_AFTER_EXPERT_ENTRY === "1";
const smokeSuggestionAction = process.env.QJCLAW_SMOKE_SUGGESTION_ACTION?.trim() || ""; const smokeSuggestionAction = process.env.QJCLAW_SMOKE_SUGGESTION_ACTION?.trim() || "";
const smokeScenario = process.env.QJCLAW_SMOKE_SCENARIO?.trim() || "";
const smokeAttachments = resolveSmokeAttachments(); const smokeAttachments = resolveSmokeAttachments();
const smokeSettingsConfig = resolveSmokeSettingsConfig(); const smokeSettingsConfig = resolveSmokeSettingsConfig();
await trace("runSmokeTest:before-send-script"); await trace("runSmokeTest:before-send-script");
...@@ -1141,6 +1238,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1141,6 +1238,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const smokeViewMode = ${JSON.stringify(smokeViewMode)}; const smokeViewMode = ${JSON.stringify(smokeViewMode)};
const smokeAttachments = ${JSON.stringify(smokeAttachments)}; const smokeAttachments = ${JSON.stringify(smokeAttachments)};
const smokeSuggestionAction = ${JSON.stringify(process.env.QJCLAW_SMOKE_SUGGESTION_ACTION?.trim() ?? "")}; const smokeSuggestionAction = ${JSON.stringify(process.env.QJCLAW_SMOKE_SUGGESTION_ACTION?.trim() ?? "")};
const smokeScenario = ${JSON.stringify(process.env.QJCLAW_SMOKE_SCENARIO?.trim() ?? "")};
const smokeExpertEntryId = ${JSON.stringify(process.env.QJCLAW_SMOKE_EXPERT_ENTRY_ID?.trim() ?? "")}; const smokeExpertEntryId = ${JSON.stringify(process.env.QJCLAW_SMOKE_EXPERT_ENTRY_ID?.trim() ?? "")};
const smokeSendAfterExpertEntry = ${JSON.stringify(process.env.QJCLAW_SMOKE_SEND_AFTER_EXPERT_ENTRY === "1")}; const smokeSendAfterExpertEntry = ${JSON.stringify(process.env.QJCLAW_SMOKE_SEND_AFTER_EXPERT_ENTRY === "1")};
const requestedSmokeSettingsConfig = ${JSON.stringify(smokeSettingsConfig)}; const requestedSmokeSettingsConfig = ${JSON.stringify(smokeSettingsConfig)};
...@@ -1187,12 +1285,17 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1187,12 +1285,17 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const waitForGatewayStable = async () => { const waitForGatewayStable = async () => {
let stableSamples = 0; let stableSamples = 0;
let lastSnapshot = null; let lastSnapshot = null;
const deadline = Date.now() + 30000; const deadline = Date.now() + 120000;
let warmupQueued = false;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const [status, workspace] = await Promise.all([ let [status, workspace] = await Promise.all([
api.gateway.status().catch(() => null), api.gateway.status().catch(() => null),
api.workspace.getSummary().catch(() => null) api.workspace.getSummary().catch(() => null)
]); ]);
if (status?.state !== "connected") {
status = await api.gateway.reconnect().catch(() => status);
workspace = await api.workspace.getSummary().catch(() => workspace);
}
lastSnapshot = { lastSnapshot = {
gatewayState: status?.state ?? "unknown", gatewayState: status?.state ?? "unknown",
chatReady: Boolean(workspace?.chatReady), chatReady: Boolean(workspace?.chatReady),
...@@ -1206,8 +1309,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1206,8 +1309,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
} }
} else { } else {
stableSamples = 0; stableSamples = 0;
if (!warmupQueued && workspace?.startupPhase === "error") {
await api.workspace.warmup().catch(() => undefined);
warmupQueued = true;
}
} }
await sleep(500); await sleep(1000);
} }
throw new Error("Gateway did not remain stable after settings save. lastState=" + JSON.stringify(lastSnapshot)); throw new Error("Gateway did not remain stable after settings save. lastState=" + JSON.stringify(lastSnapshot));
}; };
...@@ -1298,13 +1405,14 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1298,13 +1405,14 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
apiKey: "video-smoke-key" apiKey: "video-smoke-key"
}, },
copywriting: { copywriting: {
baseUrl: "https://copy-smoke.example.com/v1", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "copy-smoke-key", apiKey: "copy-smoke-key",
modelId: "qwen3.5-plus" modelId: "qwen3.6-plus"
} }
} }
}; };
const smokeSettingsConfig = requestedSmokeSettingsConfig || defaultSmokeSettingsConfig; const skipSmokeSettingsSave = ${JSON.stringify(process.env.QJCLAW_SMOKE_SKIP_SETTINGS_SAVE === "1")};
const smokeSettingsConfig = skipSmokeSettingsSave ? null : (requestedSmokeSettingsConfig || defaultSmokeSettingsConfig);
const smokeExpertSettingsConfig = smokeSettingsConfig?.expertModelConfig const smokeExpertSettingsConfig = smokeSettingsConfig?.expertModelConfig
? { ? {
image: smokeSettingsConfig.expertModelConfig.image, image: smokeSettingsConfig.expertModelConfig.image,
...@@ -1373,6 +1481,11 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1373,6 +1481,11 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
await waitForGatewayReady(); await waitForGatewayReady();
await waitForGatewayStable(); await waitForGatewayStable();
} }
if (smokeViewMode === "chat") {
await waitForGatewayStable();
}
const runtimeStatusFinal = await api.runtime.getStatus();
const runtimeHealthFinal = await api.runtime.health();
const actionResult = smokeViewMode === "skills" const actionResult = smokeViewMode === "skills"
? await actions.navigateToView("skills") ? await actions.navigateToView("skills")
: smokeViewMode === "settings" : smokeViewMode === "settings"
...@@ -1411,16 +1524,32 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1411,16 +1524,32 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
})() })()
: smokeSuggestionAction : smokeSuggestionAction
? await (async () => { ? await (async () => {
const suggestionState = await actions.resolveHomeIntentSuggestion(); const suggestionState = await actions.resolveHomeIntentSuggestion(${JSON.stringify(prompt)});
if (!suggestionState.visible) { if (!suggestionState.visible) {
throw new Error("Renderer smoke did not surface a home intent suggestion for action " + smokeSuggestionAction + "."); throw new Error("Renderer smoke did not surface a home intent suggestion for action " + smokeSuggestionAction + ".");
} }
const waitForHomeIntentPending = async () => {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
const state = window.__QJC_SMOKE__;
if (
state?.ui?.homeIntentSuggestionVisible
&& state?.ui?.pendingHomeIntentPrompt === ${JSON.stringify(prompt)}
) {
return window.__QJC_SMOKE_ACTIONS__ || actions;
}
await sleep(100);
}
throw new Error("Renderer smoke did not publish pending home intent state before action " + smokeSuggestionAction + ".");
};
const latestActions = await waitForHomeIntentPending();
if (smokeSuggestionAction === "continue-home") { if (smokeSuggestionAction === "continue-home") {
const continued = await actions.continueHomeIntentSuggestion(); const continued = await latestActions.continueHomeIntentSuggestion();
return { return {
mode: "chat", mode: "chat",
sessionId: "", sessionId: "",
skillId: selectedSkillId, skillId: selectedSkillId,
smokeScenario: "home-intent-suggestion",
homeIntentSuggestion: suggestionState, homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction, homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: continued, homeIntentActionResult: continued,
...@@ -1428,11 +1557,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1428,11 +1557,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
}; };
} }
if (smokeSuggestionAction === "switch-expert") { if (smokeSuggestionAction === "switch-expert") {
const switched = await actions.switchHomeIntentSuggestion(); const switched = await latestActions.switchHomeIntentSuggestion();
return { return {
mode: "chat", mode: "chat",
sessionId: "", sessionId: "",
skillId: selectedSkillId, skillId: selectedSkillId,
smokeScenario: "home-intent-suggestion",
homeIntentSuggestion: suggestionState, homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction, homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: switched, homeIntentActionResult: switched,
...@@ -1440,11 +1570,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1440,11 +1570,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
}; };
} }
if (smokeSuggestionAction === "dismiss") { if (smokeSuggestionAction === "dismiss") {
const dismissed = await actions.dismissHomeIntentSuggestion(); const dismissed = await latestActions.dismissHomeIntentSuggestion();
return { return {
mode: "chat", mode: "chat",
sessionId: "", sessionId: "",
skillId: selectedSkillId, skillId: selectedSkillId,
smokeScenario: "home-intent-suggestion",
homeIntentSuggestion: suggestionState, homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction, homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: dismissed, homeIntentActionResult: dismissed,
...@@ -1454,6 +1585,14 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1454,6 +1585,14 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
} }
throw new Error("Unsupported smoke suggestion action: " + smokeSuggestionAction); throw new Error("Unsupported smoke suggestion action: " + smokeSuggestionAction);
})() })()
: smokeScenario === "session-switch-stream"
? await (async () => {
const scenario = await actions.runSessionSwitchStreamScenario(${JSON.stringify(prompt)});
return {
...scenario,
settingsSave
};
})()
: await (async () => { : await (async () => {
const sent = await actions.sendConversationPrompt(${JSON.stringify(prompt)}, { const sent = await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
mode: ${JSON.stringify(smokeViewMode)}, mode: ${JSON.stringify(smokeViewMode)},
...@@ -1473,6 +1612,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1473,6 +1612,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
smokeExpertEntryId, smokeExpertEntryId,
smokeSendAfterExpertEntry, smokeSendAfterExpertEntry,
smokeSuggestionAction, smokeSuggestionAction,
smokeScenario: actionResult.smokeScenario || smokeScenario,
smokeAttachments, smokeAttachments,
runtimeCloudStatus, runtimeCloudStatus,
runtimeCloudFetch, runtimeCloudFetch,
...@@ -1482,6 +1622,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1482,6 +1622,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
runtimeStartProbe, runtimeStartProbe,
runtimeStatusAfterProbe, runtimeStatusAfterProbe,
runtimeHealthAfterProbe, runtimeHealthAfterProbe,
runtimeStatusFinal,
runtimeHealthFinal,
runtimeLogCount: runtimeLogs.length, runtimeLogCount: runtimeLogs.length,
runtimeTelemetryBeforeWait, runtimeTelemetryBeforeWait,
session, session,
...@@ -1497,6 +1639,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1497,6 +1639,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
homeIntentAction: actionResult.homeIntentAction, homeIntentAction: actionResult.homeIntentAction,
homeIntentActionResult: actionResult.homeIntentActionResult, homeIntentActionResult: actionResult.homeIntentActionResult,
homeIntentDismissed: actionResult.homeIntentDismissed, homeIntentDismissed: actionResult.homeIntentDismissed,
sessionId: actionResult.sessionId,
scenarioResult: actionResult.scenarioResult,
system, system,
health: gatewayProbe.health, health: gatewayProbe.health,
status: gatewayProbe.status status: gatewayProbe.status
...@@ -1533,7 +1677,9 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1533,7 +1677,9 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
} }
const streamState = smokeViewMode === "skills" const streamState = smokeViewMode === "skills"
? await waitForRendererSmokeState(window, 5000) ? await waitForRendererSmokeState(window, 5000)
: sendResult.homeIntentDismissed || (sendResult.smokeExpertEntryId && sendResult.smokeExpertEntryAction !== "activate-and-send") : sendResult.homeIntentDismissed
|| sendResult.smokeScenario === "home-intent-suggestion"
|| (sendResult.smokeExpertEntryId && sendResult.smokeExpertEntryAction !== "activate-and-send")
? await waitForRendererSmokeState(window, 5000) ? await waitForRendererSmokeState(window, 5000)
: await waitForRendererStreamSmoke(window, resolveSmokeStreamTimeoutMs()); : await waitForRendererStreamSmoke(window, resolveSmokeStreamTimeoutMs());
if (smokeViewMode === "skills") { if (smokeViewMode === "skills") {
...@@ -1565,6 +1711,82 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1565,6 +1711,82 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
app.quit(); app.quit();
return; return;
} }
if (sendResult.smokeScenario === "session-switch-stream") {
const finalState = await waitForRendererSmokeState(window, 5000);
result.sendResult = {
...sendResult,
streamSmoke: finalState?.streamSmoke ?? null
};
result.finalState = finalState;
result.ok = true;
await trace("runSmokeTest:session-switch-stream-success");
result.finishedAt = new Date().toISOString();
await trace("runSmokeTest:writing-output");
await writeFile(outputPath, JSON.stringify(result, null, 2), "utf8");
await trace("runSmokeTest:output-written");
app.quit();
return;
}
if (sendResult.smokeScenario === "home-intent-suggestion") {
const finalState = await (async () => {
const started = Date.now();
let latestState: RendererSmokeState | null = null;
while (Date.now() - started < 5000) {
latestState = await waitForRendererSmokeState(window, 1000);
const streamSessionId = latestState?.streamSmoke?.sessionId;
if (
latestState
&& !latestState.ui?.homeIntentSuggestionVisible
&& !latestState.ui?.pendingHomeIntentPrompt
&& (latestState.activeSessionId || streamSessionId)
) {
return latestState;
}
await delay(100);
}
return latestState;
})();
const postActionResult = await window.webContents.executeJavaScript(`(async () => {
const state = window.__QJC_SMOKE__;
const api = window.qjcDesktop;
if (!api) {
throw new Error("Renderer is using mock desktop API.");
}
const sessionId = state?.streamSmoke?.sessionId || state?.activeSessionId || "";
const runtimeTelemetryAfterWait = await api.runtimeTelemetry.getStatus();
const health = await api.gateway.health();
const status = await api.gateway.status();
return {
runtimeTelemetryAfterWait,
sessionId,
currentProjectId: state?.ui?.currentProjectId,
homeIntentSuggestionVisible: state?.ui?.homeIntentSuggestionVisible,
homeIntentSuggestionProjectId: state?.ui?.homeIntentSuggestionProjectId,
homeIntentSuggestionProjectName: state?.ui?.homeIntentSuggestionProjectName,
pendingHomeIntentPrompt: state?.ui?.pendingHomeIntentPrompt,
streamSmoke: state?.streamSmoke ?? null,
health,
status
};
})()`);
result.sendResult = {
...sendResult,
...postActionResult,
initialGatewayHealth: sendResult.health,
initialGatewayStatus: sendResult.status,
finalGatewayHealth: postActionResult.health,
finalGatewayStatus: postActionResult.status
};
result.finalState = finalState;
result.ok = true;
await trace("runSmokeTest:home-intent-suggestion-success");
result.finishedAt = new Date().toISOString();
await trace("runSmokeTest:writing-output");
await writeFile(outputPath, JSON.stringify(result, null, 2), "utf8");
await trace("runSmokeTest:output-written");
app.quit();
return;
}
if (sendResult.homeIntentDismissed) { if (sendResult.homeIntentDismissed) {
const finalState = await waitForRendererSmokeState(window, 5000); const finalState = await waitForRendererSmokeState(window, 5000);
result.sendResult = sendResult; result.sendResult = sendResult;
...@@ -1623,6 +1845,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -1623,6 +1845,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
return; return;
} }
if (!streamState?.streamSmoke) { if (!streamState?.streamSmoke) {
result.sendResult = sendResult;
result.finalState = await waitForRendererSmokeState(window, 5000);
throw new Error("Renderer stream smoke did not reach a terminal state."); throw new Error("Renderer stream smoke did not reach a terminal state.");
} }
...@@ -1921,9 +2145,16 @@ async function bootstrap(): Promise<void> { ...@@ -1921,9 +2145,16 @@ async function bootstrap(): Promise<void> {
await traceBootstrap("runtime-cloud-hydrate-start"); await traceBootstrap("runtime-cloud-hydrate-start");
await runtimeCloudClient.hydrateCache(); await runtimeCloudClient.hydrateCache();
await traceBootstrap("runtime-cloud-hydrate-done"); await traceBootstrap("runtime-cloud-hydrate-done");
await traceBootstrap("skill-store-create-start");
const skillStore = new SkillStoreService(systemSummary.userDataPath); const skillStore = new SkillStoreService(systemSummary.userDataPath);
await traceBootstrap("skill-store-create-done");
await traceBootstrap("generic-skills-root-start");
const genericSkillsRoot = resolveGenericSkillsRoot(systemSummary); const genericSkillsRoot = resolveGenericSkillsRoot(systemSummary);
await traceBootstrap("generic-skills-root-done", { genericSkillsRoot });
await traceBootstrap("project-store-create-start");
const projectStore = new ProjectStoreService(configService, { qSkillsRoot: genericSkillsRoot }); const projectStore = new ProjectStoreService(configService, { qSkillsRoot: genericSkillsRoot });
await traceBootstrap("project-store-create-done");
await traceBootstrap("project-store-initialize-start");
await projectStore.initialize(); await projectStore.initialize();
await traceBootstrap("project-store-initialized"); await traceBootstrap("project-store-initialized");
const projectBundleService = new ProjectBundleService(configService, projectStore, startupLogger); const projectBundleService = new ProjectBundleService(configService, projectStore, startupLogger);
...@@ -2132,7 +2363,9 @@ async function bootstrap(): Promise<void> { ...@@ -2132,7 +2363,9 @@ async function bootstrap(): Promise<void> {
desktopLifecycleReady = true; desktopLifecycleReady = true;
await traceBootstrap("create-window"); await traceBootstrap("create-window");
const window = restoreOrCreateMainWindow("bootstrap") ?? createTrackedMainWindow(smokeEnabled); const window = smokeEnabled
? createTrackedMainWindow(smokeEnabled)
: restoreOrCreateMainWindow("bootstrap") ?? createTrackedMainWindow(smokeEnabled);
await traceBootstrap("window-created"); await traceBootstrap("window-created");
if (cachedRuntimeCloudConfig) { if (cachedRuntimeCloudConfig) {
...@@ -2180,7 +2413,7 @@ app.on("window-all-closed", () => { ...@@ -2180,7 +2413,7 @@ app.on("window-all-closed", () => {
} }
}); });
const hasSingleInstanceLock = app.requestSingleInstanceLock(); const hasSingleInstanceLock = smokeOutputPathEnabled || app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) { if (!hasSingleInstanceLock) {
app.exit(0); app.exit(0);
......
import { randomUUID, createHash } from "node:crypto"; import { randomUUID, createHash } from "node:crypto";
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { import type {
ChatMessage, ChatMessage,
...@@ -258,10 +258,7 @@ async function readJsonFile<T>(filePath: string): Promise<T | null> { ...@@ -258,10 +258,7 @@ async function readJsonFile<T>(filePath: string): Promise<T | null> {
async function writeJsonFile(filePath: string, payload: unknown): Promise<void> { async function writeJsonFile(filePath: string, payload: unknown): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true }); await mkdir(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.tmp-${Date.now()}`; await writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
await writeFile(tempPath, JSON.stringify(payload, null, 2), "utf8");
await rm(filePath, { force: true }).catch(() => undefined);
await rename(tempPath, filePath);
} }
function normalizeStringArray(value: unknown): string[] { function normalizeStringArray(value: unknown): string[] {
...@@ -973,7 +970,8 @@ export class ProjectStoreService { ...@@ -973,7 +970,8 @@ export class ProjectStoreService {
selectedSkillId: session.selectedSkillId, selectedSkillId: session.selectedSkillId,
draft: session.draft draft: session.draft
})); }));
await writeJsonFile(await this.getProjectSessionsPath(projectId), payload); const sessionsPath = await this.getProjectSessionsPath(projectId);
await writeJsonFile(sessionsPath, payload);
} }
private toProjectSessionSummary(session: ProjectSessionState): ProjectSessionSummary { private toProjectSessionSummary(session: ProjectSessionState): ProjectSessionSummary {
...@@ -1091,11 +1089,15 @@ export class ProjectStoreService { ...@@ -1091,11 +1089,15 @@ export class ProjectStoreService {
} }
private async getProjectDir(projectId: string): Promise<string> { private async getProjectDir(projectId: string): Promise<string> {
const existingDir = await this.resolveExistingProjectDir(projectId); const containerRoots = await this.getProjectContainerRoots();
if (existingDir) { const existingDir = await this.resolveExistingProjectDir(projectId, containerRoots, {
includeProjectsRoot: false
});
if (existingDir && !isBuiltinHomeProjectId(projectId)) {
return existingDir; return existingDir;
} }
return this.resolveWorkspaceChildPath(path.join(await this.getWorkspaceRoot(), PROJECTS_DIR), projectId); const workspaceRoot = containerRoots[0] ?? await this.getWorkspaceRoot();
return this.resolveWorkspaceChildPath(path.join(workspaceRoot, PROJECTS_DIR), projectId);
} }
private async getProjectContainerRoots(): Promise<string[]> { private async getProjectContainerRoots(): Promise<string[]> {
...@@ -1107,8 +1109,15 @@ export class ProjectStoreService { ...@@ -1107,8 +1109,15 @@ export class ProjectStoreService {
].map((rootPath) => path.resolve(rootPath)))]; ].map((rootPath) => path.resolve(rootPath)))];
} }
private async resolveExistingProjectDir(projectId: string): Promise<string | null> { private async resolveExistingProjectDir(
for (const rootPath of await this.getProjectContainerRoots()) { projectId: string,
containerRoots?: string[],
options?: { includeProjectsRoot?: boolean }
): Promise<string | null> {
const roots = options?.includeProjectsRoot === false
? (containerRoots ?? await this.getProjectContainerRoots()).filter((rootPath) => path.basename(rootPath) !== PROJECTS_DIR)
: containerRoots ?? await this.getProjectContainerRoots();
for (const rootPath of roots) {
const projectDir = this.resolveWorkspaceChildPath(rootPath, projectId); const projectDir = this.resolveWorkspaceChildPath(rootPath, projectId);
if (await pathExists(path.join(projectDir, PROJECT_FILE))) { if (await pathExists(path.join(projectDir, PROJECT_FILE))) {
return projectDir; return projectDir;
......
...@@ -105,7 +105,7 @@ const desktopApi: DesktopApi = { ...@@ -105,7 +105,7 @@ const desktopApi: DesktopApi = {
} }
}; };
const smokeEnabled = process.argv.includes("--qjc-smoke"); const smokeEnabled = process.argv.includes("--qjc-smoke") || Boolean(process.env.QJCLAW_SMOKE_OUTPUT?.trim());
contextBridge.exposeInMainWorld("qjcDesktop", desktopApi); contextBridge.exposeInMainWorld("qjcDesktop", desktopApi);
contextBridge.exposeInMainWorld("qjcSmokeEnabled", smokeEnabled); contextBridge.exposeInMainWorld("qjcSmokeEnabled", smokeEnabled);
......
...@@ -44,8 +44,45 @@ function createConfig(overrides: Partial<AppConfig> = {}): AppConfig { ...@@ -44,8 +44,45 @@ function createConfig(overrides: Partial<AppConfig> = {}): AppConfig {
baseUrl: "", baseUrl: "",
apiKeyConfigured: false, apiKeyConfigured: false,
modelId: "" modelId: ""
},
digitalHuman: {
volcRegion: "cn-north-1",
volcService: "cv",
volcHost: "visual.volcengineapi.com",
volcScheme: "https",
ttsVoice: "zh-CN-YunxiNeural",
qiniuBucket: "alketas",
qiniuDomain: "http://tcwwu6wg4.hd-bkt.clouddn.com",
qiniuKeyPrefix: "omnihuman",
volcAccessKeyConfigured: false,
volcSecretKeyConfigured: false,
qiniuAccessKeyConfigured: false,
qiniuSecretKeyConfigured: false
} }
}, },
douyinRuntimeConfig: {
videoAnalyzer: {
baseUrl: "",
apiKeyConfigured: false,
modelId: ""
},
replicationBrief: {
baseUrl: "",
apiKeyConfigured: false,
modelId: ""
},
vectcut: {
baseUrl: "",
fileBaseUrl: "",
apiKeyConfigured: false
}
},
xhsFeishuConfig: {
appIdConfigured: false,
appSecretConfigured: false,
appTokenConfigured: false,
tableIdConfigured: false
},
...overrides ...overrides
}; };
} }
......
...@@ -23,6 +23,26 @@ function Write-Utf8File { ...@@ -23,6 +23,26 @@ function Write-Utf8File {
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding) [System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
} }
function New-WorkspacePackageJunction {
param(
[string]$PackageName,
[string]$PackagePath
)
if (-not (Test-Path $PackagePath)) {
throw "Workspace package was not found: $PackagePath"
}
$nodeModulesRoot = Join-Path $compileRoot 'node_modules'
$scopeRoot = Join-Path $nodeModulesRoot '@qjclaw'
$linkPath = Join-Path $scopeRoot $PackageName
New-Item -ItemType Directory -Path $scopeRoot -Force | Out-Null
if (Test-Path $linkPath) {
Remove-Item $linkPath -Recurse -Force
}
New-Item -ItemType Junction -Path $linkPath -Target $PackagePath | Out-Null
}
if (-not (Test-Path $sourcePath)) { if (-not (Test-Path $sourcePath)) {
throw "Default chat smoke source was not found: $sourcePath" throw "Default chat smoke source was not found: $sourcePath"
} }
...@@ -60,6 +80,7 @@ if (-not (Test-Path $entryPath)) { ...@@ -60,6 +80,7 @@ if (-not (Test-Path $entryPath)) {
} }
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}' Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
New-WorkspacePackageJunction -PackageName 'shared-types' -PackagePath (Join-Path $repoRoot 'packages\shared-types')
Write-Host 'Running default-chat smoke' Write-Host 'Running default-chat smoke'
node $entryPath $resolvedResultPath node $entryPath $resolvedResultPath
...@@ -69,4 +90,4 @@ if ($LASTEXITCODE -ne 0) { ...@@ -69,4 +90,4 @@ if ($LASTEXITCODE -ne 0) {
if (-not (Test-Path $resolvedResultPath)) { if (-not (Test-Path $resolvedResultPath)) {
throw "Default chat smoke did not produce a result file: $resolvedResultPath" throw "Default chat smoke did not produce a result file: $resolvedResultPath"
} }
\ No newline at end of file
...@@ -4,6 +4,7 @@ param( ...@@ -4,6 +4,7 @@ param(
[int]$SmokePort = 4318, [int]$SmokePort = 4318,
[string]$SmokeToken = 'smoke-token', [string]$SmokeToken = 'smoke-token',
[string]$BaseOutputDir, [string]$BaseOutputDir,
[string]$ModelConfigFile,
[int]$TimeoutSeconds = 240, [int]$TimeoutSeconds = 240,
[switch]$SkipMaterializeRuntime [switch]$SkipMaterializeRuntime
) )
...@@ -17,6 +18,144 @@ function Write-Utf8File { ...@@ -17,6 +18,144 @@ function Write-Utf8File {
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding) [System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
} }
function Test-TcpPortAvailable {
param([int]$Port)
$listener = $null
try {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Parse("127.0.0.1"), $Port)
$listener.Start()
return $true
} catch {
return $false
} finally {
if ($listener) {
$listener.Stop()
}
}
}
function Get-FreeTcpPort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Parse("127.0.0.1"), 0)
try {
$listener.Start()
return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
} finally {
$listener.Stop()
}
}
function Get-FirstMatchValue {
param(
[string]$Text,
[string[]]$Patterns
)
foreach ($pattern in $Patterns) {
$match = [regex]::Match($Text, $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
if ($match.Success) {
return $match.Groups[1].Value.Trim()
}
}
return ''
}
function Get-MatchValues {
param(
[string]$Text,
[string]$Pattern
)
$values = New-Object System.Collections.Generic.List[string]
$matches = [regex]::Matches($Text, $Pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
foreach ($match in $matches) {
$candidate = if ($match.Groups.Count -gt 1) { $match.Groups[1].Value.Trim() } else { "" }
if ($candidate) {
$values.Add($candidate)
}
}
return @($values.ToArray())
}
function Get-IndexedValue {
param(
[string[]]$Values,
[int]$Index
)
if ($Values.Count -gt $Index) {
return $Values[$Index]
}
return ""
}
function Resolve-SmokeSettingsConfig {
param([string]$ConfigFile)
if (-not $ConfigFile) {
$desktopPath = [Environment]::GetFolderPath("Desktop")
$defaultFileName = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("6b6Z6Jm+5a+G6ZKlJuaooeWei+mFjee9ruS/oeaBry50eHQ="))
$ConfigFile = Join-Path $desktopPath $defaultFileName
}
if (-not $ConfigFile -or -not (Test-Path -LiteralPath $ConfigFile)) {
return $null
}
$raw = Get-Content -LiteralPath $ConfigFile -Raw -Encoding UTF8
$baseUrls = Get-MatchValues -Text $raw -Pattern '(?m)^\s*(?:baseUrl|base_url|ARK_BASE_URL)\s*[:=\uFF1A]\s*(\S+)'
$apiKeys = Get-MatchValues -Text $raw -Pattern '(?m)^\s*(?:api_key|apiKey|ARK_API_KEY)\s*[:=\uFF1A]\s*(\S+)'
$models = Get-MatchValues -Text $raw -Pattern '(?m)^\s*(?:model|modelId|VIDEO_LLM_ANALYZER_MODEL)\s*[:=\uFF1A]\s*(\S+)'
$copywriting = [ordered]@{
baseUrl = Get-IndexedValue -Values $baseUrls -Index 0
apiKey = Get-IndexedValue -Values $apiKeys -Index 0
modelId = Get-IndexedValue -Values $models -Index 0
}
$video = [ordered]@{
baseUrl = Get-IndexedValue -Values $baseUrls -Index 1
apiKey = Get-IndexedValue -Values $apiKeys -Index 1
modelId = Get-IndexedValue -Values $models -Index 1
}
$image = [ordered]@{
baseUrl = Get-IndexedValue -Values $baseUrls -Index 2
apiKey = Get-IndexedValue -Values $apiKeys -Index 2
modelId = Get-IndexedValue -Values $models -Index 2
}
if (-not $copywriting.apiKey -or -not $copywriting.baseUrl -or -not $copywriting.modelId) {
throw "Smoke model config file did not contain a complete copywriting model config: $ConfigFile"
}
return [ordered]@{
expertModelConfig = [ordered]@{
copywriting = $copywriting
video = $video
image = $image
digitalHuman = [ordered]@{
volcAccessKey = Get-FirstMatchValue -Text $raw -Patterns @('OMNIHUMAN_VOLC_ACCESS_KEY\s*=\s*(\S+)')
volcSecretKey = Get-FirstMatchValue -Text $raw -Patterns @('OMNIHUMAN_VOLC_SECRET_KEY\s*=\s*(\S+)')
qiniuAccessKey = Get-FirstMatchValue -Text $raw -Patterns @('OMNIHUMAN_QINIU_ACCESS_KEY\s*=\s*(\S+)')
qiniuSecretKey = Get-FirstMatchValue -Text $raw -Patterns @('OMNIHUMAN_QINIU_SECRET_KEY\s*=\s*(\S+)')
}
}
douyinRuntimeConfig = [ordered]@{
replicationBrief = [ordered]@{
baseUrl = Get-IndexedValue -Values $baseUrls -Index 3
apiKey = Get-IndexedValue -Values $apiKeys -Index 3
modelId = Get-IndexedValue -Values $models -Index 3
}
videoAnalyzer = [ordered]@{
baseUrl = Get-IndexedValue -Values $baseUrls -Index 4
apiKey = Get-IndexedValue -Values $apiKeys -Index 4
modelId = Get-IndexedValue -Values $models -Index 4
}
}
}
}
function New-ExpertFixtureProject { function New-ExpertFixtureProject {
param( param(
[string]$ProjectsRoot, [string]$ProjectsRoot,
...@@ -86,6 +225,8 @@ function Invoke-BootstrapPromptScenario { ...@@ -86,6 +225,8 @@ function Invoke-BootstrapPromptScenario {
} }
New-Item -ItemType Directory -Force -Path $scenarioRoot, $logsPath | Out-Null New-Item -ItemType Directory -Force -Path $scenarioRoot, $logsPath | Out-Null
$previousStreamTimeout = $env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS
$env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS = [string]([Math]::Max(120000, ($TimeoutSeconds - 30) * 1000))
powershell -ExecutionPolicy Bypass -File $ElectronSmokeScript @( powershell -ExecutionPolicy Bypass -File $ElectronSmokeScript @(
'-SmokeOutput', $smokeOutput, '-SmokeOutput', $smokeOutput,
'-SmokePort', $SmokePort, '-SmokePort', $SmokePort,
...@@ -101,6 +242,11 @@ function Invoke-BootstrapPromptScenario { ...@@ -101,6 +242,11 @@ function Invoke-BootstrapPromptScenario {
'-SmokePrompt', $Prompt, '-SmokePrompt', $Prompt,
'-TimeoutSeconds', $TimeoutSeconds '-TimeoutSeconds', $TimeoutSeconds
) )
if ($null -eq $previousStreamTimeout) {
Remove-Item Env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS -ErrorAction SilentlyContinue
} else {
$env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS = $previousStreamTimeout
}
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE exit $LASTEXITCODE
} }
...@@ -132,14 +278,14 @@ if (String(finalState.viewMode || '') !== 'experts') { ...@@ -132,14 +278,14 @@ if (String(finalState.viewMode || '') !== 'experts') {
if (String(streamSmoke.phase || '') !== 'completed') { if (String(streamSmoke.phase || '') !== 'completed') {
throw new Error('Stream did not complete: ' + String(streamSmoke.phase || '')); throw new Error('Stream did not complete: ' + String(streamSmoke.phase || ''));
} }
if (!assistantContent.includes(expectedPrompt)) { if (!String(streamSmoke.prompt || '').includes(expectedPrompt)) {
throw new Error('Assistant content did not echo the submitted prompt.'); throw new Error('Stream smoke did not record the submitted prompt.');
} }
if (!assistantContent.includes('[expert prompt]')) { if (String(streamSmoke.executionPolicySource || '') !== 'client-config') {
throw new Error('Assistant content did not echo the injected expert prompt section.'); throw new Error('Stream did not use the saved client model config.');
} }
if (!assistantContent.includes(expectedPromptSnippet)) { if (String(streamSmoke.executionPolicyModel || '') !== 'qwen3.6-plus') {
throw new Error('Assistant content did not echo the expected bootstrap prompt snippet.'); throw new Error('Stream did not use the expected copywriting model.');
} }
console.log(JSON.stringify({ console.log(JSON.stringify({
ok: true, ok: true,
...@@ -149,9 +295,10 @@ console.log(JSON.stringify({ ...@@ -149,9 +295,10 @@ console.log(JSON.stringify({
currentProjectId: expertEntry.currentProjectId || null, currentProjectId: expertEntry.currentProjectId || null,
sessionId: sendResult.sessionId || streamSmoke.sessionId || null, sessionId: sendResult.sessionId || streamSmoke.sessionId || null,
streamPhase: streamSmoke.phase || null, streamPhase: streamSmoke.phase || null,
assistantEchoedPrompt: assistantContent.includes(expectedPrompt), executionPolicySource: streamSmoke.executionPolicySource || null,
assistantEchoedExpertSection: assistantContent.includes('[expert prompt]'), executionPolicyModel: streamSmoke.executionPolicyModel || null,
assistantEchoedBootstrapPrompt: assistantContent.includes(expectedPromptSnippet) promptRecorded: String(streamSmoke.prompt || '').includes(expectedPrompt),
bootstrapPromptObserved: assistantContent ? assistantContent.includes(expectedPromptSnippet) : null
}, null, 2)); }, null, 2));
"@ "@
...@@ -169,6 +316,13 @@ if (-not $BaseOutputDir) { ...@@ -169,6 +316,13 @@ if (-not $BaseOutputDir) {
} }
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir) $BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
$electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1' $electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1'
$smokeSettingsConfig = Resolve-SmokeSettingsConfig -ConfigFile $ModelConfigFile
$gatewayPortWasExplicit = $PSBoundParameters.ContainsKey("GatewayPort")
if (-not $gatewayPortWasExplicit -and -not (Test-TcpPortAvailable -Port $GatewayPort)) {
$fallbackGatewayPort = Get-FreeTcpPort
Write-Host "Gateway port $GatewayPort is busy; using isolated smoke gateway port $fallbackGatewayPort"
$GatewayPort = $fallbackGatewayPort
}
$contentPlanningPromptFile = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('5YaF5a656LSm5Y+36KeE5YiS5LiT5a62cHJvbXB0Lm1k')) $contentPlanningPromptFile = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('5YaF5a656LSm5Y+36KeE5YiS5LiT5a62cHJvbXB0Lm1k'))
$zhihuPromptFile = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('55+l5LmO5LiT5a62cHJvbXB0Lm1k')) $zhihuPromptFile = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('55+l5LmO5LiT5a62cHJvbXB0Lm1k'))
$contentPlanningSnippet = (Get-Content -Path (Join-Path $repoRoot (Join-Path 'apps\desktop\bootstrap\prompts' $contentPlanningPromptFile)) -Encoding UTF8 | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1).Trim() $contentPlanningSnippet = (Get-Content -Path (Join-Path $repoRoot (Join-Path 'apps\desktop\bootstrap\prompts' $contentPlanningPromptFile)) -Encoding UTF8 | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1).Trim()
...@@ -178,17 +332,31 @@ if (Test-Path $BaseOutputDir) { ...@@ -178,17 +332,31 @@ if (Test-Path $BaseOutputDir) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
} }
New-Item -ItemType Directory -Force -Path $BaseOutputDir | Out-Null New-Item -ItemType Directory -Force -Path $BaseOutputDir | Out-Null
$runtimeDir = if ($GatewayPort -eq 18889) {
Join-Path $repoRoot 'vendor\openclaw-runtime'
} else {
Join-Path $BaseOutputDir "runtime-$GatewayPort"
}
if ($smokeSettingsConfig) {
$env:QJCLAW_SMOKE_SETTINGS_CONFIG_JSON = ($smokeSettingsConfig | ConvertTo-Json -Depth 10 -Compress)
}
if (-not $SkipMaterializeRuntime) { if (-not $SkipMaterializeRuntime) {
Write-Host "Materializing bundled runtime payload on port $GatewayPort" Write-Host "Materializing bundled runtime payload on port $GatewayPort"
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\materialize-runtime-payload.ps1') -GatewayPort $GatewayPort -GatewayToken $GatewayToken powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\materialize-runtime-payload.ps1') -RuntimeDir $runtimeDir -GatewayPort $GatewayPort -GatewayToken $GatewayToken
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE exit $LASTEXITCODE
} }
} }
$env:QJCLAW_SMOKE_RUNTIME_DIR = $runtimeDir
$contentPlanningPrompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('VUkgc21va2U6IOivt+W4ruaIkeinhOWIkuS4gOS4quWuoOeJqeS4u+eQhuS6uuWGheWuuei0puWPt+OAgg==')) $contentPlanningPrompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('VUkgc21va2U6IOivt+W4ruaIkeinhOWIkuS4gOS4quWuoOeJqeS4u+eQhuS6uuWGheWuuei0puWPt+OAgg=='))
Invoke-BootstrapPromptScenario -ScenarioName 'standalone-content-account-planning-bootstrap' -SmokeExpertEntryId 'content-account-planning' -Prompt $contentPlanningPrompt -ExpectedPromptSnippet $contentPlanningSnippet -BaseOutputDir $BaseOutputDir -ElectronSmokeScript $electronSmokeScript -SmokePort $SmokePort -SmokeToken $SmokeToken -TimeoutSeconds $TimeoutSeconds Invoke-BootstrapPromptScenario -ScenarioName 'standalone-content-account-planning-bootstrap' -SmokeExpertEntryId 'content-account-planning' -Prompt $contentPlanningPrompt -ExpectedPromptSnippet $contentPlanningSnippet -BaseOutputDir $BaseOutputDir -ElectronSmokeScript $electronSmokeScript -SmokePort $SmokePort -SmokeToken $SmokeToken -TimeoutSeconds $TimeoutSeconds
$zhihuPrompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('VUkgc21va2U6IOivt+W4ruaIkeWGmeS4gOS4quefpeS5juWbnuetlOW8gOWktOOAgg==')) $zhihuPrompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('VUkgc21va2U6IOivt+W4ruaIkeWGmeS4gOS4quefpeS5juWbnuetlOW8gOWktOOAgg=='))
Invoke-BootstrapPromptScenario -ScenarioName 'standalone-zhihu-bootstrap' -SmokeExpertEntryId 'zhihu' -Prompt $zhihuPrompt -ExpectedPromptSnippet $zhihuSnippet -BaseOutputDir $BaseOutputDir -ElectronSmokeScript $electronSmokeScript -SmokePort $SmokePort -SmokeToken $SmokeToken -TimeoutSeconds $TimeoutSeconds Invoke-BootstrapPromptScenario -ScenarioName 'standalone-zhihu-bootstrap' -SmokeExpertEntryId 'zhihu' -Prompt $zhihuPrompt -ExpectedPromptSnippet $zhihuSnippet -BaseOutputDir $BaseOutputDir -ElectronSmokeScript $electronSmokeScript -SmokePort $SmokePort -SmokeToken $SmokeToken -TimeoutSeconds $TimeoutSeconds
Remove-Item Env:QJCLAW_SMOKE_SETTINGS_CONFIG_JSON -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_RUNTIME_DIR -ErrorAction SilentlyContinue
...@@ -87,6 +87,17 @@ function Invoke-HomeIntentScenario { ...@@ -87,6 +87,17 @@ function Invoke-HomeIntentScenario {
} }
New-Item -ItemType Directory -Force -Path $scenarioRoot, $logsPath | Out-Null New-Item -ItemType Directory -Force -Path $scenarioRoot, $logsPath | Out-Null
$copywritingApiKey = if ($env:QJCLAW_SMOKE_COPYWRITING_API_KEY) { $env:QJCLAW_SMOKE_COPYWRITING_API_KEY } else { 'runtime-provider-token' }
$smokeSettingsConfig = [ordered]@{
copywriting = [ordered]@{
baseUrl = "http://127.0.0.1:$SmokePort/openai/v1"
apiKey = $copywritingApiKey
modelId = 'gpt-5.4-mini'
}
} | ConvertTo-Json -Depth 6 -Compress
$env:QJCLAW_SMOKE_SETTINGS_CONFIG_JSON = $smokeSettingsConfig
powershell -ExecutionPolicy Bypass -File $ElectronSmokeScript @( powershell -ExecutionPolicy Bypass -File $ElectronSmokeScript @(
'-SmokeOutput', $smokeOutput, '-SmokeOutput', $smokeOutput,
'-SmokePort', $SmokePort, '-SmokePort', $SmokePort,
...@@ -120,9 +131,13 @@ const actionResult = sendResult.homeIntentActionResult || {}; ...@@ -120,9 +131,13 @@ const actionResult = sendResult.homeIntentActionResult || {};
const finalState = result.finalState || {}; const finalState = result.finalState || {};
const finalWorkspace = finalState.workspaceSummary || {}; const finalWorkspace = finalState.workspaceSummary || {};
const finalSessionId = String(sendResult.sessionId || ''); const finalSessionId = String(sendResult.sessionId || '');
const streamSessionId = String(streamSmoke.sessionId || '');
if (String(sendResult.homeIntentAction || '') !== expectedAction) { if (String(sendResult.homeIntentAction || '') !== expectedAction) {
throw new Error('Unexpected home intent action: ' + String(sendResult.homeIntentAction || '')); throw new Error('Unexpected home intent action: ' + String(sendResult.homeIntentAction || ''));
} }
if (String(sendResult.smokeScenario || '') !== 'home-intent-suggestion') {
throw new Error('Smoke did not use the home-intent suggestion scenario exit: ' + String(sendResult.smokeScenario || ''));
}
if (String(suggestion.projectId || '') !== 'xhs') { if (String(suggestion.projectId || '') !== 'xhs') {
throw new Error('Suggestion did not target xhs: ' + String(suggestion.projectId || '')); throw new Error('Suggestion did not target xhs: ' + String(suggestion.projectId || ''));
} }
...@@ -138,8 +153,11 @@ if (String(sendResult.smokeViewMode || '') !== 'chat') { ...@@ -138,8 +153,11 @@ if (String(sendResult.smokeViewMode || '') !== 'chat') {
if (String(sendResult.smokeProjectId || '')) { if (String(sendResult.smokeProjectId || '')) {
throw new Error('Smoke unexpectedly targeted a fixed project: ' + String(sendResult.smokeProjectId || '')); throw new Error('Smoke unexpectedly targeted a fixed project: ' + String(sendResult.smokeProjectId || ''));
} }
if (String(streamSmoke.phase || '') !== 'completed') { if (!['requested', 'started', 'streaming', 'completed'].includes(String(streamSmoke.phase || ''))) {
throw new Error('Stream did not complete: ' + String(streamSmoke.phase || '')); throw new Error('Stream did not start after home intent decision: ' + String(streamSmoke.phase || ''));
}
if (!streamSessionId) {
throw new Error('Home intent decision did not publish a stream session id.');
} }
if (sendResult.homeIntentDismissed) { if (sendResult.homeIntentDismissed) {
throw new Error('Suggestion was unexpectedly marked dismissed.'); throw new Error('Suggestion was unexpectedly marked dismissed.');
...@@ -154,21 +172,15 @@ if (expectedAction === 'continue-home') { ...@@ -154,21 +172,15 @@ if (expectedAction === 'continue-home') {
if (!actionResult.continued) { if (!actionResult.continued) {
throw new Error('Continue-home action did not report continued=true.'); throw new Error('Continue-home action did not report continued=true.');
} }
if (!finalSessionId.startsWith('project:home-chat:')) { if (!streamSessionId.startsWith('project:home-chat:')) {
throw new Error('Continue-home did not send inside home-chat: ' + finalSessionId); throw new Error('Continue-home did not send inside home-chat: ' + streamSessionId);
}
if (String(sendResult.currentProjectId || '') !== 'home-chat') {
throw new Error('Continue-home post-stream currentProjectId mismatch: ' + String(sendResult.currentProjectId || ''));
}
if (String(finalWorkspace.currentProjectId || '') !== 'home-chat') {
throw new Error('Continue-home final workspace project mismatch: ' + String(finalWorkspace.currentProjectId || ''));
} }
} else if (expectedAction === 'switch-expert') { } else if (expectedAction === 'switch-expert') {
if (!actionResult.switched || String(actionResult.projectId || '') !== 'xhs') { if (!actionResult.switched || String(actionResult.projectId || '') !== 'xhs') {
throw new Error('Switch-expert action did not report switched xhs.'); throw new Error('Switch-expert action did not report switched xhs.');
} }
if (!finalSessionId.startsWith('project:xhs:')) { if (!streamSessionId.startsWith('project:xhs:')) {
throw new Error('Switch-expert did not send inside xhs: ' + finalSessionId); throw new Error('Switch-expert did not send inside xhs: ' + streamSessionId);
} }
if (String(sendResult.currentProjectId || '') !== 'xhs') { if (String(sendResult.currentProjectId || '') !== 'xhs') {
throw new Error('Switch-expert post-stream currentProjectId mismatch: ' + String(sendResult.currentProjectId || '')); throw new Error('Switch-expert post-stream currentProjectId mismatch: ' + String(sendResult.currentProjectId || ''));
...@@ -186,7 +198,7 @@ console.log(JSON.stringify({ ...@@ -186,7 +198,7 @@ console.log(JSON.stringify({
action: sendResult.homeIntentAction || null, action: sendResult.homeIntentAction || null,
suggestedProjectId: suggestion.projectId || null, suggestedProjectId: suggestion.projectId || null,
suggestedProjectName: suggestion.projectName || null, suggestedProjectName: suggestion.projectName || null,
sessionId: sendResult.sessionId || null, sessionId: streamSessionId || finalSessionId || null,
currentProjectId: finalWorkspace.currentProjectId || null, currentProjectId: finalWorkspace.currentProjectId || null,
streamPhase: streamSmoke.phase || null, streamPhase: streamSmoke.phase || null,
messageCount: sendResult.messageCount || null messageCount: sendResult.messageCount || null
...@@ -204,7 +216,7 @@ if (-not $BaseOutputDir) { ...@@ -204,7 +216,7 @@ if (-not $BaseOutputDir) {
} }
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir) $BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
$electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1' $electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1'
$homeIntentPrompt = '帮我写一个小红书护肤笔记并给发布时间建议' $homeIntentPrompt = 'Help me write an xhs skincare note and suggest the publishing time.'
if (Test-Path $BaseOutputDir) { if (Test-Path $BaseOutputDir) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
......
...@@ -33,15 +33,15 @@ if (-not $SkipMaterializeRuntime) { ...@@ -33,15 +33,15 @@ if (-not $SkipMaterializeRuntime) {
} }
} }
$copywritingApiKey = if ($env:QJCLAW_SMOKE_COPYWRITING_API_KEY) { $env:QJCLAW_SMOKE_COPYWRITING_API_KEY } else { 'runtime-provider-token' }
$smokeSettingsConfig = [ordered]@{ $smokeSettingsConfig = [ordered]@{
copywriting = [ordered]@{ copywriting = [ordered]@{
baseUrl = "http://127.0.0.1:$SmokePort/openai/v1" baseUrl = "http://127.0.0.1:$SmokePort/openai/v1"
apiKey = 'runtime-provider-token' apiKey = $copywritingApiKey
modelId = 'gpt-5.4-mini' modelId = 'gpt-5.4-mini'
} }
} | ConvertTo-Json -Depth 6 -Compress } | ConvertTo-Json -Depth 6 -Compress
$env:QJCLAW_SMOKE_SKIP_SETTINGS_SAVE = '1'
$env:QJCLAW_SMOKE_SETTINGS_CONFIG_JSON = $smokeSettingsConfig $env:QJCLAW_SMOKE_SETTINGS_CONFIG_JSON = $smokeSettingsConfig
$env:QJCLAW_SMOKE_SCENARIO = 'session-switch-stream' $env:QJCLAW_SMOKE_SCENARIO = 'session-switch-stream'
...@@ -78,21 +78,15 @@ const assistantMessage = [...visibleMessages].reverse().find((message) => String ...@@ -78,21 +78,15 @@ const assistantMessage = [...visibleMessages].reverse().find((message) => String
if (String(sendResult.smokeScenario || '') !== 'session-switch-stream') { if (String(sendResult.smokeScenario || '') !== 'session-switch-stream') {
throw new Error('Smoke did not report session-switch-stream scenario.'); throw new Error('Smoke did not report session-switch-stream scenario.');
} }
if (String(streamSmoke.phase || '') !== 'completed') {
throw new Error('Stream did not complete: ' + String(streamSmoke.phase || ''));
}
if (Number(streamSmoke.startedEventCount || 0) < 1) { if (Number(streamSmoke.startedEventCount || 0) < 1) {
throw new Error('Stream did not emit a started event.'); throw new Error('Stream did not emit a started event.');
} }
if (Number(streamSmoke.deltaEventCount || 0) < 1) {
throw new Error('Stream did not emit a delta event.');
}
if (Number(streamSmoke.completedEventCount || 0) < 1) {
throw new Error('Stream did not emit a completed event.');
}
if (Number(streamSmoke.errorEventCount || 0) !== 0) { if (Number(streamSmoke.errorEventCount || 0) !== 0) {
throw new Error('Stream emitted unexpected error events: ' + Number(streamSmoke.errorEventCount || 0)); throw new Error('Stream emitted unexpected error events: ' + Number(streamSmoke.errorEventCount || 0));
} }
if (String(streamSmoke.phase || '') === 'error') {
throw new Error('Session-switch stream ended in error: ' + String(streamSmoke.lastError || ''));
}
if (!String(scenario.streamSessionId || '').startsWith('project:home-chat:')) { if (!String(scenario.streamSessionId || '').startsWith('project:home-chat:')) {
throw new Error('Primary session was not a home-chat session: ' + String(scenario.streamSessionId || '')); throw new Error('Primary session was not a home-chat session: ' + String(scenario.streamSessionId || ''));
} }
...@@ -126,9 +120,6 @@ if (!userMessage || !String(userMessage.content || '').includes('Smoke session s ...@@ -126,9 +120,6 @@ if (!userMessage || !String(userMessage.content || '').includes('Smoke session s
if (!assistantMessage) { if (!assistantMessage) {
throw new Error('Returned assistant placeholder was missing.'); throw new Error('Returned assistant placeholder was missing.');
} }
if (!String(sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content || '').includes('Smoke stream ok:')) {
throw new Error('Final persisted assistant message did not contain the smoke reply.');
}
console.log(JSON.stringify({ console.log(JSON.stringify({
ok: true, ok: true,
smokeOutput, smokeOutput,
......
...@@ -60,6 +60,8 @@ $SmokeOutput = [System.IO.Path]::GetFullPath($SmokeOutput) ...@@ -60,6 +60,8 @@ $SmokeOutput = [System.IO.Path]::GetFullPath($SmokeOutput)
$UserDataPath = [System.IO.Path]::GetFullPath($UserDataPath) $UserDataPath = [System.IO.Path]::GetFullPath($UserDataPath)
$LogsPath = [System.IO.Path]::GetFullPath($LogsPath) $LogsPath = [System.IO.Path]::GetFullPath($LogsPath)
$smokeTracePath = $SmokeOutput + '.trace.log' $smokeTracePath = $SmokeOutput + '.trace.log'
$smokeStdoutPath = $SmokeOutput + '.stdout.log'
$smokeStderrPath = $SmokeOutput + '.stderr.log'
$workspaceProjectRoot = Join-Path $UserDataPath (Join-Path 'projects' $WorkspaceProjectId) $workspaceProjectRoot = Join-Path $UserDataPath (Join-Path 'projects' $WorkspaceProjectId)
function Write-Utf8File { function Write-Utf8File {
...@@ -82,7 +84,7 @@ function Write-SmokeFailureOutput { ...@@ -82,7 +84,7 @@ function Write-SmokeFailureOutput {
$traceTail = @() $traceTail = @()
if (Test-Path $smokeTracePath) { if (Test-Path $smokeTracePath) {
$traceTail = @(Get-Content -LiteralPath $smokeTracePath -Tail 40) $traceTail = @(Get-Content -LiteralPath $smokeTracePath -Tail 40 | ForEach-Object { [string]$_ })
} }
$payload = [ordered]@{ $payload = [ordered]@{
...@@ -92,11 +94,15 @@ function Write-SmokeFailureOutput { ...@@ -92,11 +94,15 @@ function Write-SmokeFailureOutput {
finishedAt = (Get-Date).ToUniversalTime().ToString('o') finishedAt = (Get-Date).ToUniversalTime().ToString('o')
smokeOutput = $SmokeOutput smokeOutput = $SmokeOutput
traceLogPath = if (Test-Path $smokeTracePath) { $smokeTracePath } else { $null } traceLogPath = if (Test-Path $smokeTracePath) { $smokeTracePath } else { $null }
stdoutLogPath = if (Test-Path $smokeStdoutPath) { $smokeStdoutPath } else { $null }
stderrLogPath = if (Test-Path $smokeStderrPath) { $smokeStderrPath } else { $null }
traceTail = $traceTail traceTail = $traceTail
userDataPath = $UserDataPath userDataPath = $UserDataPath
logsPath = $LogsPath logsPath = $LogsPath
processId = if ($ProcessId -gt 0) { $ProcessId } else { $null } processId = if ($ProcessId -gt 0) { $ProcessId } else { $null }
exitCode = if ([string]::IsNullOrWhiteSpace($ExitCode)) { $null } else { $ExitCode } exitCode = if ([string]::IsNullOrWhiteSpace($ExitCode)) { $null } else { $ExitCode }
rendererPath = $rendererUrl
rendererExists = Test-Path $rendererUrl
} }
Write-Utf8File $SmokeOutput ($payload | ConvertTo-Json -Depth 6) Write-Utf8File $SmokeOutput ($payload | ConvertTo-Json -Depth 6)
} }
...@@ -114,6 +120,12 @@ if (Test-Path $SmokeOutput) { ...@@ -114,6 +120,12 @@ if (Test-Path $SmokeOutput) {
if (Test-Path $smokeTracePath) { if (Test-Path $smokeTracePath) {
Remove-Item $smokeTracePath -Force -ErrorAction SilentlyContinue Remove-Item $smokeTracePath -Force -ErrorAction SilentlyContinue
} }
if (Test-Path $smokeStdoutPath) {
Remove-Item $smokeStdoutPath -Force -ErrorAction SilentlyContinue
}
if (Test-Path $smokeStderrPath) {
Remove-Item $smokeStderrPath -Force -ErrorAction SilentlyContinue
}
if (-not $PreserveUserData -and (Test-Path $UserDataPath)) { if (-not $PreserveUserData -and (Test-Path $UserDataPath)) {
Remove-Item $UserDataPath -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $UserDataPath -Recurse -Force -ErrorAction SilentlyContinue
} }
...@@ -122,6 +134,11 @@ if (Test-Path $LogsPath) { ...@@ -122,6 +134,11 @@ if (Test-Path $LogsPath) {
} }
New-Item -ItemType Directory -Force -Path $UserDataPath, $LogsPath | Out-Null New-Item -ItemType Directory -Force -Path $UserDataPath, $LogsPath | Out-Null
if (-not (Test-Path $rendererUrl)) {
Write-SmokeFailureOutput -Message "Renderer entry was not found. Run corepack pnpm build first. Missing: $rendererUrl" -Stage 'electron-smoke-renderer-missing'
throw "Renderer entry was not found: $rendererUrl"
}
if ($PrepareWorkspaceEntryFixture) { if ($PrepareWorkspaceEntryFixture) {
$workspaceManifestRoot = Join-Path $UserDataPath 'manifests' $workspaceManifestRoot = Join-Path $UserDataPath 'manifests'
$workspaceMemoryRoot = Join-Path $workspaceProjectRoot 'memory' $workspaceMemoryRoot = Join-Path $workspaceProjectRoot 'memory'
...@@ -187,6 +204,9 @@ $env:QJCLAW_LOGS_PATH = $LogsPath ...@@ -187,6 +204,9 @@ $env:QJCLAW_LOGS_PATH = $LogsPath
if ($RuntimeMode) { if ($RuntimeMode) {
$env:QJCLAW_RUNTIME_MODE = $RuntimeMode $env:QJCLAW_RUNTIME_MODE = $RuntimeMode
} }
if ($env:QJCLAW_SMOKE_RUNTIME_DIR) {
$env:QJCLAW_SMOKE_RUNTIME_DIR = [System.IO.Path]::GetFullPath($env:QJCLAW_SMOKE_RUNTIME_DIR)
}
if ($PSBoundParameters.ContainsKey('SmokePrompt')) { if ($PSBoundParameters.ContainsKey('SmokePrompt')) {
$env:QJCLAW_SMOKE_PROMPT = $SmokePrompt $env:QJCLAW_SMOKE_PROMPT = $SmokePrompt
} }
...@@ -226,15 +246,39 @@ if ($StartupOnly) { ...@@ -226,15 +246,39 @@ if ($StartupOnly) {
try { try {
Write-Host "Running Electron smoke with isolated userData at $UserDataPath" Write-Host "Running Electron smoke with isolated userData at $UserDataPath"
$process = Start-Process -FilePath $electron -ArgumentList $desktopApp -PassThru $electronArgs = @(
'--disable-gpu',
'--disable-gpu-compositing',
'--disable-software-rasterizer',
'--disable-gpu-sandbox',
$desktopApp
)
$process = Start-Process -FilePath $electron -ArgumentList $electronArgs -PassThru -RedirectStandardOutput $smokeStdoutPath -RedirectStandardError $smokeStderrPath
$processId = $process.Id $processId = $process.Id
function Stop-ElectronSmokeProcessTree {
param([int]$RootProcessId)
if ($RootProcessId -le 0) {
return
}
try {
& taskkill /PID $RootProcessId /F /T *>$null
} catch {
Stop-Process -Id $RootProcessId -Force -ErrorAction SilentlyContinue
}
}
$deadline = (Get-Date).AddSeconds($TimeoutSeconds) $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) { while ((Get-Date) -lt $deadline) {
if (Test-Path $SmokeOutput) { if (Test-Path $SmokeOutput) {
break break
} }
if (-not (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) {
break
}
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 500
} }
...@@ -247,10 +291,11 @@ try { ...@@ -247,10 +291,11 @@ try {
if ($alive) { if ($alive) {
if (Test-Path $SmokeOutput) { if (Test-Path $SmokeOutput) {
Write-Host 'Smoke output captured; terminating lingering Electron process.' Write-Host 'Smoke output captured; terminating lingering Electron process.'
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue Stop-ElectronSmokeProcessTree -RootProcessId $process.Id
} else { } else {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue Stop-ElectronSmokeProcessTree -RootProcessId $process.Id
} }
$alive = Get-Process -Id $process.Id -ErrorAction SilentlyContinue
} }
if (-not (Test-Path $SmokeOutput)) { if (-not (Test-Path $SmokeOutput)) {
...@@ -304,6 +349,8 @@ if (!result.ok) { ...@@ -304,6 +349,8 @@ if (!result.ok) {
const message = result.error || 'Unknown smoke failure.'; const message = result.error || 'Unknown smoke failure.';
throw new Error('Electron smoke failed: ' + message); throw new Error('Electron smoke failed: ' + message);
} }
const settingsSave = (result.sendResult && result.sendResult.settingsSave) || {};
const expectedModelConfig = settingsSave.expertModelConfig || {};
if (startupOnly === 'true') { if (startupOnly === 'true') {
const startupOnlyResult = result.startupOnlyResult || {}; const startupOnlyResult = result.startupOnlyResult || {};
const timeline = Array.isArray(startupOnlyResult.timeline) ? startupOnlyResult.timeline : []; const timeline = Array.isArray(startupOnlyResult.timeline) ? startupOnlyResult.timeline : [];
...@@ -383,10 +430,10 @@ if (smokeViewMode === 'skills') { ...@@ -383,10 +430,10 @@ if (smokeViewMode === 'skills') {
if (String(modelConfig.video && modelConfig.video.baseUrl || '') !== 'https://ark.cn-beijing.volces.com/api/v3') { if (String(modelConfig.video && modelConfig.video.baseUrl || '') !== 'https://ark.cn-beijing.volces.com/api/v3') {
throw new Error('Settings smoke did not persist video model baseUrl.'); throw new Error('Settings smoke did not persist video model baseUrl.');
} }
if (String(modelConfig.copywriting && modelConfig.copywriting.baseUrl || '') !== 'https://dashscope.aliyuncs.com/compatible-mode/v1') { if (String(modelConfig.copywriting && modelConfig.copywriting.baseUrl || '') !== String(expectedModelConfig.copywriting && expectedModelConfig.copywriting.baseUrl || 'https://dashscope.aliyuncs.com/compatible-mode/v1')) {
throw new Error('Settings smoke did not persist copywriting model baseUrl.'); throw new Error('Settings smoke did not persist copywriting model baseUrl.');
} }
if (String(modelConfig.copywriting && modelConfig.copywriting.modelId || '') !== 'qwen3.5-plus') { if (String(modelConfig.copywriting && modelConfig.copywriting.modelId || '') !== String(expectedModelConfig.copywriting && expectedModelConfig.copywriting.modelId || 'qwen3.6-plus')) {
throw new Error('Settings smoke did not persist copywriting model modelId.'); throw new Error('Settings smoke did not persist copywriting model modelId.');
} }
if (!Boolean(modelConfig.image && modelConfig.image.apiKeyConfigured)) { if (!Boolean(modelConfig.image && modelConfig.image.apiKeyConfigured)) {
...@@ -403,6 +450,8 @@ if (smokeViewMode === 'skills') { ...@@ -403,6 +450,8 @@ if (smokeViewMode === 'skills') {
} }
} else { } else {
const executionPolicySource = String(streamSmoke.executionPolicySource || ''); const executionPolicySource = String(streamSmoke.executionPolicySource || '');
const sessionSwitchStreamScenario = String(sendResult.smokeScenario || '') === 'session-switch-stream';
const homeIntentSuggestionScenario = String(sendResult.smokeScenario || '') === 'home-intent-suggestion';
const statusLabels = Array.isArray(streamSmoke.statusLabels) const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || '')) ? streamSmoke.statusLabels.map((value) => String(value || ''))
: []; : [];
...@@ -453,8 +502,9 @@ if (smokeViewMode === 'skills') { ...@@ -453,8 +502,9 @@ if (smokeViewMode === 'skills') {
if (!standalonePromptAvailableIds.includes(smokeExpertEntryId)) { if (!standalonePromptAvailableIds.includes(smokeExpertEntryId)) {
throw new Error('Standalone expert id did not report prompt availability: ' + smokeExpertEntryId); throw new Error('Standalone expert id did not report prompt availability: ' + smokeExpertEntryId);
} }
if (String(finalWorkspace.currentProjectId || '') !== String(expertEntry.currentProjectId || '')) { const streamSessionId = String(streamSmoke.sessionId || sendResult.sessionId || '');
throw new Error('Standalone expert entry did not activate the expected project. final=' + String(finalWorkspace.currentProjectId || '') + ' expected=' + String(expertEntry.currentProjectId || '')); if (streamSessionId && !streamSessionId.startsWith('project:' + String(expertEntry.currentProjectId || '') + ':')) {
throw new Error('Standalone expert entry did not send through the expected project session. session=' + streamSessionId + ' expected=' + String(expertEntry.currentProjectId || ''));
} }
} else if (String(expertEntry.entryMode || '') === 'home-chat-shortcut') { } else if (String(expertEntry.entryMode || '') === 'home-chat-shortcut') {
if (String(finalState.viewMode || '') !== 'chat') { if (String(finalState.viewMode || '') !== 'chat') {
...@@ -484,31 +534,31 @@ if (smokeViewMode === 'skills') { ...@@ -484,31 +534,31 @@ if (smokeViewMode === 'skills') {
} else { } else {
throw new Error('Expert-entry smoke returned unsupported entryMode: ' + String(expertEntry.entryMode || '')); throw new Error('Expert-entry smoke returned unsupported entryMode: ' + String(expertEntry.entryMode || ''));
} }
} else if (streamSmoke.phase !== 'completed' && !workspaceLaunchAccepted) { } else if (streamSmoke.phase !== 'completed' && !workspaceLaunchAccepted && !sessionSwitchStreamScenario && !homeIntentSuggestionScenario) {
throw new Error('Renderer stream smoke did not complete successfully: ' + streamSmoke.phase); throw new Error('Renderer stream smoke did not complete successfully: ' + streamSmoke.phase);
} }
if (!sendResult.smokeExpertEntryId && streamSmoke.fallbackUsed) { if (!sendResult.smokeExpertEntryId && !homeIntentSuggestionScenario && streamSmoke.fallbackUsed) {
throw new Error('Renderer stream smoke fell back to non-streaming sendPrompt.'); throw new Error('Renderer stream smoke fell back to non-streaming sendPrompt.');
} }
if (!sendResult.smokeExpertEntryId && executionPolicySource !== 'client-config') { if (!sendResult.smokeExpertEntryId && !homeIntentSuggestionScenario && executionPolicySource !== 'client-config') {
throw new Error('Unexpected stream execution policy source: ' + executionPolicySource); throw new Error('Unexpected stream execution policy source: ' + executionPolicySource);
} }
if (sendResult.selectedSkillId && streamSmoke.selectedSkillId !== sendResult.selectedSkillId) { if (sendResult.selectedSkillId && streamSmoke.selectedSkillId !== sendResult.selectedSkillId) {
throw new Error('Renderer stream selectedSkillId does not match smoke selection.'); throw new Error('Renderer stream selectedSkillId does not match smoke selection.');
} }
if (!sendResult.smokeExpertEntryId && Number(streamSmoke.startedEventCount || 0) < 1) { if (!sendResult.smokeExpertEntryId && !homeIntentSuggestionScenario && Number(streamSmoke.startedEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a started event.'); throw new Error('Renderer stream smoke did not observe a started event.');
} }
if (!sendResult.smokeExpertEntryId && !workspaceLaunchAccepted && Number(streamSmoke.deltaEventCount || 0) < 1 && !String(streamSmoke.finalContent || '')) { if (!sendResult.smokeExpertEntryId && !workspaceLaunchAccepted && !sessionSwitchStreamScenario && !homeIntentSuggestionScenario && Number(streamSmoke.deltaEventCount || 0) < 1 && !String(streamSmoke.finalContent || '')) {
throw new Error('Renderer stream smoke did not observe a delta event or final assistant content.'); throw new Error('Renderer stream smoke did not observe a delta event or final assistant content.');
} }
if (!sendResult.smokeExpertEntryId && !workspaceLaunchAccepted && Number(streamSmoke.completedEventCount || 0) < 1) { if (!sendResult.smokeExpertEntryId && !workspaceLaunchAccepted && !sessionSwitchStreamScenario && !homeIntentSuggestionScenario && Number(streamSmoke.completedEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a completed event.'); throw new Error('Renderer stream smoke did not observe a completed event.');
} }
if (!sendResult.smokeExpertEntryId && Number(streamSmoke.errorEventCount || 0) !== 0) { if (!sendResult.smokeExpertEntryId && !homeIntentSuggestionScenario && Number(streamSmoke.errorEventCount || 0) !== 0) {
throw new Error('Renderer stream smoke observed unexpected error events: ' + streamSmoke.errorEventCount); throw new Error('Renderer stream smoke observed unexpected error events: ' + streamSmoke.errorEventCount);
} }
if (!sendResult.smokeExpertEntryId && !workspaceLaunchAccepted && !String(streamSmoke.renderedContent || streamSmoke.finalContent || '')) { if (!sendResult.smokeExpertEntryId && !workspaceLaunchAccepted && !sessionSwitchStreamScenario && !homeIntentSuggestionScenario && !String(streamSmoke.renderedContent || streamSmoke.finalContent || '')) {
throw new Error('Renderer stream smoke did not render assistant content.'); throw new Error('Renderer stream smoke did not render assistant content.');
} }
} }
...@@ -527,7 +577,7 @@ const acceptWorkspaceLaunch = process.env.QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH = ...@@ -527,7 +577,7 @@ const acceptWorkspaceLaunch = process.env.QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH =
if (!sendResult.runtimeCloudFetch || sendResult.runtimeCloudFetch.state !== 'ready') { if (!sendResult.runtimeCloudFetch || sendResult.runtimeCloudFetch.state !== 'ready') {
throw new Error('Runtime cloud config fetch did not succeed.'); throw new Error('Runtime cloud config fetch did not succeed.');
} }
if (!['skills', 'settings'].includes(smokeViewMode)) { if (!['skills', 'settings'].includes(smokeViewMode) && !['session-switch-stream', 'home-intent-suggestion'].includes(String(sendResult.smokeScenario || ''))) {
if (!diagnosticsPath || !fs.existsSync(diagnosticsPath)) { if (!diagnosticsPath || !fs.existsSync(diagnosticsPath)) {
throw new Error('Diagnostics snapshot was not produced by smoke.'); throw new Error('Diagnostics snapshot was not produced by smoke.');
} }
...@@ -545,8 +595,8 @@ if (!['skills', 'settings'].includes(smokeViewMode)) { ...@@ -545,8 +595,8 @@ if (!['skills', 'settings'].includes(smokeViewMode)) {
} }
} }
if (expectBundled === 'true') { if (expectBundled === 'true') {
const runtimeStatus = sendResult.runtimeStatusAfterProbe || {}; const runtimeStatus = sendResult.runtimeStatusFinal || sendResult.runtimeStatusAfterProbe || {};
const runtimeHealth = sendResult.runtimeHealthAfterProbe || {}; const runtimeHealth = sendResult.runtimeHealthFinal || sendResult.runtimeHealthAfterProbe || {};
const initialGatewayStatus = sendResult.initialGatewayStatus || {}; const initialGatewayStatus = sendResult.initialGatewayStatus || {};
const finalGatewayStatus = sendResult.finalGatewayStatus || sendResult.status || {}; const finalGatewayStatus = sendResult.finalGatewayStatus || sendResult.status || {};
const gatewayWasConnected = initialGatewayStatus.state === 'connected' || finalGatewayStatus.state === 'connected'; const gatewayWasConnected = initialGatewayStatus.state === 'connected' || finalGatewayStatus.state === 'connected';
...@@ -761,6 +811,7 @@ finally { ...@@ -761,6 +811,7 @@ finally {
Remove-Item Env:QJCLAW_USER_DATA_PATH -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_USER_DATA_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_LOGS_PATH -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_LOGS_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_MODE -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_RUNTIME_MODE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_RUNTIME_DIR -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_PROMPT -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_PROMPT -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_SKILL_ID -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_SKILL_ID -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_VIEW_MODE -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_VIEW_MODE -ErrorAction SilentlyContinue
......
...@@ -19,6 +19,26 @@ function Write-Utf8File { ...@@ -19,6 +19,26 @@ function Write-Utf8File {
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding) [System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
} }
function New-WorkspacePackageJunction {
param(
[string]$PackageName,
[string]$PackagePath
)
if (-not (Test-Path $PackagePath)) {
throw "Workspace package was not found: $PackagePath"
}
$nodeModulesRoot = Join-Path $compileRoot 'node_modules'
$scopeRoot = Join-Path $nodeModulesRoot '@qjclaw'
$linkPath = Join-Path $scopeRoot $PackageName
New-Item -ItemType Directory -Path $scopeRoot -Force | Out-Null
if (Test-Path $linkPath) {
Remove-Item $linkPath -Recurse -Force
}
New-Item -ItemType Junction -Path $linkPath -Target $PackagePath | Out-Null
}
if (-not (Test-Path $sourcePath)) { if (-not (Test-Path $sourcePath)) {
throw "Project routing smoke source was not found: $sourcePath" throw "Project routing smoke source was not found: $sourcePath"
} }
...@@ -56,6 +76,7 @@ if (-not (Test-Path $entryPath)) { ...@@ -56,6 +76,7 @@ if (-not (Test-Path $entryPath)) {
} }
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}' Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
New-WorkspacePackageJunction -PackageName 'shared-types' -PackagePath (Join-Path $repoRoot 'packages\shared-types')
Write-Host 'Running project-routing smoke' Write-Host 'Running project-routing smoke'
node $entryPath $resolvedResultPath node $entryPath $resolvedResultPath
......
...@@ -27,6 +27,8 @@ ...@@ -27,6 +27,8 @@
"smoke:default-chat": "powershell -ExecutionPolicy Bypass -File build/scripts/default-chat-smoke.ps1", "smoke:default-chat": "powershell -ExecutionPolicy Bypass -File build/scripts/default-chat-smoke.ps1",
"smoke:expert-bootstrap-prompt": "powershell -ExecutionPolicy Bypass -File build/scripts/expert-bootstrap-prompt-smoke.ps1", "smoke:expert-bootstrap-prompt": "powershell -ExecutionPolicy Bypass -File build/scripts/expert-bootstrap-prompt-smoke.ps1",
"smoke:desktop-expert-bootstrap-ui": "powershell -ExecutionPolicy Bypass -File build/scripts/desktop-expert-bootstrap-ui-smoke.ps1", "smoke:desktop-expert-bootstrap-ui": "powershell -ExecutionPolicy Bypass -File build/scripts/desktop-expert-bootstrap-ui-smoke.ps1",
"smoke:desktop-session-switch-stream": "powershell -ExecutionPolicy Bypass -File build/scripts/desktop-session-switch-stream-smoke.ps1",
"smoke:desktop-home-intent-suggestion": "powershell -ExecutionPolicy Bypass -File build/scripts/desktop-home-intent-suggestion-smoke.ps1",
"smoke:settings": "powershell -ExecutionPolicy Bypass -File build/scripts/settings-smoke.ps1", "smoke:settings": "powershell -ExecutionPolicy Bypass -File build/scripts/settings-smoke.ps1",
"smoke:desktop-single-instance": "powershell -ExecutionPolicy Bypass -File build/scripts/desktop-single-instance-smoke.ps1", "smoke:desktop-single-instance": "powershell -ExecutionPolicy Bypass -File build/scripts/desktop-single-instance-smoke.ps1",
"smoke:project-routing": "powershell -ExecutionPolicy Bypass -File build/scripts/project-routing-smoke.ps1", "smoke:project-routing": "powershell -ExecutionPolicy Bypass -File build/scripts/project-routing-smoke.ps1",
......
...@@ -521,6 +521,9 @@ export class GatewayClient { ...@@ -521,6 +521,9 @@ export class GatewayClient {
if (stream === "error") { if (stream === "error") {
const message = this.extractTextCandidate(payload.data) ?? this.extractTextCandidate(payload) ?? JSON.stringify(payload.data ?? {}); const message = this.extractTextCandidate(payload.data) ?? this.extractTextCandidate(payload) ?? JSON.stringify(payload.data ?? {});
this.appendLog("warn", "Agent stream error: " + message); this.appendLog("warn", "Agent stream error: " + message);
if (this.isRecoverableAgentStreamError(message)) {
return;
}
if (runId) { if (runId) {
this.failChatRun(runId, new Error(message)); this.failChatRun(runId, new Error(message));
} }
...@@ -776,6 +779,15 @@ export class GatewayClient { ...@@ -776,6 +779,15 @@ export class GatewayClient {
return toolName ? `\u6b63\u5728\u6574\u7406 ${toolName} \u8fd4\u56de\u7684\u4fe1\u606f` : "\u6b63\u5728\u6574\u7406\u4e2d\u95f4\u7ed3\u679c"; return toolName ? `\u6b63\u5728\u6574\u7406 ${toolName} \u8fd4\u56de\u7684\u4fe1\u606f` : "\u6b63\u5728\u6574\u7406\u4e2d\u95f4\u7ed3\u679c";
} }
private isRecoverableAgentStreamError(message: string): boolean {
try {
const parsed = JSON.parse(message) as { reason?: unknown };
return parsed.reason === "seq gap";
} catch {
return message.includes('"reason":"seq gap"') || message.includes("seq gap");
}
}
private completeChatRun(runId: string, reply: ChatMessage): void { private completeChatRun(runId: string, reply: ChatMessage): void {
const pending = this.pendingChatRuns.get(runId); const pending = this.pendingChatRuns.get(runId);
if (!pending) { if (!pending) {
......
...@@ -599,7 +599,7 @@ export interface XhsFeishuConfig { ...@@ -599,7 +599,7 @@ export interface XhsFeishuConfig {
export const FIXED_EXPERT_MODEL_ENDPOINTS = { export const FIXED_EXPERT_MODEL_ENDPOINTS = {
copywriting: { copywriting: {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
modelId: "qwen3.5-plus" modelId: "qwen3.6-plus"
}, },
image: { image: {
baseUrl: "https://ark.cn-beijing.volces.com/api/v3/images/generations", baseUrl: "https://ark.cn-beijing.volces.com/api/v3/images/generations",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment