mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Compare commits
8 commits
188760151d
...
a97927d216
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a97927d216 | ||
|
|
b6f0c562b3 | ||
|
|
333a62cda6 | ||
|
|
def2e9fd2e | ||
|
|
729ce2b0cb | ||
|
|
e8c179d3a6 | ||
|
|
0b493a66c0 | ||
|
|
8448b1105c |
37 changed files with 2817 additions and 492 deletions
16
apps/daemon/bin/od.mjs
Executable file
16
apps/daemon/bin/od.mjs
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const entryDir = dirname(fileURLToPath(import.meta.url));
|
||||
const distEntry = resolve(entryDir, "../dist/cli.js");
|
||||
|
||||
if (!existsSync(distEntry)) {
|
||||
throw new Error(
|
||||
`Open Design daemon dist entry not found at ${distEntry}. Run "pnpm --filter @open-design/daemon build" first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await import(pathToFileURL(distEntry).href);
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
"main": "./dist/cli.js",
|
||||
"types": "./dist/cli.d.ts",
|
||||
"bin": {
|
||||
"od": "./dist/cli.js"
|
||||
"od": "./bin/od.mjs"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
}
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import { isSafeId as isSafeProjectId } from './projects.js';
|
|||
import { projectKindToTracking } from '@open-design/contracts/analytics';
|
||||
import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js';
|
||||
import { googleStreamGenerateContentUrl } from './google-models.js';
|
||||
import { parseMediaExecutionPolicyInput } from './media-policy.js';
|
||||
import { createRoleMarkerGuard } from './role-marker-guard.js';
|
||||
|
||||
// Allowlist for the `/feedback` route. Mirrors the
|
||||
|
|
@ -45,7 +44,7 @@ export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'htt
|
|||
export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||
const { db, design } = ctx;
|
||||
const { sendApiError, createSseResponse } = ctx.http;
|
||||
const { startChatRun, submitToolResultToRun } = ctx.chat;
|
||||
const { submitToolResultToRun } = ctx.chat;
|
||||
const { testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, listProviderModels } = ctx.agents;
|
||||
const {
|
||||
handleCritiqueArtifact,
|
||||
|
|
@ -54,7 +53,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
critiqueResponseCapBytes,
|
||||
critiqueRunRegistry,
|
||||
} = ctx.critique;
|
||||
const isDaemonShuttingDown = ctx.lifecycle?.isDaemonShuttingDown ?? (() => false);
|
||||
const rejectProxyPluginContext = (body: Record<string, unknown>, res: any) => {
|
||||
if (
|
||||
(typeof body.pluginId === 'string' && body.pluginId.trim().length > 0) ||
|
||||
|
|
@ -79,6 +77,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
// so any handler we wired here was shadowed and never executed. Plugin
|
||||
// snapshot resolution, clientType inference, and the daemon-side
|
||||
// run_created/finished analytics all live in `server.ts` now.
|
||||
// POST /api/chat is likewise owned by `server.ts`; keep the chat run
|
||||
// launch path single-sourced so validation changes land on the live route.
|
||||
|
||||
app.get('/api/runs', (req, res) => {
|
||||
const { projectId, conversationId, status } = req.query;
|
||||
|
|
@ -218,23 +218,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
res.status(202).json(outcome);
|
||||
});
|
||||
|
||||
app.post('/api/chat', (req, res) => {
|
||||
if (isDaemonShuttingDown()) {
|
||||
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
||||
}
|
||||
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const mediaExecution = parseMediaExecutionPolicyInput(
|
||||
(body as { mediaExecution?: unknown }).mediaExecution,
|
||||
);
|
||||
if (!mediaExecution.ok) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
||||
}
|
||||
const runBody = { ...body, mediaExecution: mediaExecution.policy };
|
||||
const run = design.runs.create(runBody);
|
||||
design.runs.stream(run, req, res);
|
||||
design.runs.start(run, () => startChatRun(runBody, run));
|
||||
});
|
||||
|
||||
// ---- Connection tests (single-shot JSON; no SSE) ------------------------
|
||||
// Settings dialog uses these to verify a config works without sending a
|
||||
// real chat. Always return HTTP 200 with `ok: false` on upstream-caused
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import path from 'node:path';
|
||||
|
||||
import { redactSecrets } from './redact.js';
|
||||
|
||||
export interface ClaudeCliDiagnosticInput {
|
||||
|
|
@ -7,6 +9,7 @@ export interface ClaudeCliDiagnosticInput {
|
|||
stderrTail?: string | null;
|
||||
stdoutTail?: string | null;
|
||||
env?: Record<string, unknown> | null;
|
||||
resolvedBin?: string | null;
|
||||
}
|
||||
|
||||
export interface ClaudeCliDiagnostic {
|
||||
|
|
@ -51,6 +54,15 @@ function withContext(
|
|||
};
|
||||
}
|
||||
|
||||
function selectedClaudeCompatibleRuntime(input: ClaudeCliDiagnosticInput): 'claude' | 'openclaude' {
|
||||
if (typeof input.resolvedBin !== 'string' || !input.resolvedBin.trim()) return 'claude';
|
||||
const base = path
|
||||
.basename(input.resolvedBin.trim().replace(/\\/g, '/'))
|
||||
.replace(/\.(exe|cmd|bat)$/i, '')
|
||||
.toLowerCase();
|
||||
return base === 'openclaude' ? 'openclaude' : 'claude';
|
||||
}
|
||||
|
||||
export function diagnoseClaudeCliFailure(
|
||||
input: ClaudeCliDiagnosticInput,
|
||||
): ClaudeCliDiagnostic | null {
|
||||
|
|
@ -61,6 +73,8 @@ export function diagnoseClaudeCliFailure(
|
|||
const normalized = text.toLowerCase();
|
||||
const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null;
|
||||
const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null;
|
||||
const runtime = selectedClaudeCompatibleRuntime(input);
|
||||
const isOpenClaude = runtime === 'openclaude';
|
||||
|
||||
const customEndpointConnectionFailure =
|
||||
hasCustomBaseUrl &&
|
||||
|
|
@ -90,6 +104,13 @@ export function diagnoseClaudeCliFailure(
|
|||
);
|
||||
}
|
||||
if (authFailure) {
|
||||
if (isOpenClaude) {
|
||||
return withContext(
|
||||
'OpenClaude could not authenticate with its configured endpoint.',
|
||||
'The spawned OpenClaude process exited before producing a response. Check the OpenClaude API key, endpoint, and local configuration, then retry.',
|
||||
input,
|
||||
);
|
||||
}
|
||||
const configHint = hasConfigDir
|
||||
? 'The configured Claude config directory may contain stale or expired auth state.'
|
||||
: 'If you use multiple Claude profiles, set CLAUDE_CONFIG_DIR in Settings so Open Design spawns the same profile that works in your terminal.';
|
||||
|
|
@ -147,6 +168,13 @@ export function diagnoseClaudeCliFailure(
|
|||
}
|
||||
|
||||
if (!text.trim() && input.exitCode === 1) {
|
||||
if (isOpenClaude) {
|
||||
return withContext(
|
||||
'OpenClaude exited before producing diagnostics.',
|
||||
'Check the OpenClaude API key, endpoint, and local configuration, then retry.',
|
||||
input,
|
||||
);
|
||||
}
|
||||
const message = hasConfigDir
|
||||
? 'Claude Code exited before producing diagnostics while using the configured Claude profile.'
|
||||
: 'Claude Code exited before producing diagnostics.';
|
||||
|
|
|
|||
|
|
@ -1869,6 +1869,8 @@ async function testAgentConnectionInternal(
|
|||
...(def.env || {}),
|
||||
},
|
||||
configuredAgentEnv,
|
||||
undefined,
|
||||
{ resolvedBin: executableResolution.selectedPath },
|
||||
);
|
||||
const env = applyAgentLaunchEnv(baseEnv, executableResolution);
|
||||
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
|
||||
|
|
@ -2033,6 +2035,7 @@ async function testAgentConnectionInternal(
|
|||
stderrTail,
|
||||
stdoutTail: rawStdoutTail || buffered,
|
||||
env,
|
||||
resolvedBin: executableResolution.selectedPath,
|
||||
});
|
||||
if (claudeDiagnostic) {
|
||||
console.warn(
|
||||
|
|
|
|||
|
|
@ -752,12 +752,23 @@ export function listConversations(db: SqliteDb, projectId: string) {
|
|||
AND m.run_status IS NOT NULL
|
||||
)
|
||||
WHERE rn = 1
|
||||
),
|
||||
total_run_durations AS (
|
||||
SELECT m.conversation_id AS conversationId,
|
||||
SUM(${terminalRunDurationSql('m')}) AS totalDurationMs
|
||||
FROM messages m
|
||||
JOIN project_conversations c ON c.id = m.conversation_id
|
||||
WHERE m.role = 'assistant'
|
||||
AND m.run_status IN ('succeeded', 'failed', 'canceled')
|
||||
GROUP BY m.conversation_id
|
||||
)
|
||||
SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt,
|
||||
lr.latestRunStatus, lr.latestRunStartedAt,
|
||||
lr.latestRunEndedAt, lr.latestRunEventsJson
|
||||
lr.latestRunEndedAt, lr.latestRunEventsJson,
|
||||
trd.totalDurationMs
|
||||
FROM project_conversations c
|
||||
LEFT JOIN latest_runs lr ON lr.conversationId = c.id
|
||||
LEFT JOIN total_run_durations trd ON trd.conversationId = c.id
|
||||
ORDER BY c.updatedAt DESC`,
|
||||
)
|
||||
.all(projectId)).map(normalizeConversation);
|
||||
|
|
@ -775,6 +786,7 @@ export function getConversation(db: SqliteDb, id: string) {
|
|||
return {
|
||||
...normalizeConversation(r),
|
||||
latestRun: latestConversationRunSummary(db, r.id) ?? undefined,
|
||||
...numberProperty('totalDurationMs', totalConversationRunDurationMs(db, r.id)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -791,10 +803,16 @@ function normalizeConversation(r: DbRow) {
|
|||
title: r.title ?? null,
|
||||
createdAt: Number(r.createdAt),
|
||||
updatedAt: Number(r.updatedAt),
|
||||
...numberProperty('totalDurationMs', r.totalDurationMs),
|
||||
latestRun: latestRun ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function numberProperty(key: string, value: unknown) {
|
||||
const n = value == null ? undefined : Number(value);
|
||||
return typeof n === 'number' && Number.isFinite(n) ? { [key]: n } : {};
|
||||
}
|
||||
|
||||
function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
|
||||
const row = db
|
||||
.prepare(
|
||||
|
|
@ -813,6 +831,50 @@ function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
|
|||
return conversationRunSummaryFromRow(row);
|
||||
}
|
||||
|
||||
function totalConversationRunDurationMs(db: SqliteDb, conversationId: string): number | undefined {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT SUM(${terminalRunDurationSql()}) AS totalDurationMs
|
||||
FROM messages
|
||||
WHERE conversation_id = ?
|
||||
AND role = 'assistant'
|
||||
AND run_status IN ('succeeded', 'failed', 'canceled')`,
|
||||
)
|
||||
.get(conversationId) as DbRow | undefined;
|
||||
return row?.totalDurationMs == null ? undefined : Number(row.totalDurationMs);
|
||||
}
|
||||
|
||||
function terminalRunDurationSql(alias?: string) {
|
||||
const p = alias ? `${alias}.` : '';
|
||||
return `CASE
|
||||
WHEN ${p}started_at IS NOT NULL AND ${p}ended_at IS NOT NULL THEN
|
||||
CASE
|
||||
WHEN CAST(${p}ended_at AS INTEGER) >= CAST(${p}started_at AS INTEGER)
|
||||
THEN CAST(${p}ended_at AS INTEGER) - CAST(${p}started_at AS INTEGER)
|
||||
ELSE 0
|
||||
END
|
||||
ELSE (
|
||||
SELECT CASE
|
||||
WHEN json_extract(usage_event.value, '$.durationMs') >= 0
|
||||
THEN json_extract(usage_event.value, '$.durationMs')
|
||||
ELSE 0
|
||||
END
|
||||
FROM json_each(
|
||||
CASE
|
||||
WHEN json_valid(${p}events_json) AND json_type(${p}events_json) = 'array'
|
||||
THEN ${p}events_json
|
||||
ELSE '[]'
|
||||
END
|
||||
) AS usage_event
|
||||
WHERE usage_event.type = 'object'
|
||||
AND json_extract(usage_event.value, '$.kind') = 'usage'
|
||||
AND json_type(usage_event.value, '$.durationMs') IN ('integer', 'real')
|
||||
ORDER BY CAST(usage_event.key AS INTEGER) DESC
|
||||
LIMIT 1
|
||||
)
|
||||
END`;
|
||||
}
|
||||
|
||||
function conversationRunSummaryFromRow(row: DbRow | undefined) {
|
||||
if (!row || typeof row.runStatus !== 'string') return null;
|
||||
const startedAt = row.startedAt == null ? undefined : Number(row.startedAt);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { access, constants as fsConstants } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Express } from 'express';
|
||||
import type {
|
||||
HostEditor,
|
||||
|
|
@ -159,6 +160,28 @@ function applicableForPlatform(entry: CatalogueEntry, platform: Platform): boole
|
|||
return true;
|
||||
}
|
||||
|
||||
function projectHostOpenDir(
|
||||
projectsRoot: string,
|
||||
project: { id: string; metadata?: { baseDir?: unknown } | null },
|
||||
resolveProjectDir: (
|
||||
projectsRoot: string,
|
||||
projectId: string,
|
||||
metadata?: unknown,
|
||||
opts?: { allowUnavailableSandboxImportedProject?: boolean },
|
||||
) => string,
|
||||
): string {
|
||||
const importedBaseDir =
|
||||
typeof project.metadata?.baseDir === 'string'
|
||||
? path.normalize(project.metadata.baseDir)
|
||||
: '';
|
||||
if (importedBaseDir && path.isAbsolute(importedBaseDir)) {
|
||||
return importedBaseDir;
|
||||
}
|
||||
return resolveProjectDir(projectsRoot, project.id, project.metadata, {
|
||||
allowUnavailableSandboxImportedProject: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRoutesDeps) {
|
||||
const { db } = ctx;
|
||||
const { sendApiError } = ctx.http;
|
||||
|
|
@ -209,7 +232,11 @@ export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRout
|
|||
if (!project) {
|
||||
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
||||
}
|
||||
const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
|
||||
const resolvedDir = projectHostOpenDir(
|
||||
PROJECTS_DIR,
|
||||
project,
|
||||
resolveProjectDir,
|
||||
);
|
||||
const probe = await resolveEntry(entry);
|
||||
if (!probe.available || !probe.launch) {
|
||||
return sendApiError(res, 409, 'EDITOR_NOT_AVAILABLE', `${entry.label} is not installed`);
|
||||
|
|
|
|||
|
|
@ -872,7 +872,13 @@ async function callLocalCli(provider, system, user, options) {
|
|||
}
|
||||
|
||||
const env = applyAgentLaunchEnv(
|
||||
spawnEnvForAgent(def.id, { ...process.env, ...(def.env || {}) }, configuredAgentEnv),
|
||||
spawnEnvForAgent(
|
||||
def.id,
|
||||
{ ...process.env, ...(def.env || {}) },
|
||||
configuredAgentEnv,
|
||||
undefined,
|
||||
{ resolvedBin: launch.selectedPath },
|
||||
),
|
||||
launch,
|
||||
);
|
||||
const invocation = createCommandInvocation({
|
||||
|
|
|
|||
185
apps/daemon/src/run-tool-bundle.ts
Normal file
185
apps/daemon/src/run-tool-bundle.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import type { McpAuthMode, McpServerConfig, McpTransport } from './mcp-config.js';
|
||||
import type { RuntimeAgentDef } from './runtimes/types.js';
|
||||
import { sanitizeMcpConfig, sanitizeMcpServer } from './mcp-config.js';
|
||||
|
||||
export interface RunToolBundle {
|
||||
mcpServers: McpServerConfig[];
|
||||
}
|
||||
|
||||
export interface RunToolBundleSummary {
|
||||
mcpServers: Array<{
|
||||
id: string;
|
||||
label?: string;
|
||||
templateId?: string;
|
||||
transport: McpTransport;
|
||||
enabled: boolean;
|
||||
authMode?: McpAuthMode;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ExternalMcpSelection {
|
||||
enabledServers: McpServerConfig[];
|
||||
persistedTokenServerIds: Set<string>;
|
||||
}
|
||||
|
||||
export type RunToolBundleParseResult =
|
||||
| { ok: true; bundle: RunToolBundle }
|
||||
| { ok: false; message: string };
|
||||
|
||||
export type RunToolBundleValidationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; message: string };
|
||||
|
||||
export type RunToolBundleDeliveryTarget =
|
||||
| 'managed-project'
|
||||
| 'external-project'
|
||||
| 'none';
|
||||
|
||||
export interface RunToolBundleValidationOptions {
|
||||
deliveryTarget?: RunToolBundleDeliveryTarget;
|
||||
}
|
||||
|
||||
type RunToolBundleAgent = Pick<
|
||||
RuntimeAgentDef,
|
||||
'id' | 'name' | 'externalMcpInjection'
|
||||
>;
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function agentLabel(agent: RunToolBundleAgent): string {
|
||||
return agent.name ? `${agent.name} (${agent.id})` : agent.id;
|
||||
}
|
||||
|
||||
export function normalizeRunToolBundleForRun(raw: unknown): RunToolBundle {
|
||||
if (!isPlainObject(raw)) return { mcpServers: [] };
|
||||
return {
|
||||
mcpServers: sanitizeMcpConfig({ servers: raw.mcpServers }).servers,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRunToolBundleForRequest(raw: unknown): RunToolBundleParseResult {
|
||||
if (raw == null) return { ok: true, bundle: { mcpServers: [] } };
|
||||
if (!isPlainObject(raw)) {
|
||||
return { ok: false, message: 'toolBundle must be an object' };
|
||||
}
|
||||
if (raw.mcpServers == null) return { ok: true, bundle: { mcpServers: [] } };
|
||||
if (!Array.isArray(raw.mcpServers)) {
|
||||
return { ok: false, message: 'toolBundle.mcpServers must be an array' };
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const servers: McpServerConfig[] = [];
|
||||
for (const [index, entry] of raw.mcpServers.entries()) {
|
||||
const server = sanitizeMcpServer(entry);
|
||||
if (!server) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `toolBundle.mcpServers[${index}] is invalid`,
|
||||
};
|
||||
}
|
||||
if (seen.has(server.id)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `toolBundle.mcpServers[${index}] duplicates server id "${server.id}"`,
|
||||
};
|
||||
}
|
||||
seen.add(server.id);
|
||||
servers.push(server);
|
||||
}
|
||||
return { ok: true, bundle: { mcpServers: servers } };
|
||||
}
|
||||
|
||||
export function summarizeRunToolBundle(bundle: RunToolBundle | null | undefined): RunToolBundleSummary {
|
||||
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
|
||||
return {
|
||||
mcpServers: servers.map((server) => ({
|
||||
id: server.id,
|
||||
...(server.label ? { label: server.label } : {}),
|
||||
...(server.templateId ? { templateId: server.templateId } : {}),
|
||||
transport: server.transport,
|
||||
enabled: server.enabled,
|
||||
...(server.authMode ? { authMode: server.authMode } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateRunToolBundleForAgent(
|
||||
bundle: RunToolBundle | null | undefined,
|
||||
agent: RunToolBundleAgent | null | undefined,
|
||||
options: RunToolBundleValidationOptions = {},
|
||||
): RunToolBundleValidationResult {
|
||||
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
|
||||
const enabledServers = servers.filter((server) => server.enabled);
|
||||
if (enabledServers.length === 0) return { ok: true };
|
||||
if (!agent) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'toolBundle requires a supported agentId',
|
||||
};
|
||||
}
|
||||
|
||||
if (agent.externalMcpInjection === 'claude-mcp-json') {
|
||||
if (options.deliveryTarget && options.deliveryTarget !== 'managed-project') {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
`${agentLabel(agent)} receives run-scoped MCP tool bundles through project .mcp.json, ` +
|
||||
'so toolBundle requires a daemon-managed project',
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (agent.externalMcpInjection === 'opencode-env-content') {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (agent.externalMcpInjection === 'acp-merge') {
|
||||
const unsupported = servers.findIndex(
|
||||
(server) => server.enabled && server.transport !== 'stdio',
|
||||
);
|
||||
if (unsupported === -1) return { ok: true };
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
`toolBundle.mcpServers[${unsupported}] uses ${servers[unsupported]?.transport} transport, ` +
|
||||
`but ${agentLabel(agent)} only supports stdio run-scoped MCP servers`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: `${agentLabel(agent)} does not support run-scoped MCP tool bundles`,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveExternalMcpServersForRun({
|
||||
persistedServers,
|
||||
runScopedServers,
|
||||
sandboxMode,
|
||||
}: {
|
||||
persistedServers: McpServerConfig[];
|
||||
runScopedServers: McpServerConfig[];
|
||||
sandboxMode: boolean;
|
||||
}): ExternalMcpSelection {
|
||||
const runScopedIds = new Set(runScopedServers.map((server) => server.id));
|
||||
const persistedForRun = sandboxMode ? [] : persistedServers;
|
||||
const byId = new Map<string, McpServerConfig>();
|
||||
|
||||
for (const server of persistedForRun) byId.set(server.id, server);
|
||||
for (const server of runScopedServers) byId.set(server.id, server);
|
||||
|
||||
const persistedTokenServerIds = new Set<string>();
|
||||
for (const server of persistedForRun) {
|
||||
if (!server.enabled) continue;
|
||||
if (runScopedIds.has(server.id)) continue;
|
||||
persistedTokenServerIds.add(server.id);
|
||||
}
|
||||
|
||||
return {
|
||||
enabledServers: Array.from(byId.values()).filter((server) => server.enabled),
|
||||
persistedTokenServerIds,
|
||||
};
|
||||
}
|
||||
|
|
@ -3,6 +3,10 @@ import { randomUUID } from 'node:crypto';
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { normalizeMediaExecutionPolicyForRun } from './media-policy.js';
|
||||
import {
|
||||
normalizeRunToolBundleForRun,
|
||||
summarizeRunToolBundle,
|
||||
} from './run-tool-bundle.js';
|
||||
|
||||
export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
|
||||
|
||||
|
|
@ -57,6 +61,7 @@ export function createChatRunService({
|
|||
pluginId:
|
||||
typeof meta.pluginId === 'string' && meta.pluginId ? meta.pluginId : null,
|
||||
mediaExecution: normalizeMediaExecutionPolicyForRun(meta.mediaExecution),
|
||||
toolBundle: normalizeRunToolBundleForRun(meta.toolBundle),
|
||||
status: 'queued',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
|
@ -149,6 +154,7 @@ export function createChatRunService({
|
|||
errorCode: run.errorCode ?? null,
|
||||
eventsLogPath: run.eventsLogPath ?? null,
|
||||
mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null),
|
||||
toolBundle: summarizeRunToolBundle(run.toolBundle),
|
||||
});
|
||||
|
||||
const finish = (run, status, code: number | null = null, signal: string | null = null) => {
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ async function probe(
|
|||
...(def.env || {}),
|
||||
},
|
||||
configuredEnv,
|
||||
undefined,
|
||||
{ resolvedBin: launch.selectedPath },
|
||||
),
|
||||
launch,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import {
|
|||
} from '../sandbox-mode.js';
|
||||
|
||||
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
|
||||
type SpawnEnvOptions = {
|
||||
resolvedBin?: string | null;
|
||||
};
|
||||
|
||||
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
|
|
@ -51,6 +54,7 @@ export function spawnEnvForAgent(
|
|||
baseEnv: RuntimeEnvMap,
|
||||
configuredEnv: unknown = {},
|
||||
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
|
||||
options: SpawnEnvOptions = {},
|
||||
): NodeJS.ProcessEnv {
|
||||
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
|
||||
const env = mergeProxyAwareEnv(
|
||||
|
|
@ -75,7 +79,9 @@ export function spawnEnvForAgent(
|
|||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||
}
|
||||
if (agentId === 'claude') {
|
||||
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
|
||||
if (!isOpenClaudeExecutable(options.resolvedBin)) {
|
||||
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
|
||||
}
|
||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||
}
|
||||
if (agentId === 'codex') {
|
||||
|
|
@ -88,6 +94,15 @@ export function spawnEnvForAgent(
|
|||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||
}
|
||||
|
||||
function isOpenClaudeExecutable(resolvedBin: string | null | undefined): boolean {
|
||||
if (typeof resolvedBin !== 'string' || !resolvedBin.trim()) return false;
|
||||
const base = path
|
||||
.basename(resolvedBin.trim().replace(/\\/g, '/'))
|
||||
.replace(/\.(exe|cmd|bat)$/i, '')
|
||||
.toLowerCase();
|
||||
return base === 'openclaude';
|
||||
}
|
||||
|
||||
function sandboxRuntimeConfigForBaseEnv(
|
||||
baseEnv: RuntimeEnvMap,
|
||||
): SandboxRuntimeConfig | null {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { accessSync, constants, existsSync, statSync } from 'node:fs';
|
||||
import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs';
|
||||
import { delimiter } from 'node:path';
|
||||
import path from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
|
@ -286,10 +286,7 @@ export function inspectAgentExecutableResolution(
|
|||
// falling back to first-match (#978).
|
||||
let pathResolvedPath: string | null = null;
|
||||
if (!configuredOverridePath && def.minVersion) {
|
||||
const cached = versionAwareCache.get(def.id);
|
||||
if (cached && existsSync(cached)) {
|
||||
pathResolvedPath = cached;
|
||||
}
|
||||
pathResolvedPath = cachedVersionAwarePath(def.id);
|
||||
}
|
||||
if (!pathResolvedPath) {
|
||||
const candidates = [
|
||||
|
|
@ -324,13 +321,28 @@ export function inspectAgentExecutableResolution(
|
|||
|
||||
const VERSION_PROBE_TIMEOUT_MS = 1_500;
|
||||
|
||||
// agent.id → resolved path that passed the version gate. Populated by
|
||||
// `chooseExecutableByMinVersion`; consulted by
|
||||
interface VersionAwareExecutableIdentity {
|
||||
realpath: string;
|
||||
dev: number;
|
||||
ino: number;
|
||||
mode: number;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
}
|
||||
|
||||
interface VersionAwareCacheEntry {
|
||||
path: string;
|
||||
identity: VersionAwareExecutableIdentity;
|
||||
}
|
||||
|
||||
// agent.id → resolved path + file identity that passed the version gate.
|
||||
// Populated by `chooseExecutableByMinVersion`; consulted by
|
||||
// `inspectAgentExecutableResolution` so the sync chat-spawn path sees
|
||||
// the same pick detection landed on. Only writes for the auto-pick path
|
||||
// — an explicit `<AGENT>_BIN` override is intentionally NOT cached so
|
||||
// clearing the env reliably falls back to auto-pick (#1007 round-2 P2).
|
||||
const versionAwareCache = new Map<string, string>();
|
||||
const versionAwareCache = new Map<string, VersionAwareCacheEntry>();
|
||||
|
||||
export function clearVersionAwareResolutionCache(agentId?: string): void {
|
||||
if (agentId === undefined) {
|
||||
|
|
@ -340,6 +352,63 @@ export function clearVersionAwareResolutionCache(agentId?: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
function readVersionAwareExecutableIdentity(filePath: string): VersionAwareExecutableIdentity | null {
|
||||
try {
|
||||
const realpath = realpathSync(filePath);
|
||||
const stat = statSync(realpath);
|
||||
if (!stat.isFile()) return null;
|
||||
return {
|
||||
realpath,
|
||||
dev: stat.dev,
|
||||
ino: stat.ino,
|
||||
mode: stat.mode,
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
ctimeMs: stat.ctimeMs,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function versionAwareExecutableIdentityEquals(
|
||||
a: VersionAwareExecutableIdentity,
|
||||
b: VersionAwareExecutableIdentity,
|
||||
): boolean {
|
||||
return (
|
||||
a.realpath === b.realpath &&
|
||||
a.dev === b.dev &&
|
||||
a.ino === b.ino &&
|
||||
a.mode === b.mode &&
|
||||
a.size === b.size &&
|
||||
a.mtimeMs === b.mtimeMs &&
|
||||
a.ctimeMs === b.ctimeMs
|
||||
);
|
||||
}
|
||||
|
||||
function cachedVersionAwarePath(agentId: string): string | null {
|
||||
const cached = versionAwareCache.get(agentId);
|
||||
if (!cached) return null;
|
||||
const currentIdentity = readVersionAwareExecutableIdentity(cached.path);
|
||||
if (
|
||||
!currentIdentity ||
|
||||
!versionAwareExecutableIdentityEquals(cached.identity, currentIdentity)
|
||||
) {
|
||||
versionAwareCache.delete(agentId);
|
||||
return null;
|
||||
}
|
||||
return cached.path;
|
||||
}
|
||||
|
||||
function rememberVersionAwarePath(agentId: string, selectedPath: string): void {
|
||||
const identity = readVersionAwareExecutableIdentity(selectedPath);
|
||||
if (!identity) {
|
||||
versionAwareCache.delete(agentId);
|
||||
return;
|
||||
}
|
||||
versionAwareCache.set(agentId, { path: selectedPath, identity });
|
||||
}
|
||||
|
||||
// Strict, anchored semver parse. Accepts a leading `v` and tolerates
|
||||
// trailing pre-release (`-rc.1`) / build metadata (`+build.5`) but
|
||||
// only major.minor.patch participates in comparison. Returns `null`
|
||||
|
|
@ -396,8 +465,8 @@ export async function chooseExecutableByMinVersion(
|
|||
// (line ~390 below) and via `clearVersionAwareResolutionCache()`, so
|
||||
// a missing or relocated cached pick still gets re-probed at the
|
||||
// next launch.
|
||||
const cached = versionAwareCache.get(def.id);
|
||||
if (cached && existsSync(cached)) {
|
||||
const cached = cachedVersionAwarePath(def.id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
|
|
@ -433,7 +502,7 @@ export async function chooseExecutableByMinVersion(
|
|||
for (const probe of probes) {
|
||||
const cmp = compareSemver(probe.version, def.minVersion);
|
||||
if (cmp !== null && cmp >= 0) {
|
||||
versionAwareCache.set(def.id, probe.path);
|
||||
rememberVersionAwarePath(def.id, probe.path);
|
||||
return probe.path;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -317,6 +317,11 @@ import {
|
|||
readMcpConfig,
|
||||
writeMcpConfig,
|
||||
} from './mcp-config.js';
|
||||
import {
|
||||
parseRunToolBundleForRequest,
|
||||
resolveExternalMcpServersForRun,
|
||||
validateRunToolBundleForAgent,
|
||||
} from './run-tool-bundle.js';
|
||||
import {
|
||||
beginAuth,
|
||||
exchangeCodeForToken,
|
||||
|
|
@ -10776,8 +10781,8 @@ export async function startServer({
|
|||
// doesn't exist yet). Without one we don't pass cwd to spawn — the
|
||||
// agent then runs in whatever inherited dir, which still lets API
|
||||
// mode work but loses file-tool addressability.
|
||||
// For git-linked projects (metadata.baseDir), use that folder directly
|
||||
// so the agent writes back to the user's original source tree.
|
||||
// Project directory resolution lives in projects.ts so sandbox mode can
|
||||
// consistently reject imported-folder metadata that has no managed copy.
|
||||
let cwd = null;
|
||||
let existingProjectFiles = [];
|
||||
if (typeof projectId === 'string' && projectId) {
|
||||
|
|
@ -10902,57 +10907,71 @@ export async function startServer({
|
|||
// values further down at .mcp.json write time — see the spawn block
|
||||
// below — instead of re-reading.
|
||||
let externalMcpConfig = { servers: [] };
|
||||
try {
|
||||
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[mcp-config] read failed:',
|
||||
err && err.message ? err.message : err,
|
||||
);
|
||||
if (!SANDBOX_RUNTIME.enabled) {
|
||||
try {
|
||||
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[mcp-config] read failed:',
|
||||
err && err.message ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
const enabledExternalMcp = externalMcpConfig.servers.filter((s) => s.enabled);
|
||||
const runScopedMcpServers = Array.isArray(run?.toolBundle?.mcpServers)
|
||||
? run.toolBundle.mcpServers
|
||||
: [];
|
||||
const {
|
||||
enabledServers: enabledExternalMcp,
|
||||
persistedTokenServerIds,
|
||||
} = resolveExternalMcpServersForRun({
|
||||
persistedServers: externalMcpConfig.servers,
|
||||
runScopedServers: runScopedMcpServers,
|
||||
sandboxMode: SANDBOX_RUNTIME.enabled,
|
||||
});
|
||||
const oauthTokensForSpawn = {};
|
||||
try {
|
||||
const stored = await readAllTokens(RUNTIME_DATA_DIR);
|
||||
for (const [serverId, tok] of Object.entries(stored)) {
|
||||
if (!enabledExternalMcp.find((s) => s.id === serverId)) continue;
|
||||
// Default to the persisted access token; null it out if expired so
|
||||
// we never inject a stale `Authorization: Bearer …` header. The
|
||||
// model treats a server with a Bearer pinned as connected and
|
||||
// discourages re-auth, which is the worst possible UX when the
|
||||
// token is going to 401 every call.
|
||||
let access = isTokenExpired(tok) ? null : tok.accessToken;
|
||||
if (isTokenExpired(tok) && tok.refreshToken) {
|
||||
try {
|
||||
const refreshed = await refreshAndPersistToken(
|
||||
RUNTIME_DATA_DIR,
|
||||
serverId,
|
||||
tok,
|
||||
);
|
||||
if (refreshed) access = refreshed.accessToken;
|
||||
} catch (err) {
|
||||
if (persistedTokenServerIds.size > 0) {
|
||||
try {
|
||||
const stored = await readAllTokens(RUNTIME_DATA_DIR);
|
||||
for (const [serverId, tok] of Object.entries(stored)) {
|
||||
if (!persistedTokenServerIds.has(serverId)) continue;
|
||||
// Default to the persisted access token; null it out if expired so
|
||||
// we never inject a stale `Authorization: Bearer …` header. The
|
||||
// model treats a server with a Bearer pinned as connected and
|
||||
// discourages re-auth, which is the worst possible UX when the
|
||||
// token is going to 401 every call.
|
||||
let access = isTokenExpired(tok) ? null : tok.accessToken;
|
||||
if (isTokenExpired(tok) && tok.refreshToken) {
|
||||
try {
|
||||
const refreshed = await refreshAndPersistToken(
|
||||
RUNTIME_DATA_DIR,
|
||||
serverId,
|
||||
tok,
|
||||
);
|
||||
if (refreshed) access = refreshed.accessToken;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[mcp-oauth] refresh failed for',
|
||||
serverId,
|
||||
err && err.message ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (access) {
|
||||
oauthTokensForSpawn[serverId] = access;
|
||||
} else {
|
||||
console.warn(
|
||||
'[mcp-oauth] refresh failed for',
|
||||
'[mcp-oauth] skipping expired token for',
|
||||
serverId,
|
||||
err && err.message ? err.message : err,
|
||||
'— reconnect required',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (access) {
|
||||
oauthTokensForSpawn[serverId] = access;
|
||||
} else {
|
||||
console.warn(
|
||||
'[mcp-oauth] skipping expired token for',
|
||||
serverId,
|
||||
'— reconnect required',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[mcp-tokens] read failed:',
|
||||
err && err.message ? err.message : err,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[mcp-tokens] read failed:',
|
||||
err && err.message ? err.message : err,
|
||||
);
|
||||
}
|
||||
const connectedExternalMcp = enabledExternalMcp
|
||||
.filter((s) => typeof oauthTokensForSpawn[s.id] === 'string')
|
||||
|
|
@ -11321,6 +11340,8 @@ export async function startServer({
|
|||
...(def.env || {}),
|
||||
},
|
||||
configuredAgentEnv,
|
||||
undefined,
|
||||
{ resolvedBin: agentLaunch.selectedPath },
|
||||
),
|
||||
agentLaunch,
|
||||
)
|
||||
|
|
@ -11703,6 +11724,8 @@ export async function startServer({
|
|||
...(def.env || {}),
|
||||
},
|
||||
configuredAgentEnv,
|
||||
undefined,
|
||||
{ resolvedBin: agentLaunch.selectedPath },
|
||||
);
|
||||
if (def.id === 'amr') {
|
||||
const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv);
|
||||
|
|
@ -12656,6 +12679,7 @@ export async function startServer({
|
|||
stderrTail: agentStderrTail,
|
||||
stdoutTail: agentStdoutTail,
|
||||
env: spawnedAgentEnv,
|
||||
resolvedBin: agentLaunch.selectedPath,
|
||||
});
|
||||
// A non-zero exit whose output reads as an auth / quota / upstream
|
||||
// problem (typical of Claude Code, codex, …) gets the specific code
|
||||
|
|
@ -13001,14 +13025,33 @@ export async function startServer({
|
|||
};
|
||||
});
|
||||
|
||||
function runToolBundleDeliveryTargetForProject(projectId, metadata) {
|
||||
if (typeof projectId !== 'string' || !projectId || !isSafeId(projectId)) {
|
||||
return 'none';
|
||||
}
|
||||
try {
|
||||
const cwd = resolveProjectDir(PROJECTS_DIR, projectId, metadata, {
|
||||
allowUnavailableSandboxImportedProject: true,
|
||||
});
|
||||
return isManagedProjectCwd(cwd, PROJECTS_DIR) ? 'managed-project' : 'external-project';
|
||||
} catch {
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/api/runs', async (req, res) => {
|
||||
if (daemonShuttingDown) {
|
||||
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
||||
}
|
||||
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
|
||||
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
|
||||
if (!mediaExecution.ok) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
||||
}
|
||||
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
|
||||
if (!toolBundle.ok) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
|
||||
}
|
||||
// Plan §3.A1 / spec §11.5: resolve any pluginId / appliedPluginSnapshotId
|
||||
// before the run is created. The resolver returns null when the body
|
||||
// does not mention a plugin (legacy runs unchanged), an error envelope
|
||||
|
|
@ -13024,7 +13067,7 @@ export async function startServer({
|
|||
// bundled scenario that is not installed leaves the run plugin-less,
|
||||
// which matches the legacy path.
|
||||
let resolvedSnapshot = null;
|
||||
if (typeof req.body?.projectId === 'string' && req.body.projectId) {
|
||||
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
|
||||
let registryView;
|
||||
try {
|
||||
registryView = await loadPluginRegistryView();
|
||||
|
|
@ -13032,26 +13075,26 @@ export async function startServer({
|
|||
return res.status(500).json({ error: String(err) });
|
||||
}
|
||||
const explicitPlugin =
|
||||
req.body && (req.body.pluginId || req.body.appliedPluginSnapshotId);
|
||||
let runResolveBody = req.body;
|
||||
requestBody.pluginId || requestBody.appliedPluginSnapshotId;
|
||||
let runResolveBody = requestBody;
|
||||
if (!explicitPlugin) {
|
||||
const projectRow = getProject(db, req.body.projectId);
|
||||
const projectRow = getProject(db, requestBody.projectId);
|
||||
const hasPin =
|
||||
typeof projectRow?.appliedPluginSnapshotId === 'string'
|
||||
&& projectRow.appliedPluginSnapshotId.length > 0;
|
||||
if (!hasPin) {
|
||||
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectRow?.metadata);
|
||||
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
|
||||
runResolveBody = { ...req.body, pluginId: fallbackPluginId };
|
||||
runResolveBody = { ...requestBody, pluginId: fallbackPluginId };
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolved = resolvePluginSnapshot({
|
||||
db,
|
||||
body: runResolveBody,
|
||||
projectId: req.body.projectId,
|
||||
conversationId: typeof req.body.conversationId === 'string'
|
||||
? req.body.conversationId
|
||||
projectId: requestBody.projectId,
|
||||
conversationId: typeof requestBody.conversationId === 'string'
|
||||
? requestBody.conversationId
|
||||
: null,
|
||||
registry: registryView,
|
||||
connectorProbe: buildConnectorProbe(connectorService),
|
||||
|
|
@ -13059,7 +13102,7 @@ export async function startServer({
|
|||
if (resolved && !resolved.ok) {
|
||||
if (!explicitPlugin) {
|
||||
console.warn(
|
||||
`[plugins] default-scenario fallback skipped for run on project ${req.body.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
|
||||
`[plugins] default-scenario fallback skipped for run on project ${requestBody.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
|
||||
);
|
||||
} else {
|
||||
return res.status(resolved.status).json(resolved.body);
|
||||
|
|
@ -13068,7 +13111,11 @@ export async function startServer({
|
|||
resolvedSnapshot = resolved;
|
||||
}
|
||||
}
|
||||
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
|
||||
const meta = {
|
||||
...requestBody,
|
||||
mediaExecution: mediaExecution.policy,
|
||||
toolBundle: toolBundle.bundle,
|
||||
};
|
||||
if (resolvedSnapshot?.ok) {
|
||||
meta.appliedPluginSnapshotId = resolvedSnapshot.snapshotId;
|
||||
if (!meta.pluginId) meta.pluginId = resolvedSnapshot.snapshot.pluginId;
|
||||
|
|
@ -13080,6 +13127,53 @@ export async function startServer({
|
|||
if (renderedQuery.length > 0) meta.message = renderedQuery;
|
||||
}
|
||||
}
|
||||
let runProject = null;
|
||||
if (typeof meta.projectId === 'string' && meta.projectId) {
|
||||
try {
|
||||
runProject = getProject(db, meta.projectId);
|
||||
assertSandboxProjectRootAvailable(runProject?.metadata);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxImportedProjectError) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// MCP / SDK callers may omit agentId. Resolve it before any run-create
|
||||
// side effects so unsupported run-scoped tool bundles can fail cleanly.
|
||||
if (typeof meta.agentId !== 'string' || !meta.agentId) {
|
||||
try {
|
||||
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
|
||||
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
|
||||
? appCfg.agentId
|
||||
: null;
|
||||
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
|
||||
const cfgAgentAvailable = cfgAgent
|
||||
? agents.some((agent) => agent.id === cfgAgent && agent.available)
|
||||
: false;
|
||||
if (cfgAgent && cfgAgentAvailable) {
|
||||
meta.agentId = cfgAgent;
|
||||
} else {
|
||||
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
|
||||
if (firstAvailable) meta.agentId = firstAvailable;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[runs] agent id fallback failed', err);
|
||||
}
|
||||
}
|
||||
const toolBundleSupport = validateRunToolBundleForAgent(
|
||||
toolBundle.bundle,
|
||||
typeof meta.agentId === 'string' ? getAgentDef(meta.agentId) : null,
|
||||
{
|
||||
deliveryTarget: runToolBundleDeliveryTargetForProject(
|
||||
meta.projectId,
|
||||
runProject?.metadata,
|
||||
),
|
||||
},
|
||||
);
|
||||
if (!toolBundleSupport.ok) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
|
||||
}
|
||||
// MCP / SDK callers POST /api/runs with just a projectId — no
|
||||
// conversationId, no pre-created assistantMessageId — because they
|
||||
// don't know about OD's chat-row lifecycle. The web flow
|
||||
|
|
@ -13139,30 +13233,6 @@ export async function startServer({
|
|||
console.warn('[runs] mcp conversation fallback failed', err);
|
||||
}
|
||||
}
|
||||
// MCP / SDK callers may omit agentId. Resolve it from the saved
|
||||
// app-config agent (the user's configured default) or the first
|
||||
// available CLI so the run does not immediately fail with
|
||||
// "unknown agent: undefined" inside startChatRun.
|
||||
if (typeof meta.agentId !== 'string' || !meta.agentId) {
|
||||
try {
|
||||
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
|
||||
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
|
||||
? appCfg.agentId
|
||||
: null;
|
||||
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
|
||||
const cfgAgentAvailable = cfgAgent
|
||||
? agents.some((agent) => agent.id === cfgAgent && agent.available)
|
||||
: false;
|
||||
if (cfgAgent && cfgAgentAvailable) {
|
||||
meta.agentId = cfgAgent;
|
||||
} else {
|
||||
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
|
||||
if (firstAvailable) meta.agentId = firstAvailable;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[runs] agent id fallback failed', err);
|
||||
}
|
||||
}
|
||||
const run = design.runs.create(meta);
|
||||
try {
|
||||
pinAssistantMessageOnRunCreate(db, run);
|
||||
|
|
@ -13577,11 +13647,45 @@ export async function startServer({
|
|||
if (daemonShuttingDown) {
|
||||
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
||||
}
|
||||
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
|
||||
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
|
||||
if (!mediaExecution.ok) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
||||
}
|
||||
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
|
||||
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
|
||||
if (!toolBundle.ok) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
|
||||
}
|
||||
let chatProject = null;
|
||||
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
|
||||
try {
|
||||
chatProject = getProject(db, requestBody.projectId);
|
||||
assertSandboxProjectRootAvailable(chatProject?.metadata);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxImportedProjectError) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const toolBundleSupport = validateRunToolBundleForAgent(
|
||||
toolBundle.bundle,
|
||||
typeof requestBody.agentId === 'string' ? getAgentDef(requestBody.agentId) : null,
|
||||
{
|
||||
deliveryTarget: runToolBundleDeliveryTargetForProject(
|
||||
requestBody.projectId,
|
||||
chatProject?.metadata,
|
||||
),
|
||||
},
|
||||
);
|
||||
if (!toolBundleSupport.ok) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
|
||||
}
|
||||
const meta = {
|
||||
...requestBody,
|
||||
mediaExecution: mediaExecution.policy,
|
||||
toolBundle: toolBundle.bundle,
|
||||
};
|
||||
const run = design.runs.create(meta);
|
||||
design.runs.stream(run, req, res);
|
||||
design.runs.start(run, () => startChatRun(meta, run));
|
||||
|
|
@ -13658,6 +13762,7 @@ export async function startServer({
|
|||
if (routine.target.mode === 'reuse') {
|
||||
const project = getProject(db, routine.target.projectId);
|
||||
if (!project) throw new Error(`Routine target project ${routine.target.projectId} not found`);
|
||||
assertSandboxProjectRootAvailable(project.metadata);
|
||||
projectId = project.id;
|
||||
projectName = project.name;
|
||||
previousProjectSnapshotId = project.appliedPluginSnapshotId ?? null;
|
||||
|
|
|
|||
|
|
@ -86,6 +86,42 @@ async function withFakeAgent<T>(
|
|||
}
|
||||
}
|
||||
|
||||
async function withOnlyFakeAgent<T>(
|
||||
binName: string,
|
||||
script: string,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-bin-'));
|
||||
const oldPath = process.env.PATH;
|
||||
const oldAgentHome = process.env.OD_AGENT_HOME;
|
||||
const oldClaudeBin = process.env.CLAUDE_BIN;
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const runner = path.join(dir, `${binName}-test-runner.cjs`);
|
||||
await fsp.writeFile(runner, script);
|
||||
await fsp.writeFile(
|
||||
path.join(dir, `${binName}.cmd`),
|
||||
`@echo off\r\nnode "${runner}" %*\r\n`,
|
||||
);
|
||||
} else {
|
||||
const bin = path.join(dir, binName);
|
||||
await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`);
|
||||
await fsp.chmod(bin, 0o755);
|
||||
}
|
||||
process.env.PATH = dir;
|
||||
process.env.OD_AGENT_HOME = dir;
|
||||
delete process.env.CLAUDE_BIN;
|
||||
return await run();
|
||||
} finally {
|
||||
process.env.PATH = oldPath;
|
||||
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
|
||||
else process.env.OD_AGENT_HOME = oldAgentHome;
|
||||
if (oldClaudeBin === undefined) delete process.env.CLAUDE_BIN;
|
||||
else process.env.CLAUDE_BIN = oldClaudeBin;
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function withFakeCodex<T>(script: string, run: () => Promise<T>): Promise<T> {
|
||||
return withFakeAgent('codex', script, run);
|
||||
}
|
||||
|
|
@ -94,6 +130,10 @@ async function withFakeClaude<T>(script: string, run: () => Promise<T>): Promise
|
|||
return withFakeAgent('claude', script, run);
|
||||
}
|
||||
|
||||
async function withOnlyFakeOpenClaude<T>(script: string, run: () => Promise<T>): Promise<T> {
|
||||
return withOnlyFakeAgent('openclaude', script, run);
|
||||
}
|
||||
|
||||
async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promise<T> {
|
||||
return withFakeAgent('opencode', script, run);
|
||||
}
|
||||
|
|
@ -2199,6 +2239,58 @@ process.stdin.on('end', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('preserves ANTHROPIC_API_KEY when Claude adapter launches the OpenClaude fallback', async () => {
|
||||
const envFile = path.join(os.tmpdir(), `od-openclaude-env-${Date.now()}-${Math.random()}.json`);
|
||||
const previousKey = process.env.ANTHROPIC_API_KEY;
|
||||
try {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-openclaude-test';
|
||||
await withOnlyFakeOpenClaude(
|
||||
`
|
||||
const fs = require('node:fs');
|
||||
fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || null,
|
||||
}));
|
||||
let input = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => { input += chunk; });
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
JSON.parse(input.trim());
|
||||
console.log(JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
id: 'msg_1',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
stop_reason: 'end_turn',
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
`,
|
||||
async () => {
|
||||
const result = await testAgentConnection({ agentId: 'claude' });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
kind: 'success',
|
||||
agentName: 'Claude Code',
|
||||
});
|
||||
await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe(
|
||||
JSON.stringify({ ANTHROPIC_API_KEY: 'sk-openclaude-test' }),
|
||||
);
|
||||
expect(result.diagnostics?.binaryPath ?? '').toMatch(/openclaude/i);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (previousKey === undefined) delete process.env.ANTHROPIC_API_KEY;
|
||||
else process.env.ANTHROPIC_API_KEY = previousKey;
|
||||
await fsp.rm(envFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => {
|
||||
await withFakeClaude(
|
||||
`console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type http from 'node:http';
|
||||
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { chmod, mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
|
@ -56,26 +56,6 @@ describe('POST /api/import/folder', () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function waitForRunStatus(
|
||||
runId: string,
|
||||
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
|
||||
let lastStatus = 'unknown';
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
|
||||
const statusBody = (await statusResponse.json()) as {
|
||||
status: string;
|
||||
error?: string | null;
|
||||
errorCode?: string | null;
|
||||
};
|
||||
lastStatus = statusBody.status;
|
||||
if (statusBody.status !== 'queued' && statusBody.status !== 'running') {
|
||||
return statusBody;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
throw new Error(`run did not reach a terminal status; last status: ${lastStatus}`);
|
||||
}
|
||||
|
||||
it('creates a project rooted at the submitted folder', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
|
@ -105,7 +85,7 @@ describe('POST /api/import/folder', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('fails sandbox runs for imported folders instead of using an empty managed project', async () => {
|
||||
it('rejects sandbox runs for imported folders before creating a run', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
|
|
@ -123,15 +103,78 @@ describe('POST /api/import/folder', () => {
|
|||
message: 'Inspect the imported project.',
|
||||
}),
|
||||
});
|
||||
expect(runResp.status).toBe(202);
|
||||
const { runId } = (await runResp.json()) as { runId: string };
|
||||
const status = await waitForRunStatus(runId);
|
||||
expect(status.status).toBe('failed');
|
||||
expect(status.errorCode).toBe('BAD_REQUEST');
|
||||
expect(status.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||
expect(runResp.status).toBe(400);
|
||||
const body = (await runResp.json()) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects sandbox chat runs for imported folders before creating a run', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
const importResp = await importFolder({ baseDir: folder });
|
||||
expect(importResp.status).toBe(200);
|
||||
const { project } = (await importResp.json()) as { project: { id: string } };
|
||||
|
||||
await withSandboxMode(async () => {
|
||||
const chatResp = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'claude',
|
||||
projectId: project.id,
|
||||
message: 'Inspect the imported project.',
|
||||
}),
|
||||
});
|
||||
expect(chatResp.status).toBe(400);
|
||||
const body = (await chatResp.json()) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||
|
||||
const runsResp = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(project.id)}`);
|
||||
expect(runsResp.status).toBe(200);
|
||||
const runsBody = (await runsResp.json()) as { runs: unknown[] };
|
||||
expect(runsBody.runs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens imported-folder projects through host editor routes in sandbox mode', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
const binDir = makeFolder();
|
||||
const cursorBin = path.join(
|
||||
binDir,
|
||||
process.platform === 'win32' ? 'cursor.cmd' : 'cursor',
|
||||
);
|
||||
await writeFile(
|
||||
cursorBin,
|
||||
process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n',
|
||||
);
|
||||
await chmod(cursorBin, 0o755);
|
||||
|
||||
const importResp = await importFolder({ baseDir: folder });
|
||||
expect(importResp.status).toBe(200);
|
||||
const { project } = (await importResp.json()) as { project: { id: string } };
|
||||
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`;
|
||||
try {
|
||||
await withSandboxMode(async () => {
|
||||
const resp = await fetch(`${baseUrl}/api/projects/${project.id}/open-in`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ editorId: 'cursor' }),
|
||||
});
|
||||
expect(resp.status).toBe(200);
|
||||
const body = (await resp.json()) as { path?: string };
|
||||
expect(body.path).toBe(await realpath(folder));
|
||||
});
|
||||
} finally {
|
||||
if (previousPath == null) delete process.env.PATH;
|
||||
else process.env.PATH = previousPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('still opens an imported-folder project record in sandbox mode', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
|
|
|||
|
|
@ -68,10 +68,14 @@ process.exit(0);
|
|||
async function waitForRunStatus(
|
||||
baseUrl: string,
|
||||
runId: string,
|
||||
): Promise<{ status: string }> {
|
||||
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
const r = await fetch(`${baseUrl}/api/runs/${runId}`);
|
||||
const body = (await r.json()) as { status: string };
|
||||
const body = (await r.json()) as {
|
||||
status: string;
|
||||
error?: string | null;
|
||||
errorCode?: string | null;
|
||||
};
|
||||
if (body.status !== 'queued' && body.status !== 'running') return body;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
|
@ -82,6 +86,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
|||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
const projectsToClean: string[] = [];
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = (await startServer({ port: 0, returnServer: true })) as {
|
||||
|
|
@ -106,9 +111,12 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
|||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ servers: [] }),
|
||||
}).catch(() => {});
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createProject(): Promise<{ id: string; dir: string }> {
|
||||
async function createProject(): Promise<{ id: string; dir: string; conversationId: string }> {
|
||||
const id = `mcp-spawn-${randomUUID()}`;
|
||||
const r = await fetch(`${baseUrl}/api/projects`, {
|
||||
method: 'POST',
|
||||
|
|
@ -116,6 +124,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
|||
body: JSON.stringify({ id, name: id }),
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
const body = (await r.json()) as { conversationId: string };
|
||||
projectsToClean.push(id);
|
||||
// The daemon owns its data dir; we discover the on-disk project path by
|
||||
// having the daemon return the upload root, then composing path manually.
|
||||
|
|
@ -123,7 +132,46 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
|||
const projectsBase = process.env.OD_DATA_DIR
|
||||
? join(process.env.OD_DATA_DIR, 'projects')
|
||||
: join(process.cwd(), '.od', 'projects');
|
||||
return { id, dir: join(projectsBase, id) };
|
||||
return { id, dir: join(projectsBase, id), conversationId: body.conversationId };
|
||||
}
|
||||
|
||||
async function importFolderProject(): Promise<{
|
||||
id: string;
|
||||
dir: string;
|
||||
externalDir: string;
|
||||
conversationId: string;
|
||||
}> {
|
||||
const externalDir = await fsp.mkdtemp(join(tmpdir(), 'od-mcp-import-'));
|
||||
tempDirs.push(externalDir);
|
||||
await fsp.writeFile(join(externalDir, 'index.html'), '<!doctype html>');
|
||||
const r = await fetch(`${baseUrl}/api/import/folder`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseDir: externalDir }),
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
const body = (await r.json()) as { project: { id: string }; conversationId: string };
|
||||
projectsToClean.push(body.project.id);
|
||||
const projectsBase = process.env.OD_DATA_DIR
|
||||
? join(process.env.OD_DATA_DIR, 'projects')
|
||||
: join(process.cwd(), '.od', 'projects');
|
||||
return {
|
||||
id: body.project.id,
|
||||
dir: join(projectsBase, body.project.id),
|
||||
externalDir,
|
||||
conversationId: body.conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previous = process.env.OD_SANDBOX_MODE;
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (previous == null) delete process.env.OD_SANDBOX_MODE;
|
||||
else process.env.OD_SANDBOX_MODE = previous;
|
||||
}
|
||||
}
|
||||
|
||||
it('writes .mcp.json into the per-project dir, then removes it when servers are cleared', async () => {
|
||||
|
|
@ -197,6 +245,347 @@ describe('spawn writes external MCP config for Claude Code', () => {
|
|||
});
|
||||
}, 30_000);
|
||||
|
||||
it('fails sandbox runs for imported-folder projects before writing MCP config', async () => {
|
||||
await withFakeClaude(async () => {
|
||||
const putRes = await fetch(`${baseUrl}/api/mcp/servers`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
servers: [
|
||||
{
|
||||
id: 'sandbox-run',
|
||||
transport: 'sse',
|
||||
enabled: true,
|
||||
url: 'https://mcp.example.test',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(putRes.ok).toBe(true);
|
||||
|
||||
const { id, dir, externalDir, conversationId } = await importFolderProject();
|
||||
|
||||
await withSandboxMode(async () => {
|
||||
const chatRes = await fetch(`${baseUrl}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'claude',
|
||||
projectId: id,
|
||||
message: 'hello sandbox mcp',
|
||||
}),
|
||||
});
|
||||
expect(chatRes.status).toBe(400);
|
||||
const body = (await chatRes.json()) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||
});
|
||||
|
||||
const managedTarget = join(dir, '.mcp.json');
|
||||
expect(existsSync(managedTarget)).toBe(false);
|
||||
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
|
||||
const messagesRes = await fetch(
|
||||
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
|
||||
);
|
||||
expect(messagesRes.ok).toBe(true);
|
||||
const messagesBody = (await messagesRes.json()) as {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
};
|
||||
expect(messagesBody.messages.some((msg) => msg.content === 'hello sandbox mcp')).toBe(false);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it('rejects sandbox routine reuse of imported-folder projects before creating run state', async () => {
|
||||
const { id } = await importFolderProject();
|
||||
const conversationsBeforeRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
|
||||
expect(conversationsBeforeRes.ok).toBe(true);
|
||||
const conversationsBeforeBody = (await conversationsBeforeRes.json()) as {
|
||||
conversations: Array<{ id: string }>;
|
||||
};
|
||||
const conversationIdsBefore = conversationsBeforeBody.conversations.map((conversation) => conversation.id);
|
||||
|
||||
let routineId: string | null = null;
|
||||
try {
|
||||
const createRoutineRes = await fetch(`${baseUrl}/api/routines`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Sandbox imported folder routine',
|
||||
prompt: 'try to run inside an imported folder',
|
||||
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
||||
target: { mode: 'reuse', projectId: id },
|
||||
agentId: 'claude',
|
||||
enabled: false,
|
||||
}),
|
||||
});
|
||||
expect(createRoutineRes.status).toBe(201);
|
||||
const createRoutineBody = (await createRoutineRes.json()) as {
|
||||
routine: { id: string };
|
||||
};
|
||||
routineId = createRoutineBody.routine.id;
|
||||
|
||||
await withSandboxMode(async () => {
|
||||
const runRoutineRes = await fetch(`${baseUrl}/api/routines/${routineId}/run`, {
|
||||
method: 'POST',
|
||||
});
|
||||
expect(runRoutineRes.status).toBe(500);
|
||||
const runRoutineBody = (await runRoutineRes.json()) as { error?: string };
|
||||
expect(runRoutineBody.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||
});
|
||||
|
||||
const routineRunsRes = await fetch(`${baseUrl}/api/routines/${routineId}/runs?limit=10`);
|
||||
expect(routineRunsRes.ok).toBe(true);
|
||||
const routineRunsBody = (await routineRunsRes.json()) as { runs: unknown[] };
|
||||
expect(routineRunsBody.runs).toHaveLength(0);
|
||||
|
||||
const runsRes = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(id)}`);
|
||||
expect(runsRes.ok).toBe(true);
|
||||
const runsBody = (await runsRes.json()) as { runs: unknown[] };
|
||||
expect(runsBody.runs).toHaveLength(0);
|
||||
|
||||
const conversationsAfterRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
|
||||
expect(conversationsAfterRes.ok).toBe(true);
|
||||
const conversationsAfterBody = (await conversationsAfterRes.json()) as {
|
||||
conversations: Array<{ id: string }>;
|
||||
};
|
||||
expect(conversationsAfterBody.conversations.map((conversation) => conversation.id)).toEqual(
|
||||
conversationIdsBefore,
|
||||
);
|
||||
} finally {
|
||||
if (routineId) {
|
||||
await fetch(`${baseUrl}/api/routines/${routineId}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('injects run-scoped MCP servers without saving them to the persistent registry', async () => {
|
||||
await withFakeClaude(async () => {
|
||||
const { id, dir } = await createProject();
|
||||
|
||||
const chatRes = await fetch(`${baseUrl}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'claude',
|
||||
projectId: id,
|
||||
message: 'hello run-scoped mcp',
|
||||
toolBundle: {
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'run-local',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['run-tool.js'],
|
||||
env: { RUN_ONLY: '1' },
|
||||
},
|
||||
{
|
||||
id: 'run-remote',
|
||||
transport: 'http',
|
||||
enabled: true,
|
||||
authMode: 'none',
|
||||
url: 'https://example.test/mcp',
|
||||
headers: { 'X-Run': 'ok' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(chatRes.status).toBe(202);
|
||||
const { runId } = (await chatRes.json()) as { runId: string };
|
||||
const status = await waitForRunStatus(baseUrl, runId) as {
|
||||
status: string;
|
||||
toolBundle?: { mcpServers?: Array<{ id: string }> };
|
||||
};
|
||||
expect(status.status).toBe('succeeded');
|
||||
expect(status.toolBundle?.mcpServers?.map((server) => server.id)).toEqual([
|
||||
'run-local',
|
||||
'run-remote',
|
||||
]);
|
||||
|
||||
const target = join(dir, '.mcp.json');
|
||||
expect(existsSync(target)).toBe(true);
|
||||
const written = JSON.parse(await fsp.readFile(target, 'utf8'));
|
||||
expect(written.mcpServers.run_local).toBeUndefined();
|
||||
expect(written.mcpServers['run-local']).toMatchObject({
|
||||
command: 'node',
|
||||
args: ['run-tool.js'],
|
||||
env: { RUN_ONLY: '1' },
|
||||
});
|
||||
expect(written.mcpServers['run-remote']).toMatchObject({
|
||||
type: 'http',
|
||||
url: 'https://example.test/mcp',
|
||||
headers: { 'X-Run': 'ok' },
|
||||
});
|
||||
|
||||
const persistedRes = await fetch(`${baseUrl}/api/mcp/servers`);
|
||||
expect(persistedRes.ok).toBe(true);
|
||||
const persisted = (await persistedRes.json()) as { servers: unknown[] };
|
||||
expect(persisted.servers).toEqual([]);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it('rejects Claude run-scoped MCP bundles for imported-folder projects', async () => {
|
||||
const { id, dir, externalDir, conversationId } = await importFolderProject();
|
||||
|
||||
const runsRes = await fetch(`${baseUrl}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'claude',
|
||||
projectId: id,
|
||||
message: 'imported run-scoped tools',
|
||||
toolBundle: {
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'run-local',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(runsRes.status).toBe(400);
|
||||
const runsBody = (await runsRes.json()) as { error?: { message?: string } };
|
||||
expect(runsBody.error?.message).toContain('toolBundle requires a daemon-managed project');
|
||||
|
||||
const chatRes = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'claude',
|
||||
projectId: id,
|
||||
message: 'imported chat-scoped tools',
|
||||
toolBundle: {
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'run-local-chat',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(chatRes.status).toBe(400);
|
||||
const chatBody = (await chatRes.json()) as { error?: { message?: string } };
|
||||
expect(chatBody.error?.message).toContain('toolBundle requires a daemon-managed project');
|
||||
|
||||
expect(existsSync(join(dir, '.mcp.json'))).toBe(false);
|
||||
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
|
||||
const messagesRes = await fetch(
|
||||
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
|
||||
);
|
||||
expect(messagesRes.ok).toBe(true);
|
||||
const messagesBody = (await messagesRes.json()) as {
|
||||
messages: Array<{ content: string }>;
|
||||
};
|
||||
expect(messagesBody.messages.some((msg) => msg.content === 'imported run-scoped tools')).toBe(false);
|
||||
expect(messagesBody.messages.some((msg) => msg.content === 'imported chat-scoped tools')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed run-scoped MCP bundles before creating runs', async () => {
|
||||
const { id } = await createProject();
|
||||
|
||||
const invalidRunsRes = await fetch(`${baseUrl}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'claude',
|
||||
projectId: id,
|
||||
message: 'bad tools',
|
||||
toolBundle: {
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'missing-command',
|
||||
transport: 'stdio',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(invalidRunsRes.status).toBe(400);
|
||||
const runsBody = (await invalidRunsRes.json()) as { error?: { message?: string } };
|
||||
expect(runsBody.error?.message).toContain('toolBundle.mcpServers[0] is invalid');
|
||||
|
||||
const invalidChatRes = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'claude',
|
||||
projectId: id,
|
||||
message: 'bad tools',
|
||||
toolBundle: 'bad',
|
||||
}),
|
||||
});
|
||||
expect(invalidChatRes.status).toBe(400);
|
||||
const chatBody = (await invalidChatRes.json()) as { error?: { message?: string } };
|
||||
expect(chatBody.error?.message).toContain('toolBundle must be an object');
|
||||
});
|
||||
|
||||
it('rejects run-scoped MCP bundles the selected runtime cannot receive', async () => {
|
||||
const { id, conversationId } = await createProject();
|
||||
|
||||
const unsupportedRuntimeRes = await fetch(`${baseUrl}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'codex',
|
||||
projectId: id,
|
||||
message: 'bad tools',
|
||||
toolBundle: {
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'run-local',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(unsupportedRuntimeRes.status).toBe(400);
|
||||
const unsupportedRuntimeBody = (await unsupportedRuntimeRes.json()) as {
|
||||
error?: { message?: string };
|
||||
};
|
||||
expect(unsupportedRuntimeBody.error?.message).toContain(
|
||||
'Codex CLI (codex) does not support run-scoped MCP tool bundles',
|
||||
);
|
||||
const messagesRes = await fetch(
|
||||
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
|
||||
);
|
||||
expect(messagesRes.ok).toBe(true);
|
||||
const messagesBody = (await messagesRes.json()) as {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
};
|
||||
expect(messagesBody.messages.some((msg) => msg.content === 'bad tools')).toBe(false);
|
||||
|
||||
const unsupportedTransportRes = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'hermes',
|
||||
projectId: id,
|
||||
message: 'bad remote tools',
|
||||
toolBundle: {
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'run-remote',
|
||||
transport: 'http',
|
||||
url: 'https://example.test/mcp',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(unsupportedTransportRes.status).toBe(400);
|
||||
const unsupportedTransportBody = (await unsupportedTransportRes.json()) as {
|
||||
error?: { message?: string };
|
||||
};
|
||||
expect(unsupportedTransportBody.error?.message).toContain(
|
||||
'Hermes (hermes) only supports stdio run-scoped MCP servers',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not write .mcp.json for ACP agents (Hermes wires via session args)', async () => {
|
||||
// ACP agents (Hermes/Kimi) consume the `mcpServers` array via the ACP
|
||||
// session/new params instead of `.mcp.json`. The `.mcp.json` write path
|
||||
|
|
|
|||
|
|
@ -124,6 +124,95 @@ test('conversation latest run follows assistant message position', () => {
|
|||
assert.equal(getConversation(db, conversationId)?.latestRun?.status, 'running');
|
||||
});
|
||||
|
||||
test('conversation summaries expose cumulative completed run duration', () => {
|
||||
const db = createDb();
|
||||
insertProject(db, {
|
||||
id: 'project-duration',
|
||||
name: 'project-duration',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
});
|
||||
insertConversation(db, {
|
||||
id: 'project-duration-conversation',
|
||||
projectId: 'project-duration',
|
||||
title: 'Duration test',
|
||||
createdAt: 1,
|
||||
updatedAt: 4,
|
||||
});
|
||||
upsertMessage(db, 'project-duration-conversation', {
|
||||
id: 'project-duration-first',
|
||||
role: 'assistant',
|
||||
content: 'first done',
|
||||
runId: 'project-duration-first-run',
|
||||
runStatus: 'succeeded',
|
||||
startedAt: 10_000,
|
||||
endedAt: 40_000,
|
||||
});
|
||||
upsertMessage(db, 'project-duration-conversation', {
|
||||
id: 'project-duration-running',
|
||||
role: 'assistant',
|
||||
content: 'still running',
|
||||
runId: 'project-duration-running-run',
|
||||
runStatus: 'running',
|
||||
startedAt: 45_000,
|
||||
});
|
||||
upsertMessage(db, 'project-duration-conversation', {
|
||||
id: 'project-duration-second',
|
||||
role: 'assistant',
|
||||
content: 'second done',
|
||||
runId: 'project-duration-second-run',
|
||||
runStatus: 'failed',
|
||||
startedAt: 50_000,
|
||||
endedAt: 125_000,
|
||||
});
|
||||
|
||||
const listed = listConversations(db, 'project-duration')[0] as { totalDurationMs?: number };
|
||||
const fetched = getConversation(db, 'project-duration-conversation') as { totalDurationMs?: number } | null;
|
||||
|
||||
assert.equal(listed.totalDurationMs, 105_000);
|
||||
assert.equal(fetched?.totalDurationMs, 105_000);
|
||||
});
|
||||
|
||||
test('conversation summaries include usage-only terminal run durations', () => {
|
||||
const db = createDb();
|
||||
insertProject(db, {
|
||||
id: 'project-usage-duration',
|
||||
name: 'project-usage-duration',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
});
|
||||
insertConversation(db, {
|
||||
id: 'project-usage-duration-conversation',
|
||||
projectId: 'project-usage-duration',
|
||||
title: 'Usage duration test',
|
||||
createdAt: 1,
|
||||
updatedAt: 4,
|
||||
});
|
||||
upsertMessage(db, 'project-usage-duration-conversation', {
|
||||
id: 'project-usage-duration-imported',
|
||||
role: 'assistant',
|
||||
content: 'imported done',
|
||||
runId: 'project-usage-duration-imported-run',
|
||||
runStatus: 'succeeded',
|
||||
events: [{ kind: 'usage', durationMs: 22_000 }],
|
||||
});
|
||||
upsertMessage(db, 'project-usage-duration-conversation', {
|
||||
id: 'project-usage-duration-timestamped',
|
||||
role: 'assistant',
|
||||
content: 'timestamped done',
|
||||
runId: 'project-usage-duration-timestamped-run',
|
||||
runStatus: 'succeeded',
|
||||
startedAt: 30_000,
|
||||
endedAt: 60_000,
|
||||
});
|
||||
|
||||
const listed = listConversations(db, 'project-usage-duration')[0] as { totalDurationMs?: number };
|
||||
const fetched = getConversation(db, 'project-usage-duration-conversation') as { totalDurationMs?: number } | null;
|
||||
|
||||
assert.equal(listed.totalDurationMs, 52_000);
|
||||
assert.equal(fetched?.totalDurationMs, 52_000);
|
||||
});
|
||||
|
||||
test('conversation listing batches latest run summaries for large projects', () => {
|
||||
const db = createDb();
|
||||
insertProject(db, {
|
||||
|
|
|
|||
231
apps/daemon/tests/run-tool-bundle.test.ts
Normal file
231
apps/daemon/tests/run-tool-bundle.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeRunToolBundleForRun,
|
||||
parseRunToolBundleForRequest,
|
||||
resolveExternalMcpServersForRun,
|
||||
summarizeRunToolBundle,
|
||||
validateRunToolBundleForAgent,
|
||||
} from '../src/run-tool-bundle.js';
|
||||
|
||||
describe('run-scoped tool bundles', () => {
|
||||
it('sanitizes MCP servers onto the run and redacts spawn-only details in summaries', () => {
|
||||
const bundle = normalizeRunToolBundleForRun({
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'local-tools',
|
||||
label: 'Local tools',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js', '--token=secret'],
|
||||
env: { API_TOKEN: 'secret' },
|
||||
},
|
||||
{
|
||||
id: 'remote-tools',
|
||||
transport: 'http',
|
||||
url: 'https://example.test/mcp',
|
||||
headers: { Authorization: 'Bearer secret' },
|
||||
},
|
||||
{
|
||||
id: '../bad',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(bundle.mcpServers).toHaveLength(2);
|
||||
expect(bundle.mcpServers[0]).toMatchObject({
|
||||
id: 'local-tools',
|
||||
command: 'node',
|
||||
env: { API_TOKEN: 'secret' },
|
||||
});
|
||||
|
||||
const summary = summarizeRunToolBundle(bundle);
|
||||
expect(summary).toEqual({
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'local-tools',
|
||||
label: 'Local tools',
|
||||
transport: 'stdio',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'remote-tools',
|
||||
transport: 'http',
|
||||
enabled: true,
|
||||
authMode: 'oauth',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(summary)).not.toContain('secret');
|
||||
expect(JSON.stringify(summary)).not.toContain('server.js');
|
||||
});
|
||||
|
||||
it('uses only run-scoped MCP servers in sandbox mode', () => {
|
||||
const persistedServers = normalizeRunToolBundleForRun({
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'persisted',
|
||||
transport: 'http',
|
||||
url: 'https://persisted.example.test/mcp',
|
||||
},
|
||||
],
|
||||
}).mcpServers;
|
||||
const runScopedServers = normalizeRunToolBundleForRun({
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'run-only',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['run-tool.js'],
|
||||
},
|
||||
],
|
||||
}).mcpServers;
|
||||
|
||||
const selection = resolveExternalMcpServersForRun({
|
||||
persistedServers,
|
||||
runScopedServers,
|
||||
sandboxMode: true,
|
||||
});
|
||||
|
||||
expect(selection.enabledServers.map((server) => server.id)).toEqual(['run-only']);
|
||||
expect([...selection.persistedTokenServerIds]).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects malformed run-scoped MCP server entries for request payloads', () => {
|
||||
expect(parseRunToolBundleForRequest('bad')).toEqual({
|
||||
ok: false,
|
||||
message: 'toolBundle must be an object',
|
||||
});
|
||||
expect(parseRunToolBundleForRequest({ mcpServers: 'bad' })).toEqual({
|
||||
ok: false,
|
||||
message: 'toolBundle.mcpServers must be an array',
|
||||
});
|
||||
expect(parseRunToolBundleForRequest({
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'missing-command',
|
||||
transport: 'stdio',
|
||||
},
|
||||
],
|
||||
})).toEqual({
|
||||
ok: false,
|
||||
message: 'toolBundle.mcpServers[0] is invalid',
|
||||
});
|
||||
expect(parseRunToolBundleForRequest({
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'dup',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
},
|
||||
{
|
||||
id: 'dup',
|
||||
transport: 'http',
|
||||
url: 'https://example.test/mcp',
|
||||
},
|
||||
],
|
||||
})).toEqual({
|
||||
ok: false,
|
||||
message: 'toolBundle.mcpServers[1] duplicates server id "dup"',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets a run-scoped server override persisted config without inheriting persisted tokens', () => {
|
||||
const persistedServers = normalizeRunToolBundleForRun({
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'shared',
|
||||
transport: 'http',
|
||||
url: 'https://persisted.example.test/mcp',
|
||||
},
|
||||
{
|
||||
id: 'persisted-only',
|
||||
transport: 'http',
|
||||
url: 'https://persisted-only.example.test/mcp',
|
||||
},
|
||||
],
|
||||
}).mcpServers;
|
||||
const runScopedServers = normalizeRunToolBundleForRun({
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'shared',
|
||||
transport: 'http',
|
||||
url: 'https://run.example.test/mcp',
|
||||
headers: { Authorization: 'Bearer run-token' },
|
||||
},
|
||||
],
|
||||
}).mcpServers;
|
||||
|
||||
const selection = resolveExternalMcpServersForRun({
|
||||
persistedServers,
|
||||
runScopedServers,
|
||||
sandboxMode: false,
|
||||
});
|
||||
|
||||
expect(selection.enabledServers).toHaveLength(2);
|
||||
expect(selection.enabledServers.find((server) => server.id === 'shared')).toMatchObject({
|
||||
url: 'https://run.example.test/mcp',
|
||||
});
|
||||
expect([...selection.persistedTokenServerIds]).toEqual(['persisted-only']);
|
||||
});
|
||||
|
||||
it('rejects bundles for runtimes that cannot receive the requested servers', () => {
|
||||
const stdioOnly = normalizeRunToolBundleForRun({
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'local',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
},
|
||||
],
|
||||
});
|
||||
const remote = normalizeRunToolBundleForRun({
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'remote',
|
||||
transport: 'http',
|
||||
url: 'https://example.test/mcp',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(validateRunToolBundleForAgent(stdioOnly, {
|
||||
id: 'codex',
|
||||
name: 'Codex CLI',
|
||||
})).toEqual({
|
||||
ok: false,
|
||||
message: 'Codex CLI (codex) does not support run-scoped MCP tool bundles',
|
||||
});
|
||||
|
||||
expect(validateRunToolBundleForAgent(remote, {
|
||||
id: 'hermes',
|
||||
name: 'Hermes',
|
||||
externalMcpInjection: 'acp-merge',
|
||||
})).toEqual({
|
||||
ok: false,
|
||||
message:
|
||||
'toolBundle.mcpServers[0] uses http transport, but Hermes (hermes) only supports stdio run-scoped MCP servers',
|
||||
});
|
||||
|
||||
expect(validateRunToolBundleForAgent(remote, {
|
||||
id: 'claude',
|
||||
name: 'Claude Code',
|
||||
externalMcpInjection: 'claude-mcp-json',
|
||||
})).toEqual({ ok: true });
|
||||
|
||||
expect(validateRunToolBundleForAgent(remote, {
|
||||
id: 'claude',
|
||||
name: 'Claude Code',
|
||||
externalMcpInjection: 'claude-mcp-json',
|
||||
}, {
|
||||
deliveryTarget: 'external-project',
|
||||
})).toEqual({
|
||||
ok: false,
|
||||
message:
|
||||
'Claude Code (claude) receives run-scoped MCP tool bundles through project .mcp.json, ' +
|
||||
'so toolBundle requires a daemon-managed project',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -80,6 +80,45 @@ describe('chat run service shutdown', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('stores a run-scoped tool bundle and returns a redacted status summary', () => {
|
||||
const runs = createRuns();
|
||||
const run = runs.create({
|
||||
projectId: 'project-1',
|
||||
conversationId: 'conv-a',
|
||||
toolBundle: {
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'run-tools',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js', '--token=secret'],
|
||||
env: { API_TOKEN: 'secret' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}) as any;
|
||||
|
||||
expect(run.toolBundle.mcpServers).toHaveLength(1);
|
||||
expect(run.toolBundle.mcpServers[0]).toMatchObject({
|
||||
id: 'run-tools',
|
||||
command: 'node',
|
||||
env: { API_TOKEN: 'secret' },
|
||||
});
|
||||
|
||||
const status = runs.statusBody(run);
|
||||
expect(status.toolBundle).toEqual({
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'run-tools',
|
||||
transport: 'stdio',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(status)).not.toContain('secret');
|
||||
expect(JSON.stringify(status)).not.toContain('server.js');
|
||||
});
|
||||
|
||||
it('cancels active runs and terminates their child process during daemon shutdown', async () => {
|
||||
const runs = createRuns();
|
||||
const child = new FakeChildProcess({ closeOn: 'SIGTERM' });
|
||||
|
|
|
|||
|
|
@ -957,6 +957,22 @@ test('spawnEnvForAgent strips ANTHROPIC_API_KEY case-insensitively for the claud
|
|||
assert.equal(env.PATH, '/usr/bin');
|
||||
});
|
||||
|
||||
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY when claude resolves to OpenClaude fallback', () => {
|
||||
const env = spawnEnvForAgent(
|
||||
'claude',
|
||||
{
|
||||
ANTHROPIC_API_KEY: 'sk-openclaude',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{},
|
||||
{},
|
||||
{ resolvedBin: '/tools/openclaude' },
|
||||
);
|
||||
|
||||
assert.equal(env.ANTHROPIC_API_KEY, 'sk-openclaude');
|
||||
assert.equal(env.PATH, '/usr/bin');
|
||||
});
|
||||
|
||||
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => {
|
||||
for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) {
|
||||
const env = spawnEnvForAgent(agentId, {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
|
|
@ -373,6 +374,47 @@ describe('chooseExecutableByMinVersion (#978: skip stale binaries that fail the
|
|||
rmSync(newDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
fsTest('cached pick replaced in place with an older binary is re-probed instead of returned stale', async () => {
|
||||
const cachedDir = mkdtempSync(join(tmpdir(), 'od-cache-replace-cached-'));
|
||||
const fallbackDir = mkdtempSync(join(tmpdir(), 'od-cache-replace-fallback-'));
|
||||
try {
|
||||
const cachedGemini = join(cachedDir, 'gemini');
|
||||
const fallbackGemini = join(fallbackDir, 'gemini');
|
||||
writeFileSync(cachedGemini, '0.40.1\n');
|
||||
writeFileSync(fallbackGemini, '0.41.0\n');
|
||||
chmodSync(cachedGemini, 0o755);
|
||||
chmodSync(fallbackGemini, 0o755);
|
||||
process.env.OD_AGENT_HOME = cachedDir;
|
||||
process.env.PATH = `${cachedDir}${delimiter}${fallbackDir}`;
|
||||
|
||||
const def = minimalAgentDef({ id: 'gemini', bin: 'gemini', minVersion: '0.30.0' });
|
||||
let probes = 0;
|
||||
const runVersion = async (p: string) => {
|
||||
probes += 1;
|
||||
return readFileSync(p, 'utf8');
|
||||
};
|
||||
|
||||
const firstChosen = await chooseExecutableByMinVersion(def, {}, { runVersion });
|
||||
expect(firstChosen).toBe(cachedGemini);
|
||||
const probesAfterCold = probes;
|
||||
expect(probesAfterCold).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Simulate a package-manager rewrite at the same visible path:
|
||||
// the path still exists, but it now points at an older build that
|
||||
// should not keep bypassing the min-version gate until daemon restart.
|
||||
rmSync(cachedGemini);
|
||||
writeFileSync(cachedGemini, '0.1.12 downgraded in-place\n');
|
||||
chmodSync(cachedGemini, 0o755);
|
||||
|
||||
const secondChosen = await chooseExecutableByMinVersion(def, {}, { runVersion });
|
||||
expect(secondChosen).toBe(fallbackGemini);
|
||||
expect(probes).toBeGreaterThan(probesAfterCold);
|
||||
} finally {
|
||||
rmSync(cachedDir, { recursive: true, force: true });
|
||||
rmSync(fallbackDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('inspectAgentExecutableResolution + minVersion cache wiring (#978)', () => {
|
||||
|
|
|
|||
|
|
@ -2658,6 +2658,11 @@ function ToolsPluginsPanel({
|
|||
<button
|
||||
type="button"
|
||||
className="composer-tools-row-main"
|
||||
// Match the @-mention popover: prevent the textarea from
|
||||
// losing focus before the click handler runs so
|
||||
// selectionStart isn't reset to 0 and the inserted token
|
||||
// lands at the user's actual cursor position (#3195).
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={async () => {
|
||||
setPendingId(p.id);
|
||||
try {
|
||||
|
|
@ -2749,6 +2754,10 @@ function ToolsMcpPanel({
|
|||
type="button"
|
||||
role="menuitem"
|
||||
className="composer-tools-row"
|
||||
// Match the @-mention popover: prevent the textarea from
|
||||
// losing focus before the click handler runs so
|
||||
// selectionStart isn't reset to 0 (#3195).
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onInsert(s.id)}
|
||||
title={`Insert a hint that nudges the model to use ${s.label || s.id}`}
|
||||
>
|
||||
|
|
@ -2839,6 +2848,10 @@ function ToolsSkillsPanel({
|
|||
type="button"
|
||||
role="menuitem"
|
||||
className={`composer-tools-row${active ? ' active' : ''}`}
|
||||
// Match the @-mention popover: prevent the textarea from
|
||||
// losing focus before the click handler runs so
|
||||
// selectionStart isn't reset to 0 (#3195).
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={async () => {
|
||||
setPendingId(skill.id);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -2014,6 +2014,16 @@ export function conversationMetaLabel(
|
|||
t: TranslateFn,
|
||||
): string {
|
||||
const latestRun = conversation.latestRun;
|
||||
if (
|
||||
latestRun &&
|
||||
(latestRun.status === 'succeeded' ||
|
||||
latestRun.status === 'failed' ||
|
||||
latestRun.status === 'canceled') &&
|
||||
typeof conversation.totalDurationMs === 'number' &&
|
||||
Number.isFinite(conversation.totalDurationMs)
|
||||
) {
|
||||
return formatDurationShort(conversation.totalDurationMs);
|
||||
}
|
||||
if (
|
||||
latestRun &&
|
||||
(latestRun.status === 'succeeded' ||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,14 @@ export type ManualEditPendingStyleSave = {
|
|||
};
|
||||
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
|
||||
type PreviewCanvasSize = { width: number; height: number };
|
||||
type CommentPreviewCanvasOptions = {
|
||||
boardMode: boolean;
|
||||
sidePanelCollapsed: boolean;
|
||||
viewport?: PreviewViewportId;
|
||||
};
|
||||
type PreviewScaleOptions = {
|
||||
canvasPadding?: number;
|
||||
};
|
||||
type PreviewViewportPreset = {
|
||||
id: PreviewViewportId;
|
||||
width: number | null;
|
||||
|
|
@ -214,6 +222,18 @@ const PREVIEW_VIEWPORT_PRESETS: PreviewViewportPreset[] = [
|
|||
},
|
||||
];
|
||||
const EXPORT_READY_NUDGE_STORAGE_PREFIX = 'open-design:export-ready-nudge:';
|
||||
const COMMENT_SIDE_DOCK_WIDTH = 320;
|
||||
const COMMENT_SIDE_DOCK_RAIL_WIDTH = 42;
|
||||
const COMMENT_SIDE_DOCK_GAP = 12;
|
||||
const COMMENT_SIDE_DOCK_PADDING = 8;
|
||||
const COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING = 24;
|
||||
const COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH = 280;
|
||||
const COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT = 220;
|
||||
const COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT = 48;
|
||||
const COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION =
|
||||
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT;
|
||||
const COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION =
|
||||
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT;
|
||||
|
||||
// The five basic style facets the inspect panel exposes. Kept narrow on
|
||||
// purpose — open-slide's design tokens panel only edits global tokens, so
|
||||
|
|
@ -500,10 +520,11 @@ function previewViewportStyle(
|
|||
viewport: PreviewViewportId,
|
||||
previewScale = 1,
|
||||
canvasSize?: PreviewCanvasSize,
|
||||
options?: PreviewScaleOptions,
|
||||
): CSSProperties & Record<string, string | number> {
|
||||
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport) ?? PREVIEW_VIEWPORT_PRESETS[0]!;
|
||||
if (!preset.width) return {};
|
||||
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize);
|
||||
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize, options);
|
||||
return {
|
||||
'--preview-viewport-width': `${preset.width}px`,
|
||||
'--preview-viewport-height': `${preset.height}px`,
|
||||
|
|
@ -512,15 +533,54 @@ function previewViewportStyle(
|
|||
};
|
||||
}
|
||||
|
||||
export function commentPreviewCanvasSize(
|
||||
canvasSize: PreviewCanvasSize | undefined,
|
||||
options: CommentPreviewCanvasOptions,
|
||||
): PreviewCanvasSize | undefined {
|
||||
if (!canvasSize || !options.boardMode) return canvasSize;
|
||||
const dockPadding = options.viewport && options.viewport !== 'desktop'
|
||||
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
|
||||
: COMMENT_SIDE_DOCK_PADDING;
|
||||
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
|
||||
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
|
||||
if (usesStackedCommentSideDock(canvasSize, options)) {
|
||||
const stackedHeightDeduction = options.sidePanelCollapsed
|
||||
? COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION
|
||||
: COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION;
|
||||
return {
|
||||
width: Math.max(1, canvasSize.width - (COMMENT_SIDE_DOCK_PADDING * 2)),
|
||||
height: Math.max(1, canvasSize.height - stackedHeightDeduction),
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: Math.max(1, dockedWidth),
|
||||
height: Math.max(1, canvasSize.height - (dockPadding * 2)),
|
||||
};
|
||||
}
|
||||
|
||||
function usesStackedCommentSideDock(
|
||||
canvasSize: PreviewCanvasSize | undefined,
|
||||
options: CommentPreviewCanvasOptions,
|
||||
) {
|
||||
if (!canvasSize || !options.boardMode) return false;
|
||||
const dockPadding = options.viewport && options.viewport !== 'desktop'
|
||||
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
|
||||
: COMMENT_SIDE_DOCK_PADDING;
|
||||
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
|
||||
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
|
||||
return dockedWidth < COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH;
|
||||
}
|
||||
|
||||
export function effectivePreviewScale(
|
||||
viewport: PreviewViewportId,
|
||||
previewScale: number,
|
||||
canvasSize?: PreviewCanvasSize,
|
||||
options?: PreviewScaleOptions,
|
||||
) {
|
||||
if (viewport === 'desktop') return previewScale;
|
||||
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport);
|
||||
if (!preset?.width || !preset.height || !canvasSize?.width || !canvasSize.height) return previewScale;
|
||||
const canvasPadding = 48;
|
||||
const canvasPadding = options?.canvasPadding ?? 48;
|
||||
const availableWidth = Math.max(1, canvasSize.width - canvasPadding);
|
||||
const availableHeight = Math.max(1, canvasSize.height - canvasPadding);
|
||||
const fitScale = Math.min(1, availableWidth / preset.width, availableHeight / preset.height);
|
||||
|
|
@ -2086,7 +2146,6 @@ export function CommentSidePanel({
|
|||
activeCommentId,
|
||||
collapsed,
|
||||
onCollapsedChange,
|
||||
onClose,
|
||||
onToggleSelect,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
|
|
@ -2102,7 +2161,6 @@ export function CommentSidePanel({
|
|||
activeCommentId: string | null;
|
||||
collapsed: boolean;
|
||||
onCollapsedChange: (collapsed: boolean) => void;
|
||||
onClose: () => void;
|
||||
onToggleSelect: (commentId: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onClearSelection: () => void;
|
||||
|
|
@ -2119,21 +2177,48 @@ export function CommentSidePanel({
|
|||
const selectedCount = visibleSelectedIds.size;
|
||||
const allSelected = comments.length > 0 && selectedCount === comments.length;
|
||||
const commentsLabel = t('chat.tabComments');
|
||||
const collapsedRailRef = useRef<HTMLButtonElement | null>(null);
|
||||
const expandedToggleRef = useRef<HTMLButtonElement | null>(null);
|
||||
const pendingToggleFocusRef = useRef<'collapsed' | 'expanded' | null>(null);
|
||||
const panelId = useId();
|
||||
const canCreateComment = Boolean(onCreateComment) && newCommentDraft.trim().length > 0 && !sending;
|
||||
const submitNewComment = async () => {
|
||||
if (!onCreateComment || !newCommentDraft.trim()) return;
|
||||
const saved = await onCreateComment(newCommentDraft.trim());
|
||||
if (saved) setNewCommentDraft('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const target =
|
||||
pendingToggleFocusRef.current === 'collapsed'
|
||||
? collapsedRailRef.current
|
||||
: pendingToggleFocusRef.current === 'expanded'
|
||||
? expandedToggleRef.current
|
||||
: null;
|
||||
if (!target) return;
|
||||
pendingToggleFocusRef.current = null;
|
||||
target.focus();
|
||||
}, [collapsed]);
|
||||
|
||||
const handleCollapsedChange = (
|
||||
nextCollapsed: boolean,
|
||||
nextFocusTarget: 'collapsed' | 'expanded',
|
||||
) => {
|
||||
pendingToggleFocusRef.current = nextFocusTarget;
|
||||
onCollapsedChange(nextCollapsed);
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
ref={collapsedRailRef}
|
||||
type="button"
|
||||
className="comment-side-rail"
|
||||
data-testid="comment-side-collapsed-rail"
|
||||
aria-label={t('preview.showSidebar', { label: commentsLabel })}
|
||||
aria-expanded={false}
|
||||
title={t('preview.showSidebar', { label: commentsLabel })}
|
||||
onClick={() => onCollapsedChange(false)}
|
||||
onClick={() => handleCollapsedChange(false, 'expanded')}
|
||||
>
|
||||
<RemixIcon name="message-3-line" size={15} />
|
||||
<span>{commentsLabel}</span>
|
||||
|
|
@ -2143,7 +2228,7 @@ export function CommentSidePanel({
|
|||
}
|
||||
|
||||
return (
|
||||
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
|
||||
<aside id={panelId} className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
|
||||
<div className="comment-side-header">
|
||||
<div className="comment-side-title">
|
||||
<RemixIcon name="message-3-line" size={15} />
|
||||
|
|
@ -2160,15 +2245,18 @@ export function CommentSidePanel({
|
|||
{t('chat.comments.selectAll')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="comment-side-close"
|
||||
aria-label={t('common.close')}
|
||||
title={t('common.close')}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
<button
|
||||
ref={expandedToggleRef}
|
||||
type="button"
|
||||
className="comment-side-collapse"
|
||||
aria-label={t('preview.hideSidebar', { label: commentsLabel })}
|
||||
aria-controls={panelId}
|
||||
aria-expanded={true}
|
||||
title={t('preview.hideSidebar', { label: commentsLabel })}
|
||||
onClick={() => handleCollapsedChange(true, 'collapsed')}
|
||||
>
|
||||
<Icon name="chevron-right" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="comment-side-list">
|
||||
|
|
@ -2299,6 +2387,62 @@ export function CommentSidePanel({
|
|||
);
|
||||
}
|
||||
|
||||
function CommentSideDock({
|
||||
comments,
|
||||
selectedIds,
|
||||
activeCommentId,
|
||||
collapsed,
|
||||
onCollapsedChange,
|
||||
onToggleSelect,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
onReply,
|
||||
onSendSelected,
|
||||
onCreateComment,
|
||||
sending,
|
||||
t,
|
||||
composer,
|
||||
}: {
|
||||
comments: PreviewComment[];
|
||||
selectedIds: Set<string>;
|
||||
activeCommentId: string | null;
|
||||
collapsed: boolean;
|
||||
onCollapsedChange: (collapsed: boolean) => void;
|
||||
onToggleSelect: (commentId: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onClearSelection: () => void;
|
||||
onReply: (comment: PreviewComment) => void;
|
||||
onSendSelected: () => void | Promise<void>;
|
||||
onCreateComment?: (note: string) => boolean | Promise<boolean>;
|
||||
sending: boolean;
|
||||
t: TranslateFn;
|
||||
composer?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`comment-side-dock${collapsed ? ' collapsed' : ''}`}
|
||||
data-testid="comment-side-dock"
|
||||
>
|
||||
<CommentSidePanel
|
||||
comments={comments}
|
||||
selectedIds={selectedIds}
|
||||
activeCommentId={activeCommentId}
|
||||
collapsed={collapsed}
|
||||
onCollapsedChange={onCollapsedChange}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onSelectAll={onSelectAll}
|
||||
onClearSelection={onClearSelection}
|
||||
onReply={onReply}
|
||||
onSendSelected={onSendSelected}
|
||||
onCreateComment={onCreateComment}
|
||||
sending={sending}
|
||||
t={t}
|
||||
composer={composer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Maps a CSS computed value (e.g. "rgb(40, 50, 60)" or "16px") to a form
|
||||
// input value. Browsers return colors as rgb()/rgba(); HTML <input type=color>
|
||||
// only accepts "#rrggbb". Lengths come back as "12px" or "0px"; we strip
|
||||
|
|
@ -4309,6 +4453,17 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
|
||||
const previewStateKey = `${projectId}:${file.name}`;
|
||||
const previewScale = zoom / 100;
|
||||
const localCommentSideDockActive = commentPanelOpen && !commentPortalHost;
|
||||
const boardPreviewCanvasSize = commentPreviewCanvasSize(previewBodySize, {
|
||||
boardMode: localCommentSideDockActive,
|
||||
sidePanelCollapsed: commentSidePanelCollapsed,
|
||||
viewport: previewViewport,
|
||||
});
|
||||
const boardSideDockStacked = usesStackedCommentSideDock(previewBodySize, {
|
||||
boardMode: localCommentSideDockActive,
|
||||
sidePanelCollapsed: commentSidePanelCollapsed,
|
||||
viewport: previewViewport,
|
||||
});
|
||||
|
||||
function deploymentMapForCurrentFile(items: WebDeploymentInfo[]) {
|
||||
const next: Partial<Record<WebDeployProviderId, WebDeploymentInfo>> = {};
|
||||
|
|
@ -4432,8 +4587,18 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
const [slideState, setSlideState] = useState<SlideState | null>(
|
||||
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
|
||||
);
|
||||
const overlayPreviewTransform = previewOverlayTransform(previewViewport, previewScale, previewBodySize);
|
||||
const overlayPreviewScale = overlayPreviewTransform.scale;
|
||||
const boardPreviewScaleOptions = localCommentSideDockActive ? { canvasPadding: 0 } : undefined;
|
||||
const overlayPreviewScale = effectivePreviewScale(
|
||||
previewViewport,
|
||||
previewScale,
|
||||
boardPreviewCanvasSize,
|
||||
boardPreviewScaleOptions,
|
||||
);
|
||||
const overlayPreviewTransform: PreviewOverlayTransform = {
|
||||
scale: overlayPreviewScale,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
const shareRef = useRef<HTMLDivElement | null>(null);
|
||||
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
|
|
@ -6479,6 +6644,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
};
|
||||
const boardAvailable = mode === 'preview' && source !== null;
|
||||
const showPreviewToolbarControls = mode === 'preview';
|
||||
const commentPreviewLayoutClass = [
|
||||
'comment-preview-layer',
|
||||
localCommentSideDockActive ? 'comment-preview-layer-with-side-dock' : '',
|
||||
localCommentSideDockActive && commentSidePanelCollapsed ? 'comment-preview-layer-dock-collapsed' : '',
|
||||
boardSideDockStacked ? 'comment-preview-layer-side-dock-stacked' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const manualEditPanel = manualEditMode ? (
|
||||
<ManualEditPanel
|
||||
targets={manualEditTargets}
|
||||
|
|
@ -6588,19 +6759,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
/>
|
||||
) : null;
|
||||
const commentSidePanel = commentPanelOpen ? (
|
||||
<CommentSidePanel
|
||||
<CommentSideDock
|
||||
comments={visibleSideComments}
|
||||
selectedIds={selectedSideCommentIds}
|
||||
activeCommentId={activeSideCommentId}
|
||||
collapsed={commentPortalHost ? false : commentSidePanelCollapsed}
|
||||
onCollapsedChange={setCommentSidePanelCollapsed}
|
||||
onClose={() => {
|
||||
setCommentPanelOpen(false);
|
||||
setCommentSidePanelCollapsed(false);
|
||||
setCommentCreateMode(false);
|
||||
setBoardMode(false);
|
||||
clearBoardComposer();
|
||||
}}
|
||||
onToggleSelect={(commentId) => {
|
||||
setSelectedSideCommentIds((current) => {
|
||||
const next = new Set(current);
|
||||
|
|
@ -7102,201 +7266,258 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||||
) : mode === 'preview' ? (
|
||||
<div
|
||||
className={manualEditMode
|
||||
? `manual-edit-workspace preview-viewport preview-viewport-${previewViewport}`
|
||||
: [
|
||||
'comment-preview-layer',
|
||||
`preview-viewport preview-viewport-${previewViewport}`,
|
||||
].filter(Boolean).join(' ')}
|
||||
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
|
||||
className={`${manualEditMode ? 'manual-edit-workspace' : commentPreviewLayoutClass} preview-viewport preview-viewport-${previewViewport}`}
|
||||
data-testid={manualEditMode ? undefined : 'comment-preview-layout'}
|
||||
style={previewViewportStyle(previewViewport, previewScale, boardPreviewCanvasSize, boardPreviewScaleOptions)}
|
||||
>
|
||||
{manualEditPanel}
|
||||
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}>
|
||||
<div
|
||||
style={
|
||||
manualEditMode
|
||||
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
|
||||
: previewScaleShellStyle(previewViewport, previewScale)
|
||||
}
|
||||
>
|
||||
<PreviewDrawOverlay
|
||||
active={drawOverlayOpen}
|
||||
onActiveChange={setDrawOverlayOpen}
|
||||
captureTarget={null}
|
||||
filePath={file.name}
|
||||
sendDisabled={streaming}
|
||||
sendDisabledReason={t('chat.annotationSendDisabledReason')}
|
||||
<div
|
||||
className={manualEditMode ? 'manual-edit-canvas' : 'comment-preview-canvas'}
|
||||
data-testid={manualEditMode ? undefined : 'comment-preview-canvas'}
|
||||
>
|
||||
<div className={manualEditMode ? undefined : 'comment-frame-clip'}>
|
||||
<div
|
||||
style={
|
||||
manualEditMode
|
||||
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
|
||||
: previewScaleShellStyle(previewViewport, previewScale)
|
||||
}
|
||||
>
|
||||
<div className="artifact-preview-transport-stack">
|
||||
{OD_PREVIEW_KEEP_ALIVE ? (
|
||||
<PooledIframe
|
||||
ref={urlPreviewIframeRef}
|
||||
cacheKey={urlPreviewKeepAliveKey}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||
data-od-render-mode="url-load"
|
||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||
aria-hidden={useUrlLoadPreview ? undefined : true}
|
||||
tabIndex={useUrlLoadPreview ? 0 : -1}
|
||||
title={file.name}
|
||||
sandbox="allow-scripts allow-downloads"
|
||||
src={urlTransportSrc}
|
||||
onLoad={() => {
|
||||
const frame = urlPreviewIframeRef.current;
|
||||
if (useUrlLoadPreview) iframeRef.current = frame;
|
||||
dcViewportRestoreAtRef.current = Date.now();
|
||||
frame?.contentWindow?.postMessage({
|
||||
type: '__dc_set_viewport',
|
||||
...dcViewportRef.current,
|
||||
}, '*');
|
||||
syncBridgeModes(frame);
|
||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PreviewDrawOverlay
|
||||
active={drawOverlayOpen}
|
||||
onActiveChange={setDrawOverlayOpen}
|
||||
captureTarget={null}
|
||||
filePath={file.name}
|
||||
sendDisabled={streaming}
|
||||
sendDisabledReason={t('chat.annotationSendDisabledReason')}
|
||||
>
|
||||
<div className="artifact-preview-transport-stack">
|
||||
{OD_PREVIEW_KEEP_ALIVE ? (
|
||||
<PooledIframe
|
||||
ref={urlPreviewIframeRef}
|
||||
cacheKey={urlPreviewKeepAliveKey}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||
data-od-render-mode="url-load"
|
||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||
aria-hidden={useUrlLoadPreview ? undefined : true}
|
||||
tabIndex={useUrlLoadPreview ? 0 : -1}
|
||||
title={file.name}
|
||||
sandbox="allow-scripts allow-downloads"
|
||||
src={urlTransportSrc}
|
||||
onLoad={() => {
|
||||
const frame = urlPreviewIframeRef.current;
|
||||
if (useUrlLoadPreview) iframeRef.current = frame;
|
||||
dcViewportRestoreAtRef.current = Date.now();
|
||||
frame?.contentWindow?.postMessage({
|
||||
type: '__dc_set_viewport',
|
||||
...dcViewportRef.current,
|
||||
}, '*');
|
||||
syncBridgeModes(frame);
|
||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
ref={urlPreviewIframeRef}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||
data-od-render-mode="url-load"
|
||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||
aria-hidden={useUrlLoadPreview ? undefined : true}
|
||||
tabIndex={useUrlLoadPreview ? 0 : -1}
|
||||
title={file.name}
|
||||
sandbox="allow-scripts allow-downloads"
|
||||
src={urlTransportSrc}
|
||||
onLoad={() => {
|
||||
const frame = urlPreviewIframeRef.current;
|
||||
if (useUrlLoadPreview) iframeRef.current = frame;
|
||||
dcViewportRestoreAtRef.current = Date.now();
|
||||
frame?.contentWindow?.postMessage({
|
||||
type: '__dc_set_viewport',
|
||||
...dcViewportRef.current,
|
||||
}, '*');
|
||||
syncBridgeModes(frame);
|
||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<iframe
|
||||
ref={urlPreviewIframeRef}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||
data-od-render-mode="url-load"
|
||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||
aria-hidden={useUrlLoadPreview ? undefined : true}
|
||||
tabIndex={useUrlLoadPreview ? 0 : -1}
|
||||
key={srcDocTransportResetKey}
|
||||
ref={srcDocPreviewIframeRef}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
|
||||
data-od-render-mode="srcdoc"
|
||||
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
|
||||
aria-hidden={useUrlLoadPreview ? true : undefined}
|
||||
tabIndex={useUrlLoadPreview ? -1 : 0}
|
||||
title={file.name}
|
||||
sandbox="allow-scripts allow-downloads"
|
||||
src={urlTransportSrc}
|
||||
srcDoc={srcDocTransportContent}
|
||||
onLoad={() => {
|
||||
const frame = urlPreviewIframeRef.current;
|
||||
if (useUrlLoadPreview) iframeRef.current = frame;
|
||||
const frame = srcDocPreviewIframeRef.current;
|
||||
if (!useUrlLoadPreview) iframeRef.current = frame;
|
||||
// Reset the activation dedupe exactly ONCE per
|
||||
// freshly mounted iframe DOM node, never on the
|
||||
// subsequent load events that the same node
|
||||
// emits during normal srcDoc rendering.
|
||||
//
|
||||
// The iframe's load event fires twice for one
|
||||
// successful activation: once when the lazy
|
||||
// transport shell HTML loads, and again when
|
||||
// our own document.open/write/close inside the
|
||||
// shell finishes. PR #2699 reset the dedupe on
|
||||
// every load so that switching
|
||||
// preview -> source -> preview (which remounts
|
||||
// this iframe as a fresh DOM node) would
|
||||
// re-activate the new shell. But resetting on
|
||||
// every load also re-activated on the SECOND
|
||||
// load of a non-remounted frame, which
|
||||
// re-triggered document.open/write/close, which
|
||||
// re-fired the load event, ad infinitum. The
|
||||
// dedupe ref oscillated between null and the
|
||||
// current srcDoc thousands of times per render
|
||||
// and each iteration restarted every CSS
|
||||
// animation from its `from` keyframe. Designs
|
||||
// using `animation-fill-mode: both` with
|
||||
// `from { opacity: 0 }` stayed at opacity 0
|
||||
// forever and the preview read as blank.
|
||||
// That is issue #2361.
|
||||
//
|
||||
// Tracking the last frame we reset for lets us
|
||||
// keep PR #2699's "remount after Source toggle"
|
||||
// fix while breaking the loop on plain renders.
|
||||
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
|
||||
srcDocFrameDedupeResetForRef.current = frame;
|
||||
activatedSrcDocTransportHtmlRef.current = null;
|
||||
}
|
||||
if (useLazySrcDocTransport) setSrcDocShellReady(true);
|
||||
activateLoadedSrcDocTransport(frame);
|
||||
dcViewportRestoreAtRef.current = Date.now();
|
||||
frame?.contentWindow?.postMessage({
|
||||
type: '__dc_set_viewport',
|
||||
...dcViewportRef.current,
|
||||
}, '*');
|
||||
replayInspectOverridesToIframe(frame);
|
||||
syncBridgeModes(frame);
|
||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
if (!useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<iframe
|
||||
key={srcDocTransportResetKey}
|
||||
ref={srcDocPreviewIframeRef}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
|
||||
data-od-render-mode="srcdoc"
|
||||
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
|
||||
aria-hidden={useUrlLoadPreview ? true : undefined}
|
||||
tabIndex={useUrlLoadPreview ? -1 : 0}
|
||||
title={file.name}
|
||||
sandbox="allow-scripts allow-downloads"
|
||||
srcDoc={srcDocTransportContent}
|
||||
onLoad={() => {
|
||||
const frame = srcDocPreviewIframeRef.current;
|
||||
if (!useUrlLoadPreview) iframeRef.current = frame;
|
||||
// Reset the activation dedupe exactly ONCE per
|
||||
// freshly mounted iframe DOM node, never on the
|
||||
// subsequent load events that the same node
|
||||
// emits during normal srcDoc rendering.
|
||||
//
|
||||
// The iframe's load event fires twice for one
|
||||
// successful activation: once when the lazy
|
||||
// transport shell HTML loads, and again when
|
||||
// our own document.open/write/close inside the
|
||||
// shell finishes. PR #2699 reset the dedupe on
|
||||
// every load so that switching
|
||||
// preview -> source -> preview (which remounts
|
||||
// this iframe as a fresh DOM node) would
|
||||
// re-activate the new shell. But resetting on
|
||||
// every load also re-activated on the SECOND
|
||||
// load of a non-remounted frame, which
|
||||
// re-triggered document.open/write/close, which
|
||||
// re-fired the load event, ad infinitum. The
|
||||
// dedupe ref oscillated between null and the
|
||||
// current srcDoc thousands of times per render
|
||||
// and each iteration restarted every CSS
|
||||
// animation from its `from` keyframe. Designs
|
||||
// using `animation-fill-mode: both` with
|
||||
// `from { opacity: 0 }` stayed at opacity 0
|
||||
// forever and the preview read as blank.
|
||||
// That is issue #2361.
|
||||
//
|
||||
// Tracking the last frame we reset for lets us
|
||||
// keep PR #2699's "remount after Source toggle"
|
||||
// fix while breaking the loop on plain renders.
|
||||
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
|
||||
srcDocFrameDedupeResetForRef.current = frame;
|
||||
activatedSrcDocTransportHtmlRef.current = null;
|
||||
}
|
||||
if (useLazySrcDocTransport) setSrcDocShellReady(true);
|
||||
activateLoadedSrcDocTransport(frame);
|
||||
dcViewportRestoreAtRef.current = Date.now();
|
||||
frame?.contentWindow?.postMessage({
|
||||
type: '__dc_set_viewport',
|
||||
...dcViewportRef.current,
|
||||
}, '*');
|
||||
replayInspectOverridesToIframe(frame);
|
||||
syncBridgeModes(frame);
|
||||
if (!useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PreviewDrawOverlay>
|
||||
</div>
|
||||
</PreviewDrawOverlay>
|
||||
</div>
|
||||
</div>
|
||||
{boardMode ? (
|
||||
<CommentPreviewOverlays
|
||||
comments={commentCreateMode ? visibleSideComments : []}
|
||||
liveTargets={liveCommentTargets}
|
||||
hoveredTarget={hoveredCommentTarget}
|
||||
hoveredPodMemberId={hoveredPodMemberId}
|
||||
activeTarget={activeCommentTarget}
|
||||
boardTool={boardTool}
|
||||
showActivePin={commentCreateMode}
|
||||
scale={overlayPreviewScale}
|
||||
offsetX={overlayPreviewTransform.offsetX}
|
||||
offsetY={overlayPreviewTransform.offsetY}
|
||||
strokePoints={strokePoints}
|
||||
onOpenComment={(comment, snapshot) => {
|
||||
setCommentPanelOpen(true);
|
||||
setCommentSidePanelCollapsed(false);
|
||||
setCommentCreateMode(true);
|
||||
setBoardMode(true);
|
||||
setActiveCommentTarget(snapshot);
|
||||
setHoveredCommentTarget(snapshot);
|
||||
setActivePreviewCommentId(comment.id);
|
||||
setCommentDraft(comment.note);
|
||||
setQueuedBoardNotes([]);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{exportToast ? (
|
||||
<div className="comment-toast-anchor">
|
||||
<Toast
|
||||
message={exportToast}
|
||||
ttlMs={2200}
|
||||
onDismiss={() => setExportToast(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{commentSavedToast ? (
|
||||
<div className="comment-toast-anchor">
|
||||
<Toast
|
||||
message={commentSavedToast}
|
||||
ttlMs={2200}
|
||||
onDismiss={() => setCommentSavedToast(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{templateSavedToast ? (
|
||||
<div className="comment-toast-anchor">
|
||||
<Toast
|
||||
message={templateSavedToast}
|
||||
ttlMs={2200}
|
||||
onDismiss={() => setTemplateSavedToast(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{commentComposer}
|
||||
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
|
||||
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
|
||||
) : null}
|
||||
{/*
|
||||
Hint banner for Inspect / Picker modes. The bridge in
|
||||
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
|
||||
with every element annotated with `data-od-id` /
|
||||
`data-screen-label`, so `liveCommentTargets.size` is the
|
||||
authoritative annotation count for the current artifact.
|
||||
|
||||
Two states:
|
||||
- "has targets": the existing copy ("Click any element with
|
||||
`data-od-id` to tune its style.") for users who just don't
|
||||
see the crosshair cursor.
|
||||
- "no targets" (issue #890): a freeform-generated artifact
|
||||
(e.g. PRD → HTML through a Claude-Code-compatible CLI
|
||||
without a skill) ships zero `data-od-id` annotations. The
|
||||
bridge's click handler walks up to <html>, finds nothing,
|
||||
and bails — clicks no-op silently. The static copy made
|
||||
this look broken; the empty-state copy explains what's
|
||||
missing and how to fix it. Mirrored across Inspect and
|
||||
element-pick annotation mode because the failure surface is identical.
|
||||
*/}
|
||||
{inspectMode
|
||||
&& openHintBox
|
||||
&& !activeInspectTarget
|
||||
&& !activeCommentTarget ? (
|
||||
<div
|
||||
className="inspect-empty-hint-container"
|
||||
data-testid="inspect-empty-hint-container"
|
||||
>
|
||||
{liveCommentTargets.size === 0 ? (
|
||||
<div
|
||||
className="inspect-empty-hint"
|
||||
data-testid="inspect-empty-hint-no-targets"
|
||||
>
|
||||
{inspectMode
|
||||
? t('chat.inspect.noEditableTargets')
|
||||
: t('chat.inspect.noCommentTargets')}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="inspect-empty-hint"
|
||||
data-testid="inspect-empty-hint"
|
||||
>
|
||||
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title="Close Inspect Hint"
|
||||
aria-label="Close Inspect Hint"
|
||||
onClick={() => setOpenHintBox(false)}
|
||||
className="orbit-artifact-ghost"
|
||||
>
|
||||
<Icon className="" name="close" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{boardMode ? (
|
||||
<CommentPreviewOverlays
|
||||
comments={commentCreateMode ? visibleSideComments : []}
|
||||
liveTargets={liveCommentTargets}
|
||||
hoveredTarget={hoveredCommentTarget}
|
||||
hoveredPodMemberId={hoveredPodMemberId}
|
||||
activeTarget={activeCommentTarget}
|
||||
boardTool={boardTool}
|
||||
showActivePin={commentCreateMode}
|
||||
scale={overlayPreviewScale}
|
||||
offsetX={overlayPreviewTransform.offsetX}
|
||||
offsetY={overlayPreviewTransform.offsetY}
|
||||
strokePoints={strokePoints}
|
||||
onOpenComment={(comment, snapshot) => {
|
||||
setCommentPanelOpen(true);
|
||||
setCommentSidePanelCollapsed(false);
|
||||
setCommentCreateMode(true);
|
||||
setBoardMode(true);
|
||||
setActiveCommentTarget(snapshot);
|
||||
setHoveredCommentTarget(snapshot);
|
||||
setActivePreviewCommentId(comment.id);
|
||||
setCommentDraft(comment.note);
|
||||
setQueuedBoardNotes([]);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{exportToast ? (
|
||||
<div className="comment-toast-anchor">
|
||||
<Toast
|
||||
message={exportToast}
|
||||
ttlMs={2200}
|
||||
onDismiss={() => setExportToast(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{commentSavedToast ? (
|
||||
<div className="comment-toast-anchor">
|
||||
<Toast
|
||||
message={commentSavedToast}
|
||||
ttlMs={2200}
|
||||
onDismiss={() => setCommentSavedToast(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{templateSavedToast ? (
|
||||
<div className="comment-toast-anchor">
|
||||
<Toast
|
||||
message={templateSavedToast}
|
||||
ttlMs={2200}
|
||||
onDismiss={() => setTemplateSavedToast(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{commentComposer}
|
||||
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
|
||||
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
|
||||
) : null}
|
||||
{commentPortalHost && commentSidePanel
|
||||
? createPortal(commentSidePanel, commentPortalHost)
|
||||
: commentPortalId
|
||||
|
|
@ -7339,64 +7560,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
error={inspectError}
|
||||
/>
|
||||
) : null}
|
||||
{/*
|
||||
Hint banner for Inspect / Picker modes. The bridge in
|
||||
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
|
||||
with every element annotated with `data-od-id` /
|
||||
`data-screen-label`, so `liveCommentTargets.size` is the
|
||||
authoritative annotation count for the current artifact.
|
||||
|
||||
Two states:
|
||||
- "has targets": the existing copy ("Click any element with
|
||||
`data-od-id` to tune its style.") for users who just don't
|
||||
see the crosshair cursor.
|
||||
- "no targets" (issue #890): a freeform-generated artifact
|
||||
(e.g. PRD → HTML through a Claude-Code-compatible CLI
|
||||
without a skill) ships zero `data-od-id` annotations. The
|
||||
bridge's click handler walks up to <html>, finds nothing,
|
||||
and bails — clicks no-op silently. The static copy made
|
||||
this look broken; the empty-state copy explains what's
|
||||
missing and how to fix it. Mirrored across Inspect and
|
||||
element-pick annotation mode because the failure surface is identical.
|
||||
*/}
|
||||
{inspectMode
|
||||
&& openHintBox
|
||||
&& !activeInspectTarget
|
||||
&& !activeCommentTarget ? (
|
||||
<div
|
||||
className={`inspect-empty-hint-container${
|
||||
commentPanelOpen && !commentSidePanelCollapsed ? ' comment-side-panel-open' : ''
|
||||
}`}
|
||||
data-testid="inspect-empty-hint-container"
|
||||
>
|
||||
{liveCommentTargets.size === 0 ? (
|
||||
<div
|
||||
className="inspect-empty-hint"
|
||||
data-testid="inspect-empty-hint-no-targets"
|
||||
>
|
||||
{inspectMode
|
||||
? t('chat.inspect.noEditableTargets')
|
||||
: t('chat.inspect.noCommentTargets')}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="inspect-empty-hint"
|
||||
data-testid="inspect-empty-hint"
|
||||
>
|
||||
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title="Close Inspect Hint"
|
||||
aria-label="Close Inspect Hint"
|
||||
onClick={() => setOpenHintBox(false)}
|
||||
className="orbit-artifact-ghost"
|
||||
>
|
||||
<Icon className="" name="close" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="viewer-source">{source}</pre>
|
||||
|
|
|
|||
|
|
@ -978,6 +978,53 @@
|
|||
.live-artifact-preview-layer.preview-viewport[data-active='false'] {
|
||||
display: none;
|
||||
}
|
||||
.comment-preview-layer {
|
||||
--comment-side-dock-width: 320px;
|
||||
--comment-side-dock-rail-width: 42px;
|
||||
--comment-side-dock-stacked-height: 220px;
|
||||
--comment-side-dock-stacked-rail-height: 48px;
|
||||
}
|
||||
.comment-preview-layer-with-side-dock {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--comment-side-dock-width);
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-with-side-dock {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
padding: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-side-dock-stacked {
|
||||
padding: 8px;
|
||||
}
|
||||
.comment-preview-layer-with-side-dock.comment-preview-layer-dock-collapsed {
|
||||
grid-template-columns: minmax(0, 1fr) var(--comment-side-dock-rail-width);
|
||||
}
|
||||
.comment-preview-layer-with-side-dock.comment-preview-layer-side-dock-stacked {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) var(--comment-side-dock-stacked-height);
|
||||
}
|
||||
.comment-preview-layer-with-side-dock.comment-preview-layer-side-dock-stacked.comment-preview-layer-dock-collapsed {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) var(--comment-side-dock-stacked-rail-height);
|
||||
}
|
||||
.comment-preview-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.comment-preview-layer-with-side-dock .comment-preview-canvas {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop) {
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
|
|
@ -995,7 +1042,7 @@
|
|||
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip,
|
||||
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip,
|
||||
.preview-viewport:not(.preview-viewport-desktop):not(.comment-preview-layer-with-side-dock) .comment-preview-canvas,
|
||||
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas {
|
||||
width: calc(var(--preview-viewport-width) * var(--preview-scale, 1));
|
||||
height: calc(var(--preview-viewport-height) * var(--preview-scale, 1));
|
||||
|
|
@ -1008,19 +1055,23 @@
|
|||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.22);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-with-side-dock .comment-preview-canvas {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip > div,
|
||||
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip > div,
|
||||
.preview-viewport:not(.preview-viewport-desktop) .comment-preview-canvas > .comment-frame-clip > div,
|
||||
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas > div {
|
||||
will-change: transform;
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip,
|
||||
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip,
|
||||
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
}
|
||||
.preview-viewport-mobile .preview-frame-clip,
|
||||
.preview-viewport-mobile .comment-frame-clip,
|
||||
.preview-viewport-mobile:not(.comment-preview-layer-with-side-dock) .comment-preview-canvas,
|
||||
.preview-viewport-mobile.manual-edit-workspace .manual-edit-canvas {
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
|
@ -1324,29 +1375,59 @@
|
|||
color: var(--red);
|
||||
}
|
||||
/* Right-side comment thread panel. Shown while board (comment) mode
|
||||
is on; takes the place of the chat sidebar's removed Comments tab.
|
||||
Floats over the artifact preview at the right edge. */
|
||||
is on; it docks beside the artifact preview so canvas clicks remain
|
||||
available for placing comments. */
|
||||
.comment-side-dock {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: var(--comment-side-dock-width);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
.comment-side-dock.collapsed {
|
||||
width: var(--comment-side-dock-rail-width);
|
||||
}
|
||||
.comment-preview-layer-side-dock-stacked .comment-side-dock {
|
||||
width: 100%;
|
||||
height: var(--comment-side-dock-stacked-height);
|
||||
}
|
||||
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed {
|
||||
width: 100%;
|
||||
height: var(--comment-side-dock-stacked-rail-height);
|
||||
}
|
||||
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed .comment-side-rail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed .comment-side-rail span {
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
.comment-side-panel {
|
||||
--comment-accent: #ff5a3c;
|
||||
--comment-accent-strong: color-mix(in srgb, var(--comment-accent) 78%, var(--text));
|
||||
--comment-accent-surface: color-mix(in srgb, var(--comment-accent) 10%, var(--bg-panel));
|
||||
--comment-accent-surface-strong: color-mix(in srgb, var(--comment-accent) 18%, var(--bg-panel));
|
||||
--comment-accent-border: color-mix(in srgb, var(--comment-accent) 64%, var(--border));
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
position: relative;
|
||||
width: 320px;
|
||||
max-width: calc(100% - 16px);
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
background: var(--bg-panel, #fff);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 30;
|
||||
overflow: hidden;
|
||||
}
|
||||
.comment-preview-layer-side-dock-stacked .comment-side-panel {
|
||||
width: 100%;
|
||||
}
|
||||
.comment-side-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1394,7 +1475,7 @@
|
|||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.comment-side-close {
|
||||
.comment-side-collapse {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 auto;
|
||||
|
|
@ -1408,18 +1489,15 @@
|
|||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.comment-side-close:hover {
|
||||
.comment-side-collapse:hover {
|
||||
background: var(--bg-subtle);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.comment-side-rail {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 30;
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
|
@ -1689,16 +1767,15 @@
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
/* Inspect panel — sibling of the comment popover. Anchored to the
|
||||
right side of the preview surface. Width is fixed so layout doesn't
|
||||
reflow as the user scrubs slider values; controls reserve space for
|
||||
their numeric readouts. */
|
||||
/* Inspect panel — sibling of the preview canvas and comment popover.
|
||||
Keep the usual 296px width, but allow narrow board layouts to shrink it
|
||||
inside the unclipped preview layer instead of losing controls. */
|
||||
.inspect-panel {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 5;
|
||||
width: 296px;
|
||||
width: min(296px, calc(100% - 28px));
|
||||
max-height: calc(100% - 28px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
|
|
@ -1885,20 +1962,6 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inspect-empty-hint-container.comment-side-panel-open {
|
||||
right: 340px;
|
||||
max-width: min(480px, calc(100% - 368px));
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.inspect-empty-hint-container.comment-side-panel-open {
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
top: 54px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.inspect-empty-hint-container button,
|
||||
.inspect-empty-hint-container .close-button {
|
||||
pointer-events: auto;
|
||||
|
|
|
|||
193
apps/web/tests/components/ChatComposer.tools-menu-caret.test.tsx
Normal file
193
apps/web/tests/components/ChatComposer.tools-menu-caret.test.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatComposer } from '../../src/components/ChatComposer';
|
||||
|
||||
// Regression coverage for #3195. The mention popover (typed `@`) prevents
|
||||
// the textarea from losing focus on mousedown for every picker button —
|
||||
// the comment at `ChatComposer.tsx:3039-3043` explains why: without it,
|
||||
// `selectionStart` resets on the focus transfer and the insert handler
|
||||
// targets the wrong substring (caret jumps to the start, the inserted
|
||||
// token lands at offset 0 instead of at the user's cursor).
|
||||
//
|
||||
// The right-side `@`-button tools popover (`ToolsPluginsPanel`,
|
||||
// `ToolsSkillsPanel`, `ToolsMcpPanel`) skips that protection — pick rows
|
||||
// have `onClick` but no `onMouseDown={(e) => e.preventDefault()}`. So
|
||||
// every insertion through the tools popover is at risk of the same caret
|
||||
// reset whenever a real mouse triggers focus transfer first.
|
||||
//
|
||||
// We can't reliably observe the focus transfer in jsdom (it does not
|
||||
// move focus on raw mousedown), so the test asserts the contract
|
||||
// directly: the picker row must call `preventDefault()` on mousedown so
|
||||
// that browsers never get to move focus before the click handler reads
|
||||
// `textarea.selectionStart`.
|
||||
|
||||
const COMMUNITY_PLUGIN = {
|
||||
id: 'sample-plugin',
|
||||
title: 'Sample Plugin',
|
||||
version: '1.0.0',
|
||||
trust: 'restricted' as const,
|
||||
sourceKind: 'bundled' as const,
|
||||
source: 'bundled/sample',
|
||||
capabilitiesGranted: [],
|
||||
manifest: {
|
||||
name: 'sample-plugin',
|
||||
title: 'Sample Plugin',
|
||||
description: 'Sample',
|
||||
od: { kind: 'skill' },
|
||||
},
|
||||
fsPath: '/plugins/sample',
|
||||
installedAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
const SKILL = {
|
||||
id: 'deck-builder',
|
||||
name: 'Deck Builder',
|
||||
description: 'Build a polished slide deck.',
|
||||
triggers: ['deck'],
|
||||
mode: 'deck' as const,
|
||||
previewType: 'html',
|
||||
designSystemRequired: false,
|
||||
defaultFor: [],
|
||||
upstream: null,
|
||||
hasBody: true,
|
||||
examplePrompt: 'Make a deck',
|
||||
aggregatesExamples: false,
|
||||
};
|
||||
|
||||
const MCP_SERVER = {
|
||||
id: 'slack',
|
||||
label: 'Slack MCP',
|
||||
transport: 'stdio' as const,
|
||||
enabled: true,
|
||||
command: 'slack-mcp',
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let plugins = [COMMUNITY_PLUGIN];
|
||||
let skills = [SKILL];
|
||||
let servers = [MCP_SERVER];
|
||||
|
||||
function renderComposer(
|
||||
overrides: Partial<ComponentProps<typeof ChatComposer>> = {},
|
||||
) {
|
||||
return render(
|
||||
<ChatComposer
|
||||
projectId="project-1"
|
||||
projectFiles={[]}
|
||||
streaming={false}
|
||||
onEnsureProject={async () => 'project-1'}
|
||||
onSend={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onOpenMcpSettings={vi.fn()}
|
||||
skills={skills}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
plugins = [COMMUNITY_PLUGIN];
|
||||
skills = [SKILL];
|
||||
servers = [MCP_SERVER];
|
||||
fetchMock = vi.fn(async (url: string) => {
|
||||
if (url === '/api/mcp/servers') {
|
||||
return new Response(JSON.stringify({ servers, templates: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/plugins') {
|
||||
return new Response(JSON.stringify({ plugins }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/skills') {
|
||||
return new Response(JSON.stringify({ skills }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/projects/project-1') {
|
||||
return new Response(JSON.stringify({ project: { id: 'project-1', skillId: SKILL.id } }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
async function openToolsPopover() {
|
||||
const trigger = document.querySelector(
|
||||
'.composer-tools-trigger',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(trigger).toBeTruthy();
|
||||
fireEvent.click(trigger!);
|
||||
await waitFor(() => expect(screen.getByRole('menu')).toBeTruthy());
|
||||
}
|
||||
|
||||
function selectTab(label: string) {
|
||||
const tab = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>('.composer-tools-tab'),
|
||||
).find((el) => el.textContent?.trim() === label);
|
||||
expect(tab).toBeTruthy();
|
||||
fireEvent.click(tab!);
|
||||
}
|
||||
|
||||
function rowByText(text: string): HTMLButtonElement {
|
||||
// Skill / MCP rows are themselves the picker `<button>` (.composer-tools-row).
|
||||
// Plugin rows wrap two buttons inside a `<div>`; the picker is the
|
||||
// `.composer-tools-row-main` child, so prefer that selector first.
|
||||
const row = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>('button.composer-tools-row-main, button.composer-tools-row'),
|
||||
).find((btn) => btn.textContent?.includes(text));
|
||||
expect(row).toBeTruthy();
|
||||
return row!;
|
||||
}
|
||||
|
||||
describe('ChatComposer tools-menu picker mousedown protection (#3195)', () => {
|
||||
it('the skills picker prevents default on mousedown so the caret survives focus transfer', async () => {
|
||||
renderComposer();
|
||||
await openToolsPopover();
|
||||
selectTab('Skills');
|
||||
|
||||
const row = rowByText('Deck Builder');
|
||||
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
|
||||
row.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('the plugins picker prevents default on mousedown so the caret survives focus transfer', async () => {
|
||||
renderComposer();
|
||||
await openToolsPopover();
|
||||
selectTab('Plugins');
|
||||
|
||||
const row = rowByText('Sample Plugin');
|
||||
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
|
||||
row.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('the MCP picker prevents default on mousedown so the caret survives focus transfer', async () => {
|
||||
renderComposer();
|
||||
await openToolsPopover();
|
||||
selectTab('MCP');
|
||||
|
||||
const row = rowByText('Slack MCP');
|
||||
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
|
||||
row.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
LiveArtifactRefreshHistoryPanel,
|
||||
SvgViewer,
|
||||
applyInspectOverridesToSource,
|
||||
commentPreviewCanvasSize,
|
||||
effectivePreviewScale,
|
||||
parseInspectOverridesFromSource,
|
||||
previewOverlayTransform,
|
||||
|
|
@ -75,6 +76,30 @@ function deferredResponse() {
|
|||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
|
||||
return calls
|
||||
.map(([message]) => message)
|
||||
.filter((message): message is { type: 'od:srcdoc-transport-activate'; html: string } => {
|
||||
if (typeof message !== 'object' || message === null) return false;
|
||||
const data = message as { type?: unknown; html?: unknown };
|
||||
return data.type === 'od:srcdoc-transport-activate' && typeof data.html === 'string';
|
||||
});
|
||||
}
|
||||
|
||||
function testRect(left: number, top: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
width,
|
||||
height,
|
||||
top,
|
||||
left,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
function clickAgentTool(testId: string) {
|
||||
fireEvent.click(screen.getByTestId(testId));
|
||||
}
|
||||
|
|
@ -95,7 +120,10 @@ describe('FileViewer preview scale', () => {
|
|||
'.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas',
|
||||
);
|
||||
expect(css).toMatch(
|
||||
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\) \.comment-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*position: relative;/,
|
||||
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\):not\(\.comment-preview-layer-with-side-dock\) \.comment-preview-canvas,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*width: calc\(var\(--preview-viewport-width\) \* var\(--preview-scale, 1\)\);/,
|
||||
);
|
||||
expect(css).toMatch(
|
||||
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*position: relative;/,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -121,6 +149,60 @@ describe('FileViewer preview scale', () => {
|
|||
expect(effectivePreviewScale('tablet', 1.25, { width: 820, height: 700 })).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('uses the reduced board canvas size when the side dock is open', () => {
|
||||
const dockedCanvas = commentPreviewCanvasSize(
|
||||
{ width: 900, height: 700 },
|
||||
{ boardMode: true, sidePanelCollapsed: false },
|
||||
);
|
||||
|
||||
expect(dockedCanvas).toEqual({ width: 552, height: 684 });
|
||||
expect(effectivePreviewScale('tablet', 1, dockedCanvas)).toBeLessThan(
|
||||
effectivePreviewScale('tablet', 1, { width: 900, height: 700 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses stacked canvas sizing for narrow board panes instead of a 1px docked canvas', () => {
|
||||
const narrowCanvas = commentPreviewCanvasSize(
|
||||
{ width: 400, height: 700 },
|
||||
{ boardMode: true, sidePanelCollapsed: false },
|
||||
);
|
||||
|
||||
expect(narrowCanvas).toEqual({ width: 384, height: 452 });
|
||||
});
|
||||
|
||||
it('subtracts only the collapsed stacked rail height when the side dock is collapsed in the stacked layout', () => {
|
||||
const expandedStackedCanvas = commentPreviewCanvasSize(
|
||||
{ width: 300, height: 700 },
|
||||
{ boardMode: true, sidePanelCollapsed: false },
|
||||
);
|
||||
const collapsedStackedCanvas = commentPreviewCanvasSize(
|
||||
{ width: 300, height: 700 },
|
||||
{ boardMode: true, sidePanelCollapsed: true },
|
||||
);
|
||||
|
||||
expect(expandedStackedCanvas).toEqual({ width: 284, height: 452 });
|
||||
expect(collapsedStackedCanvas).toEqual({ width: 284, height: 624 });
|
||||
expect(collapsedStackedCanvas!.height).toBeGreaterThan(expandedStackedCanvas!.height);
|
||||
});
|
||||
|
||||
it('matches the rendered non-desktop dock padding in board canvas sizing', () => {
|
||||
const dockedCanvas = commentPreviewCanvasSize(
|
||||
{ width: 900, height: 700 },
|
||||
{ boardMode: true, sidePanelCollapsed: false, viewport: 'tablet' },
|
||||
);
|
||||
|
||||
expect(dockedCanvas).toEqual({ width: 520, height: 652 });
|
||||
});
|
||||
|
||||
it('fits non-desktop board previews against the inner canvas without subtracting viewport padding again', () => {
|
||||
const dockedCanvas = commentPreviewCanvasSize(
|
||||
{ width: 900, height: 700 },
|
||||
{ boardMode: true, sidePanelCollapsed: false, viewport: 'tablet' },
|
||||
);
|
||||
|
||||
expect(effectivePreviewScale('tablet', 1, dockedCanvas, { canvasPadding: 0 })).toBeCloseTo(652 / 1180);
|
||||
});
|
||||
|
||||
it('offsets tablet and mobile overlays to the centered viewport card', () => {
|
||||
expect(previewOverlayTransform('desktop', 1.25, { width: 1200, height: 800 })).toEqual({
|
||||
scale: 1.25,
|
||||
|
|
@ -2034,23 +2116,69 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
expect(screen.queryByTestId('inspect-empty-hint-container')).toBeNull();
|
||||
});
|
||||
|
||||
it('exits comment mode when closing the comments side panel', () => {
|
||||
const openComment: PreviewComment = {
|
||||
id: 'comment-open',
|
||||
projectId: 'project-1',
|
||||
conversationId: 'conversation-1',
|
||||
filePath: 'preview.html',
|
||||
elementId: 'pin-open',
|
||||
selector: '[data-od-pin="pin-open"]',
|
||||
label: 'pin-open',
|
||||
text: '',
|
||||
htmlHint: '',
|
||||
position: { x: 24, y: 32, width: 18, height: 18 },
|
||||
note: 'Open comment',
|
||||
status: 'open',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
it('keeps the picker hint inside the canvas and clear of the open comment side panel', () => {
|
||||
render(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
projectKind="prototype"
|
||||
file={htmlPreviewFile()}
|
||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
|
||||
|
||||
const canvas = screen.getByTestId('comment-preview-canvas');
|
||||
const dock = screen.getByTestId('comment-side-dock');
|
||||
|
||||
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
|
||||
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
|
||||
expect(dock.contains(screen.getByTestId('artifact-preview-frame'))).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /hide comments/i }));
|
||||
|
||||
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
|
||||
expect(screen.getByTestId('comment-side-collapsed-rail')).toBeTruthy();
|
||||
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
|
||||
expect(dock.contains(screen.getByTestId('artifact-preview-frame'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps non-docked tablet comment-tool previews fitted to the padded canvas', async () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
|
||||
if (this.classList.contains('viewer-body')) return testRect(0, 0, 900, 700);
|
||||
return testRect(0, 0, 0, 0);
|
||||
});
|
||||
|
||||
render(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
projectKind="prototype"
|
||||
file={htmlPreviewFile()}
|
||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Preview viewport'));
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Tablet' }));
|
||||
clickAgentTool('board-mode-toggle');
|
||||
|
||||
const layout = screen.getByTestId('comment-preview-layout');
|
||||
await waitFor(() => {
|
||||
expect(layout.className).not.toContain('comment-preview-layer-with-side-dock');
|
||||
expect(Number(layout.style.getPropertyValue('--preview-scale'))).toBeCloseTo((700 - 48) / 1180);
|
||||
});
|
||||
});
|
||||
|
||||
it('docks the comment side panel outside the clickable preview canvas', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
|
||||
if (this.classList.contains('viewer-body')) return testRect(0, 0, 900, 700);
|
||||
if (this.dataset.testid === 'comment-preview-canvas') return testRect(8, 8, 552, 684);
|
||||
if (this.dataset.testid === 'comment-side-dock') return testRect(572, 8, 320, 684);
|
||||
if (this.dataset.testid === 'comment-side-panel') return testRect(572, 8, 320, 684);
|
||||
return testRect(0, 0, 0, 0);
|
||||
});
|
||||
|
||||
render(
|
||||
<FileViewer
|
||||
|
|
@ -2058,22 +2186,53 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
projectKind="prototype"
|
||||
file={htmlPreviewFile()}
|
||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
previewComments={[openComment]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
|
||||
|
||||
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
|
||||
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('true');
|
||||
expect(screen.getByTestId('comment-saved-marker-pin-open')).toBeTruthy();
|
||||
const canvas = screen.getByTestId('comment-preview-canvas');
|
||||
const dock = screen.getByTestId('comment-side-dock');
|
||||
const panel = screen.getByTestId('comment-side-panel');
|
||||
const canvasBox = canvas.getBoundingClientRect();
|
||||
const dockBox = dock.getBoundingClientRect();
|
||||
const panelBox = panel.getBoundingClientRect();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
|
||||
expect(dock.contains(panel)).toBe(true);
|
||||
expect(canvas.contains(panel)).toBe(false);
|
||||
expect(screen.getByTestId('comment-preview-layout').className).toContain(
|
||||
'comment-preview-layer-with-side-dock',
|
||||
);
|
||||
expect(dockBox.left).toBeGreaterThanOrEqual(canvasBox.right);
|
||||
expect(panelBox.left).toBeGreaterThanOrEqual(canvasBox.right);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
|
||||
expect(screen.queryByTestId('comment-saved-marker-pin-open')).toBeNull();
|
||||
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('false');
|
||||
expect(screen.getByTestId('board-mode-toggle').getAttribute('aria-pressed')).toBe('false');
|
||||
it('uses the narrow board layout when docking would leave too little canvas', async () => {
|
||||
const getBoundingClientRectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
|
||||
if (this.classList.contains('viewer-body')) return testRect(0, 0, 400, 700);
|
||||
return testRect(0, 0, 0, 0);
|
||||
});
|
||||
|
||||
render(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
projectKind="prototype"
|
||||
file={htmlPreviewFile()}
|
||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('comment-preview-layout').className).toContain(
|
||||
'comment-preview-layer-side-dock-stacked',
|
||||
);
|
||||
});
|
||||
|
||||
getBoundingClientRectSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps saved comment pins visible while adding another comment', async () => {
|
||||
|
|
@ -2543,16 +2702,14 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
expect(screen.queryByText('Do not recreate this stale comment')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the comment side panel from the header close button', () => {
|
||||
it('moves focus between comment side panel toggles when collapsing and expanding without a pre-focused click target', async () => {
|
||||
const onCollapseChange = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const onSelectAll = vi.fn();
|
||||
const onReply = vi.fn();
|
||||
|
||||
function Harness() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [open, setOpen] = useState(true);
|
||||
return open ? (
|
||||
return (
|
||||
<CommentSidePanel
|
||||
comments={[
|
||||
{
|
||||
|
|
@ -2579,10 +2736,6 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
onCollapseChange(next);
|
||||
setCollapsed(next);
|
||||
}}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
setOpen(false);
|
||||
}}
|
||||
onToggleSelect={() => {}}
|
||||
onSelectAll={onSelectAll}
|
||||
onClearSelection={() => {}}
|
||||
|
|
@ -2591,7 +2744,7 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
sending={false}
|
||||
t={t}
|
||||
/>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
|
@ -2603,13 +2756,69 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
fireEvent.click(screen.getByText('不要github,换成微信').closest('[data-testid="comment-side-item"]')!);
|
||||
expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ id: 'comment-1' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||
const hideComments = screen.getByRole('button', { name: /hide comments/i });
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
expect(onCollapseChange).not.toHaveBeenCalled();
|
||||
fireEvent.click(hideComments);
|
||||
|
||||
expect(onCollapseChange).toHaveBeenLastCalledWith(true);
|
||||
expect(screen.queryByText('不要github,换成微信')).toBeNull();
|
||||
expect(screen.queryByTestId('comment-side-selectbar')).toBeNull();
|
||||
expect(screen.queryByTestId('comment-side-collapsed-rail')).toBeNull();
|
||||
const showComments = screen.getByTestId('comment-side-collapsed-rail');
|
||||
await waitFor(() => expect(document.activeElement).toBe(showComments));
|
||||
|
||||
fireEvent.click(showComments);
|
||||
|
||||
expect(onCollapseChange).toHaveBeenLastCalledWith(false);
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: /hide comments/i }));
|
||||
});
|
||||
});
|
||||
|
||||
it('announces comment side dock disclosure state without pointing at an unmounted panel', () => {
|
||||
function Harness() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
return (
|
||||
<CommentSidePanel
|
||||
comments={[]}
|
||||
selectedIds={new Set()}
|
||||
activeCommentId={null}
|
||||
collapsed={collapsed}
|
||||
onCollapsedChange={setCollapsed}
|
||||
onToggleSelect={() => {}}
|
||||
onSelectAll={() => {}}
|
||||
onClearSelection={() => {}}
|
||||
onReply={() => {}}
|
||||
onSendSelected={() => {}}
|
||||
sending={false}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
const panel = screen.getByTestId('comment-side-panel');
|
||||
const hideComments = screen.getByRole('button', { name: /hide comments/i });
|
||||
const panelId = panel.id;
|
||||
|
||||
expect(panelId).toBeTruthy();
|
||||
expect(hideComments.getAttribute('aria-controls')).toBe(panelId);
|
||||
expect(hideComments.getAttribute('aria-expanded')).toBe('true');
|
||||
|
||||
fireEvent.click(hideComments);
|
||||
|
||||
const showComments = screen.getByTestId('comment-side-collapsed-rail');
|
||||
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
|
||||
expect(document.getElementById(panelId)).toBeNull();
|
||||
expect(showComments.getAttribute('aria-controls')).toBeNull();
|
||||
expect(showComments.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('lets the inspect panel shrink inside narrow preview layouts', () => {
|
||||
const css = readFileSync(join(process.cwd(), 'src/styles/viewer/core.css'), 'utf8');
|
||||
const rule = css.match(/\.inspect-panel\s*\{[^}]+\}/)?.[0] ?? '';
|
||||
|
||||
expect(rule).toContain('width: min(296px, calc(100% - 28px));');
|
||||
});
|
||||
|
||||
it('does not classify text labels containing a standalone article as links', () => {
|
||||
|
|
@ -2637,7 +2846,6 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
activeCommentId={null}
|
||||
collapsed={false}
|
||||
onCollapsedChange={() => {}}
|
||||
onClose={() => {}}
|
||||
onToggleSelect={() => {}}
|
||||
onSelectAll={() => {}}
|
||||
onClearSelection={() => {}}
|
||||
|
|
|
|||
|
|
@ -132,4 +132,27 @@ describe('conversation timestamps', () => {
|
|||
|
||||
expect(conversationMetaLabel(conversation, t as never)).toBe('15s');
|
||||
});
|
||||
|
||||
it('prefers cumulative conversation duration over the latest run duration', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-15T14:00:00Z'));
|
||||
const t = (key: string, vars?: Record<string, string | number>) =>
|
||||
key === 'common.minutesShort' ? `${vars?.n}m` : key;
|
||||
const conversation = {
|
||||
id: 'conv-1',
|
||||
projectId: 'project-1',
|
||||
title: 'Multi-run session',
|
||||
createdAt: Date.parse('2025-01-15T12:00:00Z'),
|
||||
updatedAt: Date.parse('2025-01-15T12:03:00Z'),
|
||||
totalDurationMs: 85_000,
|
||||
latestRun: {
|
||||
status: 'succeeded',
|
||||
startedAt: 120_000,
|
||||
endedAt: 130_000,
|
||||
durationMs: 10_000,
|
||||
},
|
||||
} satisfies Conversation & { totalDurationMs: number };
|
||||
|
||||
expect(conversationMetaLabel(conversation, t as never)).toBe('1m 25s');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,5 +10,5 @@
|
|||
# 2. Run the relevant nix build/flake check
|
||||
# 3. Copy the expected hash printed by Nix into the matching field below
|
||||
daemonHash = "sha256-nSMVyVSHfcXV5fLMXM3tfdQxZRb+FNF6P4iuJw/Z8Mo=";
|
||||
webHash = "sha256-QOufFb3Hb5js3jK6QEl3WfnxNAa4DdZfMKoALTHY4hI=";
|
||||
webHash = "sha256-IlXE7iNoT/+mcVbtzhJdcP5fNs7Hk8AYZMxfJ33dXck=";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"description": "Local-first design product: detects your installed code-agent CLI, runs design skills + design systems, streams artifacts into a sandboxed preview.",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"od": "./apps/daemon/dist/cli.js"
|
||||
"od": "./apps/daemon/bin/od.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node ./scripts/postinstall.mjs",
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
"tools-pack": "pnpm exec tools-pack",
|
||||
"tools-serve": "pnpm exec tools-serve",
|
||||
"nix:update-hash": "node --experimental-strip-types ./scripts/update-nix-pnpm-deps-hash.ts",
|
||||
"guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts scripts/approve-fork-pr-workflows.test.ts",
|
||||
"guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts scripts/approve-fork-pr-workflows.test.ts scripts/postinstall.test.ts",
|
||||
"i18n:check": "tsx ./scripts/i18n-check.ts",
|
||||
"i18n:coverage": "tsx ./scripts/i18n-coverage-report.ts",
|
||||
"sync:community-pets": "node --experimental-strip-types scripts/sync-community-pets.ts",
|
||||
|
|
@ -25,6 +25,7 @@
|
|||
"typecheck": "pnpm -r --workspace-concurrency=4 --if-present run typecheck && tsc -p scripts/tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@open-design/daemon": "workspace:*",
|
||||
"@open-design/tools-dev": "workspace:*",
|
||||
"@open-design/tools-pack": "workspace:*",
|
||||
"@open-design/tools-serve": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
import type { ResearchOptions } from './research';
|
||||
import type { RunContextSelection } from './context.js';
|
||||
import type { MediaExecutionPolicy } from './media.js';
|
||||
import type { McpAuthMode, McpServerConfig, McpTransport } from './mcp';
|
||||
|
||||
export type ChatRole = 'user' | 'assistant';
|
||||
export type ChatCommentSelectionKind = PreviewCommentSelectionKind | 'visual';
|
||||
|
|
@ -45,6 +46,12 @@ export interface ChatRequest {
|
|||
* local providers.
|
||||
*/
|
||||
mediaExecution?: MediaExecutionPolicy;
|
||||
/**
|
||||
* Run-scoped tool bundle supplied by an orchestrator such as Muginn.
|
||||
* These servers are made available only to the spawned agent for this run
|
||||
* and are never written into the persistent Settings MCP registry.
|
||||
*/
|
||||
toolBundle?: RunScopedToolBundle;
|
||||
/**
|
||||
* Optional analytics context for the v2 run_created / run_finished
|
||||
* events. The daemon never trusts these for behavior — they only
|
||||
|
|
@ -109,6 +116,31 @@ export interface ChatAnalyticsHints {
|
|||
designSystemRunContext?: ChatAnalyticsDesignSystemRunContext;
|
||||
}
|
||||
|
||||
export interface RunScopedMcpServerConfig extends Omit<McpServerConfig, 'enabled'> {
|
||||
/**
|
||||
* Omitted means enabled for this run. The daemon normalizes run-scoped
|
||||
* inputs through the same sanitizer as persisted MCP config, but callers
|
||||
* should not need to send persisted-settings boilerplate for disposable
|
||||
* tool bundles.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface RunScopedToolBundle {
|
||||
mcpServers?: RunScopedMcpServerConfig[];
|
||||
}
|
||||
|
||||
export interface RunScopedToolBundleSummary {
|
||||
mcpServers: Array<{
|
||||
id: string;
|
||||
label?: string;
|
||||
templateId?: string;
|
||||
transport: McpTransport;
|
||||
enabled: boolean;
|
||||
authMode?: McpAuthMode;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ChatRunCreateRequest extends ChatRequest {
|
||||
projectId: string;
|
||||
conversationId: string;
|
||||
|
|
@ -130,6 +162,8 @@ export interface McpRunCreateRequest {
|
|||
pluginId?: string;
|
||||
model?: string;
|
||||
pluginInputs?: Record<string, unknown>;
|
||||
mediaExecution?: MediaExecutionPolicy;
|
||||
toolBundle?: RunScopedToolBundle;
|
||||
}
|
||||
|
||||
export type ChatRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled';
|
||||
|
|
@ -219,6 +253,8 @@ export interface ChatRunStatusResponse {
|
|||
eventsLogPath?: string | null;
|
||||
/** Present on daemon run status responses that know the effective run policy. */
|
||||
mediaExecution?: MediaExecutionPolicy;
|
||||
/** Run-scoped tool bundle summary with secrets and command details redacted. */
|
||||
toolBundle?: RunScopedToolBundleSummary;
|
||||
}
|
||||
|
||||
export interface ChatRunListResponse {
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ export interface Conversation {
|
|||
title: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
totalDurationMs?: number;
|
||||
latestRun?: {
|
||||
status: ChatRunStatus;
|
||||
startedAt?: number;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ importers:
|
|||
|
||||
.:
|
||||
devDependencies:
|
||||
'@open-design/daemon':
|
||||
specifier: workspace:*
|
||||
version: link:apps/daemon
|
||||
'@open-design/tools-dev':
|
||||
specifier: workspace:*
|
||||
version: link:tools/dev
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ const residualAllowedExactPaths = new Set([
|
|||
// executed directly by Node and are not loaded by the app runtime.
|
||||
"scripts/import-prompt-templates.mjs",
|
||||
"scripts/postinstall.mjs",
|
||||
// Checked-in bin shim so pnpm can link `od` before daemon dist output exists.
|
||||
"apps/daemon/bin/od.mjs",
|
||||
"apps/packaged/esbuild.config.mjs",
|
||||
// Browser service workers must be served as JavaScript files.
|
||||
"apps/web/public/od-notifications-sw.js",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const buildTargets = [
|
|||
"packages/sidecar-proto",
|
||||
"packages/sidecar",
|
||||
"packages/diagnostics",
|
||||
"apps/daemon",
|
||||
"tools/dev",
|
||||
"tools/pack",
|
||||
"tools/serve",
|
||||
|
|
|
|||
159
scripts/postinstall.test.ts
Normal file
159
scripts/postinstall.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const repoRoot = join(import.meta.dirname, "..");
|
||||
|
||||
function readJson(path: string): unknown {
|
||||
return JSON.parse(readFileSync(join(repoRoot, path), "utf8"));
|
||||
}
|
||||
|
||||
function readPackageJson(path: string): Record<string, unknown> {
|
||||
const manifest = readJson(path);
|
||||
assert(typeof manifest === "object" && manifest !== null);
|
||||
return manifest as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function packageName(manifest: unknown): string {
|
||||
assert(typeof manifest === "object" && manifest !== null);
|
||||
const name = (manifest as { name?: unknown }).name;
|
||||
assert(typeof name === "string");
|
||||
return name;
|
||||
}
|
||||
|
||||
function packageBinTargets(manifest: unknown): string[] {
|
||||
assert(typeof manifest === "object" && manifest !== null);
|
||||
const bin = (manifest as { bin?: unknown }).bin;
|
||||
if (typeof bin === "string") return [bin];
|
||||
if (typeof bin !== "object" || bin === null) return [];
|
||||
return Object.values(bin).filter((value): value is string => typeof value === "string");
|
||||
}
|
||||
|
||||
function dependencySpecifier(manifest: Record<string, unknown>, name: string): string | undefined {
|
||||
const dependencyFields = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"] as const;
|
||||
for (const field of dependencyFields) {
|
||||
const dependencies = manifest[field];
|
||||
if (typeof dependencies !== "object" || dependencies === null) continue;
|
||||
const specifier = (dependencies as Record<string, unknown>)[name];
|
||||
if (typeof specifier === "string") return specifier;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function distDelegatingBinTargets(directory: string, manifest: unknown): string[] {
|
||||
return packageBinTargets(manifest).filter((binTarget) => {
|
||||
if (binTarget.startsWith("./dist/")) return true;
|
||||
const source = readFileSync(join(repoRoot, directory, binTarget), "utf8");
|
||||
return source.includes("../dist/") || source.includes("./dist/") || source.includes("/dist/");
|
||||
});
|
||||
}
|
||||
|
||||
function workspaceDependencyNames(manifest: unknown, includeDevDependencies = false): Set<string> {
|
||||
assert(typeof manifest === "object" && manifest !== null);
|
||||
const dependencyFields = includeDevDependencies
|
||||
? ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]
|
||||
: ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const field of dependencyFields) {
|
||||
const dependencies = (manifest as Record<string, unknown>)[field];
|
||||
if (typeof dependencies !== "object" || dependencies === null) continue;
|
||||
for (const [name, version] of Object.entries(dependencies)) {
|
||||
if (typeof version === "string" && version.startsWith("workspace:")) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function postinstallBuildTargets(): Set<string> {
|
||||
const source = readFileSync(join(repoRoot, "scripts/postinstall.mjs"), "utf8");
|
||||
const targets = [...source.matchAll(/"([^"]+)"/g)]
|
||||
.map((match) => match[1])
|
||||
.filter((value): value is string => value != null && /^(?:apps|packages|tools)\//.test(value));
|
||||
return new Set(targets);
|
||||
}
|
||||
|
||||
function workspacePackageDirectories(): string[] {
|
||||
const scopedPackageDirectories = ["apps", "packages", "tools"].flatMap((scope) =>
|
||||
readdirSync(join(repoRoot, scope), { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => `${scope}/${entry.name}`),
|
||||
);
|
||||
return ["e2e", ...scopedPackageDirectories]
|
||||
.filter((directory) => existsSync(join(repoRoot, directory, "package.json")))
|
||||
.sort();
|
||||
}
|
||||
|
||||
test("workspace bin entries use checked-in targets so pnpm can link them before postinstall", () => {
|
||||
const manifests = new Map(
|
||||
workspacePackageDirectories().map((directory) => [
|
||||
directory,
|
||||
readJson(`${directory}/package.json`),
|
||||
]),
|
||||
);
|
||||
const consumedWorkspacePackages = new Set<string>();
|
||||
for (const manifest of manifests.values()) {
|
||||
for (const name of workspaceDependencyNames(manifest)) {
|
||||
consumedWorkspacePackages.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
const unlinkableBins = [...manifests.entries()]
|
||||
.filter(([, manifest]) => consumedWorkspacePackages.has(packageName(manifest)))
|
||||
.flatMap(([directory, manifest]) =>
|
||||
packageBinTargets(manifest).map((binTarget) => ({
|
||||
binTarget,
|
||||
directory,
|
||||
resolvedPath: join(repoRoot, directory, binTarget),
|
||||
})),
|
||||
)
|
||||
.filter(({ resolvedPath }) => !existsSync(resolvedPath))
|
||||
.map(({ binTarget, directory }) => `${directory}:${binTarget}`);
|
||||
|
||||
assert.deepEqual(unlinkableBins, []);
|
||||
});
|
||||
|
||||
test("root workspace depends on the daemon package so pnpm exec resolves the od bin", () => {
|
||||
const rootManifest = readPackageJson("package.json");
|
||||
const daemonManifest = readPackageJson("apps/daemon/package.json");
|
||||
|
||||
assert.equal(dependencySpecifier(rootManifest, "@open-design/daemon"), "workspace:*");
|
||||
assert.deepEqual((rootManifest as { bin?: unknown }).bin, {
|
||||
od: "./apps/daemon/bin/od.mjs",
|
||||
});
|
||||
assert.deepEqual((daemonManifest as { bin?: unknown }).bin, {
|
||||
od: "./bin/od.mjs",
|
||||
});
|
||||
assert.equal(existsSync(join(repoRoot, "apps/daemon/bin/od.mjs")), true);
|
||||
});
|
||||
|
||||
test("postinstall builds workspace packages whose linkable bins delegate to dist", () => {
|
||||
const rootManifest = readPackageJson("package.json");
|
||||
const manifests = new Map(
|
||||
workspacePackageDirectories().map((directory) => [
|
||||
directory,
|
||||
readJson(`${directory}/package.json`),
|
||||
]),
|
||||
);
|
||||
const consumedWorkspacePackages = new Set<string>();
|
||||
for (const name of workspaceDependencyNames(rootManifest, true)) {
|
||||
consumedWorkspacePackages.add(name);
|
||||
}
|
||||
for (const manifest of manifests.values()) {
|
||||
for (const name of workspaceDependencyNames(manifest)) {
|
||||
consumedWorkspacePackages.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
const missingBuildTargets = [...manifests.entries()]
|
||||
.filter(([, manifest]) => consumedWorkspacePackages.has(packageName(manifest)))
|
||||
.filter(([directory, manifest]) => distDelegatingBinTargets(directory, manifest).length > 0)
|
||||
.map(([directory]) => directory)
|
||||
.filter((directory) => !postinstallBuildTargets().has(directory));
|
||||
|
||||
assert.deepEqual(missingBuildTargets, []);
|
||||
});
|
||||
Loading…
Reference in a new issue