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";
import path from "node:path";
import { pathToFileURL } from "node:url";
function resolveRendererEntry(): string {
export function resolveRendererEntry(): string {
if (!app.isPackaged) {
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 {
export function createMainWindow(smokeEnabled = false): BrowserWindow {
Menu.setApplicationMenu(null);
const preloadPath = path.join(__dirname, "..", "preload", "index.js");
const window = new BrowserWindow({
return new BrowserWindow({
width: 1400,
height: 920,
minWidth: 960,
......@@ -37,14 +37,13 @@ export function createMainWindow(smokeEnabled = false): BrowserWindow {
preload: preloadPath
}
});
}
export function loadMainWindowRenderer(window: BrowserWindow): Promise<void> {
const rendererEntry = resolveRendererEntry();
if (rendererEntry.startsWith("http://") || rendererEntry.startsWith("https://")) {
void window.loadURL(rendererEntry);
} else {
void window.loadURL(pathToFileURL(rendererEntry).toString());
return window.loadURL(rendererEntry);
}
return window;
return window.loadURL(pathToFileURL(rendererEntry).toString());
}
......@@ -4,7 +4,7 @@ import { BrowserWindow, app } from "electron";
import { GatewayClient } from "@qjclaw/gateway-client";
import { RuntimeManager } from "@qjclaw/runtime-manager";
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 { AppConfigService } from "./services/app-config.js";
import { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ProfileClient } from "./services/cloud-api.js";
......@@ -165,6 +165,10 @@ app.setName(APP_DISPLAY_NAME);
if (smokeOutputPathEnabled) {
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) {
......@@ -227,12 +231,13 @@ function snapshotMainWindowState(window: BrowserWindow | null): Record<string, u
}
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;
url?: string;
errorCode?: number;
errorDescription?: string;
reason?: string;
message?: string;
}
interface WindowInventorySnapshot {
......@@ -304,6 +309,10 @@ function updateTrackedMainWindowLoadState(window: BrowserWindow, nextState: Omit
void startupLoggerRef?.info("bootstrap", "window.load-state", "Tracked main window load state updated.", {
...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 {
......@@ -346,6 +355,20 @@ function attachMainWindow(window: BrowserWindow, smokeEnabled = mainWindowSmokeE
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", () => {
updateTrackedMainWindowLoadState(window, {
status: "web-contents-destroyed"
......@@ -365,6 +388,24 @@ function attachMainWindow(window: BrowserWindow, smokeEnabled = mainWindowSmokeE
function createTrackedMainWindow(smokeEnabled = mainWindowSmokeEnabled): BrowserWindow {
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) {
pendingMainWindowReveal = false;
focusMainWindow(window);
......@@ -533,6 +574,11 @@ function hasConfiguredClientChatModel(config: AppConfig, apiKey?: string | null)
}
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) {
return path.join(systemSummary.resourcesPath, "vendor", "openclaw-runtime");
}
......@@ -556,9 +602,17 @@ async function waitForRendererSmokeState(window: BrowserWindow, timeoutMs = 2000
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;
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 {
await delay(250);
continue;
......@@ -579,19 +633,26 @@ function matchesExpectedSmokeModelConfig(state: RendererSmokeState | null | unde
return false;
}
return String(modelConfig.image?.baseUrl || "") === "https://image-smoke.example.com/v1"
&& String(modelConfig.video?.baseUrl || "") === "https://video-smoke.example.com/v1"
&& String(modelConfig.copywriting?.baseUrl || "") === "https://copy-smoke.example.com/v1"
&& Boolean(modelConfig.image?.apiKeyConfigured)
return Boolean(modelConfig.image?.apiKeyConfigured)
&& Boolean(modelConfig.video?.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) => {
let settled = false;
let timeout: NodeJS.Timeout | undefined;
const cleanup = () => {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
window.removeListener("closed", onClosed);
if (!window.webContents.isDestroyed()) {
window.webContents.removeListener("did-fail-load", onFailLoad);
......@@ -613,7 +674,9 @@ async function waitForRendererSmokeBootstrap(window: BrowserWindow, timeoutMs =
}
settled = true;
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) => {
if (!isMainFrame) {
......@@ -638,15 +701,36 @@ async function waitForRendererSmokeBootstrap(window: BrowserWindow, timeoutMs =
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 () => {
try {
await trace?.("renderer-smoke-bootstrap:wait-state-start");
const state = await waitForRendererSmokeState(window, timeoutMs);
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;
}
await trace?.("renderer-smoke-bootstrap:state-ready");
finish(state);
} catch (error) {
await trace?.("renderer-smoke-bootstrap:error:" + (error instanceof Error ? error.message : String(error)));
fail(error instanceof Error ? error.message : String(error));
}
})();
......@@ -762,6 +846,17 @@ function resolveSmokeSettingsConfig(): Pick<SaveConfigInput, "expertModelConfig"
}
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 };
video?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown };
copywriting?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown };
......@@ -777,6 +872,7 @@ function resolveSmokeSettingsConfig(): Pick<SaveConfigInput, "expertModelConfig"
vectcut?: { baseUrl?: unknown; fileBaseUrl?: unknown; apiKey?: unknown };
};
};
const expertModelInput = input.expertModelConfig ?? input;
type SmokeSettingsEntry = {
baseUrl: string;
......@@ -881,10 +977,10 @@ function resolveSmokeSettingsConfig(): Pick<SaveConfigInput, "expertModelConfig"
const resolved = {
expertModelConfig: {
image: normalizeEntry(input.image),
video: normalizeEntry(input.video),
copywriting: normalizeEntry(input.copywriting),
digitalHuman: normalizeDigitalHumanEntry(input.digitalHuman)
image: normalizeEntry(expertModelInput.image),
video: normalizeEntry(expertModelInput.video),
copywriting: normalizeEntry(expertModelInput.copywriting),
digitalHuman: normalizeDigitalHumanEntry(expertModelInput.digitalHuman)
},
douyinRuntimeConfig: {
videoAnalyzer: normalizeDouyinTextEntry(input.douyinRuntimeConfig?.videoAnalyzer),
......@@ -1015,7 +1111,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
}
await trace("runSmokeTest:loading-renderer-state");
let initialState = await waitForRendererSmokeBootstrap(window);
let initialState = await waitForRendererSmokeBootstrap(window, 20000, trace);
const readyDeadline = Date.now() + 30000;
while (
......@@ -1121,6 +1217,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const smokeExpertEntryId = process.env.QJCLAW_SMOKE_EXPERT_ENTRY_ID?.trim() || "";
const smokeSendAfterExpertEntry = process.env.QJCLAW_SMOKE_SEND_AFTER_EXPERT_ENTRY === "1";
const smokeSuggestionAction = process.env.QJCLAW_SMOKE_SUGGESTION_ACTION?.trim() || "";
const smokeScenario = process.env.QJCLAW_SMOKE_SCENARIO?.trim() || "";
const smokeAttachments = resolveSmokeAttachments();
const smokeSettingsConfig = resolveSmokeSettingsConfig();
await trace("runSmokeTest:before-send-script");
......@@ -1141,6 +1238,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const smokeViewMode = ${JSON.stringify(smokeViewMode)};
const smokeAttachments = ${JSON.stringify(smokeAttachments)};
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 smokeSendAfterExpertEntry = ${JSON.stringify(process.env.QJCLAW_SMOKE_SEND_AFTER_EXPERT_ENTRY === "1")};
const requestedSmokeSettingsConfig = ${JSON.stringify(smokeSettingsConfig)};
......@@ -1187,12 +1285,17 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const waitForGatewayStable = async () => {
let stableSamples = 0;
let lastSnapshot = null;
const deadline = Date.now() + 30000;
const deadline = Date.now() + 120000;
let warmupQueued = false;
while (Date.now() < deadline) {
const [status, workspace] = await Promise.all([
let [status, workspace] = await Promise.all([
api.gateway.status().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 = {
gatewayState: status?.state ?? "unknown",
chatReady: Boolean(workspace?.chatReady),
......@@ -1206,8 +1309,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
}
} else {
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));
};
......@@ -1298,13 +1405,14 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
apiKey: "video-smoke-key"
},
copywriting: {
baseUrl: "https://copy-smoke.example.com/v1",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
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
? {
image: smokeSettingsConfig.expertModelConfig.image,
......@@ -1373,6 +1481,11 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
await waitForGatewayReady();
await waitForGatewayStable();
}
if (smokeViewMode === "chat") {
await waitForGatewayStable();
}
const runtimeStatusFinal = await api.runtime.getStatus();
const runtimeHealthFinal = await api.runtime.health();
const actionResult = smokeViewMode === "skills"
? await actions.navigateToView("skills")
: smokeViewMode === "settings"
......@@ -1411,16 +1524,32 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
})()
: smokeSuggestionAction
? await (async () => {
const suggestionState = await actions.resolveHomeIntentSuggestion();
const suggestionState = await actions.resolveHomeIntentSuggestion(${JSON.stringify(prompt)});
if (!suggestionState.visible) {
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") {
const continued = await actions.continueHomeIntentSuggestion();
const continued = await latestActions.continueHomeIntentSuggestion();
return {
mode: "chat",
sessionId: "",
skillId: selectedSkillId,
smokeScenario: "home-intent-suggestion",
homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: continued,
......@@ -1428,11 +1557,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
};
}
if (smokeSuggestionAction === "switch-expert") {
const switched = await actions.switchHomeIntentSuggestion();
const switched = await latestActions.switchHomeIntentSuggestion();
return {
mode: "chat",
sessionId: "",
skillId: selectedSkillId,
smokeScenario: "home-intent-suggestion",
homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: switched,
......@@ -1440,11 +1570,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
};
}
if (smokeSuggestionAction === "dismiss") {
const dismissed = await actions.dismissHomeIntentSuggestion();
const dismissed = await latestActions.dismissHomeIntentSuggestion();
return {
mode: "chat",
sessionId: "",
skillId: selectedSkillId,
smokeScenario: "home-intent-suggestion",
homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: dismissed,
......@@ -1454,6 +1585,14 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
}
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 () => {
const sent = await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
mode: ${JSON.stringify(smokeViewMode)},
......@@ -1473,6 +1612,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
smokeExpertEntryId,
smokeSendAfterExpertEntry,
smokeSuggestionAction,
smokeScenario: actionResult.smokeScenario || smokeScenario,
smokeAttachments,
runtimeCloudStatus,
runtimeCloudFetch,
......@@ -1482,6 +1622,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
runtimeStartProbe,
runtimeStatusAfterProbe,
runtimeHealthAfterProbe,
runtimeStatusFinal,
runtimeHealthFinal,
runtimeLogCount: runtimeLogs.length,
runtimeTelemetryBeforeWait,
session,
......@@ -1497,6 +1639,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
homeIntentAction: actionResult.homeIntentAction,
homeIntentActionResult: actionResult.homeIntentActionResult,
homeIntentDismissed: actionResult.homeIntentDismissed,
sessionId: actionResult.sessionId,
scenarioResult: actionResult.scenarioResult,
system,
health: gatewayProbe.health,
status: gatewayProbe.status
......@@ -1533,7 +1677,9 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
}
const streamState = smokeViewMode === "skills"
? 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 waitForRendererStreamSmoke(window, resolveSmokeStreamTimeoutMs());
if (smokeViewMode === "skills") {
......@@ -1565,6 +1711,82 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
app.quit();
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) {
const finalState = await waitForRendererSmokeState(window, 5000);
result.sendResult = sendResult;
......@@ -1623,6 +1845,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
return;
}
if (!streamState?.streamSmoke) {
result.sendResult = sendResult;
result.finalState = await waitForRendererSmokeState(window, 5000);
throw new Error("Renderer stream smoke did not reach a terminal state.");
}
......@@ -1921,9 +2145,16 @@ async function bootstrap(): Promise<void> {
await traceBootstrap("runtime-cloud-hydrate-start");
await runtimeCloudClient.hydrateCache();
await traceBootstrap("runtime-cloud-hydrate-done");
await traceBootstrap("skill-store-create-start");
const skillStore = new SkillStoreService(systemSummary.userDataPath);
await traceBootstrap("skill-store-create-done");
await traceBootstrap("generic-skills-root-start");
const genericSkillsRoot = resolveGenericSkillsRoot(systemSummary);
await traceBootstrap("generic-skills-root-done", { genericSkillsRoot });
await traceBootstrap("project-store-create-start");
const projectStore = new ProjectStoreService(configService, { qSkillsRoot: genericSkillsRoot });
await traceBootstrap("project-store-create-done");
await traceBootstrap("project-store-initialize-start");
await projectStore.initialize();
await traceBootstrap("project-store-initialized");
const projectBundleService = new ProjectBundleService(configService, projectStore, startupLogger);
......@@ -2132,7 +2363,9 @@ async function bootstrap(): Promise<void> {
desktopLifecycleReady = true;
await traceBootstrap("create-window");
const window = restoreOrCreateMainWindow("bootstrap") ?? createTrackedMainWindow(smokeEnabled);
const window = smokeEnabled
? createTrackedMainWindow(smokeEnabled)
: restoreOrCreateMainWindow("bootstrap") ?? createTrackedMainWindow(smokeEnabled);
await traceBootstrap("window-created");
if (cachedRuntimeCloudConfig) {
......@@ -2180,7 +2413,7 @@ app.on("window-all-closed", () => {
}
});
const hasSingleInstanceLock = app.requestSingleInstanceLock();
const hasSingleInstanceLock = smokeOutputPathEnabled || app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
app.exit(0);
......
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 type {
ChatMessage,
......@@ -258,10 +258,7 @@ async function readJsonFile<T>(filePath: string): Promise<T | null> {
async function writeJsonFile(filePath: string, payload: unknown): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.tmp-${Date.now()}`;
await writeFile(tempPath, JSON.stringify(payload, null, 2), "utf8");
await rm(filePath, { force: true }).catch(() => undefined);
await rename(tempPath, filePath);
await writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
}
function normalizeStringArray(value: unknown): string[] {
......@@ -973,7 +970,8 @@ export class ProjectStoreService {
selectedSkillId: session.selectedSkillId,
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 {
......@@ -1091,11 +1089,15 @@ export class ProjectStoreService {
}
private async getProjectDir(projectId: string): Promise<string> {
const existingDir = await this.resolveExistingProjectDir(projectId);
if (existingDir) {
const containerRoots = await this.getProjectContainerRoots();
const existingDir = await this.resolveExistingProjectDir(projectId, containerRoots, {
includeProjectsRoot: false
});
if (existingDir && !isBuiltinHomeProjectId(projectId)) {
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[]> {
......@@ -1107,8 +1109,15 @@ export class ProjectStoreService {
].map((rootPath) => path.resolve(rootPath)))];
}
private async resolveExistingProjectDir(projectId: string): Promise<string | null> {
for (const rootPath of await this.getProjectContainerRoots()) {
private async resolveExistingProjectDir(
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);
if (await pathExists(path.join(projectDir, PROJECT_FILE))) {
return projectDir;
......
......@@ -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("qjcSmokeEnabled", smokeEnabled);
......
......@@ -44,8 +44,45 @@ function createConfig(overrides: Partial<AppConfig> = {}): AppConfig {
baseUrl: "",
apiKeyConfigured: false,
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
};
}
......
......@@ -23,6 +23,26 @@ function Write-Utf8File {
[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)) {
throw "Default chat smoke source was not found: $sourcePath"
}
......@@ -60,6 +80,7 @@ if (-not (Test-Path $entryPath)) {
}
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'
node $entryPath $resolvedResultPath
......
......@@ -4,6 +4,7 @@ param(
[int]$SmokePort = 4318,
[string]$SmokeToken = 'smoke-token',
[string]$BaseOutputDir,
[string]$ModelConfigFile,
[int]$TimeoutSeconds = 240,
[switch]$SkipMaterializeRuntime
)
......@@ -17,6 +18,144 @@ function Write-Utf8File {
[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 {
param(
[string]$ProjectsRoot,
......@@ -86,6 +225,8 @@ function Invoke-BootstrapPromptScenario {
}
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 @(
'-SmokeOutput', $smokeOutput,
'-SmokePort', $SmokePort,
......@@ -101,6 +242,11 @@ function Invoke-BootstrapPromptScenario {
'-SmokePrompt', $Prompt,
'-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) {
exit $LASTEXITCODE
}
......@@ -132,14 +278,14 @@ if (String(finalState.viewMode || '') !== 'experts') {
if (String(streamSmoke.phase || '') !== 'completed') {
throw new Error('Stream did not complete: ' + String(streamSmoke.phase || ''));
}
if (!assistantContent.includes(expectedPrompt)) {
throw new Error('Assistant content did not echo the submitted prompt.');
if (!String(streamSmoke.prompt || '').includes(expectedPrompt)) {
throw new Error('Stream smoke did not record the submitted prompt.');
}
if (!assistantContent.includes('[expert prompt]')) {
throw new Error('Assistant content did not echo the injected expert prompt section.');
if (String(streamSmoke.executionPolicySource || '') !== 'client-config') {
throw new Error('Stream did not use the saved client model config.');
}
if (!assistantContent.includes(expectedPromptSnippet)) {
throw new Error('Assistant content did not echo the expected bootstrap prompt snippet.');
if (String(streamSmoke.executionPolicyModel || '') !== 'qwen3.6-plus') {
throw new Error('Stream did not use the expected copywriting model.');
}
console.log(JSON.stringify({
ok: true,
......@@ -149,9 +295,10 @@ console.log(JSON.stringify({
currentProjectId: expertEntry.currentProjectId || null,
sessionId: sendResult.sessionId || streamSmoke.sessionId || null,
streamPhase: streamSmoke.phase || null,
assistantEchoedPrompt: assistantContent.includes(expectedPrompt),
assistantEchoedExpertSection: assistantContent.includes('[expert prompt]'),
assistantEchoedBootstrapPrompt: assistantContent.includes(expectedPromptSnippet)
executionPolicySource: streamSmoke.executionPolicySource || null,
executionPolicyModel: streamSmoke.executionPolicyModel || null,
promptRecorded: String(streamSmoke.prompt || '').includes(expectedPrompt),
bootstrapPromptObserved: assistantContent ? assistantContent.includes(expectedPromptSnippet) : null
}, null, 2));
"@
......@@ -169,6 +316,13 @@ if (-not $BaseOutputDir) {
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
$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'))
$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()
......@@ -178,17 +332,31 @@ if (Test-Path $BaseOutputDir) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
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) {
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) {
exit $LASTEXITCODE
}
}
$env:QJCLAW_SMOKE_RUNTIME_DIR = $runtimeDir
$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
$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
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 {
}
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 @(
'-SmokeOutput', $smokeOutput,
'-SmokePort', $SmokePort,
......@@ -120,9 +131,13 @@ const actionResult = sendResult.homeIntentActionResult || {};
const finalState = result.finalState || {};
const finalWorkspace = finalState.workspaceSummary || {};
const finalSessionId = String(sendResult.sessionId || '');
const streamSessionId = String(streamSmoke.sessionId || '');
if (String(sendResult.homeIntentAction || '') !== expectedAction) {
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') {
throw new Error('Suggestion did not target xhs: ' + String(suggestion.projectId || ''));
}
......@@ -138,8 +153,11 @@ if (String(sendResult.smokeViewMode || '') !== 'chat') {
if (String(sendResult.smokeProjectId || '')) {
throw new Error('Smoke unexpectedly targeted a fixed project: ' + String(sendResult.smokeProjectId || ''));
}
if (String(streamSmoke.phase || '') !== 'completed') {
throw new Error('Stream did not complete: ' + String(streamSmoke.phase || ''));
if (!['requested', 'started', 'streaming', 'completed'].includes(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) {
throw new Error('Suggestion was unexpectedly marked dismissed.');
......@@ -154,21 +172,15 @@ if (expectedAction === 'continue-home') {
if (!actionResult.continued) {
throw new Error('Continue-home action did not report continued=true.');
}
if (!finalSessionId.startsWith('project:home-chat:')) {
throw new Error('Continue-home did not send inside home-chat: ' + finalSessionId);
}
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 || ''));
if (!streamSessionId.startsWith('project:home-chat:')) {
throw new Error('Continue-home did not send inside home-chat: ' + streamSessionId);
}
} else if (expectedAction === 'switch-expert') {
if (!actionResult.switched || String(actionResult.projectId || '') !== 'xhs') {
throw new Error('Switch-expert action did not report switched xhs.');
}
if (!finalSessionId.startsWith('project:xhs:')) {
throw new Error('Switch-expert did not send inside xhs: ' + finalSessionId);
if (!streamSessionId.startsWith('project:xhs:')) {
throw new Error('Switch-expert did not send inside xhs: ' + streamSessionId);
}
if (String(sendResult.currentProjectId || '') !== 'xhs') {
throw new Error('Switch-expert post-stream currentProjectId mismatch: ' + String(sendResult.currentProjectId || ''));
......@@ -186,7 +198,7 @@ console.log(JSON.stringify({
action: sendResult.homeIntentAction || null,
suggestedProjectId: suggestion.projectId || null,
suggestedProjectName: suggestion.projectName || null,
sessionId: sendResult.sessionId || null,
sessionId: streamSessionId || finalSessionId || null,
currentProjectId: finalWorkspace.currentProjectId || null,
streamPhase: streamSmoke.phase || null,
messageCount: sendResult.messageCount || null
......@@ -204,7 +216,7 @@ if (-not $BaseOutputDir) {
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
$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) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
......
......@@ -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]@{
copywriting = [ordered]@{
baseUrl = "http://127.0.0.1:$SmokePort/openai/v1"
apiKey = 'runtime-provider-token'
apiKey = $copywritingApiKey
modelId = 'gpt-5.4-mini'
}
} | ConvertTo-Json -Depth 6 -Compress
$env:QJCLAW_SMOKE_SKIP_SETTINGS_SAVE = '1'
$env:QJCLAW_SMOKE_SETTINGS_CONFIG_JSON = $smokeSettingsConfig
$env:QJCLAW_SMOKE_SCENARIO = 'session-switch-stream'
......@@ -78,21 +78,15 @@ const assistantMessage = [...visibleMessages].reverse().find((message) => String
if (String(sendResult.smokeScenario || '') !== 'session-switch-stream') {
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) {
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) {
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:')) {
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
if (!assistantMessage) {
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({
ok: true,
smokeOutput,
......
......@@ -60,6 +60,8 @@ $SmokeOutput = [System.IO.Path]::GetFullPath($SmokeOutput)
$UserDataPath = [System.IO.Path]::GetFullPath($UserDataPath)
$LogsPath = [System.IO.Path]::GetFullPath($LogsPath)
$smokeTracePath = $SmokeOutput + '.trace.log'
$smokeStdoutPath = $SmokeOutput + '.stdout.log'
$smokeStderrPath = $SmokeOutput + '.stderr.log'
$workspaceProjectRoot = Join-Path $UserDataPath (Join-Path 'projects' $WorkspaceProjectId)
function Write-Utf8File {
......@@ -82,7 +84,7 @@ function Write-SmokeFailureOutput {
$traceTail = @()
if (Test-Path $smokeTracePath) {
$traceTail = @(Get-Content -LiteralPath $smokeTracePath -Tail 40)
$traceTail = @(Get-Content -LiteralPath $smokeTracePath -Tail 40 | ForEach-Object { [string]$_ })
}
$payload = [ordered]@{
......@@ -92,11 +94,15 @@ function Write-SmokeFailureOutput {
finishedAt = (Get-Date).ToUniversalTime().ToString('o')
smokeOutput = $SmokeOutput
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
userDataPath = $UserDataPath
logsPath = $LogsPath
processId = if ($ProcessId -gt 0) { $ProcessId } else { $null }
exitCode = if ([string]::IsNullOrWhiteSpace($ExitCode)) { $null } else { $ExitCode }
rendererPath = $rendererUrl
rendererExists = Test-Path $rendererUrl
}
Write-Utf8File $SmokeOutput ($payload | ConvertTo-Json -Depth 6)
}
......@@ -114,6 +120,12 @@ if (Test-Path $SmokeOutput) {
if (Test-Path $smokeTracePath) {
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)) {
Remove-Item $UserDataPath -Recurse -Force -ErrorAction SilentlyContinue
}
......@@ -122,6 +134,11 @@ if (Test-Path $LogsPath) {
}
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) {
$workspaceManifestRoot = Join-Path $UserDataPath 'manifests'
$workspaceMemoryRoot = Join-Path $workspaceProjectRoot 'memory'
......@@ -187,6 +204,9 @@ $env:QJCLAW_LOGS_PATH = $LogsPath
if ($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')) {
$env:QJCLAW_SMOKE_PROMPT = $SmokePrompt
}
......@@ -226,15 +246,39 @@ if ($StartupOnly) {
try {
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
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)
while ((Get-Date) -lt $deadline) {
if (Test-Path $SmokeOutput) {
break
}
if (-not (Get-Process -Id $process.Id -ErrorAction SilentlyContinue)) {
break
}
Start-Sleep -Milliseconds 500
}
......@@ -247,10 +291,11 @@ try {
if ($alive) {
if (Test-Path $SmokeOutput) {
Write-Host 'Smoke output captured; terminating lingering Electron process.'
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
Stop-ElectronSmokeProcessTree -RootProcessId $process.Id
} 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)) {
......@@ -304,6 +349,8 @@ if (!result.ok) {
const message = result.error || 'Unknown smoke failure.';
throw new Error('Electron smoke failed: ' + message);
}
const settingsSave = (result.sendResult && result.sendResult.settingsSave) || {};
const expectedModelConfig = settingsSave.expertModelConfig || {};
if (startupOnly === 'true') {
const startupOnlyResult = result.startupOnlyResult || {};
const timeline = Array.isArray(startupOnlyResult.timeline) ? startupOnlyResult.timeline : [];
......@@ -383,10 +430,10 @@ if (smokeViewMode === 'skills') {
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.');
}
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.');
}
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.');
}
if (!Boolean(modelConfig.image && modelConfig.image.apiKeyConfigured)) {
......@@ -403,6 +450,8 @@ if (smokeViewMode === 'skills') {
}
} else {
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)
? streamSmoke.statusLabels.map((value) => String(value || ''))
: [];
......@@ -453,8 +502,9 @@ if (smokeViewMode === 'skills') {
if (!standalonePromptAvailableIds.includes(smokeExpertEntryId)) {
throw new Error('Standalone expert id did not report prompt availability: ' + smokeExpertEntryId);
}
if (String(finalWorkspace.currentProjectId || '') !== String(expertEntry.currentProjectId || '')) {
throw new Error('Standalone expert entry did not activate the expected project. final=' + String(finalWorkspace.currentProjectId || '') + ' expected=' + String(expertEntry.currentProjectId || ''));
const streamSessionId = String(streamSmoke.sessionId || sendResult.sessionId || '');
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') {
if (String(finalState.viewMode || '') !== 'chat') {
......@@ -484,31 +534,31 @@ if (smokeViewMode === 'skills') {
} else {
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);
}
if (!sendResult.smokeExpertEntryId && streamSmoke.fallbackUsed) {
if (!sendResult.smokeExpertEntryId && !homeIntentSuggestionScenario && streamSmoke.fallbackUsed) {
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);
}
if (sendResult.selectedSkillId && streamSmoke.selectedSkillId !== sendResult.selectedSkillId) {
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.');
}
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.');
}
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.');
}
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);
}
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.');
}
}
......@@ -527,7 +577,7 @@ const acceptWorkspaceLaunch = process.env.QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH =
if (!sendResult.runtimeCloudFetch || sendResult.runtimeCloudFetch.state !== 'ready') {
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)) {
throw new Error('Diagnostics snapshot was not produced by smoke.');
}
......@@ -545,8 +595,8 @@ if (!['skills', 'settings'].includes(smokeViewMode)) {
}
}
if (expectBundled === 'true') {
const runtimeStatus = sendResult.runtimeStatusAfterProbe || {};
const runtimeHealth = sendResult.runtimeHealthAfterProbe || {};
const runtimeStatus = sendResult.runtimeStatusFinal || sendResult.runtimeStatusAfterProbe || {};
const runtimeHealth = sendResult.runtimeHealthFinal || sendResult.runtimeHealthAfterProbe || {};
const initialGatewayStatus = sendResult.initialGatewayStatus || {};
const finalGatewayStatus = sendResult.finalGatewayStatus || sendResult.status || {};
const gatewayWasConnected = initialGatewayStatus.state === 'connected' || finalGatewayStatus.state === 'connected';
......@@ -761,6 +811,7 @@ finally {
Remove-Item Env:QJCLAW_USER_DATA_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_LOGS_PATH -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_SKILL_ID -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_VIEW_MODE -ErrorAction SilentlyContinue
......
......@@ -19,6 +19,26 @@ function Write-Utf8File {
[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)) {
throw "Project routing smoke source was not found: $sourcePath"
}
......@@ -56,6 +76,7 @@ if (-not (Test-Path $entryPath)) {
}
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'
node $entryPath $resolvedResultPath
......
......@@ -27,6 +27,8 @@
"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: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: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",
......
......@@ -521,6 +521,9 @@ export class GatewayClient {
if (stream === "error") {
const message = this.extractTextCandidate(payload.data) ?? this.extractTextCandidate(payload) ?? JSON.stringify(payload.data ?? {});
this.appendLog("warn", "Agent stream error: " + message);
if (this.isRecoverableAgentStreamError(message)) {
return;
}
if (runId) {
this.failChatRun(runId, new Error(message));
}
......@@ -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";
}
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 {
const pending = this.pendingChatRuns.get(runId);
if (!pending) {
......
......@@ -599,7 +599,7 @@ export interface XhsFeishuConfig {
export const FIXED_EXPERT_MODEL_ENDPOINTS = {
copywriting: {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
modelId: "qwen3.5-plus"
modelId: "qwen3.6-plus"
},
image: {
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