Merge origin/main (post-7c8305f4) into reconcile branch

Brings in 10 new main commits: routine deep-link to specific
conversations (#1508), Windows resource cache fix for Orbit templates,
collapsible comment side panel (#1607), routines project radio polish,
Copilot logo swap, and minor UI fixes.

Conflicts resolved:
- router.ts: garnet's home/view + marketplace routes + main's
  per-project conversationId deep-link field coexist on Route union
- ProjectView.tsx: garnet's isPhantomDaemonRunMessage helper +
  main's isStoppableAssistantMessage helper both kept
- ProjectView.run-cleanup.test.tsx: accepted HEAD (garnet's
  phantom-row regression test); main's three new tests for
  finalizeActiveAssistantMessagesOnStop / clearStreamingConversationMarker
  / shouldClearActiveRunRefs are queued as a follow-up TODO inline.
This commit is contained in:
lefarcen 2026-05-14 15:13:38 +08:00
commit 6c16283850
121 changed files with 8155 additions and 1141 deletions

View file

@ -26,10 +26,14 @@ concurrency:
jobs:
packaged_changes:
name: Detect packaged smoke changes
name: Detect PR change scopes
runs-on: ubuntu-latest
outputs:
required: ${{ steps.detect.outputs.required }}
daemon_tests_required: ${{ steps.detect.outputs.daemon_tests_required }}
web_tests_required: ${{ steps.detect.outputs.web_tests_required }}
tools_dev_tests_required: ${{ steps.detect.outputs.tools_dev_tests_required }}
tools_pack_tests_required: ${{ steps.detect.outputs.tools_pack_tests_required }}
steps:
- name: Checkout
@ -37,12 +41,16 @@ jobs:
with:
fetch-depth: 0
- name: Detect desktop/sidecar/packaging changes
- name: Detect desktop, packaging, and app test scopes
id: detect
shell: bash
run: |
set -euo pipefail
required=false
daemon_tests_required=false
web_tests_required=false
tools_dev_tests_required=false
tools_pack_tests_required=false
if [ "${{ github.event_name }}" = "pull_request" ]; then
git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" > "$RUNNER_TEMP/changed-files.txt"
patterns=(
@ -62,20 +70,53 @@ jobs:
required=true
fi
done
if [[ "$file" == "apps/daemon/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
daemon_tests_required=true
fi
if [[ "$file" == "apps/web/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
web_tests_required=true
fi
if [[ "$file" == "scripts/"* || "$file" == "assets/"* || "$file" == "skills/"* || "$file" == "prompt-templates/"* || "$file" == "design-systems/"* || "$file" == "design-templates/"* || "$file" == "craft/"* ]]; then
daemon_tests_required=true
web_tests_required=true
fi
if [[ "$file" == "tools/dev/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
tools_dev_tests_required=true
fi
if [[ "$file" == "tools/pack/"* || "$file" == "apps/packaged/"* || "$file" == "apps/desktop/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
tools_pack_tests_required=true
fi
if [[ "$file" == "e2e/specs/mac.spec.ts" || "$file" == "e2e/specs/win.spec.ts" || "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/release-beta.yml" ]]; then
required=true
daemon_tests_required=true
web_tests_required=true
tools_dev_tests_required=true
tools_pack_tests_required=true
fi
if [ "$required" = "true" ]; then
if [ "$required" = "true" ] \
&& [ "$daemon_tests_required" = "true" ] \
&& [ "$web_tests_required" = "true" ] \
&& [ "$tools_dev_tests_required" = "true" ] \
&& [ "$tools_pack_tests_required" = "true" ]; then
break
fi
done < "$RUNNER_TEMP/changed-files.txt"
else
required=true
daemon_tests_required=true
web_tests_required=true
tools_dev_tests_required=true
tools_pack_tests_required=true
fi
echo "required=$required" >> "$GITHUB_OUTPUT"
echo "daemon_tests_required=$daemon_tests_required" >> "$GITHUB_OUTPUT"
echo "web_tests_required=$web_tests_required" >> "$GITHUB_OUTPUT"
echo "tools_dev_tests_required=$tools_dev_tests_required" >> "$GITHUB_OUTPUT"
echo "tools_pack_tests_required=$tools_pack_tests_required" >> "$GITHUB_OUTPUT"
validate:
name: Validate workspace
needs: [packaged_changes]
runs-on: ubuntu-latest
timeout-minutes: 45
@ -137,12 +178,25 @@ jobs:
pnpm --filter @open-design/sidecar test
pnpm --filter @open-design/sidecar-proto test
- name: App workspace tests
- name: App workspace smoke tests
if: ${{ needs.packaged_changes.outputs.tools_dev_tests_required == 'true' || needs.packaged_changes.outputs.tools_pack_tests_required == 'true' }}
run: |
if [ "${{ needs.packaged_changes.outputs.tools_dev_tests_required }}" = "true" ]; then
pnpm --filter @open-design/tools-dev test
fi
if [ "${{ needs.packaged_changes.outputs.tools_pack_tests_required }}" = "true" ]; then
pnpm --filter @open-design/tools-pack test
fi
- name: App workspace daemon tests
if: ${{ needs.packaged_changes.outputs.daemon_tests_required == 'true' }}
run: |
pnpm --filter @open-design/daemon test
- name: App workspace web tests
if: ${{ needs.packaged_changes.outputs.web_tests_required == 'true' }}
run: |
pnpm --filter @open-design/web test
pnpm --filter @open-design/tools-dev test
pnpm --filter @open-design/tools-pack test
- name: E2E vitest
run: pnpm --filter @open-design/e2e test
@ -247,8 +301,6 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Compute Windows tools-pack cache key
id: win_tools_pack_cache_key

View file

@ -32,11 +32,14 @@ jobs:
recognize:
name: Render and post contributor card
if: |
(github.event_name == 'pull_request_target' && github.event.pull_request.merged == true) ||
(github.event_name == 'issues' && github.event.action == 'opened') ||
(github.event_name == 'discussion' && github.event.action == 'created') ||
(github.event_name == 'discussion_comment' && github.event.action == 'created') ||
github.event_name == 'workflow_dispatch'
github.repository == 'nexu-io/open-design' &&
(
(github.event_name == 'pull_request_target' && github.event.pull_request.merged == true) ||
(github.event_name == 'issues' && github.event.action == 'opened') ||
(github.event_name == 'discussion' && github.event.action == 'created') ||
(github.event_name == 'discussion_comment' && github.event.action == 'created') ||
github.event_name == 'workflow_dispatch'
)
runs-on: ubuntu-latest
timeout-minutes: 8

View file

@ -0,0 +1,72 @@
# Nightly Critique Theater conformance run (Phase 16).
#
# Feeds every registered adapter through the conformance harness and
# writes one ConformanceDay row per adapter into the daemon's data
# directory. The /api/critique/conformance route reads the rolling
# 14-day window and surfaces the ratchet's promote / hold / demote
# recommendation.
#
# Cadence: 03:00 UTC every day. The schedule is intentionally outside
# the busy generation window so the cron does not contend with real
# user runs for adapter rate-limit budgets.
#
# Operator workflow: a follow-up workflow consumes
# /api/critique/conformance and either auto-flips
# OD_CRITIQUE_ROLLOUT_PHASE or alerts a human. That step is deploy-
# pipeline-specific and intentionally not built here.
name: critique-conformance
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch: {}
jobs:
run:
name: nightly conformance + ratchet snapshot
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install workspace
run: pnpm install --frozen-lockfile
- name: Build daemon
run: pnpm --filter @open-design/daemon build
# The full adapter sweep lives in a follow-up that wires every
# production agent CLI into the harness; here we drive the
# synthetic-good and synthetic-bad fixtures so the workflow
# proves the cron plumbing end-to-end (install + build + harness
# invocation + history write) without depending on every adapter
# binary being installable in the CI image. The runner exits
# non-zero on any thrown error so the workflow fails loudly
# instead of uploading an empty history snapshot.
- name: Run conformance harness (synthetic adapters)
run: |
node --import tsx apps/daemon/src/critique/__fixtures__/run-nightly.ts
- name: Upload history snapshot
if: always()
uses: actions/upload-artifact@v4
with:
name: critique-conformance-${{ github.run_id }}
path: .od/conformance/
if-no-files-found: ignore
retention-days: 30

View file

@ -40,7 +40,7 @@ jobs:
notify:
# state_reason "completed" excludes "not planned" closures.
# We further require an actual merged-PR linkage in the script below.
if: github.event.issue.state_reason == 'completed'
if: github.repository == 'nexu-io/open-design' && github.event.issue.state_reason == 'completed'
runs-on: ubuntu-latest
steps:
- name: Find the merged PR that closed this issue

View file

@ -35,6 +35,7 @@ concurrency:
jobs:
deploy:
name: Deploy landing page
if: github.repository == 'nexu-io/open-design'
runs-on: ubuntu-latest
timeout-minutes: 20

View file

@ -18,6 +18,7 @@ permissions:
jobs:
metrics:
name: Generate repository metrics SVG
if: github.repository == 'nexu-io/open-design'
runs-on: ubuntu-latest
steps:
- name: Generate GitHub repository metrics

View file

@ -20,6 +20,7 @@ concurrency:
jobs:
refresh:
name: Refresh contributors wall cache bust
if: github.repository == 'nexu-io/open-design'
runs-on: ubuntu-latest
steps:

View file

@ -45,6 +45,7 @@ env:
jobs:
metadata:
name: Prepare beta metadata
if: github.repository == 'nexu-io/open-design'
runs-on: ubuntu-latest
env:
OPEN_DESIGN_BETA_METADATA_URL: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}/beta/latest/metadata.json

View file

@ -36,6 +36,7 @@ env:
jobs:
metadata:
name: Prepare release metadata
if: github.repository == 'nexu-io/open-design'
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}

View file

@ -27,12 +27,7 @@ import {
spawnEnvForAgent,
} from './agents.js';
import { createCommandInvocation } from '@open-design/platform';
import { attachAcpSession } from './acp.js';
import { attachPiRpcSession } from './pi-rpc.js';
import { createClaudeStreamHandler } from './claude-stream.js';
import { diagnoseClaudeCliFailure } from './claude-diagnostics.js';
import { createCopilotStreamHandler } from './copilot-stream.js';
import { createJsonEventStreamHandler } from './json-event-stream.js';
import { agentCliEnvForAgent, validateAgentCliEnv } from './app-config.js';
import {
classifyAgentAuthFailure,
@ -40,6 +35,8 @@ import {
probeAgentAuthStatus,
} from './runtimes/auth.js';
import type { AgentCliEnvPrefs } from './app-config.js';
import { createRuntimeAdapter } from './runtimes/runtime-adapter.js';
import type { RuntimeAgentDef } from './runtimes/types.js';
import {
isLoopbackApiHost,
validateBaseUrl,
@ -904,7 +901,7 @@ interface AgentSpawnHandle {
}
function attachAgentStreamHandlers(
def: { streamFormat?: string; eventParser?: string; id: string; promptViaStdin?: boolean },
def: RuntimeAgentDef,
child: ReturnType<typeof spawn>,
prompt: string,
cwd: string,
@ -918,57 +915,18 @@ function attachAgentStreamHandlers(
} | null = null;
child.stdout?.setEncoding('utf8');
child.stderr?.setEncoding('utf8');
if (def.streamFormat === 'claude-stream-json') {
const claude = createClaudeStreamHandler((ev: unknown) => send('agent', ev));
child.stdout?.on('data', (chunk: string) => {
appendRawStdout?.(chunk);
claude.feed(chunk);
});
child.on('close', () => claude.flush());
} else if (def.streamFormat === 'copilot-stream-json') {
const copilot = createCopilotStreamHandler((ev: unknown) => send('agent', ev));
child.stdout?.on('data', (chunk: string) => copilot.feed(chunk));
child.on('close', () => copilot.flush());
} else if (def.streamFormat === 'pi-rpc') {
acpSession = attachPiRpcSession({
child,
prompt,
cwd,
model: model ?? null,
send,
imagePaths: [],
});
} else if (def.streamFormat === 'acp-json-rpc') {
acpSession = attachAcpSession({
child,
prompt,
cwd,
model: model ?? null,
mcpServers: [],
send,
});
} else if (def.streamFormat === 'json-event-stream') {
const handler = createJsonEventStreamHandler(
def.eventParser || def.id,
(ev: unknown) => {
const data = (ev ?? {}) as { type?: unknown; message?: unknown };
if (data.type === 'error') {
send('error', {
message:
typeof data.message === 'string'
? data.message
: 'agent stream error',
});
return;
}
send('agent', ev);
},
);
child.stdout?.on('data', (chunk: string) => handler.feed(chunk));
child.on('close', () => handler.flush());
} else {
child.stdout?.on('data', (chunk: string) => send('stdout', { chunk }));
}
child.stdout?.on('data', (chunk: string) => appendRawStdout?.(chunk));
const adapter = createRuntimeAdapter(def);
const attachment = adapter.attach({
child,
prompt,
cwd,
model: model ?? null,
mcpServers: [],
imagePaths: [],
send,
});
acpSession = attachment.session;
child.stderr?.on('data', (chunk: string) => send('stderr', { chunk }));
return { child, acpSession };
}
@ -1004,6 +962,7 @@ async function testAgentConnectionInternal(
detail: `Unknown agent id: ${input.agentId}`,
};
}
const runtimeAdapter = createRuntimeAdapter(def);
const configuredAgentEnv = agentCliEnvForAgent(
validateAgentCliEnv(input.agentCliEnv),
input.agentId,
@ -1140,8 +1099,7 @@ async function testAgentConnectionInternal(
detail: redactSecrets(detail),
};
}
const stdinMode =
def.promptViaStdin || def.streamFormat === 'acp-json-rpc' ? 'pipe' : 'ignore';
const stdinMode = runtimeAdapter.stdinMode();
const baseEnv = spawnEnvForAgent(
input.agentId,
{
@ -1325,7 +1283,7 @@ async function testAgentConnectionInternal(
};
};
if (def.promptViaStdin && child.stdin && def.streamFormat !== 'pi-rpc') {
if (runtimeAdapter.shouldWritePromptToStdin() && child.stdin) {
child.stdin.on('error', (err: NodeJS.ErrnoException) => {
if (err.code !== 'EPIPE') {
sink.send('error', {

View file

@ -0,0 +1,104 @@
/**
* Nightly conformance entrypoint (Phase 16).
*
* The GitHub Actions workflow at `.github/workflows/critique-conformance.yml`
* invokes this script once a day. It runs the conformance harness
* against every synthetic adapter, classifies the outcome, and writes
* one `ConformanceDay` row per adapter into the daemon's history dir.
* The `/api/critique/conformance` route reads the rolling 14-day
* window and the ratchet returns its promote / hold / demote
* recommendation.
*
* Why synthetic-only for v1: the full production-adapter sweep needs
* every agent CLI installable in the CI image. Wiring that is its own
* focused follow-up. The synthetic adapters prove the cron plumbing
* (install + build + harness invocation + history write) without the
* dependency on third-party binaries. A real adapter is a one-line
* addition to `ADAPTERS` below once the harness wraps its stdout.
*
* Exit code: 0 only when every adapter ran (regardless of outcome).
* A thrown error or a missing fixture exits non-zero so the workflow
* fails the job instead of uploading an empty history snapshot
* (Codex + lefarcen P1 on PR #1499 caught the prior `|| echo` mask).
*/
import path from 'node:path';
import { runAdapterConformance } from '../conformance.js';
import { appendConformanceDay } from '../conformance-history.js';
import type { ConformanceDay } from '../ratchet.js';
import { syntheticGoodStream } from './adapters/synthetic-good.js';
import { syntheticBadStream } from './adapters/synthetic-bad.js';
interface NightlyAdapter {
id: string;
source: () => AsyncIterable<string>;
}
const ADAPTERS: readonly NightlyAdapter[] = [
{ id: 'synthetic-good', source: syntheticGoodStream },
{ id: 'synthetic-bad', source: syntheticBadStream },
];
/**
* Anchor the run at the project's `.od/` data dir by default; the
* Home Manager / NixOS / Playwright runtimes that already set
* `OD_DATA_DIR` keep their isolation here too.
*/
function resolveDataDir(): string {
const override = process.env.OD_DATA_DIR;
if (override && override.length > 0) return path.resolve(override);
return path.resolve(process.cwd(), '.od');
}
function isoDay(d: Date): string {
return d.toISOString().slice(0, 10);
}
async function runOne(adapter: NightlyAdapter, dataDir: string, date: string): Promise<void> {
const outcome = await runAdapterConformance({
adapterId: adapter.id,
runId: `nightly-${date}-${adapter.id}`,
source: adapter.source(),
});
// shippedRate is 0 or 1 per single-run synthetic adapter; the
// production sweep that follows will run N briefs and the rate
// becomes a real fraction.
const shipped = outcome.kind === 'shipped' ? 1 : 0;
// A run is "clean" when zero parser_warning events were yielded
// along the way. The harness already collects every event for the
// outcome, so we walk it here rather than re-parsing.
const hadParserWarning = outcome.events.some((e) => e.type === 'parser_warning');
const cleanParse = hadParserWarning ? 0 : 1;
const row: ConformanceDay = {
date,
adapter: adapter.id,
shippedRate: shipped,
cleanParseRate: cleanParse,
totalRuns: 1,
};
await appendConformanceDay(dataDir, row);
// eslint-disable-next-line no-console
console.log(
`[nightly] ${adapter.id} ${outcome.kind}`
+ (outcome.kind === 'degraded' || outcome.kind === 'failed'
? ` (${'reason' in outcome ? outcome.reason : outcome.cause})`
: ''),
);
}
async function main(): Promise<void> {
const dataDir = resolveDataDir();
const date = isoDay(new Date());
// eslint-disable-next-line no-console
console.log(`[nightly] writing to ${path.join(dataDir, 'conformance')} for ${date}`);
for (const adapter of ADAPTERS) {
await runOne(adapter, dataDir, date);
}
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error('[nightly] failed:', err instanceof Error ? err.message : String(err));
process.exit(1);
});

View file

@ -0,0 +1,133 @@
/**
* Conformance history persistence (Phase 16).
*
* The nightly cron writes one `ConformanceDay` row per adapter per day
* via `appendConformanceDay`. The `/api/critique/conformance` route
* reads the rolling N-day window via `readConformanceHistory` and
* feeds it into the `evaluateRollout` ratchet.
*
* Storage shape: append-only JSON-lines under
* `<dataDir>/conformance/<adapter>/<date>.jsonl`. One file per adapter
* per day so a corrupted line in one file cannot poison the read for
* another adapter / another day. JSON-lines (rather than a single JSON
* blob) so a cron interruption mid-write leaves a recoverable file:
* the writer always appends complete lines, and the reader drops any
* line that fails to parse rather than throwing.
*
* The path layout is intentionally directory-per-adapter so a future
* operator action like "purge all history for adapter X" is one `rm
* -rf`, not a grep + rewrite across a mixed-adapter file.
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import type { ConformanceDay } from './ratchet.js';
/**
* Where conformance history lives, relative to the daemon's `.od` data
* dir. Wrapped in a function so a future override (env var, test seam)
* can swap it without rewriting call sites.
*/
export function conformanceHistoryDir(dataDir: string): string {
return path.join(dataDir, 'conformance');
}
function isoDay(d: Date): string {
return d.toISOString().slice(0, 10);
}
function adapterDir(dataDir: string, adapter: string): string {
return path.join(conformanceHistoryDir(dataDir), adapter);
}
function dayFile(dataDir: string, adapter: string, date: string): string {
return path.join(adapterDir(dataDir, adapter), `${date}.jsonl`);
}
/**
* Append one conformance row to the day's file. Idempotent in the
* weak sense: calling twice for the same `(adapter, date)` appends two
* rows, and the reader keeps the last entry per (adapter, date) so a
* retry-after-failure cron produces the right answer. The strong-sense
* fix (dedupe before appending) is deliberately not v1 scope; it would
* require a read-modify-write cycle whose cost grows with history
* size.
*/
export async function appendConformanceDay(
dataDir: string,
row: ConformanceDay,
): Promise<void> {
const dir = adapterDir(dataDir, row.adapter);
await fs.mkdir(dir, { recursive: true });
const file = dayFile(dataDir, row.adapter, row.date);
const line = JSON.stringify(row) + '\n';
await fs.appendFile(file, line, 'utf8');
}
/**
* Read every `ConformanceDay` row in the trailing `windowDays` ending
* today (inclusive). Returns rows from every adapter in arbitrary
* order; the evaluator aggregates them per day.
*
* Missing adapter directories, missing day files, and malformed lines
* all collapse to "skip this row": a cron that has not run yet for a
* day is data missing, not data wrong, and the evaluator handles
* missing days by returning `hold` (insufficient data).
*/
export async function readConformanceHistory(
dataDir: string,
windowDays: number,
now: Date = new Date(),
): Promise<ConformanceDay[]> {
const root = conformanceHistoryDir(dataDir);
let adapters: string[];
try {
adapters = await fs.readdir(root);
} catch {
return [];
}
const dates: string[] = [];
for (let i = 0; i < windowDays; i++) {
const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
dates.push(isoDay(d));
}
const out: ConformanceDay[] = [];
for (const adapter of adapters) {
for (const date of dates) {
let body: string;
try {
body = await fs.readFile(dayFile(dataDir, adapter, date), 'utf8');
} catch {
continue;
}
// Keep the last entry per (adapter, date) so a retry-after-failure
// cron produces the right answer without a dedupe pass at write
// time. JSON-lines are read top-down; we overwrite as we go.
let lastForToday: ConformanceDay | null = null;
for (const line of body.split('\n')) {
if (line.length === 0) continue;
try {
const candidate = JSON.parse(line) as unknown;
if (
candidate
&& typeof candidate === 'object'
&& typeof (candidate as ConformanceDay).date === 'string'
&& typeof (candidate as ConformanceDay).adapter === 'string'
&& Number.isFinite((candidate as ConformanceDay).shippedRate)
&& Number.isFinite((candidate as ConformanceDay).cleanParseRate)
&& Number.isFinite((candidate as ConformanceDay).totalRuns)
) {
lastForToday = candidate as ConformanceDay;
}
} catch {
/* drop malformed lines */
}
}
if (lastForToday !== null) out.push(lastForToday);
}
}
return out;
}

View file

@ -0,0 +1,268 @@
/**
* M-phase rollout ratchet (Phase 16).
*
* Phase 15 shipped the rollout resolver. Phase 12 shipped the
* observability surface that lets us measure conformance. This module
* is the bridge: it takes a window of daily conformance numbers and
* answers "given the current rollout phase, should we promote, hold,
* or demote?". The answer is a recommendation, not an action. An
* operator-driven follow-up wires the actual `OD_CRITIQUE_ROLLOUT_PHASE`
* flip into a deploy pipeline; this module's job is to make the
* recommendation explicit and auditable so the operator does not have
* to read a Grafana panel to know what to do.
*
* The function is pure: it takes data in and returns a decision out,
* with no I/O. That lets the same evaluator drive both the nightly
* recommendation log line and the `GET /api/critique/conformance`
* endpoint, and lets vitest pin every cell of the decision matrix
* without standing up a daemon.
*
* Spec source: `specs/current/critique-theater.md` § Rollout, which
* states the M3 threshold as "14 consecutive days at >= 90% adapter
* conformance across the fleet". The clean-parse threshold (>= 95%
* runs free of parser_warning) is a sibling gate; both must hold.
*/
import type { RolloutPhase } from './rollout.js';
/**
* One day of conformance numbers for one adapter. The nightly cron
* collapses every (adapter, brief-template) run into one of these
* rows; the evaluator aggregates across adapters per day.
*/
export interface ConformanceDay {
/** ISO date `YYYY-MM-DD`. */
date: string;
/** Adapter id (matches `agentId` in the spawn handler). */
adapter: string;
/** Fraction of runs that hit the `shipped` terminal status, 0..1. */
shippedRate: number;
/** Fraction of runs with zero parser_warning events, 0..1. */
cleanParseRate: number;
/** Total runs the day's two rates were computed from. */
totalRuns: number;
}
export type RatchetDecision =
| {
kind: 'hold';
current: RolloutPhase;
/** Human-readable reason the rollout is holding at `current`. */
reason: string;
/** Days in the rolling window that passed every threshold. */
passingDays: number;
/** Days in the rolling window that have at least one row. */
observedDays: number;
}
| {
kind: 'promote';
from: RolloutPhase;
to: RolloutPhase;
/** Days in the rolling window that passed every threshold. */
evidenceDays: number;
}
| {
kind: 'demote';
from: RolloutPhase;
to: RolloutPhase;
/** Human-readable reason the rollout is being walked back. */
reason: string;
};
export interface EvaluateRolloutParams {
current: RolloutPhase;
/** Rolling window of daily conformance rows. Order does not matter. */
history: ConformanceDay[];
/** Window size in days; defaults to 14 (the spec value). */
windowDays?: number;
/** Minimum shipped-rate per day; defaults to 0.90 (the spec value). */
shippedThreshold?: number;
/** Minimum clean-parse-rate per day; defaults to 0.95 (the spec value). */
cleanParseThreshold?: number;
/**
* Optional clock for deterministic tests. Production callers omit;
* tests pass `() => new Date('2026-05-13T00:00:00Z')` or similar so
* the "today" anchor is fixed.
*/
now?: () => Date;
}
const PHASE_ORDER: readonly RolloutPhase[] = ['M0', 'M1', 'M2', 'M3'] as const;
function nextPhase(p: RolloutPhase): RolloutPhase | null {
const i = PHASE_ORDER.indexOf(p);
return i >= 0 && i < PHASE_ORDER.length - 1 ? PHASE_ORDER[i + 1] ?? null : null;
}
function prevPhase(p: RolloutPhase): RolloutPhase | null {
const i = PHASE_ORDER.indexOf(p);
return i > 0 ? PHASE_ORDER[i - 1] ?? null : null;
}
function isoDay(d: Date): string {
return d.toISOString().slice(0, 10);
}
/**
* Compute the set of date strings in the `windowDays` ending today
* (inclusive). Pure, no Date side effects on the input.
*/
function rollingWindowDates(now: Date, windowDays: number): string[] {
const out: string[] = [];
for (let i = 0; i < windowDays; i++) {
const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
out.push(isoDay(d));
}
return out;
}
/**
* Evaluate the rollout. Returns one of three decisions:
*
* * `promote`: every day in the rolling window had at least one row
* and every per-day fleet aggregate cleared both thresholds.
* * `demote`: at least one day in the rolling window had a fleet
* shipped-rate below the demote-floor (half of `shippedThreshold`).
* The demote-floor is intentionally lower than the promote threshold
* so a single noisy day at, say, 0.88 does not bounce the rollout
* back; the demote signal is for sustained breakage.
* * `hold`: anything else (data gaps, near-misses, or M3 with no
* headroom).
*
* The function is total: it always returns a decision; M0 cannot demote
* (already at the floor) and M3 cannot promote (already at the ceiling).
* Both of those collapse to `hold` with a reason-string that explains
* why.
*/
export function evaluateRollout(params: EvaluateRolloutParams): RatchetDecision {
const rawWindow = params.windowDays ?? 14;
const rawShipped = params.shippedThreshold ?? 0.90;
const rawClean = params.cleanParseThreshold ?? 0.95;
// Codex + lefarcen P1 on PR #1499: defend at the evaluator entry so a
// malformed query string (`?windowDays=0`) or a buggy caller cannot
// produce a zero-evidence promotion. A non-positive window would make
// `passingDays >= windowDays` trivially true at 0 >= 0, and a
// threshold outside [0, 1] would either reject every legitimate day
// (> 1) or accept nonsense (< 0). Hold with a clear reason in either
// case instead.
if (!Number.isFinite(rawWindow) || rawWindow <= 0) {
return {
kind: 'hold',
current: params.current,
reason: `invalid windowDays: ${rawWindow}`,
passingDays: 0,
observedDays: 0,
};
}
if (!Number.isFinite(rawShipped) || rawShipped < 0 || rawShipped > 1) {
return {
kind: 'hold',
current: params.current,
reason: `invalid shippedThreshold: ${rawShipped}`,
passingDays: 0,
observedDays: 0,
};
}
if (!Number.isFinite(rawClean) || rawClean < 0 || rawClean > 1) {
return {
kind: 'hold',
current: params.current,
reason: `invalid cleanParseThreshold: ${rawClean}`,
passingDays: 0,
observedDays: 0,
};
}
const windowDays = Math.floor(rawWindow);
const shippedThreshold = rawShipped;
const cleanParseThreshold = rawClean;
const now = (params.now ?? (() => new Date()))();
const windowDates = new Set(rollingWindowDates(now, windowDays));
// Aggregate per-day across adapters. Each adapter's row contributes
// its `totalRuns` weight to both numerator and denominator so the
// fleet aggregate is a weighted mean, not a simple mean of rates.
type DayAgg = { runs: number; shipped: number; clean: number };
const byDay = new Map<string, DayAgg>();
for (const row of params.history) {
if (!windowDates.has(row.date)) continue;
if (!Number.isFinite(row.totalRuns) || row.totalRuns <= 0) continue;
if (!Number.isFinite(row.shippedRate) || row.shippedRate < 0 || row.shippedRate > 1) continue;
if (!Number.isFinite(row.cleanParseRate) || row.cleanParseRate < 0 || row.cleanParseRate > 1) continue;
const agg = byDay.get(row.date) ?? { runs: 0, shipped: 0, clean: 0 };
agg.runs += row.totalRuns;
agg.shipped += row.shippedRate * row.totalRuns;
agg.clean += row.cleanParseRate * row.totalRuns;
byDay.set(row.date, agg);
}
// Walk the window day by day. Count passing days (both thresholds
// met) and observed days (any row at all). A day with no rows is
// not a failure; it is missing data, which collapses to `hold`.
let passingDays = 0;
let demoteDay: string | null = null;
const demoteFloor = shippedThreshold / 2;
for (const date of windowDates) {
const agg = byDay.get(date);
if (agg === undefined) continue;
const shippedRate = agg.shipped / agg.runs;
const cleanRate = agg.clean / agg.runs;
if (shippedRate >= shippedThreshold && cleanRate >= cleanParseThreshold) {
passingDays += 1;
}
if (shippedRate < demoteFloor && demoteDay === null) {
demoteDay = date;
}
}
const observedDays = byDay.size;
if (demoteDay !== null) {
const to = prevPhase(params.current);
if (to !== null) {
return {
kind: 'demote',
from: params.current,
to,
reason: `fleet shipped-rate fell below ${(demoteFloor * 100).toFixed(0)}% on ${demoteDay}`,
};
}
return {
kind: 'hold',
current: params.current,
reason: `would demote (shipped-rate floor breach on ${demoteDay}) but already at M0`,
passingDays,
observedDays,
};
}
if (passingDays >= windowDays) {
const to = nextPhase(params.current);
if (to !== null) {
return {
kind: 'promote',
from: params.current,
to,
evidenceDays: passingDays,
};
}
return {
kind: 'hold',
current: params.current,
reason: `would promote (${passingDays} consecutive passing days) but already at M3`,
passingDays,
observedDays,
};
}
return {
kind: 'hold',
current: params.current,
reason:
observedDays < windowDays
? `insufficient data: ${observedDays} / ${windowDays} days observed`
: `near-miss: ${passingDays} / ${windowDays} days cleared the ${(shippedThreshold * 100).toFixed(0)}% / ${(cleanParseThreshold * 100).toFixed(0)}% thresholds`,
passingDays,
observedDays,
};
}

View file

@ -691,22 +691,45 @@ function normalizeTemplate(row: DbRow) {
// ---------- conversations ----------
export function listConversations(db: SqliteDb, projectId: string) {
return (db
return rows(db
.prepare(
`SELECT id, project_id AS projectId, title,
created_at AS createdAt, updated_at AS updatedAt
FROM conversations
WHERE project_id = ?
ORDER BY updated_at DESC`,
`WITH project_conversations AS (
SELECT id, project_id AS projectId, title,
created_at AS createdAt, updated_at AS updatedAt
FROM conversations
WHERE project_id = ?
),
latest_runs AS (
SELECT conversation_id AS conversationId,
run_status AS latestRunStatus,
started_at AS latestRunStartedAt,
ended_at AS latestRunEndedAt,
events_json AS latestRunEventsJson
FROM (
SELECT m.conversation_id,
m.run_status,
m.started_at,
m.ended_at,
m.events_json,
ROW_NUMBER() OVER (
PARTITION BY m.conversation_id
ORDER BY m.position DESC
) AS rn
FROM messages m
JOIN project_conversations c ON c.id = m.conversation_id
WHERE m.role = 'assistant'
AND m.run_status IS NOT NULL
)
WHERE rn = 1
)
SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt,
lr.latestRunStatus, lr.latestRunStartedAt,
lr.latestRunEndedAt, lr.latestRunEventsJson
FROM project_conversations c
LEFT JOIN latest_runs lr ON lr.conversationId = c.id
ORDER BY c.updatedAt DESC`,
)
.all(projectId) as DbRow[])
.map((r: DbRow) => ({
id: r.id,
projectId: r.projectId,
title: r.title ?? null,
createdAt: Number(r.createdAt),
updatedAt: Number(r.updatedAt),
}));
.all(projectId)).map(normalizeConversation);
}
export function getConversation(db: SqliteDb, id: string) {
@ -718,15 +741,89 @@ export function getConversation(db: SqliteDb, id: string) {
)
.get(id) as DbRow | undefined;
if (!r) return null;
return {
...normalizeConversation(r),
latestRun: latestConversationRunSummary(db, r.id) ?? undefined,
};
}
function normalizeConversation(r: DbRow) {
const latestRun = conversationRunSummaryFromRow({
runStatus: r.latestRunStatus,
startedAt: r.latestRunStartedAt,
endedAt: r.latestRunEndedAt,
eventsJson: r.latestRunEventsJson,
});
return {
id: r.id,
projectId: r.projectId,
title: r.title ?? null,
createdAt: Number(r.createdAt),
updatedAt: Number(r.updatedAt),
latestRun: latestRun ?? undefined,
};
}
function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
const row = db
.prepare(
`SELECT run_status AS runStatus,
started_at AS startedAt,
ended_at AS endedAt,
events_json AS eventsJson
FROM messages
WHERE conversation_id = ?
AND role = 'assistant'
AND run_status IS NOT NULL
ORDER BY position DESC
LIMIT 1`,
)
.get(conversationId) as DbRow | undefined;
return conversationRunSummaryFromRow(row);
}
function conversationRunSummaryFromRow(row: DbRow | undefined) {
if (!row || typeof row.runStatus !== 'string') return null;
const startedAt = row.startedAt == null ? undefined : Number(row.startedAt);
const endedAt = row.endedAt == null ? undefined : Number(row.endedAt);
const usageDurationMs = latestUsageDurationMs(row.eventsJson);
const durationMs =
Number.isFinite(startedAt) && Number.isFinite(endedAt)
? Math.max(0, (endedAt as number) - (startedAt as number))
: usageDurationMs;
return {
status: row.runStatus,
...(Number.isFinite(startedAt) ? { startedAt } : {}),
...(Number.isFinite(endedAt) ? { endedAt } : {}),
...(typeof durationMs === 'number' && Number.isFinite(durationMs)
? { durationMs }
: {}),
};
}
function latestUsageDurationMs(eventsJson: unknown): number | undefined {
if (typeof eventsJson !== 'string' || eventsJson.length === 0) return undefined;
try {
const events = JSON.parse(eventsJson);
if (!Array.isArray(events)) return undefined;
for (let i = events.length - 1; i >= 0; i -= 1) {
const event = events[i];
if (
event &&
typeof event === 'object' &&
event.kind === 'usage' &&
typeof event.durationMs === 'number' &&
Number.isFinite(event.durationMs)
) {
return Math.max(0, event.durationMs);
}
}
} catch {
return undefined;
}
return undefined;
}
export function insertConversation(db: SqliteDb, c: DbRow) {
db.prepare(
`INSERT INTO conversations

View file

@ -90,6 +90,76 @@ export async function readDesignSystemAssets(
return { tokensCss, fixtureHtml };
}
/**
* Returns true when the daemon should inject the structured design-system
* channel (tokens.css + components.html) into the system prompt for the
* active brand. Default-on as of PR-D the only value that disables
* the channel is the literal string `'0'` on `OD_DESIGN_TOKEN_CHANNEL`,
* which acts as the kill switch. Unset, `'1'`, `'true'`, empty string,
* or any other value all keep the new default.
*
* Extracted from `server.ts` so the env-flag semantics (the single
* line PR-D actually flipped) can be unit-tested independently of the
* full daemon boot path. A regression that, say, restored the old
* `=== '1'` semantics or read the wrong env name would change the
* return value here and fail the unit test, even before any
* downstream prompt-assembly behaviour drifts.
*/
export function isDesignTokenChannelEnabled(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return env.OD_DESIGN_TOKEN_CHANNEL !== '0';
}
/**
* Resolves the structured design-system assets the daemon will hand to
* `composeSystemPrompt` for a given brand, applying both the
* `OD_DESIGN_TOKEN_CHANNEL` kill-switch and the built-in
* user-installed root fallback chain.
*
* This is the function `server.ts` calls at the prompt-assembly seam.
* Extracted so the *whole* server-side asset-resolution path
* env gate + per-file fallback + result shape is unit-testable
* end-to-end from real disk fixtures, not just the boolean predicate.
*
* Behaviour (pinned by `tests/design-system-assets.test.ts`):
*
* - **`OD_DESIGN_TOKEN_CHANNEL=0`** (kill switch) returns
* `{ tokensCss: undefined, fixtureHtml: undefined }` regardless of
* what's on disk. The composer skips both blocks, falling back to
* the pre-PR-C DESIGN.md-only prompt.
* - **Any other env state** (unset, `'1'`, `'true'`, ) reads
* `tokens.css` and `components.html` from `builtInRoot/<id>/`. Any
* file missing there falls back to `userInstalledRoot/<id>/`
* independently per file, so a brand can ship one half built-in and
* the other half from user-installed without losing either.
*
* Real fs errors that are not "file not found" still propagate (see
* `readFileOptional`), so a misconfigured brand surfaces loudly
* instead of silently degrading to the prose-only prompt.
*/
export async function resolveDesignSystemAssets(
designSystemId: string,
builtInRoot: string,
userInstalledRoot: string,
env: NodeJS.ProcessEnv = process.env,
): Promise<DesignSystemAssets> {
if (!isDesignTokenChannelEnabled(env)) {
return { tokensCss: undefined, fixtureHtml: undefined };
}
const builtIn = await readDesignSystemAssets(builtInRoot, designSystemId);
if (builtIn.tokensCss !== undefined && builtIn.fixtureHtml !== undefined) {
return builtIn;
}
const userInstalled = await readDesignSystemAssets(userInstalledRoot, designSystemId);
return {
tokensCss: builtIn.tokensCss ?? userInstalled.tokensCss,
fixtureHtml: builtIn.fixtureHtml ?? userInstalled.fixtureHtml,
};
}
async function readFileOptional(file: string): Promise<string | undefined> {
try {
return await readFile(file, 'utf8');

View file

@ -564,6 +564,8 @@ The Provenance section MUST list:
- Transcript message count
- Generated UTC timestamp
Render Provenance fields as plain Markdown bullets with no emphasis on the field labels, exactly: "- Field name: value". Do not bold, italicize, or otherwise decorate the labels or the colon. Field values may use inline code formatting (backticks) where appropriate.
Output the Markdown body only. No preamble, no chat-style framing, no
"Here's your DESIGN.md" prefix. Do not invent facts not supported by the
inputs; if an input is missing or empty, the corresponding section should

View file

@ -44,9 +44,20 @@ import { expandHomePrefix } from './home-expansion.js';
const PROVIDER_IDS = MEDIA_PROVIDERS.map((p) => p.id);
type ProviderEntry = { apiKey?: string; baseUrl?: string; model?: string };
type ProviderMap = Record<string, ProviderEntry>;
type ModelAliasMap = Record<string, string>;
type JsonRecord = Record<string, unknown>;
type OAuthCredential = { apiKey: string; source: string };
// Single env var carries the full alias map as JSON so we don't have
// to dynamically lift `OD_MEDIA_MODEL_ALIAS_<id>=value` into a record
// with all the env-var-name escaping that entails (Windows cmd.exe in
// particular rejects hyphens). The shape mirrors the on-disk
// `aliases` map so users can switch storage layers without rewriting
// their workflow:
//
// OD_MEDIA_MODEL_ALIASES='{"doubao-seedream-3-0-t2i-250415":"doubao-seedream-5-0"}'
const ENV_MODEL_ALIASES = 'OD_MEDIA_MODEL_ALIASES';
function isRecord(value: unknown): value is JsonRecord {
return value !== null && typeof value === 'object';
}
@ -128,24 +139,106 @@ function configFile(projectRoot: string): string {
return path.join(dir, 'media-config.json');
}
async function readStored(projectRoot: string): Promise<ProviderMap> {
/**
* Normalise an arbitrary unknown into a string-to-string map, dropping
* keys that have empty / non-string values. Shared by the env-var
* parser and the on-disk reader so both layers reject malformed
* entries the same way.
*/
function coerceAliasMap(raw: unknown): ModelAliasMap {
if (!isRecord(raw)) return {};
const out: ModelAliasMap = {};
for (const [k, v] of Object.entries(raw)) {
if (typeof k !== 'string' || !k.trim()) continue;
if (typeof v !== 'string' || !v.trim()) continue;
out[k.trim()] = v.trim();
}
return out;
}
async function readStoredFile(projectRoot: string): Promise<JsonRecord> {
try {
const raw = await readFile(configFile(projectRoot), 'utf8');
const parsed = JSON.parse(raw);
if (isRecord(parsed) && isRecord(parsed.providers)) {
return parsed.providers as ProviderMap;
}
return {};
return isRecord(parsed) ? parsed : {};
} catch (err) {
if (errorCode(err) === 'ENOENT') return {};
throw err;
}
}
async function writeStored(projectRoot: string, providers: ProviderMap): Promise<void> {
async function readStored(projectRoot: string): Promise<ProviderMap> {
const parsed = await readStoredFile(projectRoot);
return isRecord(parsed.providers) ? (parsed.providers as ProviderMap) : {};
}
async function readStoredAliases(projectRoot: string): Promise<ModelAliasMap> {
const parsed = await readStoredFile(projectRoot);
return coerceAliasMap(parsed.aliases);
}
async function writeStored(
projectRoot: string,
providers: ProviderMap,
aliases?: ModelAliasMap,
): Promise<void> {
const file = configFile(projectRoot);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, JSON.stringify({ providers }, null, 2), 'utf8');
// Preserve any existing aliases when the caller doesn't pass them.
// The Settings UI writes providers only; without this, every
// provider edit would silently wipe the user's model aliases (issue
// #1277 introduces aliases but the Settings UI surface for editing
// them lands in a follow-up PR).
const resolvedAliases = aliases ?? (await readStoredAliases(projectRoot));
const body: JsonRecord = { providers };
if (Object.keys(resolvedAliases).length > 0) {
body.aliases = resolvedAliases;
}
await writeFile(file, JSON.stringify(body, null, 2), 'utf8');
}
function readEnvAliases(): ModelAliasMap {
const raw = process.env[ENV_MODEL_ALIASES];
if (typeof raw !== 'string' || !raw.trim()) return {};
try {
return coerceAliasMap(JSON.parse(raw));
} catch {
// Malformed JSON is non-fatal — the user can fix the env var
// without restarting the daemon mid-generation, and silent fall-
// through to the on-disk map matches the precedent of the rest
// of the env / stored config resolution in this module.
return {};
}
}
/**
* Resolve a registered model id to the wire-name the provider should
* actually receive on the network. Env wins over stored, mirroring
* the precedence the rest of media-config uses for `apiKey` (issue
* #1277). Pass-through when no alias is configured.
*/
export async function resolveModelAlias(
projectRoot: string,
modelId: string,
): Promise<string> {
const envAliases = readEnvAliases();
if (envAliases[modelId]) return envAliases[modelId]!;
const stored = await readStoredAliases(projectRoot);
return stored[modelId] ?? modelId;
}
/**
* Read the merged alias map (env + stored). Exposed for the
* `/api/media/config` GET endpoint so the Settings UI can display
* which aliases are active and where they came from.
*/
export async function readAliasMap(
projectRoot: string,
): Promise<{ effective: ModelAliasMap; env: ModelAliasMap; stored: ModelAliasMap }> {
const env = readEnvAliases();
const stored = await readStoredAliases(projectRoot);
const effective: ModelAliasMap = { ...stored, ...env };
return { effective, env, stored };
}
function readEnvKey(providerId: string): string | null {
@ -260,9 +353,20 @@ export async function resolveProviderConfig(projectRoot: string, providerId: str
* frontend can show "••••" + a "configured" indicator without leaking
* the secret back into the DOM.
*/
export async function readMaskedConfig(projectRoot: string): Promise<{ providers: Record<string, { configured: boolean; source: string; apiKeyTail: string; baseUrl: string; model?: string }> }> {
export interface MaskedConfigResponse {
providers: Record<string, { configured: boolean; source: string; apiKeyTail: string; baseUrl: string; model?: string }>;
/**
* Effective alias map plus source attribution. The Settings UI can
* show "from env" vs "from media-config.json" badges next to each
* entry without needing a second endpoint. Empty maps mean no
* aliases are configured (issue #1277).
*/
aliases: { effective: ModelAliasMap; env: ModelAliasMap; stored: ModelAliasMap };
}
export async function readMaskedConfig(projectRoot: string): Promise<MaskedConfigResponse> {
const stored = await readStored(projectRoot);
const providers: Record<string, { configured: boolean; source: string; apiKeyTail: string; baseUrl: string; model?: string }> = {};
const providers: MaskedConfigResponse['providers'] = {};
for (const id of PROVIDER_IDS) {
const entry = stored[id] || {};
const envKey = readEnvKey(id);
@ -284,7 +388,8 @@ export async function readMaskedConfig(projectRoot: string): Promise<{ providers
: {}),
};
}
return { providers };
const aliases = await readAliasMap(projectRoot);
return { providers, aliases };
}
/**

View file

@ -53,7 +53,7 @@ import {
findProvider,
modelsForSurface,
} from './media-models.js';
import { resolveProviderConfig } from './media-config.js';
import { resolveModelAlias, resolveProviderConfig } from './media-config.js';
import {
ensureProject,
kindFor,
@ -67,7 +67,29 @@ type ProgressFn = (message: string) => void;
type ImageRef = { path: string; abs: string; mime: string; size: number; dataUrl: string };
type MediaContext = {
surface: MediaSurface;
/**
* Registered catalog id (e.g. `dall-e-3`, `gpt-4o-mini-tts`,
* `doubao-seedream-3-0-t2i-250415`). Every model-family branch in
* the renderers below keys off this field so DALL·E sizing,
* gpt-image quality, gpt-4o-mini-tts instructions, and the
* MINIMAX/FISHAUDIO TTS lookup tables continue to fire even when
* the user has aliased the catalog id to a custom wire-name via
* issue #1277's alias layer. lefarcen + codex P2 review on PR
* #1309 caught the regression where a single `ctx.model` doubled
* for both purposes and accidentally disabled the capability
* branches under aliasing.
*/
model: string;
/**
* What the provider's request body should carry as `model` (or
* what gets templated into the URL for Azure-style deployment
* routing). Equal to `model` when no alias is configured; equal
* to the user-supplied alias from `OD_MEDIA_MODEL_ALIASES` /
* `media-config.json` otherwise. Renderers must use this field
* for `body.model = ...` and for `providerNote` so users see
* what was actually sent.
*/
wireModel: string;
modelDef: MediaModel;
provider: MediaProvider | null;
prompt: string;
@ -352,9 +374,19 @@ export async function generateMedia(args: {
// and decide how to splice the data URL into their request.
const imageRef = await resolveProjectImage(image, dir);
// Resolve any user-configured model alias BEFORE we hand the id to a
// dispatcher (issue #1277). Catalog lookup + surface validation above
// ran against the original id so we still enforce the registered
// catalog; the alias only changes what the provider receives on the
// wire. lefarcen + codex P2 on PR #1309: keep BOTH values on ctx so
// capability branches (DALL-E sizing, gpt-image quality, gpt-4o-mini-tts
// instructions, MINIMAX/FISHAUDIO TTS map) continue to key off the
// catalog id while the provider's request body carries the alias.
const wireModel = await resolveModelAlias(projectRoot, model);
const ctx = {
surface,
model,
wireModel,
modelDef: def,
provider: findProvider(def.provider),
prompt: prompt || '',
@ -615,12 +647,15 @@ async function renderOpenAIImage(ctx: MediaContext, credentials: ProviderConfig)
};
// For non-Azure calls, include `model` in the body. Azure infers it
// from the deployment in the path so omitting it keeps payloads
// compatible across both flavors.
// compatible across both flavors. The wire-name (post-alias) goes
// on the body so the user's alias from issue #1277 reaches the API.
if (!azure) {
body.model = ctx.model;
body.model = ctx.wireModel;
}
// gpt-image-* returns b64_json by default and rejects response_format,
// so we only pass it for dall-e-* (where it's required).
// Capability branches key off the CATALOG id (not the alias) so a
// user who aliased `dall-e-3` to a custom Azure / proxy deployment
// still gets the DALL-E-specific quality + response_format flags
// (lefarcen + codex P2 on PR #1309).
if (ctx.model.startsWith('dall-e-')) {
body.response_format = 'b64_json';
body.quality = ctx.model === 'dall-e-3' ? 'hd' : 'standard';
@ -675,7 +710,7 @@ async function renderOpenAIImage(ctx: MediaContext, credentials: ProviderConfig)
const tag = azure ? 'azure-openai' : 'openai';
return {
bytes,
providerNote: `${tag}/${ctx.model} · ${ctx.aspect} · ${bytes.length} bytes`,
providerNote: `${tag}/${ctx.wireModel} · ${ctx.aspect} · ${bytes.length} bytes`,
suggestedExt: '.png',
};
}
@ -810,7 +845,7 @@ async function renderOpenAISpeech(ctx: MediaContext, credentials: ProviderConfig
response_format: format,
};
if (!azure) {
body.model = ctx.model;
body.model = ctx.wireModel;
}
if (instructions && ctx.model === 'gpt-4o-mini-tts') {
body.instructions = instructions;
@ -840,7 +875,7 @@ async function renderOpenAISpeech(ctx: MediaContext, credentials: ProviderConfig
throw new Error('openai speech returned zero bytes');
}
const tag = azure ? 'azure-openai' : 'openai';
const noteBits = [`${tag}/${ctx.model}`, voiceId, `${format}`, `${bytes.length} bytes`];
const noteBits = [`${tag}/${ctx.wireModel}`, voiceId, `${format}`, `${bytes.length} bytes`];
if (instructions) noteBits.splice(2, 0, 'styled');
return {
bytes,
@ -898,7 +933,7 @@ async function renderVolcengineVideo(ctx: MediaContext, credentials: ProviderCon
}
const taskBody = {
model: ctx.model,
model: ctx.wireModel,
content,
};
@ -988,7 +1023,7 @@ async function renderVolcengineVideo(ctx: MediaContext, credentials: ProviderCon
return {
bytes,
providerNote: `volcengine/${ctx.model} · ${ratio} · ${durationSec}s · ${bytes.length} bytes`,
providerNote: `volcengine/${ctx.wireModel} · ${ratio} · ${durationSec}s · ${bytes.length} bytes`,
suggestedExt: '.mp4',
};
}
@ -1012,9 +1047,12 @@ async function renderVolcengineImage(ctx: MediaContext, credentials: ProviderCon
const baseUrl = (credentials.baseUrl || 'https://ark.cn-beijing.volces.com/api/v3').replace(/\/$/, '');
const body = {
model: ctx.model,
model: ctx.wireModel,
prompt: ctx.prompt || 'A high-quality reference image.',
response_format: 'b64_json',
// openaiSizeFor branches on the catalog id (gpt-image-* vs dall-e-*
// accept different size enums), so it must NOT see the post-alias
// wire name. lefarcen + codex P2 on PR #1309.
size: openaiSizeFor(ctx.model, ctx.aspect),
};
const resp = await fetch(`${baseUrl}/images/generations`, {
@ -1049,7 +1087,7 @@ async function renderVolcengineImage(ctx: MediaContext, credentials: ProviderCon
}
return {
bytes,
providerNote: `volcengine/${ctx.model} · ${ctx.aspect} · ${bytes.length} bytes`,
providerNote: `volcengine/${ctx.wireModel} · ${ctx.aspect} · ${bytes.length} bytes`,
suggestedExt: '.png',
};
}
@ -1082,7 +1120,7 @@ async function renderGrokImage(ctx: MediaContext, credentials: ProviderConfig):
const aspectRatio = grokAspectFor(ctx.aspect);
const body = {
model: ctx.model,
model: ctx.wireModel,
prompt: ctx.prompt || 'A high-quality reference image.',
n: 1,
aspect_ratio: aspectRatio,
@ -1125,7 +1163,7 @@ async function renderGrokImage(ctx: MediaContext, credentials: ProviderConfig):
// trusts the extension.
return {
bytes,
providerNote: `grok/${ctx.model} · ${aspectRatio} · ${bytes.length} bytes`,
providerNote: `grok/${ctx.wireModel} · ${aspectRatio} · ${bytes.length} bytes`,
suggestedExt: sniffImageExt(bytes),
};
}
@ -1138,7 +1176,7 @@ async function renderNanoBananaImage(ctx: MediaContext, credentials: ProviderCon
}
const baseUrl = (credentials.baseUrl || NANOBANANA_DEFAULT_BASE_URL).replace(/\/$/, '');
const wireModel = (credentials.model || ctx.model || NANOBANANA_DEFAULT_MODEL).trim();
const wireModel = (credentials.model || ctx.wireModel || NANOBANANA_DEFAULT_MODEL).trim();
const body = {
contents: [{
parts: [{
@ -1261,7 +1299,7 @@ async function renderGrokVideo(ctx: MediaContext, credentials: ProviderConfig, o
const aspectRatio = grokAspectFor(ctx.aspect);
const body: Record<string, unknown> = {
model: ctx.model,
model: ctx.wireModel,
prompt: ctx.prompt || 'A short cinematic clip.',
duration: durationSec,
aspect_ratio: aspectRatio,
@ -1375,7 +1413,7 @@ async function renderGrokVideo(ctx: MediaContext, credentials: ProviderConfig, o
return {
bytes,
providerNote: `grok/${ctx.model} · ${aspectRatio} · ${durationSec}s · ${bytes.length} bytes`,
providerNote: `grok/${ctx.wireModel} · ${aspectRatio} · ${durationSec}s · ${bytes.length} bytes`,
suggestedExt: '.mp4',
};
}
@ -1583,7 +1621,13 @@ async function renderMinimaxTTS(ctx: MediaContext, credentials: ProviderConfig):
/\/$/,
'',
);
const wireModel = MINIMAX_TTS_MODEL_MAP[ctx.model] || ctx.model;
// Precedence: user alias from #1277 (when set) -> project's known
// MINIMAX legacy rename map -> catalog id. The user knows their
// deployment name better than our hardcoded table, so an explicit
// alias trumps the legacy mapping.
const wireModel = ctx.wireModel !== ctx.model
? ctx.wireModel
: (MINIMAX_TTS_MODEL_MAP[ctx.model] || ctx.model);
const text = (ctx.prompt && ctx.prompt.trim()) || 'This is a test.';
// Voice id picks: the agent can pass --voice to choose, otherwise we
// default to a neutral Mandarin male voice that handles both Chinese
@ -1686,7 +1730,11 @@ async function renderFishAudioTTS(ctx: MediaContext, credentials: ProviderConfig
/\/$/,
'',
);
const wireModel = FISHAUDIO_TTS_MODEL_MAP[ctx.model] || ctx.model;
// Same precedence as the MINIMAX TTS path: user alias wins, then
// the project's hardcoded fishaudio map, then catalog id.
const wireModel = ctx.wireModel !== ctx.model
? ctx.wireModel
: (FISHAUDIO_TTS_MODEL_MAP[ctx.model] || ctx.model);
const text = (ctx.prompt && ctx.prompt.trim()) || 'This is a test.';
// FishAudio's `reference_id` slot pins which voice the synth uses.

View file

@ -63,7 +63,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
app.post('/api/projects', async (req, res) => {
try {
const { id, name, skillId, designSystemId, pendingPrompt, metadata, customInstructions } =
const { id, name, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } =
req.body || {};
if (typeof id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
@ -101,6 +101,24 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
if (typeof customInstructions === 'string' && customInstructions.length > 5000) {
return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions exceeds 5 000 character limit');
}
if (skipDiscoveryBrief !== undefined && typeof skipDiscoveryBrief !== 'boolean') {
return sendApiError(res, 400, 'BAD_REQUEST', 'skipDiscoveryBrief must be a boolean');
}
const projectMetadata =
metadata && typeof metadata === 'object'
? {
...metadata,
...(skipDiscoveryBrief === true ? { skipDiscoveryBrief: true } : {}),
...(Array.isArray(metadata.linkedDirs)
? (() => {
const v = validateLinkedDirs(metadata.linkedDirs);
return v.error ? {} : { linkedDirs: v.dirs };
})()
: {}),
}
: skipDiscoveryBrief === true
? { skipDiscoveryBrief: true }
: null;
const now = Date.now();
const project = insertProject(db, {
id,
@ -108,18 +126,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
skillId: skillId ?? null,
designSystemId: designSystemId ?? null,
pendingPrompt: pendingPrompt || null,
metadata:
metadata && typeof metadata === 'object'
? {
...metadata,
...(Array.isArray(metadata.linkedDirs)
? (() => {
const v = validateLinkedDirs(metadata.linkedDirs);
return v.error ? {} : { linkedDirs: v.dirs };
})()
: {}),
}
: null,
metadata: projectMetadata,
customInstructions:
typeof customInstructions === 'string'
? customInstructions

View file

@ -3,14 +3,13 @@
*
* This is the dominant layer of the composed system prompt. It stacks
* BEFORE the official OD designer prompt so the hard rules below emit
* a discovery form on turn 1, branch into a direction picker / brand
* a discovery form on turn 1, branch into brand extraction when needed,
* extraction on turn 2, plan with TodoWrite on turn 3 beat the softer
* "skip questions for small tweaks" wording in the base prompt.
*
* The arc:
* Turn 1 one prose line + <question-form id="discovery"> + STOP
* Turn 2 branch on the brand answer:
* · "Pick a direction for me" emit a 2nd <question-form id="direction"> + STOP
* · "I have a brand spec / Match a reference site / screenshot"
* brand-spec extraction (Bash + Read), then TodoWrite
* · otherwise TodoWrite directly
@ -21,7 +20,7 @@
* op7418/guizang-ppt-skill (pre-flight asset reads, P0 self-check,
* theme-rhythm rules).
*/
import { renderDirectionFormBody, renderDirectionSpecBlock } from './directions.js';
import { renderDirectionSpecBlock } from './directions.js';
export const DISCOVERY_AND_PHILOSOPHY = `# OD core directives (read first — these override anything later in this prompt)
@ -114,29 +113,11 @@ When skipping, jump straight to RULE 3.
---
## RULE 2 turn 2 branches on the \`brand\` answer
## RULE 2 turn 2 branches on the \`brand\` answer, but never asks for visual direction again
Once the user submits the discovery form (their next message starts with \`[form answers — discovery]\`), look at the \`brand\` field and branch:
### Branch A \`brand: "Pick a direction for me"\`
Don't go to TodoWrite yet. Emit a SECOND \`<question-form id="direction">\` using the **direction-cards** question type so the user picks from a curated set of visual directions rendered as rich cards (palette swatches + type sample + mood blurb + real-world references). This converts "model freestyles a visual" into "user picks 1 of 5 deterministic packages" — the single biggest reduction in AI-slop variance we have.
Emit this verbatim (the JSON body is generated from the canonical direction library, so palette / fonts / refs match the **Direction library** spec block below):
\`\`\`
<question-form id="direction" title="Pick a visual direction">
${renderDirectionFormBody()}
</question-form>
\`\`\`
After \`</question-form>\`, stop. Wait for the user to pick.
The form's answer comes back as the direction's **id** (e.g. \`editorial-monocle\`, \`modern-minimal\`). Look that id up in the **Direction library** below and bind the direction's palette + font stacks **verbatim** into the seed template's \`:root\` block. Do not improvise palette values.
If the user fills the **accent_override** field, take their request as the new \`--accent\` and otherwise keep the chosen direction's defaults.
### Branch B \`brand: "I have a brand spec — I'll share it"\` or \`"Match a reference site / screenshot"\`
### Branch A \`brand: "I have a brand spec — I'll share it"\` or \`"Match a reference site / screenshot"\`
Run brand-spec extraction *before* TodoWrite five steps, each in its own \`Bash\` / \`Read\` / \`WebFetch\` call:
@ -151,9 +132,9 @@ Run brand-spec extraction *before* TodoWrite — five steps, each in its own \`B
Then proceed to RULE 3.
### Branch C anything else (or no brand info)
### Branch B anything else (including \`brand: "Pick a direction for me"\`, no brand info, or an active design system)
Skip directly to RULE 3.
Skip directly to RULE 3. Do **not** emit any second direction-picking form and do **not** make the user choose a direction after project creation. If an active design system is present, use its DESIGN.md as the visual direction and bind its tokens/rules first. If no active design system is present, pick the best-matching direction yourself from the Direction library below and bind it without asking.
---
@ -165,15 +146,15 @@ Emit \`<artifact>\` **only when this turn wrote a new canonical HTML file**. If
## RULE 3 TodoWrite the plan, then live updates
Once direction / brand-spec is locked, your **first tool call** is TodoWrite with a plan of 510 short imperative items in the order you'll do them. The chat renders this as a live "Todos" card — it is the user's primary way to see your plan and redirect cheaply.
Once the design-system / inferred direction / brand-spec is locked, your **first tool call** is TodoWrite with a plan of 510 short imperative items in the order you'll do them. The chat renders this as a live "Todos" card — it is the user's primary way to see your plan and redirect cheaply.
The standard plan template (adapt the middle steps to the brief):
\`\`\`
- 1. Read active DESIGN.md + skill assets (template.html, layouts.md, checklist.md)
- 2. (if branch B) Confirm brand-spec.md + bind to :root
(if branch A) Bind chosen direction's palette to :root
(else) Pick a direction matching the tone, bind to :root
- 2. (if branch A) Confirm brand-spec.md + bind to :root
(if active DESIGN.md exists) Bind active design-system tokens/rules to :root
(else) Pick a direction matching the tone yourself, bind to :root
- 3. Plan section/slide/screen list with platform variants and rhythm (state list aloud before writing)
- 4. Copy the seed template to project root
- 5. Paste & fill the planned layouts/screens/slides
@ -309,8 +290,7 @@ The single-screen \`mobile-app\` skill already inlines the iPhone frame in its s
- **Turn 1** short prose line + \`<question-form id="discovery">\` + stop.
- **Turn 2** branch on \`brand\`:
- "Pick a direction for me" emit \`<question-form id="direction">\` + stop.
- "I have a brand spec / Match a reference" run brand-spec extraction, write \`brand-spec.md\`, then TodoWrite.
- else TodoWrite directly.
- else TodoWrite directly; if a design system is active, use it as the visual direction without asking again.
- **Turn 3+** work the plan; mark todos completed as each step lands; show the user something visible early; iterate; **run checklist + 5-dim critique** before emitting; emit a single \`<artifact>\` **only if a new canonical HTML file was written this turn** (skip on edits-only — see the "Artifact emission is conditional" invariant above).
`;

View file

@ -94,6 +94,7 @@ type ProjectMetadata = {
platform?: string | null;
platformTargets?: string[] | null;
inspirationDesignSystemIds?: string[];
skipDiscoveryBrief?: boolean | null;
imageModel?: string | null;
imageAspect?: string | null;
imageStyle?: string | null;
@ -132,6 +133,10 @@ type AudioVoiceOption = {
export const BASE_SYSTEM_PROMPT = OFFICIAL_DESIGNER_PROMPT;
export const SKIP_DISCOVERY_BRIEF_OVERRIDE = `# Automated project mode — skip discovery form
This project was created through the daemon API with \`skipDiscoveryBrief: true\`. Override the discovery rules below: do NOT emit \`<question-form id="discovery">\`, do NOT show "Quick brief — 30 seconds", and do NOT ask a first-turn clarification form. Treat the user's first message and project metadata as the brief, then proceed directly to planning/building under the normal artifact workflow. Ask at most one concise follow-up only if a required detail is impossible to infer safely.`;
export interface ComposeInput {
agentId?: string | null | undefined;
includeCodexImagegenOverride?: boolean | undefined;
@ -151,9 +156,10 @@ export interface ComposeInput {
designSystemTitle?: string | undefined;
// Compiled (machine-readable) form of the active brand's design system,
// shipped as sibling files to DESIGN.md when available. Both fields are
// optional and only injected when the daemon is running with the
// `OD_DESIGN_TOKEN_CHANNEL` env flag enabled (today's experimental
// gate). When present they are appended AFTER the DESIGN.md block so
// optional; the daemon populates them by default for every brand that
// ships `tokens.css` / `components.html` (today: `default` and
// `kami`). `OD_DESIGN_TOKEN_CHANNEL=0` disables the channel as a kill
// switch. When present they are appended AFTER the DESIGN.md block so
// prose still sets the high-level voice and the structured form
// disambiguates token names + worked component shapes.
//
@ -283,6 +289,11 @@ export function composeSystemPrompt({
parts.push('\n\n---\n\n');
}
if (metadata?.skipDiscoveryBrief === true) {
parts.push(SKIP_DISCOVERY_BRIEF_OVERRIDE);
parts.push('\n\n---\n\n');
}
parts.push(
DISCOVERY_AND_PHILOSOPHY,
'\n\n---\n\n# Identity and workflow charter (background)\n\n',

View file

@ -1,7 +1,7 @@
import { execAgentFile } from './invocation.js';
import { AGENT_DEFS } from './registry.js';
import { DEFAULT_MODEL_OPTION, rememberLiveModels } from './models.js';
import { resolveAgentExecutable } from './executables.js';
import { applyAgentLaunchEnv, resolveAgentLaunch } from './launch.js';
import { spawnEnvForAgent } from './env.js';
import { probeAgentAuthStatus } from './auth.js';
import { agentCapabilities } from './capabilities.js';
@ -118,27 +118,30 @@ async function probe(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
): Promise<DetectedAgent> {
// Resolution returns whichever path the rest of the daemon will spawn
// (configured override wins, PATH fallback otherwise). Detection must
// probe THAT path and report `available` accordingly, so the Settings
// UI never advertises an executable that `resolveAgentBin` won't pick
// at run time. Surfacing a different PATH candidate as `available: true`
// while a stale configured override survives in chat/run resolution
// breaks the invariant flagged on PR #1301 review and would only swap
// the ghost in Settings for a ghost in chat (Siri-Ray, #1301 round 3).
const resolved = resolveAgentExecutable(def, configuredEnv);
if (!resolved) {
// Detection must probe the exact path the runtime will spawn, not just the
// PATH-visible shim. This is load-bearing for Codex under nvm/fnm/mise:
// the discovered `codex` entry is often a `#!/usr/bin/env node` wrapper
// that is not invocable from a GUI-launched app's stripped PATH, while the
// launch resolver can still upgrade it to the packaged native Codex binary.
// If detection probes the shim but chat/run spawns the native binary, the
// UI incorrectly reports "not installed" until the user pins CODEX_BIN by
// hand even though the real launch path is healthy.
const launch = resolveAgentLaunch(def, configuredEnv);
if (!launch.selectedPath || !launch.launchPath) {
return unavailableAgent(def);
}
const probeEnv = spawnEnvForAgent(
def.id,
{
...process.env,
...(def.env || {}),
},
configuredEnv,
const probeEnv = applyAgentLaunchEnv(
spawnEnvForAgent(
def.id,
{
...process.env,
...(def.env || {}),
},
configuredEnv,
),
launch,
);
const outcome = await probeVersionAtPath(def, resolved, probeEnv);
const outcome = await probeVersionAtPath(def, launch.launchPath, probeEnv);
if (outcome.kind === 'not-invocable') {
return unavailableAgent(def);
}
@ -147,7 +150,7 @@ async function probe(
if (def.helpArgs && def.capabilityFlags) {
const caps: RuntimeCapabilityMap = {};
try {
const { stdout } = await execAgentFile(resolved, def.helpArgs, {
const { stdout } = await execAgentFile(launch.launchPath, def.helpArgs, {
env: probeEnv,
timeout: 5000,
maxBuffer: 4 * 1024 * 1024,
@ -161,13 +164,13 @@ async function probe(
}
agentCapabilities.set(def.id, caps);
}
const models = await fetchModels(def, resolved, probeEnv);
const auth = await probeAgentAuthStatus(def.id, resolved, probeEnv);
const models = await fetchModels(def, launch.launchPath, probeEnv);
const auth = await probeAgentAuthStatus(def.id, launch.launchPath, probeEnv);
return {
...stripFns(def),
models,
available: true,
path: resolved,
path: launch.selectedPath,
version: outcome.version,
...(auth
? {

View file

@ -0,0 +1,281 @@
import type { ChildProcess } from 'node:child_process';
import { attachAcpSession, type AcpMcpServerInput } from '../acp.js';
import { createClaudeStreamHandler } from '../claude-stream.js';
import { createCopilotStreamHandler } from '../copilot-stream.js';
import { createJsonEventStreamHandler } from '../json-event-stream.js';
import { attachPiRpcSession } from '../pi-rpc.js';
import { createQoderStreamHandler } from '../qoder-stream.js';
import type { RuntimeAgentDef } from './types.js';
export const RUNTIME_STREAM_FORMATS = [
'plain',
'claude-stream-json',
'qoder-stream-json',
'copilot-stream-json',
'json-event-stream',
'pi-rpc',
'acp-json-rpc',
] as const;
export type RuntimeStreamFormat = typeof RUNTIME_STREAM_FORMATS[number];
export type RuntimeStdinMode = 'pipe' | 'ignore';
export type RuntimeSend = (event: string, payload: unknown) => void;
export type RuntimeAgentEvent = Record<string, unknown>;
export type RuntimeSessionHandle = {
abort?: () => void;
hasFatalError?: () => boolean;
completedSuccessfully?: () => boolean;
};
export type RuntimeAttachContext = {
child: ChildProcess;
prompt: string;
cwd?: string;
model?: string | null;
imagePaths?: string[];
uploadRoot?: string;
mcpServers?: AcpMcpServerInput[];
send: RuntimeSend;
emitAgentEvent?: (event: RuntimeAgentEvent) => void;
emitRuntimeError?: (message: string, details?: { raw?: unknown }) => void;
};
export type RuntimeExit = {
code: number | null;
signal: NodeJS.Signals | null;
canceled?: boolean;
};
export type RuntimeAttachment = {
session: RuntimeSessionHandle | null;
trackingSubstantiveOutput: boolean;
producedSubstantiveOutput(): boolean;
streamError(): string | null;
classifyClose(exit: RuntimeExit): 'succeeded' | 'failed' | 'canceled';
};
type MutableRuntimeAttachment = RuntimeAttachment & {
markProducedOutput(event: RuntimeAgentEvent): void;
markStreamError(message: string): void;
};
export type RuntimeAdapter = {
readonly id: string;
readonly displayName: string;
readonly streamFormat: RuntimeStreamFormat;
readonly eventParser: string;
supportsCritiqueTheater(): boolean;
acceptsExternalMcpServers(): boolean;
stdinMode(): RuntimeStdinMode;
shouldWritePromptToStdin(): boolean;
attach(context: RuntimeAttachContext): RuntimeAttachment;
};
const SUBSTANTIVE_AGENT_EVENT_TYPES = new Set([
'text_delta',
'thinking_delta',
'tool_use',
'tool_result',
'artifact',
]);
function isRuntimeStreamFormat(value: string): value is RuntimeStreamFormat {
return RUNTIME_STREAM_FORMATS.includes(value as RuntimeStreamFormat);
}
function requireStdout(child: ChildProcess, runtimeId: string) {
if (!child.stdout) {
throw new Error(`Runtime ${runtimeId} child process is missing stdout`);
}
return child.stdout;
}
function createAttachmentState(trackingSubstantiveOutput: boolean): MutableRuntimeAttachment {
let producedOutput = false;
let error: string | null = null;
return {
session: null,
trackingSubstantiveOutput,
producedSubstantiveOutput() {
return producedOutput;
},
streamError() {
return error;
},
classifyClose(exit) {
if (exit.canceled) return 'canceled';
if (this.session?.hasFatalError?.()) return 'failed';
if (error) return 'failed';
if (
exit.code === 0 &&
trackingSubstantiveOutput &&
!producedOutput
) {
return 'failed';
}
const cleanForcedShutdown =
exit.code === null &&
exit.signal === 'SIGTERM' &&
this.session?.completedSuccessfully?.() === true;
return exit.code === 0 || cleanForcedShutdown ? 'succeeded' : 'failed';
},
markProducedOutput(event: RuntimeAgentEvent) {
if (
typeof event.type === 'string' &&
SUBSTANTIVE_AGENT_EVENT_TYPES.has(event.type)
) {
producedOutput = true;
}
},
markStreamError(message: string) {
error = error ?? message;
},
};
}
export function createRuntimeAdapter(def: RuntimeAgentDef): RuntimeAdapter {
const streamFormat = def.streamFormat || 'plain';
if (!isRuntimeStreamFormat(streamFormat)) {
throw new Error(
`Unsupported streamFormat "${streamFormat}" for runtime "${def.id}"`,
);
}
const eventParser = def.eventParser || def.id;
function emitRuntimeError(
context: RuntimeAttachContext,
message: string,
details?: { raw?: unknown },
): void {
if (context.emitRuntimeError) {
context.emitRuntimeError(message, details);
return;
}
context.send('error', details?.raw === undefined ? { message } : { message, raw: details.raw });
}
return {
id: def.id,
displayName: def.name,
streamFormat,
eventParser,
supportsCritiqueTheater() {
return streamFormat === 'plain';
},
acceptsExternalMcpServers() {
return streamFormat === 'acp-json-rpc';
},
stdinMode() {
return def.promptViaStdin || streamFormat === 'acp-json-rpc'
? 'pipe'
: 'ignore';
},
shouldWritePromptToStdin() {
return Boolean(def.promptViaStdin && streamFormat !== 'pi-rpc');
},
attach(context: RuntimeAttachContext): RuntimeAttachment {
const emitAgentEvent = context.emitAgentEvent ?? ((event) => context.send('agent', event));
const createObservedAttachment = () => {
const state = createAttachmentState(true);
const observeAgentEvent = (event: RuntimeAgentEvent) => {
if (event.type === 'error') {
const message = String(event.message || 'Agent stream error');
state.markStreamError(message);
emitRuntimeError(context, message, { raw: event.raw });
return;
}
state.markProducedOutput(event);
emitAgentEvent(event);
};
return { state, observeAgentEvent };
};
if (streamFormat === 'plain') {
const state = createAttachmentState(false);
requireStdout(context.child, def.id).on('data', (chunk) => {
context.send('stdout', { chunk });
});
return state;
}
if (streamFormat === 'claude-stream-json') {
const state = createAttachmentState(false);
const handler = createClaudeStreamHandler(emitAgentEvent);
requireStdout(context.child, def.id).on('data', (chunk) => handler.feed(String(chunk)));
context.child.on('close', () => handler.flush());
return state;
}
if (streamFormat === 'qoder-stream-json') {
const { state, observeAgentEvent } = createObservedAttachment();
const handler = createQoderStreamHandler(observeAgentEvent);
requireStdout(context.child, def.id).on('data', (chunk) => handler.feed(String(chunk)));
context.child.on('close', () => handler.flush());
return state;
}
if (streamFormat === 'copilot-stream-json') {
const state = createAttachmentState(false);
const handler = createCopilotStreamHandler(emitAgentEvent);
requireStdout(context.child, def.id).on('data', (chunk) => handler.feed(String(chunk)));
context.child.on('close', () => handler.flush());
return state;
}
if (streamFormat === 'json-event-stream') {
const { state, observeAgentEvent } = createObservedAttachment();
const handler = createJsonEventStreamHandler(eventParser, observeAgentEvent);
requireStdout(context.child, def.id).on('data', (chunk) => handler.feed(String(chunk)));
context.child.on('close', () => handler.flush());
return state;
}
if (streamFormat === 'pi-rpc') {
const { state, observeAgentEvent } = createObservedAttachment();
state.session = attachPiRpcSession({
child: context.child,
prompt: context.prompt,
...(context.cwd === undefined ? {} : { cwd: context.cwd }),
...(context.model === undefined ? {} : { model: context.model }),
...(def.supportsImagePaths
? { imagePaths: context.imagePaths ?? [] }
: { imagePaths: [] }),
...(context.uploadRoot === undefined ? {} : { uploadRoot: context.uploadRoot }),
send: (channel, payload) => {
if (channel === 'agent' && payload && typeof payload === 'object') {
observeAgentEvent(payload as RuntimeAgentEvent);
return;
}
if (channel === 'error') {
const message = String(
payload && typeof payload === 'object' && 'message' in payload
? payload.message
: 'Pi session error',
);
state.markStreamError(message);
emitRuntimeError(context, message);
return;
}
context.send(channel, payload);
},
});
return state;
}
const state = createAttachmentState(false);
state.session = attachAcpSession({
child: context.child,
prompt: context.prompt,
...(context.cwd === undefined ? {} : { cwd: context.cwd }),
...(context.model === undefined ? {} : { model: context.model }),
...(context.mcpServers === undefined ? {} : { mcpServers: context.mcpServers }),
send: context.send,
});
return state;
},
};
}

View file

@ -43,7 +43,11 @@ import { installFromTarget, uninstallById, sanitizeRepoName } from './library-in
import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './native-folder-dialog.js';
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
import { syncCommunityPets } from './community-pets-sync.js';
import { listDesignSystems, readDesignSystem, readDesignSystemAssets } from './design-systems.js';
import {
listDesignSystems,
readDesignSystem,
resolveDesignSystemAssets,
} from './design-systems.js';
import {
applyDiffReviewDecisionToCwd,
applyPlugin,
@ -97,9 +101,6 @@ import {
listExtractions as listMemoryExtractions,
removeExtraction as removeMemoryExtraction,
} from './memory-extractions.js';
import { attachAcpSession } from './acp.js';
import { attachPiRpcSession } from './pi-rpc.js';
import { createClaudeStreamHandler } from './claude-stream.js';
import { diagnoseClaudeCliFailure } from './claude-diagnostics.js';
import { loadCritiqueConfigFromEnv } from './critique/config.js';
import { reconcileStaleRuns } from './critique/persistence.js';
@ -107,17 +108,17 @@ import { runOrchestrator } from './critique/orchestrator.js';
import { createRunRegistry } from './critique/run-registry.js';
import { handleCritiqueInterrupt } from './critique/interrupt-handler.js';
import { handleCritiqueArtifact } from './critique/artifact-handler.js';
import { createRuntimeAdapter } from './runtimes/runtime-adapter.js';
import { getCritiqueMetrics, register } from './metrics/index.js';
import { readConformanceHistory } from './critique/conformance-history.js';
import { evaluateRollout } from './critique/ratchet.js';
import {
isCritiqueEnabled,
parseEnvEnabled,
parseRolloutPhase,
type SkillCritiquePolicy,
} from './critique/rollout.js';
import { createCopilotStreamHandler } from './copilot-stream.js';
import { createJsonEventStreamHandler } from './json-event-stream.js';
import { classifyAgentAuthFailure, cursorAuthGuidance } from './runtimes/auth.js';
import { createQoderStreamHandler } from './qoder-stream.js';
import { subscribe as subscribeFileEvents } from './project-watchers.js';
import { renderDesignSystemPreview } from './design-system-preview.js';
import { renderDesignSystemShowcase } from './design-system-showcase.js';
@ -1262,11 +1263,6 @@ const promptFileBootstrap = (fp) =>
// surfaces immediately as a boot-time RangeError instead of silently at
// run time. Default: enabled=false (M0 dark launch).
const critiqueCfg = loadCritiqueConfigFromEnv();
// Tracks adapter streamFormat values that have already received a one-time
// warning explaining why the Critique Theater orchestrator was bypassed.
// Adapter denylist for orchestrator routing is implicit: anything that is
// not the 'plain' streamFormat falls through to legacy single-pass.
const critiqueWarnedAdapters = new Set<string>();
// In-process registry of in-flight critique runs so the interrupt endpoint
// can cascade an AbortController to the matching orchestrator invocation.
@ -2877,6 +2873,47 @@ export async function startServer({
});
}
// Phase 16 ratchet endpoint. Returns the rolling conformance window
// and the ratchet's current recommendation. Operator-driven by
// design: the recommendation does not flip OD_CRITIQUE_ROLLOUT_PHASE
// automatically, it surfaces so a deploy-pipeline follow-up can
// consume it. Tunables come from query string; defaults are the
// spec values (14 days, 0.90 shipped, 0.95 clean-parse).
// Codex + lefarcen P1 on PR #1499: clamp query inputs before the
// evaluator sees them so a request like `?windowDays=0` falls back to
// the spec default rather than producing a zero-evidence promotion.
// The evaluator also defends at its own entry; both are intentional
// (belt + suspenders) so a future caller that bypasses this route
// cannot reach an unguarded code path either.
const parsePositiveInt = (raw: unknown, fallback: number): number => {
if (typeof raw !== 'string' || raw.length === 0) return fallback;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
};
const parseRate = (raw: unknown, fallback: number): number => {
if (typeof raw !== 'string' || raw.length === 0) return fallback;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
};
app.get('/api/critique/conformance', async (req, res) => {
try {
const windowDays = parsePositiveInt(req.query.windowDays, 14);
const shippedThreshold = parseRate(req.query.shippedThreshold, 0.90);
const cleanParseThreshold = parseRate(req.query.cleanParseThreshold, 0.95);
const history = await readConformanceHistory(RUNTIME_DATA_DIR, windowDays);
const decision = evaluateRollout({
current: parseRolloutPhase(process.env.OD_CRITIQUE_ROLLOUT_PHASE),
history,
windowDays,
shippedThreshold,
cleanParseThreshold,
});
res.json({ window: { days: windowDays, history }, decision });
} catch (err) {
sendApiError(res, 500, 'INTERNAL_ERROR', err instanceof Error ? err.message : String(err));
}
});
registerConnectorRoutes(app, {
sendApiError,
authorizeToolRequest,
@ -7347,6 +7384,7 @@ export async function startServer({
projectId,
skillId,
designSystemId,
supportsCritiqueTheater,
streamFormat,
connectedExternalMcp,
appliedPluginSnapshotId,
@ -7459,12 +7497,17 @@ export async function startServer({
let designSystemBody;
let designSystemTitle;
// Compiled (tokens.css + components.html) form of the active brand.
// Gated by `OD_DESIGN_TOKEN_CHANNEL` while the experiment is in the
// smoke-test phase: flag-off keeps the daemon byte-equivalent to the
// pre-PR-C path; flag-on appends the tokens contract + reference
// fixture to the system prompt for any brand that ships those files
// (today: `default` and `kami`; every other brand falls through
// silently because the files are absent).
// Default-on as of PR-D — every chat that picks a brand with
// `tokens.css` + `components.html` siblings (today: `default` and
// `kami`; every other brand falls through silently because the
// files are absent) gets the structured token contract appended to
// the system prompt automatically.
//
// `OD_DESIGN_TOKEN_CHANNEL=0` is the kill switch: it forces the
// daemon back to the pre-PR-C DESIGN.md-only path for every brand,
// including the structured ones. Any other value (unset, `1`,
// `true`, etc.) keeps the new default. Drift on prose-only brands
// is pinned by `scripts/check-design-system-flag-parity.ts`.
let designSystemTokensCss;
let designSystemFixtureHtml;
if (effectiveDesignSystemId) {
@ -7475,23 +7518,17 @@ export async function startServer({
(await readDesignSystem(DESIGN_SYSTEMS_DIR, effectiveDesignSystemId)) ??
(await readDesignSystem(USER_DESIGN_SYSTEMS_DIR, effectiveDesignSystemId)) ??
undefined;
if (process.env.OD_DESIGN_TOKEN_CHANNEL === '1') {
// Try built-in dir first, then user-installed dir, mirroring the
// DESIGN.md fallback chain above. Any individual file may be
// missing (e.g. tokens.css present, components.html absent); the
// composer gates each block independently.
const builtIn = await readDesignSystemAssets(DESIGN_SYSTEMS_DIR, effectiveDesignSystemId);
const installed = builtIn.tokensCss && builtIn.fixtureHtml
? builtIn
: {
tokensCss: builtIn.tokensCss
?? (await readDesignSystemAssets(USER_DESIGN_SYSTEMS_DIR, effectiveDesignSystemId)).tokensCss,
fixtureHtml: builtIn.fixtureHtml
?? (await readDesignSystemAssets(USER_DESIGN_SYSTEMS_DIR, effectiveDesignSystemId)).fixtureHtml,
};
designSystemTokensCss = installed.tokensCss;
designSystemFixtureHtml = installed.fixtureHtml;
}
// Single seam: env gate + built-in→user-installed fallback chain
// live together inside `resolveDesignSystemAssets` so the whole
// server-side asset-resolution path can be tested end-to-end
// from real disk fixtures (see `tests/design-system-assets.test.ts`).
const assets = await resolveDesignSystemAssets(
effectiveDesignSystemId,
DESIGN_SYSTEMS_DIR,
USER_DESIGN_SYSTEMS_DIR,
);
designSystemTokensCss = assets.tokensCss;
designSystemFixtureHtml = assets.fixtureHtml;
}
const template =
@ -7582,12 +7619,11 @@ export async function startServer({
metadata?.kind === 'image' ||
metadata?.kind === 'video' ||
metadata?.kind === 'audio';
const isPlainAdapter = (streamFormat ?? 'plain') === 'plain';
const critiqueShouldRun = critiqueEnabledForRun
&& critiqueBrand !== undefined
&& critiqueSkill !== undefined
&& !isMediaSurface
&& isPlainAdapter;
&& supportsCritiqueTheater;
// Only thread the critique fields when the run is actually eligible;
// otherwise the composer's own internal eligibility check (cfg.enabled
// && brand && skill && !isMediaSurface) might still fire on
@ -7811,6 +7847,7 @@ export async function startServer({
);
if (!def.bin)
return design.runs.fail(run, 'AGENT_UNAVAILABLE', 'agent has no binary');
const runtimeAdapter = createRuntimeAdapter(def);
const safeCommentAttachments =
normalizeCommentAttachments(commentAttachments);
if (
@ -8026,6 +8063,7 @@ export async function startServer({
projectId,
skillId,
designSystemId,
supportsCritiqueTheater: runtimeAdapter.supportsCritiqueTheater(),
streamFormat: def?.streamFormat ?? 'plain',
connectedExternalMcp,
// Plan §3.M2 / §3.V1 — forward the run's snapshot id so the
@ -8224,7 +8262,7 @@ export async function startServer({
}
}
}
if (enabledExternalMcp.length > 0 && def.streamFormat === 'acp-json-rpc') {
if (enabledExternalMcp.length > 0 && runtimeAdapter.acceptsExternalMcpServers()) {
const acpExternal = buildAcpMcpServers(enabledExternalMcp);
mcpServers.push(...acpExternal);
}
@ -8434,7 +8472,6 @@ export async function startServer({
runId,
agentId,
bin: resolvedBin,
streamFormat: def.streamFormat ?? 'plain',
projectId: typeof projectId === 'string' ? projectId : null,
cwd,
model: safeModel,
@ -8452,10 +8489,7 @@ export async function startServer({
try {
// Prompt delivery via stdin is now the universal default. This bypasses
// both the cmd.exe 8KB limit and the CreateProcess 32KB limit.
const stdinMode =
def.promptViaStdin || def.streamFormat === 'acp-json-rpc'
? 'pipe'
: 'ignore';
const stdinMode = runtimeAdapter.stdinMode();
const env = applyAgentLaunchEnv({
...spawnEnvForAgent(
def.id,
@ -8484,7 +8518,7 @@ export async function startServer({
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
run.child = child;
if (def.promptViaStdin && child.stdin && def.streamFormat !== 'pi-rpc') {
if (runtimeAdapter.shouldWritePromptToStdin() && child.stdin) {
// EPIPE from a fast-exiting CLI (bad auth, missing model, exit on
// launch) would otherwise surface as an unhandled stream error and
// crash the daemon. Swallow it — the regular exit/close handlers
@ -8579,186 +8613,169 @@ export async function startServer({
// generation; otherwise the parser waits for <CRITIQUE_RUN> tags
// the model was never told to emit.
if (critiqueShouldRun) {
const adapterStreamFormat: string = def.streamFormat ?? 'plain';
if (adapterStreamFormat !== 'plain') {
if (!critiqueWarnedAdapters.has(adapterStreamFormat)) {
critiqueWarnedAdapters.add(adapterStreamFormat);
console.warn(`[critique] adapter format=${adapterStreamFormat} is not plain-stream; skipping orchestrator and falling through to legacy generation`);
}
} else {
const critiqueRunId = run.id;
// Per-run artifact directory keeps concurrent or sequential runs in the
// same project from overwriting each other's transcript or final HTML.
// Spec: artifacts/<projectId>/<runId>/transcript.ndjson(.gz).
const critiqueProjectKey = typeof projectId === 'string' && projectId ? projectId : critiqueRunId;
const critiqueArtifactDir = path.join(ARTIFACTS_DIR, critiqueProjectKey, critiqueRunId);
const stdoutIterable = (async function* () {
for await (const chunk of child.stdout) yield String(chunk);
})();
// Forward each CritiqueSseEvent on its own contract-defined channel
// (critique.run_started, critique.ship, critique.failed, ...) rather
// than wrapping the frame inside the legacy 'agent' channel. Clients
// that subscribe to the new event names see them directly with the
// contract payload as event.data.
//
// Critique events go to TWO sinks (codex P1 on PR #1338):
//
// 1. `design.runs.emit(...)` via `send(...)`, which fans out on
// `/api/runs/:runId/events`. Existing transport, unchanged.
// 2. The per-project event-sinks map, which fans out on
// `/api/projects/:projectId/events`. This is the transport the
// web `CritiqueTheaterMount` actually subscribes to (the mount
// is project-scoped, not run-scoped, because it lives at the
// project workspace level and follows the user across runs).
// Without this second sink the mount sees no frames in
// production and only the e2e tests' stubbed routes deliver
// anything to the reducer.
//
// The project-events route emits via `sse.send(payload.type,
// payload)`, so we pack the SSE channel name onto `payload.type`
// and let the sink push the right channel name. The web's
// `sseToPanelEvent` overwrites `type` from the channel name on the
// way back into a PanelEvent, so this round-trip stays correct.
const critiqueProjectIdForBus =
typeof projectId === 'string' && projectId ? projectId : null;
const critiqueBus = {
emit: (e) => {
send(e.event, e.data);
if (critiqueProjectIdForBus) {
const sinks = activeProjectEventSinks.get(critiqueProjectIdForBus);
if (sinks && sinks.size > 0) {
const payload = { ...e.data, type: e.event };
for (const sink of Array.from(sinks)) {
try {
sink(payload);
} catch {
sinks.delete(sink);
}
const critiqueRunId = run.id;
// Per-run artifact directory keeps concurrent or sequential runs in the
// same project from overwriting each other's transcript or final HTML.
// Spec: artifacts/<projectId>/<runId>/transcript.ndjson(.gz).
const critiqueProjectKey = typeof projectId === 'string' && projectId ? projectId : critiqueRunId;
const critiqueArtifactDir = path.join(ARTIFACTS_DIR, critiqueProjectKey, critiqueRunId);
const stdoutIterable = (async function* () {
for await (const chunk of child.stdout) yield String(chunk);
})();
// Forward each CritiqueSseEvent on its own contract-defined channel
// (critique.run_started, critique.ship, critique.failed, ...) rather
// than wrapping the frame inside the legacy 'agent' channel. Clients
// that subscribe to the new event names see them directly with the
// contract payload as event.data.
//
// Critique events go to TWO sinks (codex P1 on PR #1338):
//
// 1. `design.runs.emit(...)` via `send(...)`, which fans out on
// `/api/runs/:runId/events`. Existing transport, unchanged.
// 2. The per-project event-sinks map, which fans out on
// `/api/projects/:projectId/events`. This is the transport the
// web `CritiqueTheaterMount` actually subscribes to (the mount
// is project-scoped, not run-scoped, because it lives at the
// project workspace level and follows the user across runs).
// Without this second sink the mount sees no frames in
// production and only the e2e tests' stubbed routes deliver
// anything to the reducer.
//
// The project-events route emits via `sse.send(payload.type,
// payload)`, so we pack the SSE channel name onto `payload.type`
// and let the sink push the right channel name. The web's
// `sseToPanelEvent` overwrites `type` from the channel name on the
// way back into a PanelEvent, so this round-trip stays correct.
const critiqueProjectIdForBus =
typeof projectId === 'string' && projectId ? projectId : null;
const critiqueBus = {
emit: (e) => {
send(e.event, e.data);
if (critiqueProjectIdForBus) {
const sinks = activeProjectEventSinks.get(critiqueProjectIdForBus);
if (sinks && sinks.size > 0) {
const payload = { ...e.data, type: e.event };
for (const sink of Array.from(sinks)) {
try {
sink(payload);
} catch {
sinks.delete(sink);
}
}
}
},
};
// Register this run with the in-process registry so the interrupt
// endpoint can cascade an AbortController to the orchestrator. The
// register call must run BEFORE runOrchestrator is invoked, so a
// request that arrives between spawn and orchestrator-start cannot
// miss a runId that already has a live child process.
const critiqueAbort = new AbortController();
critiqueRunRegistry.register({
runId: critiqueRunId,
projectId: critiqueProjectKey,
abort: critiqueAbort,
startedAt: Date.now(),
});
// Stderr forwarding and child.on('error') must be wired BEFORE the
// orchestrator awaits stdout. Otherwise a CLI that floods stderr can
// fill the OS pipe and deadlock the run until the total timeout, and
// an early child error fired before the orchestrator returns has no
// listener. Both registrations are idempotent and the run lifecycle
// is owned solely by the orchestrator's awaited result below.
child.stderr.on('data', (chunk) => {
noteAgentActivity();
send('stderr', { chunk });
});
child.on('error', (err) => {
send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', err.message));
});
// Wrap the child's close event so the orchestrator can race child
// exit against parser completion, abort, and timeouts in one awaited
// flow. Without this the orchestrator can't tell a non-zero exit
// apart from a clean ship and may misclassify failures.
const childExitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
child.once('close', (code, signal) => resolve({ code, signal }));
});
try {
const orchestratorResult = await runOrchestrator({
runId: critiqueRunId,
projectId: typeof projectId === 'string' ? projectId : '',
conversationId: typeof conversationId === 'string' ? conversationId : null,
artifactId: critiqueRunId,
artifactDir: critiqueArtifactDir,
adapter: typeof agentId === 'string' ? agentId : 'unknown',
// Codex P2 on PR #1485: thread the resolved skill id into the
// orchestrator so the Phase 12 metrics carry the real label
// instead of falling through to 'unknown' for every live run.
// `effectiveSkillId` was already computed above (line ~2951) as
// the request skillId with a project-row fallback; pass it
// through verbatim, and leave the orchestrator's own default
// of 'unknown' for runs that genuinely have no skill assigned.
skill: typeof effectiveSkillId === 'string' && effectiveSkillId
? effectiveSkillId
: undefined,
cfg: critiqueCfg,
db,
bus: critiqueBus,
stdout: stdoutIterable,
child,
childExitPromise,
signal: critiqueAbort.signal,
});
// Map the critique terminal status to the chat run lifecycle.
// 'shipped' and 'below_threshold' both ran to a ship decision and
// finalize as 'succeeded'; every other status (timed_out,
// interrupted, degraded, failed, legacy) is a failure path so the
// run reflects the real outcome instead of a misleading success.
const succeeded = orchestratorResult.status === 'shipped'
|| orchestratorResult.status === 'below_threshold';
if (run.cancelRequested) {
design.runs.finish(run, 'canceled', 1, null);
} else if (succeeded) {
design.runs.finish(run, 'succeeded', 0, null);
} else {
design.runs.finish(run, 'failed', 1, null);
}
} catch (err) {
send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', err instanceof Error ? err.message : String(err)));
},
};
// Register this run with the in-process registry so the interrupt
// endpoint can cascade an AbortController to the orchestrator. The
// register call must run BEFORE runOrchestrator is invoked, so a
// request that arrives between spawn and orchestrator-start cannot
// miss a runId that already has a live child process.
const critiqueAbort = new AbortController();
critiqueRunRegistry.register({
runId: critiqueRunId,
projectId: critiqueProjectKey,
abort: critiqueAbort,
startedAt: Date.now(),
});
// Stderr forwarding and child.on('error') must be wired BEFORE the
// orchestrator awaits stdout. Otherwise a CLI that floods stderr can
// fill the OS pipe and deadlock the run until the total timeout, and
// an early child error fired before the orchestrator returns has no
// listener. Both registrations are idempotent and the run lifecycle
// is owned solely by the orchestrator's awaited result below.
child.stderr.on('data', (chunk) => {
noteAgentActivity();
send('stderr', { chunk });
});
child.on('error', (err) => {
send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', err.message));
});
// Wrap the child's close event so the orchestrator can race child
// exit against parser completion, abort, and timeouts in one awaited
// flow. Without this the orchestrator can't tell a non-zero exit
// apart from a clean ship and may misclassify failures.
const childExitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
child.once('close', (code, signal) => resolve({ code, signal }));
});
try {
const orchestratorResult = await runOrchestrator({
runId: critiqueRunId,
projectId: typeof projectId === 'string' ? projectId : '',
conversationId: typeof conversationId === 'string' ? conversationId : null,
artifactId: critiqueRunId,
artifactDir: critiqueArtifactDir,
adapter: typeof agentId === 'string' ? agentId : 'unknown',
// Codex P2 on PR #1485: thread the resolved skill id into the
// orchestrator so the Phase 12 metrics carry the real label
// instead of falling through to 'unknown' for every live run.
// `effectiveSkillId` was already computed above as the request
// skillId with a project-row fallback; pass it through verbatim,
// and leave the orchestrator's own default of 'unknown' for runs
// that genuinely have no skill assigned.
skill: typeof effectiveSkillId === 'string' && effectiveSkillId
? effectiveSkillId
: undefined,
cfg: critiqueCfg,
db,
bus: critiqueBus,
stdout: stdoutIterable,
child,
childExitPromise,
signal: critiqueAbort.signal,
});
// Map the critique terminal status to the chat run lifecycle.
// 'shipped' and 'below_threshold' both ran to a ship decision and
// finalize as 'succeeded'; every other status (timed_out,
// interrupted, degraded, failed, legacy) is a failure path so the
// run reflects the real outcome instead of a misleading success.
const succeeded = orchestratorResult.status === 'shipped'
|| orchestratorResult.status === 'below_threshold';
if (run.cancelRequested) {
design.runs.finish(run, 'canceled', 1, null);
} else if (succeeded) {
design.runs.finish(run, 'succeeded', 0, null);
} else {
design.runs.finish(run, 'failed', 1, null);
} finally {
critiqueRunRegistry.unregister(critiqueProjectKey, critiqueRunId);
}
return;
} catch (err) {
send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', err instanceof Error ? err.message : String(err)));
design.runs.finish(run, 'failed', 1, null);
} finally {
critiqueRunRegistry.unregister(critiqueProjectKey, critiqueRunId);
}
return;
}
// Structured streams (Claude Code) go through a line-delimited JSON
// parser that turns stream_event objects into UI-friendly events. For
// plain streams (most other CLIs) we forward raw chunks unchanged so
// the browser can append them to the assistant's text buffer.
let agentStreamError = null;
// Tracks whether any stream the run is using actually emitted user-
// visible content. Only the streams routed through `sendAgentEvent`
// contribute to this flag; ACP sessions and plain stdout streams are
// covered by their own success/failure paths and the empty-output
// guard below skips them via `trackingSubstantiveOutput`.
let agentProducedOutput = false;
let trackingSubstantiveOutput = false;
// Event types that count as "the agent actually produced something the
// user can see." Lifecycle markers (`status`) and meter readings
// (`usage`) deliberately do NOT count — a model can emit token-usage
// numbers for an empty completion (issue #691), and a `status:running`
// banner without any follow-up is exactly the silent-failure shape we
// want to surface as failed instead of succeeded.
const SUBSTANTIVE_AGENT_EVENT_TYPES = new Set([
'text_delta',
'thinking_delta',
'tool_use',
'tool_result',
'artifact',
]);
const sendAgentEvent = (ev) => {
if (ev?.type === 'error') {
if (agentStreamError) return;
agentStreamError = String(ev.message || 'Agent stream error');
// Runtime-specific stream/session protocols are selected by the adapter;
// this chat layer only updates generic run activity and lifecycle state.
const runtimeAttachment = runtimeAdapter.attach({
child,
prompt: composed,
cwd: effectiveCwd,
model: safeModel,
imagePaths: safeImages,
uploadRoot: UPLOAD_DIR,
mcpServers,
send: (event, data) => {
noteAgentActivity();
send(event, data);
},
emitAgentEvent: (ev) => {
lastAgentEventPhase = summarizeAgentEventForInactivity(ev);
noteAgentActivity();
send('agent', ev);
},
emitRuntimeError: (message, details) => {
clearInactivityWatchdog();
const authFailure = classifyAgentAuthFailure(
agentId,
[
agentStreamError,
typeof ev.raw === 'string' ? ev.raw : '',
message,
typeof details?.raw === 'string' ? details.raw : '',
agentStdoutTail,
agentStderrTail,
].join('\n'),
@ -8771,116 +8788,15 @@ export async function startServer({
));
return;
}
send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', agentStreamError, {
details: ev.raw ? { raw: ev.raw } : undefined,
send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', message, {
details: details?.raw ? { raw: details.raw } : undefined,
retryable: false,
}));
return;
}
lastAgentEventPhase = summarizeAgentEventForInactivity(ev);
noteAgentActivity();
if (ev?.type && SUBSTANTIVE_AGENT_EVENT_TYPES.has(ev.type)) {
agentProducedOutput = true;
}
send('agent', ev);
};
if (def.streamFormat === 'claude-stream-json') {
const claude = createClaudeStreamHandler((ev) => {
lastAgentEventPhase = summarizeAgentEventForInactivity(ev);
noteAgentActivity();
send('agent', ev);
});
child.stdout.on('data', (chunk) => claude.feed(chunk));
child.on('close', () => claude.flush());
} else if (def.streamFormat === 'qoder-stream-json') {
trackingSubstantiveOutput = true;
const qoder = createQoderStreamHandler(sendAgentEvent);
child.stdout.on('data', (chunk) => qoder.feed(chunk));
child.on('close', () => qoder.flush());
} else if (def.streamFormat === 'copilot-stream-json') {
const copilot = createCopilotStreamHandler((ev) => {
lastAgentEventPhase = summarizeAgentEventForInactivity(ev);
noteAgentActivity();
send('agent', ev);
});
child.stdout.on('data', (chunk) => copilot.feed(chunk));
child.on('close', () => copilot.flush());
} else if (def.streamFormat === 'pi-rpc') {
// Route through sendAgentEvent so that pi-rpc's error events
// (extension_error, auto_retry_end with success=false, and the
// message_update error delta) set agentStreamError and flip the
// run to `failed` on close — same path as qoder-stream-json and
// json-event-stream after issue #691. Also enables the
// substantive-output guard (agentProducedOutput) so a pi run
// that exits 0 without producing visible content is caught.
//
// attachPiRpcSession invokes its send callback with the two-arg
// channel/payload shape: send('agent', payload) for normal events
// and send('error', {message}) from fail(). sendAgentEvent
// expects a single event object, so we adapt at the call site:
// - 'agent' channel → relay payload through sendAgentEvent
// - 'error' channel → route through the daemon's error path
// (createSseErrorPayload + send SSE + set agentStreamError)
trackingSubstantiveOutput = true;
acpSession = attachPiRpcSession({
child,
prompt: composed,
cwd: effectiveCwd,
model: safeModel,
send: (channel, payload) => {
if (channel === 'agent') {
sendAgentEvent(payload);
} else if (channel === 'error') {
if (agentStreamError) return;
agentStreamError = String(payload?.message || 'Pi session error');
clearInactivityWatchdog();
send('error', createSseErrorPayload(
'AGENT_EXECUTION_FAILED',
agentStreamError,
{ retryable: false },
));
} else {
noteAgentActivity();
send(channel, payload);
}
},
imagePaths: def.supportsImagePaths ? safeImages : [],
uploadRoot: UPLOAD_DIR,
});
} else if (def.streamFormat === 'acp-json-rpc') {
acpSession = attachAcpSession({
child,
prompt: composed,
cwd: effectiveCwd,
model: safeModel,
mcpServers,
send: (event, data) => {
noteAgentActivity();
send(event, data);
},
});
} else if (def.streamFormat === 'json-event-stream') {
// Pipe through sendAgentEvent so the OpenCode `type:'error'` frame
// (now emitted as a real error event by json-event-stream.ts after
// #691) actually triggers `agentStreamError` instead of being
// forwarded as a no-op `agent` SSE event. This also wires the
// substantive-output tracking the close handler reads below.
trackingSubstantiveOutput = true;
const handler = createJsonEventStreamHandler(
def.eventParser || def.id,
sendAgentEvent,
);
child.stdout.on('data', (chunk) => handler.feed(chunk));
child.on('close', () => handler.flush());
} else {
child.stdout.on('data', (chunk) => {
noteAgentActivity();
send('stdout', { chunk });
});
}
},
});
// Wire the acpSession onto the run so cancel() can call abort()
// instead of raw SIGTERM (applies to pi-rpc and acp-json-rpc).
acpSession = runtimeAttachment.session;
run.acpSession = acpSession;
child.stderr.on('data', (chunk) => {
noteAgentActivity();
@ -8902,7 +8818,7 @@ export async function startServer({
if (acpSession?.hasFatalError()) {
return design.runs.finish(run, 'failed', code ?? 1, signal ?? null);
}
if (agentStreamError) {
if (runtimeAttachment.streamError()) {
return design.runs.finish(run, 'failed', code ?? 1, signal ?? null);
}
if (
@ -8929,8 +8845,9 @@ export async function startServer({
if (
code === 0 &&
!run.cancelRequested &&
trackingSubstantiveOutput &&
!agentProducedOutput
runtimeAttachment.trackingSubstantiveOutput &&
!runtimeAttachment.producedSubstantiveOutput() &&
!runtimeAttachment.streamError()
) {
send('error', createSseErrorPayload(
'AGENT_EXECUTION_FAILED',
@ -8939,29 +8856,11 @@ export async function startServer({
));
return design.runs.finish(run, 'failed', code, signal);
}
// ACP agents that don't shut down on stdin.end() (e.g. Devin for
// Terminal) are forced to exit via SIGTERM from attachAcpSession after
// a clean prompt completion. Without an override, the chat run would
// be marked `failed` because `code === 0` fails (code is null on a
// signal exit). `completedSuccessfully()` reports whether the ACP
// session resolved without a fatal error or abort.
//
// Scope the override narrowly to the exact forced-shutdown shape this
// PR introduces: code is null AND signal is SIGTERM AND the ACP
// session reported clean completion. Any other post-response failure
// (non-zero exit code, SIGKILL, SIGSEGV, etc.) still propagates as
// `failed`, preserving the existing close-status behavior for genuine
// post-response process problems.
const acpCleanCompletion =
typeof acpSession?.completedSuccessfully === 'function' &&
acpSession.completedSuccessfully();
const acpForcedShutdown =
code === null && signal === 'SIGTERM' && acpCleanCompletion;
const status = run.cancelRequested
? 'canceled'
: code === 0 || acpForcedShutdown
? 'succeeded'
: 'failed';
const status = runtimeAttachment.classifyClose({
code,
signal,
canceled: run.cancelRequested,
});
if (status === 'failed') {
const diagnostic = diagnoseClaudeCliFailure({
agentId: def.id,

View file

@ -0,0 +1,144 @@
/**
* Conformance history persistence (Phase 16). Pins the read-merge-write
* shape the nightly cron and the /api/critique/conformance route share:
* one JSON-line per adapter per day, with malformed lines silently
* dropped and the last entry per (adapter, date) winning so a
* retry-after-failure cron writes the right answer.
*/
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
appendConformanceDay,
conformanceHistoryDir,
readConformanceHistory,
} from '../src/critique/conformance-history.js';
let tmp: string;
beforeEach(async () => {
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'od-conformance-history-'));
});
afterEach(async () => {
await fs.rm(tmp, { recursive: true, force: true });
});
const NOW = new Date('2026-05-13T12:00:00Z');
function isoDay(offsetDays: number): string {
const d = new Date(NOW.getTime() - offsetDays * 24 * 60 * 60 * 1000);
return d.toISOString().slice(0, 10);
}
describe('conformance-history persistence (Phase 16)', () => {
it('returns an empty array when the directory does not exist yet', async () => {
const rows = await readConformanceHistory(tmp, 14, NOW);
expect(rows).toEqual([]);
});
it('round-trips a single row through append + read', async () => {
await appendConformanceDay(tmp, {
date: isoDay(0),
adapter: 'mock',
shippedRate: 0.95,
cleanParseRate: 0.98,
totalRuns: 100,
});
const rows = await readConformanceHistory(tmp, 14, NOW);
expect(rows).toHaveLength(1);
expect(rows[0]?.adapter).toBe('mock');
expect(rows[0]?.shippedRate).toBe(0.95);
});
it('keeps the last entry per (adapter, date) so retries win', async () => {
const today = isoDay(0);
await appendConformanceDay(tmp, {
date: today,
adapter: 'mock',
shippedRate: 0.50,
cleanParseRate: 0.60,
totalRuns: 50,
});
// Retry the same day with a better number.
await appendConformanceDay(tmp, {
date: today,
adapter: 'mock',
shippedRate: 0.95,
cleanParseRate: 0.98,
totalRuns: 100,
});
const rows = await readConformanceHistory(tmp, 14, NOW);
expect(rows).toHaveLength(1);
expect(rows[0]?.shippedRate).toBe(0.95);
expect(rows[0]?.totalRuns).toBe(100);
});
it('drops malformed lines silently instead of throwing', async () => {
// Hand-write a file with one valid row and one corrupt line.
const file = path.join(conformanceHistoryDir(tmp), 'mock', `${isoDay(0)}.jsonl`);
await fs.mkdir(path.dirname(file), { recursive: true });
const good = JSON.stringify({
date: isoDay(0),
adapter: 'mock',
shippedRate: 0.95,
cleanParseRate: 0.98,
totalRuns: 100,
});
await fs.writeFile(file, `${good}\nnot valid json{{\n`, 'utf8');
const rows = await readConformanceHistory(tmp, 14, NOW);
expect(rows).toHaveLength(1);
expect(rows[0]?.adapter).toBe('mock');
});
it('ignores files outside the rolling window', async () => {
await appendConformanceDay(tmp, {
date: isoDay(20),
adapter: 'mock',
shippedRate: 0.10,
cleanParseRate: 0.10,
totalRuns: 100,
});
const rows = await readConformanceHistory(tmp, 14, NOW);
expect(rows).toEqual([]);
});
it('aggregates across adapter directories', async () => {
await appendConformanceDay(tmp, {
date: isoDay(0),
adapter: 'A',
shippedRate: 0.95,
cleanParseRate: 0.98,
totalRuns: 100,
});
await appendConformanceDay(tmp, {
date: isoDay(0),
adapter: 'B',
shippedRate: 0.92,
cleanParseRate: 0.97,
totalRuns: 50,
});
const rows = await readConformanceHistory(tmp, 14, NOW);
expect(rows).toHaveLength(2);
expect(new Set(rows.map((r) => r.adapter))).toEqual(new Set(['A', 'B']));
});
it('rejects rows with non-finite numeric fields', async () => {
const file = path.join(conformanceHistoryDir(tmp), 'mock', `${isoDay(0)}.jsonl`);
await fs.mkdir(path.dirname(file), { recursive: true });
const bad = JSON.stringify({
date: isoDay(0),
adapter: 'mock',
shippedRate: 'oops',
cleanParseRate: 0.98,
totalRuns: 100,
});
await fs.writeFile(file, `${bad}\n`, 'utf8');
const rows = await readConformanceHistory(tmp, 14, NOW);
expect(rows).toEqual([]);
});
});

View file

@ -0,0 +1,235 @@
/**
* Ratchet decision matrix (Phase 16). Drives `evaluateRollout` through
* every cell of the promote / hold / demote table the spec describes.
* The function is pure, so every case here is a direct (inputs ->
* outputs) assertion with no I/O.
*/
import { describe, expect, it } from 'vitest';
import {
evaluateRollout,
type ConformanceDay,
type RatchetDecision,
} from '../src/critique/ratchet.js';
const FROZEN_NOW = () => new Date('2026-05-13T00:00:00Z');
function isoDay(offsetDays: number): string {
const d = new Date(FROZEN_NOW().getTime() - offsetDays * 24 * 60 * 60 * 1000);
return d.toISOString().slice(0, 10);
}
function passingRow(date: string, adapter = 'mock'): ConformanceDay {
return { date, adapter, shippedRate: 0.95, cleanParseRate: 0.98, totalRuns: 100 };
}
function failingRow(date: string, adapter = 'mock'): ConformanceDay {
return { date, adapter, shippedRate: 0.30, cleanParseRate: 0.40, totalRuns: 100 };
}
describe('evaluateRollout (Phase 16)', () => {
it('promotes M0 -> M1 when every day in the window passes both thresholds', () => {
const history = Array.from({ length: 14 }, (_, i) => passingRow(isoDay(i)));
const decision = evaluateRollout({ current: 'M0', history, now: FROZEN_NOW });
expect(decision.kind).toBe('promote');
if (decision.kind === 'promote') {
expect(decision.from).toBe('M0');
expect(decision.to).toBe('M1');
expect(decision.evidenceDays).toBe(14);
}
});
it('promotes M2 -> M3 when every day passes', () => {
const history = Array.from({ length: 14 }, (_, i) => passingRow(isoDay(i)));
const decision = evaluateRollout({ current: 'M2', history, now: FROZEN_NOW });
expect(decision.kind).toBe('promote');
if (decision.kind === 'promote') expect(decision.to).toBe('M3');
});
it('holds at M3 when already at the ceiling (cannot promote further)', () => {
const history = Array.from({ length: 14 }, (_, i) => passingRow(isoDay(i)));
const decision = evaluateRollout({ current: 'M3', history, now: FROZEN_NOW });
expect(decision.kind).toBe('hold');
if (decision.kind === 'hold') {
expect(decision.current).toBe('M3');
expect(decision.reason).toContain('already at M3');
expect(decision.passingDays).toBe(14);
}
});
it('holds when one day in the window is a near-miss (89% shipped)', () => {
const history: ConformanceDay[] = [];
for (let i = 0; i < 14; i++) history.push(passingRow(isoDay(i)));
// Overwrite day 5 with a near-miss row.
history[5] = { date: isoDay(5), adapter: 'mock', shippedRate: 0.89, cleanParseRate: 0.98, totalRuns: 100 };
const decision = evaluateRollout({ current: 'M1', history, now: FROZEN_NOW });
expect(decision.kind).toBe('hold');
if (decision.kind === 'hold') {
expect(decision.passingDays).toBe(13);
expect(decision.observedDays).toBe(14);
expect(decision.reason).toContain('near-miss');
}
});
it('holds with "insufficient data" when only some window days have rows', () => {
const history = Array.from({ length: 7 }, (_, i) => passingRow(isoDay(i)));
const decision = evaluateRollout({ current: 'M1', history, now: FROZEN_NOW });
expect(decision.kind).toBe('hold');
if (decision.kind === 'hold') {
expect(decision.observedDays).toBe(7);
expect(decision.passingDays).toBe(7);
expect(decision.reason).toContain('insufficient data');
}
});
it('demotes M2 -> M1 when any day breaches the demote floor (shipped < 0.45)', () => {
const history = Array.from({ length: 14 }, (_, i) => passingRow(isoDay(i)));
history[3] = failingRow(isoDay(3));
const decision = evaluateRollout({ current: 'M2', history, now: FROZEN_NOW });
expect(decision.kind).toBe('demote');
if (decision.kind === 'demote') {
expect(decision.from).toBe('M2');
expect(decision.to).toBe('M1');
expect(decision.reason).toContain('fell below 45%');
}
});
it('cannot demote past M0; holds with a "would-demote" reason instead', () => {
const history: ConformanceDay[] = [];
for (let i = 0; i < 14; i++) history.push(passingRow(isoDay(i)));
history[3] = failingRow(isoDay(3));
const decision = evaluateRollout({ current: 'M0', history, now: FROZEN_NOW });
expect(decision.kind).toBe('hold');
if (decision.kind === 'hold') {
expect(decision.current).toBe('M0');
expect(decision.reason).toContain('already at M0');
}
});
it('aggregates across adapters per day as a weighted mean', () => {
// Two adapters, both observed for all 14 days. Adapter A passes
// at 0.95 shipped, adapter B passes at 0.92 shipped, both with
// equal weight (totalRuns = 100). Fleet shipped = 0.935, still
// above 0.90, so the day passes.
const history: ConformanceDay[] = [];
for (let i = 0; i < 14; i++) {
const date = isoDay(i);
history.push({ date, adapter: 'A', shippedRate: 0.95, cleanParseRate: 0.98, totalRuns: 100 });
history.push({ date, adapter: 'B', shippedRate: 0.92, cleanParseRate: 0.97, totalRuns: 100 });
}
const decision = evaluateRollout({ current: 'M1', history, now: FROZEN_NOW });
expect(decision.kind).toBe('promote');
});
it('does not double-count rows outside the window', () => {
// Days 0..13 inside, day 14 outside. Day 14 has a failing row that
// would otherwise trip the demote, but the evaluator should ignore
// it because it falls outside the rolling 14-day window.
const history: ConformanceDay[] = Array.from({ length: 14 }, (_, i) => passingRow(isoDay(i)));
history.push(failingRow(isoDay(14)));
const decision = evaluateRollout({ current: 'M1', history, now: FROZEN_NOW });
expect(decision.kind).toBe('promote');
});
it('clean-parse-rate gate fails the day even when shipped clears', () => {
const history: ConformanceDay[] = [];
for (let i = 0; i < 14; i++) {
history.push({ date: isoDay(i), adapter: 'mock', shippedRate: 0.95, cleanParseRate: 0.88, totalRuns: 100 });
}
const decision = evaluateRollout({ current: 'M1', history, now: FROZEN_NOW });
expect(decision.kind).toBe('hold');
if (decision.kind === 'hold') {
expect(decision.passingDays).toBe(0);
expect(decision.reason).toContain('near-miss');
}
});
it('refuses to promote on a zero-windowDays request even with no history (Codex + lefarcen P1)', () => {
// 0 >= 0 would trivially pass the promote gate at zero observed
// days without the entry-validation guard; assert that we hold
// with an explicit reason instead.
const decision = evaluateRollout({ current: 'M1', history: [], windowDays: 0, now: FROZEN_NOW });
expect(decision.kind).toBe('hold');
if (decision.kind === 'hold') {
expect(decision.reason).toContain('invalid windowDays');
expect(decision.passingDays).toBe(0);
expect(decision.observedDays).toBe(0);
}
});
it('refuses to evaluate on a negative windowDays', () => {
const decision = evaluateRollout({ current: 'M1', history: [], windowDays: -7, now: FROZEN_NOW });
expect(decision.kind).toBe('hold');
if (decision.kind === 'hold') expect(decision.reason).toContain('invalid windowDays');
});
it('refuses to evaluate on an out-of-range shippedThreshold', () => {
const decision = evaluateRollout({
current: 'M1',
history: [],
shippedThreshold: 1.5,
now: FROZEN_NOW,
});
expect(decision.kind).toBe('hold');
if (decision.kind === 'hold') expect(decision.reason).toContain('invalid shippedThreshold');
});
it('refuses to evaluate on a negative cleanParseThreshold', () => {
const decision = evaluateRollout({
current: 'M1',
history: [],
cleanParseThreshold: -0.1,
now: FROZEN_NOW,
});
expect(decision.kind).toBe('hold');
if (decision.kind === 'hold') expect(decision.reason).toContain('invalid cleanParseThreshold');
});
it('refuses NaN on every numeric input (PerishCode follow-up on PR #1499)', () => {
// Number.isFinite() rejects NaN, so the guard already handles
// these. Pin the behavior explicitly so a future refactor of the
// guard (`>= 0 && <= 1`, a typed parser, a clamp helper) cannot
// accidentally let NaN through and surface a zero-evidence
// promote signal.
const windowNaN = evaluateRollout({
current: 'M1',
history: [],
windowDays: Number.NaN,
now: FROZEN_NOW,
});
expect(windowNaN.kind).toBe('hold');
if (windowNaN.kind === 'hold') expect(windowNaN.reason).toContain('invalid windowDays');
const shippedNaN = evaluateRollout({
current: 'M1',
history: [],
shippedThreshold: Number.NaN,
now: FROZEN_NOW,
});
expect(shippedNaN.kind).toBe('hold');
if (shippedNaN.kind === 'hold') expect(shippedNaN.reason).toContain('invalid shippedThreshold');
const cleanNaN = evaluateRollout({
current: 'M1',
history: [],
cleanParseThreshold: Number.NaN,
now: FROZEN_NOW,
});
expect(cleanNaN.kind).toBe('hold');
if (cleanNaN.kind === 'hold') expect(cleanNaN.reason).toContain('invalid cleanParseThreshold');
});
});
// Type assertion: every RatchetDecision should be one of three kinds.
// This is a compile-time guard, not a runtime test; if the discriminated
// union grows a new kind, this will fail to compile until updated.
const _decisionExhaustiveCheck = (d: RatchetDecision): string => {
switch (d.kind) {
case 'hold':
case 'promote':
case 'demote':
return d.kind;
}
};
void _decisionExhaustiveCheck;

View file

@ -18,6 +18,8 @@ import { loadCritiqueConfigFromEnv } from '../src/critique/config.js';
import { runOrchestrator, type CritiqueSseBus } from '../src/critique/orchestrator.js';
import type { CritiqueSseEvent } from '@open-design/contracts/critique';
import { defaultCritiqueConfig } from '@open-design/contracts/critique';
import { createRuntimeAdapter } from '../src/runtimes/runtime-adapter.js';
import { minimalAgentDef } from './runtimes/helpers/test-helpers.js';
function freshDb(): Database.Database {
const db = new Database(':memory:');
@ -172,28 +174,32 @@ describe('spawn wiring - cfg.enabled=true (orchestrator path)', () => {
});
// ---------------------------------------------------------------------------
// Stream format gating (Defect 1)
// Runtime adapter capability gating (Defect 1)
// ---------------------------------------------------------------------------
// The server gates the orchestrator path on streamFormat === 'plain'.
// The server gates the orchestrator path on adapter.supportsCritiqueTheater().
// We test the logic inline: simulate the server branch condition and verify
// that non-plain adapters skip the orchestrator entirely.
// that adapters without the capability skip the orchestrator entirely.
describe('spawn wiring - stream format gating (Defect 1)', () => {
describe('spawn wiring - runtime adapter critique capability (Defect 1)', () => {
const NON_PLAIN_FORMATS = [
'claude-stream-json',
'qoder-stream-json',
'copilot-stream-json',
'json-event-stream',
'pi-rpc',
'acp-json-rpc',
] as const;
for (const fmt of NON_PLAIN_FORMATS) {
it(`format="${fmt}" skips the orchestrator (no run row inserted)`, async () => {
// Simulate the server branch: if streamFormat !== 'plain', skip orchestrator.
it(`adapter format="${fmt}" skips the orchestrator (no run row inserted)`, async () => {
// Simulate the server branch: if critique capability is absent, skip orchestrator.
const cfg = loadCritiqueConfigFromEnv({ OD_CRITIQUE_ENABLED: '1' });
const adapterStreamFormat: string = fmt;
const adapter = createRuntimeAdapter(minimalAgentDef({
bin: `agent-${fmt}`,
streamFormat: fmt,
}));
if (cfg.enabled && adapterStreamFormat !== 'plain') {
if (cfg.enabled && !adapter.supportsCritiqueTheater()) {
// Legacy path: orchestrator NOT called.
// Nothing should be inserted.
expect(getCritiqueRun(db, `skip-${fmt}`)).toBeNull();
@ -207,11 +213,14 @@ describe('spawn wiring - stream format gating (Defect 1)', () => {
it('format="plain" routes through the orchestrator', async () => {
const cfg = loadCritiqueConfigFromEnv({ OD_CRITIQUE_ENABLED: '1' });
const adapterStreamFormat = 'plain';
const adapter = createRuntimeAdapter(minimalAgentDef({
bin: 'plain-agent',
streamFormat: 'plain',
}));
// Simulate: only call orchestrator when format is plain.
if (!cfg.enabled || adapterStreamFormat !== 'plain') {
throw new Error('Expected plain format to be routed through orchestrator');
// Simulate: only call orchestrator when the adapter advertises support.
if (!cfg.enabled || !adapter.supportsCritiqueTheater()) {
throw new Error('Expected critique-capable adapter to be routed through orchestrator');
}
const { bus } = makeBus();

View file

@ -14,7 +14,11 @@ import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { readDesignSystemAssets } from '../src/design-systems.js';
import {
isDesignTokenChannelEnabled,
readDesignSystemAssets,
resolveDesignSystemAssets,
} from '../src/design-systems.js';
function fresh(): string {
return mkdtempSync(path.join(tmpdir(), 'od-design-system-assets-'));
@ -112,3 +116,145 @@ describe('readDesignSystemAssets', () => {
expect(assets.fixtureHtml).toBeUndefined();
});
});
// Reviewer feedback (nettee, PR-D #1544): the parity guard at
// `scripts/check-design-system-flag-parity.ts` exercises the prompt
// composer directly and therefore does NOT cover the server-layer env
// gate that PR-D actually flipped — a future regression that restored
// `=== '1'`, used a typo'd env name, or stopped reading assets when
// the var is unset would still let the guard pass green. These tests
// pin the predicate that wraps the gate so the default-on flip itself
// is locked into the test suite.
describe('isDesignTokenChannelEnabled (PR-D env gate)', () => {
it('is true when OD_DESIGN_TOKEN_CHANNEL is unset (PR-D default-on)', () => {
expect(isDesignTokenChannelEnabled({})).toBe(true);
});
it('is true for the legacy explicit opt-in `1`', () => {
expect(isDesignTokenChannelEnabled({ OD_DESIGN_TOKEN_CHANNEL: '1' })).toBe(true);
});
it('is true for any non-`0` truthy-looking value (forward compatibility)', () => {
expect(isDesignTokenChannelEnabled({ OD_DESIGN_TOKEN_CHANNEL: 'true' })).toBe(true);
expect(isDesignTokenChannelEnabled({ OD_DESIGN_TOKEN_CHANNEL: 'on' })).toBe(true);
expect(isDesignTokenChannelEnabled({ OD_DESIGN_TOKEN_CHANNEL: '2' })).toBe(true);
expect(isDesignTokenChannelEnabled({ OD_DESIGN_TOKEN_CHANNEL: 'yes' })).toBe(true);
});
it('is true for an empty string (operator typed `=` and forgot the value — fail open, not closed)', () => {
expect(isDesignTokenChannelEnabled({ OD_DESIGN_TOKEN_CHANNEL: '' })).toBe(true);
});
it('is false ONLY for the literal kill-switch value `0`', () => {
expect(isDesignTokenChannelEnabled({ OD_DESIGN_TOKEN_CHANNEL: '0' })).toBe(false);
});
it('is true for whitespace-padded `0` — strict literal match prevents accidental kill-switch tripping', () => {
expect(isDesignTokenChannelEnabled({ OD_DESIGN_TOKEN_CHANNEL: ' 0' })).toBe(true);
expect(isDesignTokenChannelEnabled({ OD_DESIGN_TOKEN_CHANNEL: '0 ' })).toBe(true);
});
});
// Reviewer feedback (lefarcen, PR-D #1544 round 2): the predicate
// suite above pins the env-flag boolean but does NOT exercise the
// server's asset-resolution path that PR-D actually flipped — i.e.
// the seam where the daemon decides whether to read tokens.css /
// components.html from disk and hand them to composeSystemPrompt.
//
// `resolveDesignSystemAssets` IS that seam: server.ts at the
// prompt-assembly site is now a thin caller of this function, so a
// regression that restored the old `=== '1'` semantics, swapped in a
// wrong env name, or silently dropped the asset-read branch flips
// observable behaviour here against real disk fixtures. These tests
// run that whole pipeline (env gate → readDesignSystemAssets per
// root → fallback chain → DesignSystemAssets shape) end-to-end.
describe('resolveDesignSystemAssets (PR-D server-layer asset resolution)', () => {
it('returns the built-in assets when the channel is enabled (env unset, default-on)', async () => {
const builtInRoot = fresh();
const userRoot = fresh();
const dir = brandDir(builtInRoot, 'sample');
writeFileSync(path.join(dir, 'tokens.css'), ':root { --bg: #fff; }');
writeFileSync(path.join(dir, 'components.html'), '<button>btn</button>');
const assets = await resolveDesignSystemAssets('sample', builtInRoot, userRoot, {});
expect(assets.tokensCss).toBe(':root { --bg: #fff; }');
expect(assets.fixtureHtml).toBe('<button>btn</button>');
});
it('returns empty (kill switch) when OD_DESIGN_TOKEN_CHANNEL is `0`, even if files are on disk', async () => {
const builtInRoot = fresh();
const userRoot = fresh();
const dir = brandDir(builtInRoot, 'sample');
writeFileSync(path.join(dir, 'tokens.css'), ':root { --bg: #fff; }');
writeFileSync(path.join(dir, 'components.html'), '<button>btn</button>');
const assets = await resolveDesignSystemAssets('sample', builtInRoot, userRoot, {
OD_DESIGN_TOKEN_CHANNEL: '0',
});
expect(assets.tokensCss).toBeUndefined();
expect(assets.fixtureHtml).toBeUndefined();
});
it('still returns the assets under the legacy explicit opt-in `OD_DESIGN_TOKEN_CHANNEL=1`', async () => {
const builtInRoot = fresh();
const userRoot = fresh();
const dir = brandDir(builtInRoot, 'sample');
writeFileSync(path.join(dir, 'tokens.css'), ':root { --bg: #fff; }');
writeFileSync(path.join(dir, 'components.html'), '<button>btn</button>');
const assets = await resolveDesignSystemAssets('sample', builtInRoot, userRoot, {
OD_DESIGN_TOKEN_CHANNEL: '1',
});
expect(assets.tokensCss).toContain('--bg: #fff');
expect(assets.fixtureHtml).toContain('<button>');
});
it('falls back to user-installed root for files missing in built-in (per-file independence)', async () => {
const builtInRoot = fresh();
const userRoot = fresh();
const builtInDir = brandDir(builtInRoot, 'split');
writeFileSync(path.join(builtInDir, 'tokens.css'), ':root { --bg: built-in; }');
const userDir = brandDir(userRoot, 'split');
writeFileSync(path.join(userDir, 'components.html'), '<from-user-installed/>');
const assets = await resolveDesignSystemAssets('split', builtInRoot, userRoot, {});
expect(assets.tokensCss).toBe(':root { --bg: built-in; }');
expect(assets.fixtureHtml).toBe('<from-user-installed/>');
});
it('returns the built-in assets verbatim when both files are present built-in (skips the user-installed roundtrip)', async () => {
const builtInRoot = fresh();
const userRoot = fresh();
const dir = brandDir(builtInRoot, 'sample');
writeFileSync(path.join(dir, 'tokens.css'), ':root { --bg: built-in; }');
writeFileSync(path.join(dir, 'components.html'), '<from-built-in/>');
// Plant different content under user-installed root — if the
// fallback chain mistakenly merges, the test below would catch it.
const userDir = brandDir(userRoot, 'sample');
writeFileSync(path.join(userDir, 'tokens.css'), ':root { --bg: user-installed; }');
writeFileSync(path.join(userDir, 'components.html'), '<from-user-installed/>');
const assets = await resolveDesignSystemAssets('sample', builtInRoot, userRoot, {});
expect(assets.tokensCss).toBe(':root { --bg: built-in; }');
expect(assets.fixtureHtml).toBe('<from-built-in/>');
});
it('returns undefined for both fields when the brand ships neither file in either root (legacy ~138-brand fallback)', async () => {
const builtInRoot = fresh();
const userRoot = fresh();
brandDir(builtInRoot, 'prose-only');
const assets = await resolveDesignSystemAssets('prose-only', builtInRoot, userRoot, {});
expect(assets.tokensCss).toBeUndefined();
expect(assets.fixtureHtml).toBeUndefined();
});
it('returns undefined for both fields when the brand directory does not exist in either root', async () => {
const builtInRoot = fresh();
const userRoot = fresh();
const assets = await resolveDesignSystemAssets('nonexistent', builtInRoot, userRoot, {});
expect(assets.tokensCss).toBeUndefined();
expect(assets.fixtureHtml).toBeUndefined();
});
});

View file

@ -0,0 +1,161 @@
/**
* Regression coverage for the lefarcen + codex P2 on PR #1309: when a
* user aliases a registered catalog id to a custom wire-name via
* `OD_MEDIA_MODEL_ALIASES` or media-config.json's `aliases` map, the
* dispatcher must still apply the model-FAMILY behaviour the catalog
* id implies (DALL-E response_format, dall-e-3 hd quality,
* gpt-4o-mini-tts instructions, etc.) and only swap the value that
* goes into the provider's `body.model` field.
*
* The test stubs fetch and asserts on the request body for an
* aliased dall-e-3 -> azure-custom-deployment call. Before the fix
* ctx.model was overwritten with the alias, so the
* `startsWith('dall-e-')` and `=== 'dall-e-3'` branches stopped
* firing and the body was missing both response_format and the hd
* quality flag exactly the regression codex described.
*/
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { generateMedia } from '../src/media.js';
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2uoAAAAASUVORK5CYII=';
describe('media alias preserves catalog-keyed capability branching (#1309 review)', () => {
let root: string;
let projectRoot: string;
let projectsRoot: string;
const realFetch = globalThis.fetch;
const originalEnvAliases = process.env.OD_MEDIA_MODEL_ALIASES;
const originalOpenAIKey = process.env.OPENAI_API_KEY;
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
const originalDataDir = process.env.OD_DATA_DIR;
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'od-media-alias-cap-'));
projectRoot = path.join(root, 'project-root');
projectsRoot = path.join(projectRoot, '.od', 'projects');
await mkdir(projectsRoot, { recursive: true });
delete process.env.OD_MEDIA_MODEL_ALIASES;
delete process.env.OD_MEDIA_CONFIG_DIR;
delete process.env.OD_DATA_DIR;
process.env.OPENAI_API_KEY = 'sk-test-key';
});
afterEach(async () => {
globalThis.fetch = realFetch;
vi.unstubAllGlobals();
if (originalEnvAliases == null) {
delete process.env.OD_MEDIA_MODEL_ALIASES;
} else {
process.env.OD_MEDIA_MODEL_ALIASES = originalEnvAliases;
}
if (originalOpenAIKey == null) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = originalOpenAIKey;
}
if (originalMediaConfigDir == null) {
delete process.env.OD_MEDIA_CONFIG_DIR;
} else {
process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
}
if (originalDataDir == null) {
delete process.env.OD_DATA_DIR;
} else {
process.env.OD_DATA_DIR = originalDataDir;
}
await rm(root, { recursive: true, force: true });
});
async function writeStoredConfig(data: unknown) {
const file = path.join(projectRoot, '.od', 'media-config.json');
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, JSON.stringify(data), 'utf8');
}
it('alias dall-e-3 -> custom-deployment still sends dall-e-3 response_format + hd quality', async () => {
await writeStoredConfig({
providers: {},
aliases: { 'dall-e-3': 'azure-dalle3-deployment' },
});
let capturedBody: Record<string, unknown> | null = null;
const fetchMock = vi.fn(async (_input: unknown, init?: RequestInit) => {
capturedBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
return new Response(
JSON.stringify({ data: [{ b64_json: PNG_BASE64 }] }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
vi.stubGlobal('fetch', fetchMock);
const result = await generateMedia({
projectRoot,
projectsRoot,
projectId: 'project-1',
surface: 'image',
model: 'dall-e-3',
prompt: 'A watercolor shiba inu under cherry blossoms',
aspect: '1:1',
output: 'aliased.png',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(capturedBody).not.toBeNull();
// Wire name swap landed — the provider receives the alias.
expect(capturedBody!.model).toBe('azure-dalle3-deployment');
// Capability branches keyed on the catalog id continue to fire.
expect(capturedBody!.response_format).toBe('b64_json');
expect(capturedBody!.quality).toBe('hd');
// providerNote reflects what was actually sent, so a user
// inspecting the result sees the wire name.
expect(result.providerNote).toContain('azure-dalle3-deployment');
expect(result.providerNote).not.toContain('dall-e-3');
});
it('alias gpt-4o-mini-tts -> custom-deployment still attaches style instructions', async () => {
process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
'gpt-4o-mini-tts': 'custom-tts-deployment',
});
let capturedBody: Record<string, unknown> | null = null;
const fetchMock = vi.fn(async (_input: unknown, init?: RequestInit) => {
capturedBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
// Speech endpoints return raw audio bytes, not JSON.
return new Response(Buffer.from([1, 2, 3, 4]), {
status: 200,
headers: { 'content-type': 'audio/mpeg' },
});
});
vi.stubGlobal('fetch', fetchMock);
const result = await generateMedia({
projectRoot,
projectsRoot,
projectId: 'project-1',
surface: 'audio',
audioKind: 'speech',
model: 'gpt-4o-mini-tts',
prompt: 'Hello there.',
// gpt-4o-mini-tts accepts free-form speaking style in `voice`
// when the value isn't a known OpenAI voice id. The dispatcher
// routes that string into `body.instructions` ONLY when the
// model branch fires.
voice: 'warm and slow',
output: 'aliased.mp3',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(capturedBody).not.toBeNull();
expect(capturedBody!.model).toBe('custom-tts-deployment');
// Capability branch keyed on the catalog id continues to fire
// even though the wire-level model is the alias — the
// gpt-4o-mini-tts-specific instructions field is still attached.
expect(capturedBody!.instructions).toBe('warm and slow');
expect(result.providerNote).toContain('custom-tts-deployment');
});
});

View file

@ -4,7 +4,9 @@ import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
readAliasMap,
readMaskedConfig,
resolveModelAlias,
resolveProviderConfig,
writeConfig,
} from '../src/media-config.js';
@ -469,3 +471,197 @@ describe('media-config OpenAI OAuth fallback', () => {
});
});
});
describe('media-config model alias resolution (issue #1277)', () => {
let projectRoot: string;
const originalEnvAliases = process.env.OD_MEDIA_MODEL_ALIASES;
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
const originalDataDir = process.env.OD_DATA_DIR;
beforeEach(async () => {
projectRoot = await mkdtemp(path.join(tmpdir(), 'od-media-alias-'));
delete process.env.OD_MEDIA_MODEL_ALIASES;
delete process.env.OD_MEDIA_CONFIG_DIR;
delete process.env.OD_DATA_DIR;
});
afterEach(async () => {
if (originalEnvAliases == null) {
delete process.env.OD_MEDIA_MODEL_ALIASES;
} else {
process.env.OD_MEDIA_MODEL_ALIASES = originalEnvAliases;
}
if (originalMediaConfigDir == null) {
delete process.env.OD_MEDIA_CONFIG_DIR;
} else {
process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
}
if (originalDataDir == null) {
delete process.env.OD_DATA_DIR;
} else {
process.env.OD_DATA_DIR = originalDataDir;
}
await rm(projectRoot, { recursive: true, force: true });
});
async function writeStoredMediaConfig(data: unknown) {
const file = path.join(projectRoot, '.od', 'media-config.json');
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, JSON.stringify(data), 'utf8');
}
it('passes through unmapped model ids unchanged', async () => {
expect(await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415')).toBe(
'doubao-seedream-3-0-t2i-250415',
);
});
it('redirects via the stored aliases map in media-config.json', async () => {
// The flagship use case from the issue: registered catalog id
// -> the new model name the user actually has access to.
await writeStoredMediaConfig({
providers: {},
aliases: { 'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0' },
});
expect(
await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
).toBe('doubao-seedream-5-0');
});
it('redirects via the OD_MEDIA_MODEL_ALIASES env var', async () => {
process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0',
});
expect(
await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
).toBe('doubao-seedream-5-0');
});
it('lets the env var override an on-disk alias (env wins for power users)', async () => {
await writeStoredMediaConfig({
providers: {},
aliases: { 'doubao-seedream-3-0-t2i-250415': 'on-disk-alias' },
});
process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
'doubao-seedream-3-0-t2i-250415': 'env-alias',
});
expect(
await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
).toBe('env-alias');
});
it('tolerates malformed env JSON and falls through to the stored map', async () => {
// A user with a half-typed env var (`OD_MEDIA_MODEL_ALIASES='{'`)
// should still get their on-disk aliases, not a hard error mid-
// generation.
process.env.OD_MEDIA_MODEL_ALIASES = '{not valid json';
await writeStoredMediaConfig({
providers: {},
aliases: { 'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0' },
});
expect(
await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
).toBe('doubao-seedream-5-0');
});
it('drops non-string and empty alias entries during coercion', async () => {
// Defends against a future schema bump (number / null / nested
// object) and against accidental empty-string entries from a
// Settings UI form. The coercion must never feed garbage into a
// dispatcher's request body.
process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
'good-key': 'good-value',
'empty-key': '',
'null-key': null,
'object-key': { nested: 'no' },
'': 'blank-key-rejected',
});
expect(await resolveModelAlias(projectRoot, 'good-key')).toBe('good-value');
expect(await resolveModelAlias(projectRoot, 'empty-key')).toBe('empty-key');
expect(await resolveModelAlias(projectRoot, 'null-key')).toBe('null-key');
expect(await resolveModelAlias(projectRoot, 'object-key')).toBe('object-key');
});
it('exposes the merged map via readAliasMap so Settings can show source attribution', async () => {
await writeStoredMediaConfig({
providers: {},
aliases: { 'stored-only': 'a', 'overridden': 'stored-value' },
});
process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
'env-only': 'b',
'overridden': 'env-value',
});
const map = await readAliasMap(projectRoot);
expect(map.stored).toEqual({ 'stored-only': 'a', 'overridden': 'stored-value' });
expect(map.env).toEqual({ 'env-only': 'b', 'overridden': 'env-value' });
expect(map.effective).toEqual({
'stored-only': 'a',
'env-only': 'b',
'overridden': 'env-value',
});
});
it('readMaskedConfig surfaces the alias map for the Settings UI', async () => {
// Lefarcen P3 (#1309 review): the prior PR description claimed
// `readAliasMap` was the daemon-public API for the Settings UI,
// but the HTTP route returned only `readMaskedConfig` (which
// had no aliases field). The fix wires aliases into the GET
// response so a future Settings UI PR can consume them without
// touching the daemon.
await writeStoredMediaConfig({
providers: {},
aliases: { 'dall-e-3': 'azure-dalle3' },
});
process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
'gpt-4o-mini-tts': 'custom-tts',
});
const masked = await readMaskedConfig(projectRoot);
expect(masked.aliases.stored).toEqual({ 'dall-e-3': 'azure-dalle3' });
expect(masked.aliases.env).toEqual({ 'gpt-4o-mini-tts': 'custom-tts' });
expect(masked.aliases.effective).toEqual({
'dall-e-3': 'azure-dalle3',
'gpt-4o-mini-tts': 'custom-tts',
});
});
it('readMaskedConfig returns empty alias maps when no aliases are configured', async () => {
// Settings UI needs a stable shape so it can render "no aliases
// configured" without crashing on `aliases.effective` being
// undefined.
const masked = await readMaskedConfig(projectRoot);
expect(masked.aliases.effective).toEqual({});
expect(masked.aliases.env).toEqual({});
expect(masked.aliases.stored).toEqual({});
});
it('writeConfig preserves aliases when a Settings-style provider PUT lands', async () => {
// The Settings UI in its current shape writes providers only.
// Without alias preservation, every provider edit would wipe the
// user's aliases. This pins the regression so a future refactor
// that touches writeStored has to keep both fields.
await writeStoredMediaConfig({
providers: {},
aliases: { 'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0' },
});
await writeConfig(projectRoot, {
providers: {
openai: { apiKey: 'sk-key', baseUrl: '' },
},
});
const onDisk = JSON.parse(
await readFile(
path.join(projectRoot, '.od', 'media-config.json'),
'utf8',
),
);
expect(onDisk.providers.openai).toMatchObject({ apiKey: 'sk-key' });
expect(onDisk.aliases).toEqual({
'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0',
});
expect(
await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
).toBe('doubao-seedream-5-0');
});
});

View file

@ -154,4 +154,138 @@ describe('PATCH /api/memory/config apiKey three-state handling', () => {
expect(extraction?.provider).toBe('anthropic');
expect(extraction?.apiKey ?? '').toBe('');
});
it('clears the extraction override when the patch sends extraction: null', async () => {
await writeMemoryConfig(dataDir, {
extraction: {
provider: 'openai',
model: 'gpt-4o-mini',
apiKey: 'sk-stored-secret',
baseUrl: 'https://api.openai.com',
},
});
const res = await patchConfig({
extraction: null,
});
expect(res.status).toBe(200);
const extraction = await readStoredExtraction();
expect(extraction).toBeNull();
});
it('preserves the stored azure apiVersion when the patch omits the field', async () => {
await writeMemoryConfig(dataDir, {
extraction: {
provider: 'azure',
model: 'gpt-4.1-mini',
apiKey: 'azure-secret',
baseUrl: 'https://example.openai.azure.com',
apiVersion: '2025-01-01-preview',
},
});
const res = await patchConfig({
extraction: {
provider: 'azure',
model: 'gpt-4.1-mini',
baseUrl: 'https://example.openai.azure.com',
},
});
expect(res.status).toBe(200);
const extraction = await readStoredExtraction();
expect(extraction?.provider).toBe('azure');
expect(extraction?.apiVersion).toBe('2025-01-01-preview');
});
it('clears the stored azure apiVersion when the patch sends an explicit empty string', async () => {
await writeMemoryConfig(dataDir, {
extraction: {
provider: 'azure',
model: 'gpt-4.1-mini',
apiKey: 'azure-secret',
baseUrl: 'https://example.openai.azure.com',
apiVersion: '2025-01-01-preview',
},
});
const res = await patchConfig({
extraction: {
provider: 'azure',
model: 'gpt-4.1-mini',
baseUrl: 'https://example.openai.azure.com',
apiVersion: '',
},
});
expect(res.status).toBe(200);
const extraction = await readStoredExtraction();
expect(extraction?.provider).toBe('azure');
expect(extraction?.apiVersion ?? '').toBe('');
});
it('updates the enabled flag independently of extraction settings', async () => {
await writeMemoryConfig(dataDir, {
enabled: true,
extraction: {
provider: 'openai',
model: 'gpt-4o-mini',
apiKey: 'sk-stored-secret',
baseUrl: 'https://api.openai.com',
},
});
const res = await patchConfig({ enabled: false });
expect(res.status).toBe(200);
const json = await res.json() as {
enabled: boolean;
extraction: { provider: string; apiKeyConfigured: boolean } | null;
};
expect(json.enabled).toBe(false);
expect(json.extraction).toMatchObject({
provider: 'openai',
apiKeyConfigured: true,
});
const extraction = await readStoredExtraction();
expect(extraction?.provider).toBe('openai');
});
it('returns a masked extraction config without leaking the apiKey on GET /api/memory', async () => {
await writeMemoryConfig(dataDir, {
extraction: {
provider: 'azure',
model: 'gpt-4.1-mini',
apiKey: 'azure-secret-1234',
baseUrl: 'https://example.openai.azure.com',
apiVersion: '2025-01-01-preview',
},
});
const res = await fetch(`${baseUrl}/api/memory`);
expect(res.status).toBe(200);
const json = await res.json() as {
extraction: {
provider: string;
model: string;
baseUrl: string;
apiVersion: string;
apiKeyTail: string;
apiKeyConfigured: boolean;
apiKey?: string;
} | null;
};
expect(json.extraction).toMatchObject({
provider: 'azure',
model: 'gpt-4.1-mini',
baseUrl: 'https://example.openai.azure.com',
apiVersion: '2025-01-01-preview',
apiKeyTail: '1234',
apiKeyConfigured: true,
});
expect(json.extraction && 'apiKey' in json.extraction).toBe(false);
});
});

View file

@ -23,6 +23,12 @@ const dataDir = process.env.OD_DATA_DIR as string;
let baseUrl: string;
let server: http.Server;
const originalFetch = globalThis.fetch;
interface SseEvent {
event: string;
data: unknown;
}
async function closeServer(nextServer: http.Server | undefined): Promise<void> {
if (!nextServer) return;
@ -36,15 +42,81 @@ beforeAll(async () => {
})) as StartedServer;
baseUrl = started.url;
server = started.server;
globalThis.fetch = async (
input: Parameters<typeof fetch>[0],
init?: Parameters<typeof fetch>[1],
) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.startsWith(baseUrl)) return originalFetch(input, init);
return new Response(
JSON.stringify({
choices: [{ message: { content: '[]' } }],
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
};
});
afterAll(() => closeServer(server));
afterAll(async () => {
globalThis.fetch = originalFetch;
await closeServer(server);
});
beforeEach(async () => {
await fsp.rm(memoryDir(dataDir), { recursive: true, force: true });
__resetExtractionsForTests();
});
async function readNextSseEvent(
reader: ReadableStreamDefaultReader<Uint8Array>,
decoder: InstanceType<typeof TextDecoder>,
state: { buffer: string },
): Promise<SseEvent> {
while (true) {
const boundaryIndex = state.buffer.indexOf('\n\n');
if (boundaryIndex !== -1) {
const rawEvent = state.buffer.slice(0, boundaryIndex);
state.buffer = state.buffer.slice(boundaryIndex + 2);
const eventLine = rawEvent
.split('\n')
.find((line) => line.startsWith('event: '));
const dataLine = rawEvent
.split('\n')
.find((line) => line.startsWith('data: '));
if (!eventLine || !dataLine) continue;
return {
event: eventLine.slice('event: '.length),
data: JSON.parse(dataLine.slice('data: '.length)),
};
}
const chunk = await reader.read();
if (chunk.done) {
throw new Error('memory SSE stream ended before the next event arrived');
}
state.buffer += decoder.decode(chunk.value, { stream: true });
}
}
async function readSseEventByType(
reader: ReadableStreamDefaultReader<Uint8Array>,
decoder: InstanceType<typeof TextDecoder>,
state: { buffer: string },
eventType: string,
): Promise<SseEvent> {
while (true) {
const event = await readNextSseEvent(reader, decoder, state);
if (event.event === eventType) return event;
}
}
describe('memory routes', () => {
it('lists the default memory state when the store is empty', async () => {
const res = await fetch(`${baseUrl}/api/memory`);
@ -118,6 +190,23 @@ describe('memory routes', () => {
expect(listJson.entries).toEqual([]);
});
it('rejects invalid memory entry payloads during creation', async () => {
const res = await fetch(`${baseUrl}/api/memory`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: '',
description: 'Missing required values',
type: 'unknown',
body: '- Invalid entry',
}),
});
expect(res.status).toBe(400);
const json = await res.json() as { error: string };
expect(json.error).toContain('memory entry requires');
});
it('saves the memory index and returns it from the list payload', async () => {
const nextIndex = '# Memory\n\n- user_ui_preferences.md\n';
const putRes = await fetch(`${baseUrl}/api/memory/index`, {
@ -227,6 +316,30 @@ describe('memory routes', () => {
]);
});
it('reports attemptedLLM for post-turn extraction requests without triggering a real provider call', async () => {
const res = await fetch(`${baseUrl}/api/memory/extract`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
userMessage: 'Remember that I prefer dark mode for demos.',
assistantMessage: 'I will keep future demos darker and quieter.',
chatProvider: {
provider: 'openai',
apiKey: 'sk-test',
model: 'gpt-5-mini',
},
}),
});
expect(res.status).toBe(200);
const json = await res.json() as {
changed: Array<unknown>;
attemptedLLM: boolean;
};
expect(json.attemptedLLM).toBe(true);
expect(json.changed).toEqual([]);
});
it('returns the composed system prompt body from indexed memory entries', async () => {
await fetch(`${baseUrl}/api/memory`, {
method: 'POST',
@ -257,4 +370,82 @@ describe('memory routes', () => {
expect(json.body).toContain('### Project');
expect(json.body).toContain('**Project goal** — Ship a cleaner onboarding flow');
});
it('streams memory change events over SSE when entries are created', async () => {
const response = await fetch(`${baseUrl}/api/memory/events`);
expect(response.status).toBe(200);
expect(response.body).toBeTruthy();
const reader = response.body!.getReader();
const decoder = new TextDecoder();
const state = { buffer: '' };
try {
const connected = await readNextSseEvent(reader, decoder, state);
expect(connected.event).toBe('connected');
const createRes = await fetch(`${baseUrl}/api/memory`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Shipping priority',
description: 'Protect onboarding polish in examples',
type: 'project',
body: '- Keep onboarding examples polished',
}),
});
expect(createRes.status).toBe(200);
const change = await readSseEventByType(reader, decoder, state, 'change');
expect(change.event).toBe('change');
expect(change.data).toMatchObject({
kind: 'upsert',
id: 'project_shipping_priority',
name: 'Shipping priority',
description: 'Protect onboarding polish in examples',
type: 'project',
source: 'manual',
});
} finally {
await reader.cancel();
}
});
it('streams extraction events over SSE when the extraction buffer changes', async () => {
const response = await fetch(`${baseUrl}/api/memory/events`);
expect(response.status).toBe(200);
expect(response.body).toBeTruthy();
const reader = response.body!.getReader();
const decoder = new TextDecoder();
const state = { buffer: '' };
try {
const connected = await readNextSseEvent(reader, decoder, state);
expect(connected.event).toBe('connected');
recordHeuristic({
userMessage: 'Remember that I prefer editorial chart labels.',
writtenCount: 1,
writtenIds: ['feedback_editorial_chart_labels'],
});
const extraction = await readNextSseEvent(reader, decoder, state);
expect(extraction.event).toBe('extraction');
expect(extraction.data).toMatchObject({
kind: 'heuristic',
phase: 'success',
writtenCount: 1,
writtenIds: ['feedback_editorial_chart_labels'],
});
} finally {
await reader.cancel();
}
});
it('returns 404 when reading a missing memory entry', async () => {
const res = await fetch(`${baseUrl}/api/memory/user_missing_note`);
expect(res.status).toBe(404);
const json = await res.json() as { error: string };
expect(json.error).toBe('memory not found');
});
});

View file

@ -9,6 +9,8 @@ import {
closeDatabase,
insertConversation,
insertProject,
getConversation,
listConversations,
listLatestProjectRunStatuses,
listProjectsAwaitingInput,
openDatabase,
@ -105,6 +107,81 @@ test('plain text question does not mark awaiting input', () => {
assert.equal(listProjectsAwaitingInput(db).has('project-d'), false);
});
test('conversation latest run follows assistant message position', () => {
const db = createDb();
const conversationId = seedProject(db, 'project-latest', 'succeeded');
upsertMessage(db, conversationId, {
id: 'project-latest-running',
role: 'assistant',
content: 'working',
runId: 'project-latest-running-id',
runStatus: 'running',
startedAt: 20,
});
assert.equal(listConversations(db, 'project-latest')[0]?.latestRun?.status, 'running');
assert.equal(getConversation(db, conversationId)?.latestRun?.status, 'running');
});
test('conversation listing batches latest run summaries for large projects', () => {
const db = createDb();
insertProject(db, {
id: 'project-large',
name: 'project-large',
createdAt: 1,
updatedAt: 1,
});
for (let i = 0; i < 125; i += 1) {
const conversationId = `project-large-conversation-${i}`;
insertConversation(db, {
id: conversationId,
projectId: 'project-large',
title: `Conversation ${i}`,
createdAt: i,
updatedAt: i,
});
upsertMessage(db, conversationId, {
id: `${conversationId}-older`,
role: 'assistant',
content: 'done',
runId: `${conversationId}-older-run`,
runStatus: 'succeeded',
startedAt: 10,
endedAt: 20,
});
upsertMessage(db, conversationId, {
id: `${conversationId}-latest`,
role: 'assistant',
content: 'failed',
runId: `${conversationId}-latest-run`,
runStatus: 'failed',
startedAt: 100,
endedAt: 175,
});
}
const preparedSql: string[] = [];
const instrumentedDb = new Proxy(db, {
get(target, prop, receiver) {
if (prop === 'prepare') {
return (sql: string) => {
preparedSql.push(sql);
return target.prepare(sql);
};
}
return Reflect.get(target, prop, receiver);
},
}) as Database.Database;
const conversations = listConversations(instrumentedDb, 'project-large');
assert.equal(conversations.length, 125);
assert.equal(preparedSql.length, 1);
assert.equal(conversations[0]?.latestRun?.status, 'failed');
assert.equal(conversations[0]?.latestRun?.durationMs, 75);
});
test('only succeeded statuses are overridden by awaiting input', () => {
const db = createDb();
const failedConversationId = seedProject(db, 'project-failed', 'failed');

View file

@ -106,6 +106,45 @@ describe('GET /api/projects/:id resolvedDir', () => {
expect(path.isAbsolute(detail.resolvedDir)).toBe(true);
});
it('persists skipDiscoveryBrief for batch-created projects', async () => {
const projectId = `proj-skip-discovery-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Batch fixture',
skillId: null,
designSystemId: 'default',
metadata: { kind: 'prototype', platform: 'responsive' },
skipDiscoveryBrief: true,
}),
});
expect(createResp.status).toBe(200);
const body = (await createResp.json()) as {
project: { designSystemId?: string | null; metadata?: { skipDiscoveryBrief?: boolean } };
};
expect(body.project.designSystemId).toBe('default');
expect(body.project.metadata?.skipDiscoveryBrief).toBe(true);
});
it('rejects non-boolean skipDiscoveryBrief on POST /api/projects', async () => {
const projectId = `proj-skip-discovery-bad-${Date.now()}`;
const resp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Bad batch fixture',
skipDiscoveryBrief: 'yes',
}),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { code?: string; message?: string } };
expect(body.error?.code).toBe('BAD_REQUEST');
expect(body.error?.message).toMatch(/skipDiscoveryBrief/i);
});
it('returns 404 with PROJECT_NOT_FOUND for unknown ids', async () => {
const resp = await fetch(`${baseUrl}/api/projects/does-not-exist-${Date.now()}`);
expect(resp.status).toBe(404);

View file

@ -254,9 +254,10 @@ describe('composeSystemPrompt', () => {
// (DESIGN.md) into a machine-readable contract (tokens.css) plus a worked
// fixture (components.html) lives in PR-C. The composer exposes two new
// optional inputs (`designSystemTokensCss`, `designSystemFixtureHtml`)
// that the daemon populates only when `OD_DESIGN_TOKEN_CHANNEL=1` is set
// AND the active brand actually ships those files. These tests pin the
// injection shape so the prompt structure cannot drift silently.
// that the daemon populates by default for every brand that ships
// those files (PR-D flipped the env gate to default-on, with
// `OD_DESIGN_TOKEN_CHANNEL=0` as the kill switch). These tests pin
// the injection shape so the prompt structure cannot drift silently.
describe('design-system token + fixture injection (#PR-C)', () => {
const sampleTokensCss = ':root {\n --bg: #ffffff;\n --fg: #111111;\n --accent: #0050d8;\n}';
const sampleFixtureHtml = '<!doctype html>\n<html lang="en">\n <body><button class="btn btn-primary">Subscribe</button></body>\n</html>';

View file

@ -179,6 +179,39 @@ describe('routine routes', () => {
}
});
it('rejects patching to a missing reuse-mode target project', async () => {
const { app } = buildApp();
const { server, port } = await listen(app);
try {
const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Daily digest',
prompt: 'Summarize activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
}),
});
const created = await createRes.json() as { routine: { id: string } };
const patchRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
target: { mode: 'reuse', projectId: 'missing-project' },
}),
});
expect(patchRes.status).toBe(400);
const json = await patchRes.json() as { error: string };
expect(json.error).toContain('target project missing-project not found');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('runs a routine now and exposes its run history', async () => {
const { app, runNow } = buildApp();
const { server, port } = await listen(app);
@ -222,6 +255,100 @@ describe('routine routes', () => {
}
});
it('maps the latest persisted run into the routine contract', async () => {
const { app, db } = buildApp();
const { server, port } = await listen(app);
try {
const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Daily digest',
prompt: 'Summarize activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
}),
});
const created = await createRes.json() as { routine: { id: string } };
insertRoutineRun(db, {
id: 'run-failed-1',
routineId: created.routine.id,
trigger: 'manual',
status: 'failed',
projectId: 'proj-failed',
conversationId: 'conv-failed',
agentRunId: 'agent-run-failed',
startedAt: Date.now() - 1000,
completedAt: Date.now(),
summary: 'Connector auth failed',
error: 'provider rejected credentials',
});
const getRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}`);
expect(getRes.status).toBe(200);
const json = await getRes.json() as {
routine: {
lastRun: {
runId: string;
status: string;
trigger: string;
projectId: string;
conversationId: string;
agentRunId: string;
summary: string;
completedAt: number;
} | null;
};
};
expect(json.routine.lastRun).toMatchObject({
runId: 'run-failed-1',
status: 'failed',
trigger: 'manual',
projectId: 'proj-failed',
conversationId: 'conv-failed',
agentRunId: 'agent-run-failed',
summary: 'Connector auth failed',
});
expect(json.routine.lastRun?.completedAt).toBeTypeOf('number');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('returns 500 when running a routine now throws', async () => {
const { app, runNow } = buildApp();
runNow.mockImplementationOnce(async () => {
throw new Error('agent unavailable');
});
const { server, port } = await listen(app);
try {
const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Daily digest',
prompt: 'Summarize activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
}),
});
const created = await createRes.json() as { routine: { id: string } };
const runRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}/run`, {
method: 'POST',
});
expect(runRes.status).toBe(500);
const json = await runRes.json() as { error: string };
expect(json.error).toContain('agent unavailable');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('rejects reuse-mode creation when the target project does not exist', async () => {
const { app } = buildApp();
const { server, port } = await listen(app);
@ -251,6 +378,30 @@ describe('routine routes', () => {
}
});
it('rejects unsupported target modes during creation', async () => {
const { app } = buildApp();
const { server, port } = await listen(app);
try {
const res = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Weird target digest',
prompt: 'Summarize activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'teleport' },
enabled: true,
}),
});
expect(res.status).toBe(400);
const json = await res.json() as { error: string };
expect(json.error).toContain('Unsupported routine target mode');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('deletes a routine and unschedules it', async () => {
const { app, unschedule } = buildApp();
const { server, port } = await listen(app);
@ -317,4 +468,90 @@ describe('routine routes', () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('rejects invalid timezone values during creation', async () => {
const { app } = buildApp();
const { server, port } = await listen(app);
try {
const res = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Bad timezone digest',
prompt: 'Summarize activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'Mars/Olympus' },
target: { mode: 'create_each_run' },
enabled: true,
}),
});
expect(res.status).toBe(400);
const json = await res.json() as { error: string };
expect(json.error).toContain('Invalid timezone');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('rejects invalid weekly weekday values during creation', async () => {
const { app } = buildApp();
const { server, port } = await listen(app);
try {
const res = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Bad weekday digest',
prompt: 'Summarize activity.',
schedule: {
kind: 'weekly',
weekday: 8,
time: '09:00',
timezone: 'UTC',
},
target: { mode: 'create_each_run' },
enabled: true,
}),
});
expect(res.status).toBe(400);
const json = await res.json() as { error: string };
expect(json.error).toContain('weekly.weekday');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('rejects invalid schedule input during routine patch updates', async () => {
const { app } = buildApp();
const { server, port } = await listen(app);
try {
const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Daily digest',
prompt: 'Summarize activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
}),
});
const created = await createRes.json() as { routine: { id: string } };
const patchRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
schedule: { kind: 'daily', time: '25:99', timezone: 'UTC' },
}),
});
expect(patchRes.status).toBe(400);
const json = await patchRes.json() as { error: string };
expect(json.error).toContain('Invalid time');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});

View file

@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { nextRunAtForSchedule } from '../src/routines.js';
import {
nextRunAtForSchedule,
validateSchedule,
validateTarget,
} from '../src/routines.js';
function partsIn(timezone: string, at: Date): Record<string, string> {
const dtf = new Intl.DateTimeFormat('en-US', {
@ -114,4 +118,80 @@ describe('nextRunAtForSchedule DST handling', () => {
expect(parts.hour).toBe('02');
expect(parts.minute).toBe('30');
});
it('returns the next hourly slot strictly after now', () => {
const now = new Date('2026-05-13T10:45:30Z');
const next = nextRunAtForSchedule({ kind: 'hourly', minute: 15 }, now);
expect(next).not.toBeNull();
if (!next) return;
expect(next.toISOString()).toBe('2026-05-13T11:15:00.000Z');
});
it('returns the next weekday occurrence for weekday schedules', () => {
const now = new Date('2026-05-16T00:00:00Z'); // Saturday
const next = nextRunAtForSchedule(
{ kind: 'weekdays', time: '09:00', timezone: 'UTC' },
now,
);
expect(next).not.toBeNull();
if (!next) return;
const parts = partsIn('UTC', next);
expect(parts.year).toBe('2026');
expect(parts.month).toBe('05');
expect(parts.day).toBe('18');
expect(parts.hour).toBe('09');
expect(parts.minute).toBe('00');
});
it('returns the next requested weekday for weekly schedules', () => {
const now = new Date('2026-05-13T10:00:00Z'); // Wednesday
const next = nextRunAtForSchedule(
{ kind: 'weekly', weekday: 5, time: '08:30', timezone: 'UTC' },
now,
);
expect(next).not.toBeNull();
if (!next) return;
const parts = partsIn('UTC', next);
expect(parts.year).toBe('2026');
expect(parts.month).toBe('05');
expect(parts.day).toBe('15');
expect(parts.hour).toBe('08');
expect(parts.minute).toBe('30');
});
});
describe('routine validation', () => {
it('accepts valid schedule and target shapes', () => {
expect(() =>
validateSchedule({ kind: 'weekly', weekday: 1, time: '09:00', timezone: 'UTC' }),
).not.toThrow();
expect(() => validateTarget({ mode: 'create_each_run' })).not.toThrow();
expect(() => validateTarget({ mode: 'reuse', projectId: 'proj-1' })).not.toThrow();
});
it('rejects invalid wall times and timezones', () => {
expect(() =>
validateSchedule({ kind: 'daily', time: '25:00', timezone: 'UTC' }),
).toThrow(/Invalid time/);
expect(() =>
validateSchedule({ kind: 'daily', time: '09:00', timezone: 'Mars\/Olympus' }),
).toThrow(/Invalid timezone/);
});
it('rejects invalid weekday and unsupported target mode', () => {
expect(() =>
validateSchedule({ kind: 'weekly', weekday: 9 as 0, time: '09:00', timezone: 'UTC' }),
).toThrow(/weekly\.weekday/);
expect(() =>
validateTarget({ mode: 'teleport' } as unknown as Parameters<typeof validateTarget>[0]),
).toThrow(/Unsupported routine target mode/);
});
it('rejects reuse targets without a project id', () => {
expect(() =>
validateTarget({ mode: 'reuse', projectId: '' }),
).toThrow(/projectId/);
});
});

View file

@ -1,3 +1,4 @@
import { symlinkSync } from 'node:fs';
import { test } from 'vitest';
import { homedir } from 'node:os';
import {
@ -5,6 +6,8 @@ import {
} from './helpers/test-helpers.js';
import { isCursorAuthFailureText } from '../../src/runtimes/auth.js';
const fsTest = process.platform === 'win32' ? test.skip : test;
// Issue #398: Claude Code prefers ANTHROPIC_API_KEY over `claude login`
// credentials, silently billing API usage. Strip it for the claude
// adapter so the user's subscription wins.
@ -197,6 +200,64 @@ test('detectAgents includes sanitized install and docs metadata from split runti
}
});
fsTest('detectAgents marks Codex available when nvm exposes a node shim but launch resolution upgrades it to the native binary', async () => {
const home = mkdtempSync(join(tmpdir(), 'od-detect-codex-nvm-native-'));
try {
return await withEnvSnapshot(['HOME', 'PATH', 'OD_AGENT_HOME'], async () => {
const wrapperBinDir = join(home, '.nvm', 'versions', 'node', '24.14.1', 'bin');
const wrapperPkgDir = join(home, '.nvm', 'versions', 'node', '24.14.1', 'lib', 'node_modules', '@openai', 'codex');
const wrapperRealPath = join(wrapperPkgDir, 'bin', 'codex.js');
const wrapperLinkPath = join(wrapperBinDir, 'codex');
const nativePkgDir = join(
wrapperPkgDir,
'node_modules',
'@openai',
`codex-${process.platform}-${process.arch}`,
);
const nativeTargetTriple = codexNativeTargetTriple();
const nativePathDir = join(nativePkgDir, 'vendor', nativeTargetTriple, 'path');
const nativeBin = join(nativePkgDir, 'vendor', nativeTargetTriple, 'codex', 'codex');
mkdirSync(join(wrapperPkgDir, 'bin'), { recursive: true });
mkdirSync(wrapperBinDir, { recursive: true });
mkdirSync(join(nativePkgDir, 'vendor', nativeTargetTriple, 'codex'), { recursive: true });
mkdirSync(nativePathDir, { recursive: true });
writeFileSync(
wrapperRealPath,
'#!/usr/bin/env node\nconsole.log("wrapper should not be probed");\n',
);
writeFileSync(nativeBin, '#!/bin/sh\necho "codex 9.9.9"\n');
chmodSync(wrapperRealPath, 0o755);
chmodSync(nativeBin, 0o755);
symlinkSync(wrapperRealPath, wrapperLinkPath);
process.env.HOME = home;
process.env.PATH = '/usr/bin:/bin';
process.env.OD_AGENT_HOME = home;
const agents = await detectAgents();
const codexAgent = agents.find((agent) => agent.id === 'codex');
assert.ok(codexAgent);
assert.equal(codexAgent.available, true);
assert.equal(codexAgent.path, wrapperLinkPath);
assert.equal(codexAgent.version, 'codex 9.9.9');
});
} finally {
rmSync(home, { recursive: true, force: true });
}
});
function codexNativeTargetTriple(): string {
if (process.platform === 'darwin' && process.arch === 'arm64') return 'aarch64-apple-darwin';
if (process.platform === 'darwin' && process.arch === 'x64') return 'x86_64-apple-darwin';
if (process.platform === 'linux' && process.arch === 'arm64') return 'aarch64-unknown-linux-musl';
if (process.platform === 'linux' && process.arch === 'x64') return 'x86_64-unknown-linux-musl';
if (process.platform === 'win32' && process.arch === 'arm64') return 'aarch64-pc-windows-msvc';
if (process.platform === 'win32' && process.arch === 'x64') return 'x86_64-pc-windows-msvc';
return `${process.platform}-${process.arch}`;
}
test('resolveAgentExecutable ignores relative CODEX_BIN overrides', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-codex-bin-rel-'));
const oldCwd = process.cwd();

View file

@ -22,39 +22,51 @@
* so adapters whose `--version` flag is unsupported are not
* regressed.
*
* Detection always probes the same path `resolveAgentExecutable`
* picks for chat/run resolution, so a stale configured override that
* shadows a working PATH binary is reported as unavailable rather
* than swapped for the PATH candidate; advertising a different path
* would break the invariant that Settings and the chat spawn path
* agree on what the agent runs (PR #1301 review, Siri-Ray).
* Detection always probes the same launch path chat/run resolution
* picks, so a stale configured override that shadows a working PATH
* binary is reported as unavailable rather than swapped for the PATH
* candidate; advertising a different path would break the invariant
* that Settings and the chat spawn path agree on what the agent runs
* (PR #1301 review, Siri-Ray).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const execAgentFileMock = vi.fn();
const resolveAgentExecutableMock = vi.fn();
const resolveAgentLaunchMock = vi.fn();
vi.mock('../../src/runtimes/invocation.js', () => ({
execAgentFile: (...args: unknown[]) =>
(execAgentFileMock as unknown as (...args: unknown[]) => unknown)(...args),
}));
vi.mock('../../src/runtimes/executables.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/runtimes/executables.js')>();
vi.mock('../../src/runtimes/launch.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/runtimes/launch.js')>();
return {
...actual,
resolveAgentExecutable: (
...args: Parameters<typeof actual.resolveAgentExecutable>
resolveAgentLaunch: (
...args: Parameters<typeof actual.resolveAgentLaunch>
) =>
(
resolveAgentExecutableMock as unknown as (
...a: Parameters<typeof actual.resolveAgentExecutable>
) => ReturnType<typeof actual.resolveAgentExecutable>
resolveAgentLaunchMock as unknown as (
...a: Parameters<typeof actual.resolveAgentLaunch>
) => ReturnType<typeof actual.resolveAgentLaunch>
)(...args),
};
});
function fakeCodexLaunch() {
return {
configuredOverridePath: null,
pathResolvedPath: '/fake/bin/codex',
selectedPath: '/fake/bin/codex',
launchPath: '/fake/bin/codex',
launchKind: 'selected' as const,
childPathPrepend: ['/fake/bin'],
diagnostic: null,
};
}
function spawnError(code: 'ENOENT' | 'EACCES' | 'ENOTDIR' | 'ETIMEDOUT'): NodeJS.ErrnoException {
const error = new Error(`spawn failed (${code})`) as NodeJS.ErrnoException;
error.code = code;
@ -74,10 +86,10 @@ function exitCodeError(code: number): NodeJS.ErrnoException {
describe('probe (issue #658) — ghost CLI after the binary is uninstalled', () => {
beforeEach(() => {
execAgentFileMock.mockReset();
resolveAgentExecutableMock.mockReset();
resolveAgentLaunchMock.mockReset();
// Default: pretend every agent definition resolves to a fake bin so
// we exercise the spawn path uniformly.
resolveAgentExecutableMock.mockImplementation(() => '/fake/bin/codex');
resolveAgentLaunchMock.mockImplementation(fakeCodexLaunch);
});
for (const failingCode of ['ENOENT', 'EACCES', 'ENOTDIR'] as const) {
@ -156,25 +168,29 @@ describe('probe (issue #658) — ghost CLI after the binary is uninstalled', ()
// to fall back to a PATH candidate when the configured override
// failed to spawn, but that broke the invariant that detection and
// chat-run resolution agree on the executable. resolveAgentBin
// still resolves via resolveAgentExecutable (configured override
// still resolves via resolveAgentLaunch (configured override
// wins when present and executable), so if detection adopted a
// different PATH binary, Settings would show "available at
// /usr/local/bin/codex" while every actual run would spawn the
// stale /stale/custom/codex and fail. The fix is to keep detection
// honest: probe whichever path resolveAgentExecutable picks, and
// honest: probe whichever path resolveAgentLaunch picks, and
// report exactly that path's availability. The Settings repair
// flow (PR #1205) needs to derive its adopt-or-clear affordance
// from the resolution diagnostic — not from `available`.
const {
resolveAgentExecutable: realResolveAgentExecutable,
resolveAgentLaunch: realResolveAgentLaunch,
} = await vi.importActual<typeof import('../../src/runtimes/launch.js')>(
'../../src/runtimes/launch.js',
);
const {
inspectAgentExecutableResolution,
} = await vi.importActual<typeof import('../../src/runtimes/executables.js')>(
'../../src/runtimes/executables.js',
);
// Drive the resolver through its real path so a future refactor
// that diverges resolution from detection trips this assertion.
resolveAgentExecutableMock.mockImplementation(
(def, env) => realResolveAgentExecutable(def, env),
resolveAgentLaunchMock.mockImplementation(
(def, env) => realResolveAgentLaunch(def, env),
);
// Force a stale configured override + a working PATH candidate.
execAgentFileMock.mockImplementation((cmd: string) => {
@ -194,9 +210,9 @@ describe('probe (issue #658) — ghost CLI after the binary is uninstalled', ()
expect(codex).toBeDefined();
// Detection must report unavailable rather than swap to a hypothetical
// PATH candidate, because resolveAgentExecutable (which chat-run
// PATH candidate, because resolveAgentLaunch (which chat-run
// resolution uses) will pick whatever the same call returns.
const resolvedForRun = realResolveAgentExecutable(
const resolvedForRun = realResolveAgentLaunch(
// re-run AGENT_DEFS's codex entry through the real resolver to
// get the executable resolveAgentBin would pick at chat time.
// The detection side already validated this path.
@ -204,11 +220,11 @@ describe('probe (issue #658) — ghost CLI after the binary is uninstalled', ()
{ id: 'codex', bin: 'codex' } as any,
configuredEnv.codex,
);
if (resolvedForRun) {
if (resolvedForRun.selectedPath && resolvedForRun.launchPath) {
// If the resolver found a working PATH binary, detection must
// have reported available=true with the SAME path.
expect(codex?.available).toBe(true);
expect(codex?.path).toBe(resolvedForRun);
expect(codex?.path).toBe(resolvedForRun.selectedPath);
} else {
// Otherwise detection must report unavailable rather than invent
// a different path.

View file

@ -0,0 +1,370 @@
import { describe, test } from 'vitest';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import {
createRuntimeAdapter,
RUNTIME_STREAM_FORMATS,
} from '../../src/runtimes/runtime-adapter.js';
import { AGENT_DEFS, assert, minimalAgentDef } from './helpers/test-helpers.js';
type MockChildProcess = EventEmitter & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
killed: boolean;
kill: (signal?: NodeJS.Signals | number) => boolean;
};
type SentEvent = {
channel: string;
payload: unknown;
};
function createMockChild(): MockChildProcess {
const child = new EventEmitter() as MockChildProcess;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.killed = false;
child.kill = (signal?: NodeJS.Signals | number) => {
child.killed = true;
child.emit('close', null, signal);
return true;
};
return child;
}
function agentEvents(events: SentEvent[]) {
return events
.filter((event) => event.channel === 'agent')
.map((event) => event.payload);
}
function containsSubset(value: unknown, subset: unknown): boolean {
if (subset === null || typeof subset !== 'object') {
return Object.is(value, subset);
}
if (value === null || typeof value !== 'object') {
return false;
}
const valueRecord = value as Record<string, unknown>;
const subsetRecord = subset as Record<string, unknown>;
return Object.entries(subsetRecord).every(([key, expected]) => {
return containsSubset(valueRecord[key], expected);
});
}
describe('runtime adapter foundation', () => {
test('covers every stream format used by current runtime definitions', () => {
const definedFormats = new Set(AGENT_DEFS.map((def) => def.streamFormat));
assert.deepEqual(
[...definedFormats].sort(),
[...RUNTIME_STREAM_FORMATS].sort(),
);
for (const def of AGENT_DEFS) {
const adapter = createRuntimeAdapter(def);
assert.equal(adapter.id, def.id);
assert.equal(adapter.displayName, def.name);
assert.equal(adapter.streamFormat, def.streamFormat);
assert.equal(adapter.eventParser, def.eventParser || def.id);
}
});
test('exposes stdin behavior without leaking protocol checks to callers', () => {
for (const def of AGENT_DEFS) {
const adapter = createRuntimeAdapter(def);
assert.equal(
adapter.stdinMode(),
def.promptViaStdin || def.streamFormat === 'acp-json-rpc'
? 'pipe'
: 'ignore',
);
assert.equal(
adapter.shouldWritePromptToStdin(),
Boolean(def.promptViaStdin && def.streamFormat !== 'pi-rpc'),
);
}
});
test('keeps critique theater eligibility as an adapter capability', () => {
for (const def of AGENT_DEFS) {
assert.equal(
createRuntimeAdapter(def).supportsCritiqueTheater(),
def.streamFormat === 'plain',
);
}
});
test('exposes ACP MCP support as an adapter capability', () => {
for (const def of AGENT_DEFS) {
assert.equal(
createRuntimeAdapter(def).acceptsExternalMcpServers(),
def.streamFormat === 'acp-json-rpc',
);
}
});
test('classifies close status through attachment state', () => {
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough };
child.stdout = new PassThrough();
const plain = createRuntimeAdapter(minimalAgentDef({
bin: 'plain-agent',
streamFormat: 'plain',
})).attach({
child: child as never,
prompt: 'hello',
send: () => {},
});
assert.equal(plain.classifyClose({ code: 0, signal: null }), 'succeeded');
assert.equal(plain.classifyClose({ code: 1, signal: null }), 'failed');
assert.equal(
plain.classifyClose({ code: null, signal: 'SIGTERM', canceled: true }),
'canceled',
);
});
test('keeps structured empty-output failures in adapter close classification', () => {
const child = new EventEmitter() as EventEmitter & { stdout: PassThrough };
child.stdout = new PassThrough();
const structured = createRuntimeAdapter(minimalAgentDef({
bin: 'opencode',
id: 'opencode',
streamFormat: 'json-event-stream',
})).attach({
child: child as never,
prompt: 'hello',
send: () => {},
});
assert.equal(structured.trackingSubstantiveOutput, true);
assert.equal(structured.producedSubstantiveOutput(), false);
assert.equal(structured.classifyClose({ code: 0, signal: null }), 'failed');
});
test('attach routes structured stdout through the adapter-selected parser', () => {
const cases = [
{
name: 'claude-stream-json',
def: minimalAgentDef({
bin: 'claude',
id: 'claude',
streamFormat: 'claude-stream-json',
}),
input: `${JSON.stringify({
type: 'assistant',
message: {
id: 'msg-1',
content: [{
type: 'tool_use',
id: 'toolu-1',
name: 'TodoWrite',
input: { todos: [{ content: 'Run QA', status: 'pending' }] },
}],
},
})}\n`,
expected: [{
type: 'tool_use',
id: 'toolu-1',
name: 'TodoWrite',
input: { todos: [{ content: 'Run QA', status: 'pending' }] },
}],
trackingSubstantiveOutput: false,
producedSubstantiveOutput: false,
},
{
name: 'qoder-stream-json',
def: minimalAgentDef({
bin: 'qoder',
id: 'qoder',
streamFormat: 'qoder-stream-json',
}),
input: `${JSON.stringify({
type: 'system',
subtype: 'init',
qodercli_version: '0.2.6',
model: 'auto',
session_id: 'session-1',
})}\n${JSON.stringify({
type: 'assistant',
message: { content: [{ type: 'text', text: 'Buffered output' }] },
})}\n`,
expected: [
{
type: 'status',
label: 'initializing',
model: 'auto',
sessionId: 'session-1',
qodercliVersion: '0.2.6',
},
{ type: 'text_delta', delta: 'Buffered output' },
],
trackingSubstantiveOutput: true,
producedSubstantiveOutput: true,
},
{
name: 'copilot-stream-json',
def: minimalAgentDef({
bin: 'copilot',
id: 'copilot',
streamFormat: 'copilot-stream-json',
}),
input: `${JSON.stringify({
type: 'tool.execution_start',
data: {
toolCallId: 'call-1',
toolName: 'TodoWrite',
arguments: { todos: [{ content: 'Run QA', status: 'pending' }] },
},
})}\n`,
expected: [{
type: 'tool_use',
id: 'call-1',
name: 'TodoWrite',
input: { todos: [{ content: 'Run QA', status: 'pending' }] },
}],
trackingSubstantiveOutput: false,
producedSubstantiveOutput: false,
},
{
name: 'json-event-stream',
def: minimalAgentDef({
bin: 'opencode',
id: 'opencode',
streamFormat: 'json-event-stream',
}),
input:
'{"type":"step_start","sessionID":"ses-1","part":{"type":"step-start"}}\n' +
'{"type":"text","sessionID":"ses-1","part":{"type":"text","text":"hello"}}\n',
expected: [
{ type: 'status', label: 'running' },
{ type: 'text_delta', delta: 'hello' },
],
trackingSubstantiveOutput: true,
producedSubstantiveOutput: true,
},
];
for (const testCase of cases) {
const child = createMockChild();
const events: SentEvent[] = [];
const attachment = createRuntimeAdapter(testCase.def).attach({
child: child as never,
prompt: 'hello',
send: (channel, payload) => events.push({ channel, payload }),
});
child.stdout.write(testCase.input);
child.emit('close', 0, null);
assert.deepEqual(agentEvents(events), testCase.expected, testCase.name);
assert.equal(attachment.trackingSubstantiveOutput, testCase.trackingSubstantiveOutput);
assert.equal(attachment.producedSubstantiveOutput(), testCase.producedSubstantiveOutput);
}
});
test('attach returns a Pi session handle and forwards Pi RPC agent events', () => {
const child = createMockChild();
const events: SentEvent[] = [];
const attachment = createRuntimeAdapter(minimalAgentDef({
bin: 'pi',
id: 'pi',
streamFormat: 'pi-rpc',
})).attach({
child: child as never,
prompt: 'hello',
model: 'openai/gpt-5',
send: (channel, payload) => events.push({ channel, payload }),
});
assert.ok(attachment.session);
assert.equal(typeof attachment.session?.abort, 'function');
child.stdout.write([
{ type: 'agent_start' },
{ type: 'turn_start' },
{
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Hello from Pi' },
},
].map((line) => JSON.stringify(line)).join('\n') + '\n');
const emitted = agentEvents(events);
assert.ok(emitted.some((event) => containsSubset(event, {
type: 'status',
label: 'initializing',
model: 'openai/gpt-5',
})));
assert.ok(emitted.some((event) => containsSubset(event, {
type: 'text_delta',
delta: 'Hello from Pi',
})));
assert.equal(attachment.producedSubstantiveOutput(), true);
});
test('attach returns an ACP session handle and forwards ACP session events', () => {
const child = createMockChild();
const events: SentEvent[] = [];
const attachment = createRuntimeAdapter(minimalAgentDef({
bin: 'vibe',
id: 'vibe',
streamFormat: 'acp-json-rpc',
})).attach({
child: child as never,
prompt: 'hello',
cwd: '/tmp/od-project',
model: 'legacy-model',
mcpServers: [],
send: (channel, payload) => events.push({ channel, payload }),
});
assert.ok(attachment.session);
assert.equal(typeof attachment.session?.abort, 'function');
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`);
child.stdout.write(`${JSON.stringify({
id: 2,
result: {
sessionId: 'session-1',
models: { currentModelId: 'default' },
},
})}\n`);
child.stdout.write(`${JSON.stringify({
id: 3,
result: { models: { currentModelId: 'legacy-model' } },
})}\n`);
child.stdout.write(`${JSON.stringify({
id: 4,
result: { usage: { inputTokens: 1, outputTokens: 2 } },
})}\n`);
child.emit('close', 0, null);
const emitted = agentEvents(events);
assert.ok(emitted.some((event) => containsSubset(event, {
type: 'status',
label: 'model',
model: 'legacy-model',
})));
assert.ok(emitted.some((event) => containsSubset(event, {
type: 'usage',
usage: { input_tokens: 1, output_tokens: 2 },
})));
assert.equal(attachment.session?.completedSuccessfully?.(), true);
});
test('fails fast for unknown stream formats', () => {
const def = minimalAgentDef({
bin: 'ghost-agent',
id: 'ghost-agent',
streamFormat: 'ghost-stream',
});
assert.throws(
() => createRuntimeAdapter(def),
/Unsupported streamFormat "ghost-stream" for runtime "ghost-agent"/,
);
});
});

View file

@ -32,6 +32,34 @@ const baseSummary = {
};
describe('composeSystemPrompt — metadata.promptTemplate', () => {
it('pins the API batch-mode discovery skip before the normal discovery rules', () => {
const out = composeSystemPrompt({
metadata: {
kind: 'prototype',
skipDiscoveryBrief: true,
},
});
const overrideIdx = out.indexOf('Automated project mode — skip discovery form');
const discoveryIdx = out.indexOf('# OD core directives');
expect(overrideIdx).toBeGreaterThanOrEqual(0);
expect(discoveryIdx).toBeGreaterThanOrEqual(0);
expect(overrideIdx).toBeLessThan(discoveryIdx);
expect(out).toMatch(/do NOT emit `<question-form id="discovery">`/);
});
it('does not instruct agents to ask for a second visual-direction picker', () => {
const out = composeSystemPrompt({
metadata: { kind: 'prototype' },
designSystemBody: '# Brand\n\nUse brand tokens.',
designSystemTitle: 'Brand',
});
expect(out).not.toContain('<question-form id="direction"');
expect(out).not.toContain('Pick a visual direction');
expect(out).toContain('if a design system is active, use it as the visual direction without asking again');
});
it('inlines the prompt body, attribution, and reference-template label for image projects', () => {
const out = composeSystemPrompt({
metadata: {

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="url(#a)" d="M17.533 1.829A2.53 2.53 0 0 0 15.11 0h-.737a2.53 2.53 0 0 0-2.484 2.087l-1.263 6.937.314-1.08a2.53 2.53 0 0 1 2.424-1.833h4.284l1.797.706 1.731-.706h-.505a2.53 2.53 0 0 1-2.423-1.829z" transform="translate(0 1)"/><path fill="url(#b)" d="M6.726 20.16A2.53 2.53 0 0 0 9.152 22h1.566c1.37 0 2.49-1.1 2.525-2.48l.17-6.69-.357 1.228a2.53 2.53 0 0 1-2.423 1.83h-4.32l-1.54-.842-1.667.843h.497a2.53 2.53 0 0 1 2.426 1.84z" transform="translate(0 1)"/><path fill="url(#c)" d="M15 0H6.252c-2.5 0-4 3.331-5 6.662-1.184 3.947-2.734 9.225 1.75 9.225H6.78c1.13 0 2.12-.753 2.43-1.847.657-2.317 1.809-6.359 2.713-9.436.46-1.563.842-2.906 1.43-3.742A1.97 1.97 0 0 1 15 0" transform="translate(0 1)"/><path fill="url(#d)" d="M15 0H6.252c-2.5 0-4 3.331-5 6.662-1.184 3.947-2.734 9.225 1.75 9.225H6.78c1.13 0 2.12-.753 2.43-1.847.657-2.317 1.809-6.359 2.713-9.436.46-1.563.842-2.906 1.43-3.742A1.97 1.97 0 0 1 15 0" transform="translate(0 1)"/><path fill="url(#e)" d="M9 22h8.749c2.5 0 4-3.332 5-6.663 1.184-3.948 2.734-9.227-1.75-9.227H17.22a2.53 2.53 0 0 0-2.43 1.848 1149 1149 0 0 1-2.713 9.437c-.46 1.564-.842 2.907-1.43 3.743A1.97 1.97 0 0 1 9 22" transform="translate(0 1)"/><path fill="url(#f)" d="M9 22h8.749c2.5 0 4-3.332 5-6.663 1.184-3.948 2.734-9.227-1.75-9.227H17.22a2.53 2.53 0 0 0-2.43 1.848 1149 1149 0 0 1-2.713 9.437c-.46 1.564-.842 2.907-1.43 3.743A1.97 1.97 0 0 1 9 22" transform="translate(0 1)"/><defs><radialGradient id="a" cx="85.44%" cy="100.653%" r="105.116%" fx="85.44%" fy="100.653%" gradientTransform="matrix(-.5391 -.77634 .664 -.63031 .647 2.304)"><stop offset="9.6%" stop-color="#00aeff"/><stop offset="77.3%" stop-color="#2253ce"/><stop offset="100%" stop-color="#0736c4"/></radialGradient><radialGradient id="b" cx="18.143%" cy="32.928%" r="95.612%" fx="18.143%" fy="32.928%" gradientTransform="matrix(.5469 .78875 -.70175 .61471 .313 -.017)"><stop offset="0%" stop-color="#ffb657"/><stop offset="63.4%" stop-color="#ff5f3d"/><stop offset="92.3%" stop-color="#c02b3c"/></radialGradient><radialGradient id="e" cx="82.987%" cy="-9.792%" r="140.622%" fx="82.987%" fy="-9.792%" gradientTransform="matrix(-.32768 .89198 -.94479 -.30936 1.01 -.87)"><stop offset="6.6%" stop-color="#8c48ff"/><stop offset="50%" stop-color="#f2598a"/><stop offset="89.6%" stop-color="#ffb152"/></radialGradient><linearGradient id="c" x1="39.465%" x2="46.884%" y1="12.117%" y2="103.774%"><stop offset="15.6%" stop-color="#0d91e1"/><stop offset="48.7%" stop-color="#52b471"/><stop offset="65.2%" stop-color="#98bd42"/><stop offset="93.7%" stop-color="#ffc800"/></linearGradient><linearGradient id="d" x1="45.949%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#3dcbff"/><stop offset="24.7%" stop-color="#0588f7" stop-opacity="0"/></linearGradient><linearGradient id="f" x1="83.507%" x2="83.453%" y1="-6.106%" y2="21.131%"><stop offset="5.8%" stop-color="#f8adfa"/><stop offset="70.8%" stop-color="#a86edd" stop-opacity="0"/></linearGradient></defs></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#8534F3" d="M23.922 16.992c-.861 1.495-5.859 5.023-11.922 5.023-6.063 0-11.061-3.528-11.922-5.023A.641.641 0 0 1 0 16.736v-2.869a.841.841 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.195 10.195 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952 1.399-1.136 3.392-2.093 6.122-2.093 2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.832.832 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256ZM12.172 11h-.344a4.323 4.323 0 0 1-.355.508C10.703 12.455 9.555 13 7.965 13c-1.725 0-2.989-.359-3.782-1.259a2.005 2.005 0 0 1-.085-.104L4 11.741v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.323 4.323 0 0 1-.355-.508h-.016.016Zm.641-2.935c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z"/><path fill="#8534F3" d="M14.5 14.25a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1Zm-5 0a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1Z"/></svg>

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1130,6 +1130,7 @@ export function App() {
key={activeProject.id}
project={activeProject}
routeFileName={route.kind === 'project' ? route.fileName : null}
routeConversationId={route.kind === 'project' ? route.conversationId : null}
config={config}
agents={agents}
skills={enabledFunctionalSkills}

View file

@ -30,6 +30,7 @@ import {
getAnonymousId,
getSessionId,
} from './identity';
import { randomUUID } from '../utils/uuid';
interface AnalyticsContextValue {
// The track helper accepts any event/props pair; per-event safety is
@ -202,7 +203,7 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) {
const track = useCallback<AnalyticsContextValue['track']>(
(event, properties, options) => {
const insertId = options?.insertId ?? crypto.randomUUID();
const insertId = options?.insertId ?? randomUUID();
const requestId = options?.requestId ?? null;
// Attach request_id to the in-flight fetch wrapper too, so the daemon
// can stitch click→result pairs without the caller threading it.
@ -283,7 +284,7 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) {
},
anonymousId: identity.anonymousId,
sessionId: identity.sessionId,
newRequestId: () => crypto.randomUUID(),
newRequestId: () => randomUUID(),
}),
[track, identity, locale, appVersion],
);
@ -303,7 +304,7 @@ export function useAnalytics(): AnalyticsContextValue {
setIdentity: () => undefined,
anonymousId: 'unmounted',
sessionId: 'unmounted',
newRequestId: () => crypto.randomUUID(),
newRequestId: () => randomUUID(),
};
}
return value;

View file

@ -358,7 +358,7 @@ function AssistantFooter({
forceVisible = false,
}: AssistantFooterProps) {
const t = useT();
const elapsed = useLiveElapsed(streaming, startedAt, endedAt);
const elapsed = useLiveElapsed(streaming, startedAt, endedAt, usage?.durationMs);
if (
!forceVisible &&
!streaming &&
@ -1231,7 +1231,8 @@ function splitSystemReminders(input: string): ProseSegment[] {
function useLiveElapsed(
streaming: boolean,
startedAt: number | undefined,
endedAt: number | undefined
endedAt: number | undefined,
fixedDurationMs: number | undefined,
): string {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
@ -1239,9 +1240,16 @@ function useLiveElapsed(
const id = window.setInterval(() => setNow(Date.now()), 200);
return () => window.clearInterval(id);
}, [streaming]);
if (!startedAt) return "";
const end = streaming ? now : endedAt ?? now;
const ms = Math.max(0, end - startedAt);
if (!streaming && endedAt === undefined && typeof fixedDurationMs === "number") {
return formatElapsedMs(fixedDurationMs);
}
if (!startedAt || (!streaming && endedAt === undefined)) return "";
const end = streaming ? now : endedAt;
const ms = Math.max(0, (end ?? now) - startedAt);
return formatElapsedMs(ms);
}
function formatElapsedMs(ms: number): string {
const s = ms / 1000;
if (s < 60) return `${s.toFixed(s < 10 ? 1 : 0)}s`;
const m = Math.floor(s / 60);

View file

@ -692,9 +692,11 @@ export function ChatPane({
) : null}
{messages.map((m, i) => {
const showDaySeparator = shouldShowDaySeparator(messages[i - 1], m);
const messageStreaming =
m.role === 'assistant' &&
((streaming && m.id === lastAssistantId) || isActiveRunStatus(m.runStatus));
const messageStreaming = isAssistantMessageStreaming(
m,
streaming,
lastAssistantId,
);
return (
<Fragment key={m.id}>
{showDaySeparator ? <DaySeparator ts={messageTime(m)} /> : null}
@ -907,6 +909,24 @@ function isActiveRunStatus(status: ChatMessage['runStatus']): boolean {
return status === 'queued' || status === 'running';
}
function isTerminalRunStatus(status: ChatMessage['runStatus']): boolean {
return status === 'succeeded' || status === 'failed' || status === 'canceled';
}
export function isAssistantMessageStreaming(
message: ChatMessage,
paneStreaming: boolean,
lastAssistantId: string | null | undefined,
): boolean {
if (message.role !== 'assistant') return false;
if (isActiveRunStatus(message.runStatus)) return true;
if (message.id !== lastAssistantId) return false;
if (!paneStreaming) return false;
if (message.endedAt !== undefined) return false;
if (isTerminalRunStatus(message.runStatus)) return false;
return true;
}
function ConversationRow({
conversation,
active,
@ -967,7 +987,7 @@ function ConversationRow({
{displayTitle}
</button>
)}
<span className="chat-conv-item-meta">{relTime(conversation.updatedAt, t)}</span>
<span className="chat-conv-item-meta">{conversationMetaLabel(conversation, t)}</span>
<button
type="button"
className="chat-conv-item-del"
@ -1129,3 +1149,29 @@ function relTime(ts: number, t: TranslateFn): string {
if (diff < 7 * day) return t('common.daysShort', { n: Math.floor(diff / day) });
return new Date(ts).toLocaleDateString();
}
export function conversationMetaLabel(
conversation: Conversation,
t: TranslateFn,
): string {
const latestRun = conversation.latestRun;
if (
latestRun &&
(latestRun.status === 'succeeded' ||
latestRun.status === 'failed' ||
latestRun.status === 'canceled') &&
typeof latestRun.durationMs === 'number' &&
Number.isFinite(latestRun.durationMs)
) {
return formatDurationShort(latestRun.durationMs);
}
return relTime(conversation.updatedAt, t);
}
function formatDurationShort(ms: number): string {
const s = Math.max(0, ms) / 1000;
if (s < 60) return `${s.toFixed(s < 10 ? 1 : 0)}s`;
const m = Math.floor(s / 60);
const rem = Math.floor(s - m * 60);
return `${m}m ${rem.toString().padStart(2, '0')}s`;
}

View file

@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useT } from '../i18n';
import { conversationMetaLabel } from './ChatPane';
import type { Conversation } from '../types';
interface Props {
@ -189,7 +190,7 @@ function ConversationsDropdown({
<span className="conv-item-name">
{c.title || t('conv.untitled')}
</span>
<span className="conv-item-meta">{relTime(c.updatedAt, t)}</span>
<span className="conv-item-meta">{conversationMetaLabel(c, t)}</span>
</button>
)}
<button
@ -217,15 +218,3 @@ function ConversationsDropdown({
</div>
);
}
function relTime(ts: number, t: ReturnType<typeof useT>): string {
const diff = Date.now() - ts;
const min = 60_000;
const hr = 60 * min;
const day = 24 * hr;
if (diff < min) return t('common.now');
if (diff < hr) return t('common.minutesShort', { n: Math.floor(diff / min) });
if (diff < day) return t('common.hoursShort', { n: Math.floor(diff / hr) });
if (diff < 7 * day) return t('common.daysShort', { n: Math.floor(diff / day) });
return new Date(ts).toLocaleDateString();
}

View file

@ -46,6 +46,7 @@ import {
import type { ProjectFilePreview } from '../providers/registry';
import {
exportAsHtml,
exportAsImage,
exportAsJsx,
exportAsMd,
exportAsPdf,
@ -54,6 +55,7 @@ import {
exportReactComponentAsHtml,
exportReactComponentAsZip,
openSandboxedPreviewInNewTab,
requestPreviewSnapshot,
} from '../runtime/exports';
import { buildReactComponentSrcdoc } from '../runtime/react-component';
import { buildSrcdoc } from '../runtime/srcdoc';
@ -1991,9 +1993,11 @@ function commentAvatarInitial(comment: PreviewComment): string {
return seed.charAt(0).toUpperCase();
}
function CommentSidePanel({
export function CommentSidePanel({
comments,
selectedIds,
collapsed,
onCollapsedChange,
onToggleSelect,
onClearSelection,
onReply,
@ -2003,6 +2007,8 @@ function CommentSidePanel({
}: {
comments: PreviewComment[];
selectedIds: Set<string>;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onToggleSelect: (commentId: string) => void;
onClearSelection: () => void;
onReply: (comment: PreviewComment) => void;
@ -2013,8 +2019,41 @@ function CommentSidePanel({
const sorted = [...comments].sort((a, b) => b.createdAt - a.createdAt);
const visibleSelectedIds = new Set(comments.filter((comment) => selectedIds.has(comment.id)).map((comment) => comment.id));
const selectedCount = visibleSelectedIds.size;
const commentsLabel = t('chat.tabComments');
if (collapsed) {
return (
<button
type="button"
className="comment-side-rail"
data-testid="comment-side-collapsed-rail"
aria-label={t('preview.showSidebar', { label: commentsLabel })}
title={t('preview.showSidebar', { label: commentsLabel })}
onClick={() => onCollapsedChange(false)}
>
<Icon name="comment" size={14} />
<span>{commentsLabel}</span>
{comments.length > 0 ? <strong>{comments.length}</strong> : null}
</button>
);
}
return (
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={t('chat.tabComments')}>
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
<div className="comment-side-header">
<div className="comment-side-title">
<Icon name="comment" size={14} />
<span>{commentsLabel}</span>
</div>
<button
type="button"
className="comment-side-collapse"
aria-label={t('preview.hideSidebar', { label: commentsLabel })}
title={t('preview.hideSidebar', { label: commentsLabel })}
onClick={() => onCollapsedChange(true)}
>
<Icon name="chevron-right" size={14} />
</button>
</div>
<div className="comment-side-list">
{sorted.length === 0 ? (
<div className="comment-side-empty">
@ -3670,6 +3709,7 @@ function HtmlViewer({
const [sendingBoardBatch, setSendingBoardBatch] = useState(false);
const [commentSavedToast, setCommentSavedToast] = useState<string | null>(null);
const [selectedSideCommentIds, setSelectedSideCommentIds] = useState<Set<string>>(() => new Set());
const [commentSidePanelCollapsed, setCommentSidePanelCollapsed] = useState(false);
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
const previewStateKey = `${projectId}:${file.name}`;
const previewScale = zoom / 100;
@ -4967,6 +5007,12 @@ function HtmlViewer({
setZoom((z) => Math.max(25, Math.min(200, z + delta)));
}
function activateBoard(nextTool?: BoardTool) {
setMode('preview');
setBoardMode(true);
if (nextTool) setBoardTool(nextTool);
}
function clearBoardComposer() {
setActiveCommentTarget(null);
setHoveredCommentTarget(null);
@ -4975,12 +5021,6 @@ function HtmlViewer({
setStrokePoints([]);
}
function activateBoard(tool: BoardTool) {
setBoardTool(tool);
setDrawOverlayOpen(false);
setBoardMode(true);
}
function queueCurrentDraft() {
const note = commentDraft.trim();
if (!note) return;
@ -5268,6 +5308,78 @@ function HtmlViewer({
/>
</>
) : null}
<button
type="button"
className={`viewer-action viewer-comment-toggle${boardMode ? ' active' : ''}`}
data-testid="board-mode-toggle"
title={t('fileViewer.comment')}
aria-pressed={boardMode}
onClick={() => {
if (boardMode) {
setBoardMode(false);
clearBoardComposer();
return;
}
setManualEditMode(false);
setInspectMode(false);
setDrawOverlayOpen(false);
activateBoard(boardTool);
}}
>
<Icon name="comment" size={13} />
<span>{t('fileViewer.comment')}</span>
</button>
{boardMode ? (
<>
<button
className={`viewer-action${boardTool === 'inspect' ? ' active' : ''}`}
type="button"
data-testid="comment-mode-toggle"
title="Pick one element"
aria-label="Picker"
aria-pressed={boardTool === 'inspect'}
onClick={() => activateBoard('inspect')}
>
<Icon name="edit" size={13} />
<span>Picker</span>
</button>
<button
className={`viewer-action${boardTool === 'pod' ? ' active' : ''}`}
type="button"
title="Draw a pod selection"
aria-label="Pods"
aria-pressed={boardTool === 'pod'}
onClick={() => activateBoard('pod')}
>
<Icon name="draw" size={13} />
<span>Pods</span>
</button>
</>
) : null}
<button
className={`viewer-action${inspectMode ? ' active' : ''}`}
type="button"
data-testid="inspect-mode-toggle"
title="Inspect"
aria-pressed={inspectMode}
onClick={() => {
setInspectMode((v) => {
const next = !v;
if (next) {
setBoardMode(false);
clearBoardComposer();
setManualEditMode(false);
setDrawOverlayOpen(false);
setOpenHintBox(true);
setMode('preview');
}
return next;
});
}}
>
<Icon name="tweaks" size={13} />
<span>Inspect</span>
</button>
<button
className={`viewer-action${manualEditMode ? ' active' : ''}`}
type="button"
@ -5485,6 +5597,33 @@ function HtmlViewer({
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
<span>{t('fileViewer.exportMd')}</span>
</button>
{!useUrlLoadPreview ? (
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={async () => {
setShareMenuOpen(false);
const iframe = iframeRef.current;
if (!iframe) return;
const snap = await requestPreviewSnapshot(iframe);
try {
if (snap) {
exportAsImage(snap.dataUrl, exportTitle);
} else {
console.warn('[exportAsImage] snapshot capture returned null');
alert(t('fileViewer.exportImageFailed'));
}
} catch (err) {
console.warn('[exportAsImage] failed to convert snapshot:', err);
alert(t('fileViewer.exportImageFailed'));
}
}}
>
<span className="share-menu-icon"><Icon name="image" size={14} /></span>
<span>{t('fileViewer.exportImage')}</span>
</button>
) : null}
<div className="share-menu-divider" />
<button
type="button"
@ -5692,6 +5831,8 @@ function HtmlViewer({
<CommentSidePanel
comments={visibleSideComments}
selectedIds={selectedSideCommentIds}
collapsed={commentSidePanelCollapsed}
onCollapsedChange={setCommentSidePanelCollapsed}
onToggleSelect={(commentId) => {
setSelectedSideCommentIds((current) => {
const next = new Set(current);

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties, type Poin
import { Icon } from './Icon';
import type { PreviewVisualMarkKind } from '../types';
import { requestPreviewSnapshot } from '../runtime/exports';
export type PreviewDrawMode = 'click' | 'draw';
@ -205,25 +206,9 @@ export function PreviewDrawOverlay({
}
async function requestSnapshot(): Promise<{ dataUrl: string; w: number; h: number } | null> {
const iframe = wrapRef.current?.querySelector('iframe');
const win = iframe?.contentWindow;
if (!iframe || !win) return null;
const id = `snap-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return new Promise((resolve) => {
let done = false;
function onMsg(ev: MessageEvent) {
const d = ev.data as { type?: string; id?: string; dataUrl?: string; w?: number; h?: number; error?: string } | null;
if (!d || d.type !== 'od:snapshot:result' || d.id !== id) return;
if (done) return;
done = true;
window.removeEventListener('message', onMsg);
if (d.dataUrl && d.w && d.h) resolve({ dataUrl: d.dataUrl, w: d.w, h: d.h });
else resolve(null);
}
window.addEventListener('message', onMsg);
try { win.postMessage({ type: 'od:snapshot', id }, '*'); } catch { /* sandboxed */ }
setTimeout(() => { if (!done) { done = true; window.removeEventListener('message', onMsg); resolve(null); } }, 2500);
});
const iframe = wrapRef.current?.querySelector('iframe') as HTMLIFrameElement | null;
if (!iframe) return null;
return requestPreviewSnapshot(iframe);
}
function drawCaptureTarget(

View file

@ -112,6 +112,7 @@ import { useDesignMdState } from '../hooks/useDesignMdState';
import { useFinalizeProject } from '../hooks/useFinalizeProject';
import { useProjectDetail } from '../hooks/useProjectDetail';
import { useTerminalLaunch } from '../hooks/useTerminalLaunch';
import { buildContinueInCliToast } from '../lib/build-continue-in-cli-toast';
import { buildClipboardPrompt } from '../lib/build-clipboard-prompt';
import { copyToClipboard } from '../lib/copy-to-clipboard';
import { effectiveMaxTokens } from '../state/maxTokens';
@ -119,6 +120,18 @@ import { effectiveMaxTokens } from '../state/maxTokens';
interface Props {
project: Project;
routeFileName: string | null;
/**
* Routed conversation id. When set (the URL is
* `/projects/:id/conversations/:cid[/...]`), the project view picks
* this conversation as active instead of defaulting to `list[0]`.
* Falls through to the default picker if the conversation does not
* exist (e.g. the run was deleted between the route landing and the
* conversation list loading). Issue #1505. Optional so existing
* test harnesses that mount ProjectView with a stub props bag do
* not have to be updated; production callers in `App.tsx` always
* pass the value from `useRoute()`.
*/
routeConversationId?: string | null;
config: AppConfig;
agents: AgentInfo[];
// Mentionable functional skills — already filtered by config.disabledSkills
@ -262,6 +275,7 @@ function projectEventToAgentEvent(evt: ProjectEvent): LiveArtifactEventItem['eve
export function ProjectView({
project,
routeFileName,
routeConversationId = null,
config,
agents,
skills,
@ -304,6 +318,7 @@ export function ProjectView({
const [previewComments, setPreviewComments] = useState<PreviewComment[]>([]);
const [attachedComments, setAttachedComments] = useState<PreviewComment[]>([]);
const [streaming, setStreaming] = useState(false);
const [streamingConversationId, setStreamingConversationId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [audioVoiceOptionsError, setAudioVoiceOptionsError] = useState<string | null>(null);
const [artifact, setArtifact] = useState<Artifact | null>(null);
@ -371,6 +386,7 @@ export function ProjectView({
const [openRequest, setOpenRequest] = useState<{ name: string; nonce: number } | null>(null);
const abortRef = useRef<AbortController | null>(null);
const cancelRef = useRef<AbortController | null>(null);
const streamingConversationIdRef = useRef<string | null>(null);
const sendTextBufferRef = useRef<BufferedTextUpdates | null>(null);
const reattachTextBuffersRef = useRef<Set<BufferedTextUpdates>>(new Set());
const reattachControllersRef = useRef<Map<string, AbortController>>(new Map());
@ -422,7 +438,7 @@ export function ProjectView({
&& messagesConversationId !== activeConversationId
&& failedMessagesConversationId !== activeConversationId,
);
const currentConversationStreaming = streaming;
const currentConversationStreaming = streaming && streamingConversationId === activeConversationId;
const currentConversationBusy = currentConversationLoading
|| currentConversationStreaming
|| currentConversationHasActiveRun;
@ -449,6 +465,8 @@ export function ProjectView({
setPreviewComments([]);
setAttachedComments([]);
setStreaming(false);
streamingConversationIdRef.current = null;
setStreamingConversationId(null);
setError(null);
setAudioVoiceOptionsError(null);
setArtifact(null);
@ -469,7 +487,15 @@ export function ProjectView({
}
} else {
setConversations(list);
setActiveConversationId(list[0]!.id);
// Issue #1505: when the URL deep-links to a specific
// conversation, prefer that one. Falls through to list[0]
// when the routed id is null or no longer present (the
// routine row may have been deleted between the route
// landing and the conversation list loading).
const routedMatch = routeConversationId
? list.find((c) => c.id === routeConversationId) ?? null
: null;
setActiveConversationId(routedMatch ? routedMatch.id : list[0]!.id);
}
} catch (err) {
if (cancelled) return;
@ -485,6 +511,22 @@ export function ProjectView({
};
}, [project.id]);
// Issue #1505: when the URL changes the routed conversation id while
// we are already inside the project (e.g. the user clicks "Open
// project" on a different routine history row in the same project),
// switch the active conversation without re-fetching the list.
// Guards: only acts when the routed id is non-null AND present in
// the already-loaded list, and only when it differs from the current
// active id. Falls through to a no-op for stale / missing routes so
// the default picker above keeps its result.
useEffect(() => {
if (!routeConversationId) return;
if (conversations.length === 0) return;
if (routeConversationId === activeConversationId) return;
const match = conversations.find((c) => c.id === routeConversationId);
if (match) setActiveConversationId(match.id);
}, [routeConversationId, conversations, activeConversationId]);
useEffect(() => {
setWorkspaceFocused(false);
}, [project.id]);
@ -502,6 +544,8 @@ export function ProjectView({
setFailedMessagesConversationId(null);
messagesConversationIdRef.current = null;
setStreaming(false);
streamingConversationIdRef.current = null;
setStreamingConversationId(null);
return;
}
// Reset the initialized flag so auto-send waits for the new
@ -515,6 +559,8 @@ export function ProjectView({
setMessagesConversationId(null);
setFailedMessagesConversationId(null);
setStreaming(false);
streamingConversationIdRef.current = null;
setStreamingConversationId(null);
savedArtifactRef.current = null;
pendingWritesRef.current.clear();
if (messagesConversationIdRef.current !== activeConversationId) {
@ -809,7 +855,14 @@ export function ProjectView({
// Sync the URL when the active tab changes, so reload + share-link both
// land back on the same view. Replace (not push) on tab activation so the
// history stack doesn't fill with every tab click.
const lastSyncedFileRef = useRef<string | null>(null);
// Composite sync key: tracks BOTH the active file target AND the active
// conversation id, so a conversation-only change (e.g. `listConversations`
// resolves after `loadTabs` hydrated the active tab, or the user picks a
// different conversation under the same tab) still triggers the navigate
// and pushes `/conversations/:cid` into the URL. Keying only on the file
// target lost that update because the early-return saw `target` unchanged
// and skipped the navigate (lefarcen P1 on PR #1508).
const lastSyncedRouteKeyRef = useRef<string | null>(null);
useEffect(() => {
const target = openTabsState.active && (
openTabsState.tabs.includes(openTabsState.active)
@ -818,13 +871,27 @@ export function ProjectView({
)
? openTabsState.active
: null;
if (target === lastSyncedFileRef.current) return;
lastSyncedFileRef.current = target;
const nextKey = `${activeConversationId ?? ''}:${target ?? ''}`;
if (nextKey === lastSyncedRouteKeyRef.current) return;
lastSyncedRouteKeyRef.current = nextKey;
// PerishCode + Codex P1 on PR #1508: the prior version of this
// sync stripped any `/conversations/:cid` segment from the URL as
// soon as a tab became active, which regressed the deep-link
// behavior the parent commit was meant to add (reload / share
// would fall back to `list[0]` instead of the routed run's
// conversation). Thread the active conversation id so the URL
// always reflects the conversation the project view is actually
// showing, matching how `fileName` already tracks the active tab.
navigate(
{ kind: 'project', projectId: project.id, fileName: target },
{
kind: 'project',
projectId: project.id,
conversationId: activeConversationId,
fileName: target,
},
{ replace: true },
);
}, [openTabsState.active, projectFileNames, project.id]);
}, [openTabsState.active, projectFileNames, project.id, activeConversationId]);
const handleEnsureProject = useCallback(async (): Promise<string | null> => {
return project.id;
@ -1007,6 +1074,35 @@ export function ProjectView({
[project.id, activeConversationId],
);
const markStreamingConversation = useCallback((conversationId: string) => {
streamingConversationIdRef.current = conversationId;
setStreaming(true);
setStreamingConversationId(conversationId);
}, []);
const clearStreamingMarker = useCallback((conversationId?: string | null) => {
const next = clearStreamingConversationMarker(
streamingConversationIdRef.current,
conversationId,
);
if (next === streamingConversationIdRef.current) return;
streamingConversationIdRef.current = next;
setStreamingConversationId(next);
setStreaming(next !== null);
}, []);
const clearActiveRunRefs = useCallback((
conversationId: string,
controller: AbortController,
cancelController: AbortController,
) => {
if (!shouldClearActiveRunRefs(streamingConversationIdRef.current, conversationId)) {
return;
}
if (abortRef.current === controller) abortRef.current = null;
if (cancelRef.current === cancelController) cancelRef.current = null;
}, []);
const handleAssistantFeedback = useCallback(
(assistantMessage: ChatMessage, change: ChatMessageFeedbackChange) => {
const now = Date.now();
@ -1124,12 +1220,13 @@ export function ProjectView({
useEffect(() => {
if (!daemonLive || !activeConversationId || streaming) return;
let cancelled = false;
const reattachConversationId = activeConversationId;
const attachRecoverableRuns = async () => {
const activeRuns = messages.some(
(m) => m.role === 'assistant' && isActiveRunStatus(m.runStatus) && !m.runId,
)
? await listActiveChatRuns(project.id, activeConversationId)
? await listActiveChatRuns(project.id, reattachConversationId)
: [];
if (cancelled) return;
const activeByMessage = new Map(
@ -1198,7 +1295,7 @@ export function ProjectView({
if (!isTerminalRunStatus(status.status)) {
abortRef.current = controller;
cancelRef.current = cancelController;
setStreaming(true);
markStreamingConversation(reattachConversationId);
}
let persistTimer: ReturnType<typeof setTimeout> | null = null;
@ -1250,9 +1347,8 @@ export function ProjectView({
completedReattachRunsRef.current.add(runId);
reattachControllersRef.current.delete(runId);
reattachCancelControllersRef.current.delete(runId);
if (abortRef.current === controller) abortRef.current = null;
if (cancelRef.current === cancelController) cancelRef.current = null;
setStreaming(false);
clearActiveRunRefs(reattachConversationId, controller, cancelController);
clearStreamingMarker(reattachConversationId);
persistNow({ telemetryFinalized: true });
void refreshProjectFiles();
onProjectsRefresh();
@ -1271,9 +1367,8 @@ export function ProjectView({
completedReattachRunsRef.current.add(runId);
reattachControllersRef.current.delete(runId);
reattachCancelControllersRef.current.delete(runId);
if (abortRef.current === controller) abortRef.current = null;
if (cancelRef.current === cancelController) cancelRef.current = null;
setStreaming(false);
clearActiveRunRefs(reattachConversationId, controller, cancelController);
clearStreamingMarker(reattachConversationId);
persistNow({ telemetryFinalized: true });
},
},
@ -1294,9 +1389,8 @@ export function ProjectView({
completedReattachRunsRef.current.add(runId);
reattachControllersRef.current.delete(runId);
reattachCancelControllersRef.current.delete(runId);
if (abortRef.current === controller) abortRef.current = null;
if (cancelRef.current === cancelController) cancelRef.current = null;
setStreaming(false);
clearActiveRunRefs(reattachConversationId, controller, cancelController);
clearStreamingMarker(reattachConversationId);
persistNow({ telemetryFinalized: true });
}
},
@ -1326,8 +1420,7 @@ export function ProjectView({
if (persistTimer) clearTimeout(persistTimer);
reattachControllersRef.current.delete(runId);
reattachCancelControllersRef.current.delete(runId);
if (abortRef.current === controller) abortRef.current = null;
if (cancelRef.current === cancelController) cancelRef.current = null;
clearActiveRunRefs(reattachConversationId, controller, cancelController);
});
}
};
@ -1344,6 +1437,9 @@ export function ProjectView({
project.id,
updateMessageById,
persistMessageById,
markStreamingConversation,
clearStreamingMarker,
clearActiveRunRefs,
refreshProjectFiles,
onProjectsRefresh,
]);
@ -1359,6 +1455,7 @@ export function ProjectView({
if (messagesConversationIdRef.current !== activeConversationId) return;
if (currentConversationBusy) return;
if (!prompt.trim() && attachments.length === 0 && commentAttachments.length === 0) return;
const runConversationId = activeConversationId;
setError(null);
const startedAt = Date.now();
const userMsg: ChatMessage = {
@ -1401,10 +1498,36 @@ export function ProjectView({
runStatus: config.mode === 'daemon' ? 'running' : undefined,
startedAt,
};
const updateConversationLatestRun = (
status: NonNullable<ChatMessage['runStatus']>,
endedAt?: number,
) => {
setConversations((curr) =>
curr.map((conversation) =>
conversation.id === runConversationId
? {
...conversation,
updatedAt: endedAt ?? startedAt,
latestRun: {
status,
startedAt,
...(endedAt === undefined
? {}
: {
endedAt,
durationMs: Math.max(0, endedAt - startedAt),
}),
},
}
: conversation,
),
);
};
activeCompletionNotificationRunsRef.current.add(assistantId);
const nextHistory = [...messages, userMsg];
setMessages([...nextHistory, assistantMsg]);
setStreaming(true);
markStreamingConversation(runConversationId);
updateConversationLatestRun(config.mode === 'daemon' ? 'running' : 'queued');
setArtifact(null);
savedArtifactRef.current = null;
onTouchProject();
@ -1428,10 +1551,10 @@ export function ProjectView({
if (title) {
setConversations((curr) =>
curr.map((c) =>
c.id === activeConversationId ? { ...c, title } : c,
c.id === runConversationId ? { ...c, title } : c,
),
);
void patchConversation(project.id, activeConversationId, { title });
void patchConversation(project.id, runConversationId, { title });
}
}
@ -1575,12 +1698,13 @@ export function ProjectView({
!streamedText.trim() &&
!liveHtml.trim();
if (emptyApiResponse) {
const endedAt = Date.now();
const diagnostic = t('assistant.emptyResponseMessage');
updateMessageById(
assistantId,
(prev) => ({
...prev,
endedAt: Date.now(),
endedAt,
runStatus: 'failed',
events: [
...(prev.events ?? []),
@ -1594,24 +1718,29 @@ export function ProjectView({
if (commentAttachments.length > 0) {
void patchAttachedStatuses(commentAttachments, 'failed');
}
setStreaming(false);
abortRef.current = null;
cancelRef.current = null;
clearActiveRunRefs(runConversationId, controller, cancelController);
clearStreamingMarker(runConversationId);
updateConversationLatestRun('failed', endedAt);
void refreshProjectFiles();
onProjectsRefresh();
return;
}
updateAssistant((prev) => ({
...prev,
endedAt: Date.now(),
runStatus: resolveSucceededRunStatus(prev.runStatus),
}));
const endedAt = Date.now();
let finalRunStatus: ChatMessage['runStatus'] = 'succeeded';
updateAssistant((prev) => {
finalRunStatus = resolveSucceededRunStatus(prev.runStatus);
return {
...prev,
endedAt,
runStatus: finalRunStatus,
};
});
if (commentAttachments.length > 0) {
void patchAttachedStatuses(commentAttachments, 'needs_review');
}
setStreaming(false);
abortRef.current = null;
cancelRef.current = null;
clearActiveRunRefs(runConversationId, controller, cancelController);
clearStreamingMarker(runConversationId);
updateConversationLatestRun(finalRunStatus ?? 'succeeded', endedAt);
// Persist the finished artifact to the project folder so it shows
// up as a real tab (not just the synthetic "live" stream).
setArtifact((prev) => {
@ -1639,6 +1768,7 @@ export function ProjectView({
onProjectsRefresh();
},
onError: (err: Error) => {
const endedAt = Date.now();
textBuffer.flush();
textBuffer.cancel();
cancelSendTextBuffer();
@ -1646,7 +1776,7 @@ export function ProjectView({
appendAssistantErrorEvent(assistantId, err.message);
updateAssistant((prev) => ({
...prev,
endedAt: Date.now(),
endedAt,
runStatus: config.mode === 'api' || prev.runId || isActiveRunStatus(prev.runStatus)
? 'failed'
: prev.runStatus,
@ -1654,9 +1784,9 @@ export function ProjectView({
if (commentAttachments.length > 0) {
void patchAttachedStatuses(commentAttachments, 'failed');
}
setStreaming(false);
abortRef.current = null;
cancelRef.current = null;
clearActiveRunRefs(runConversationId, controller, cancelController);
clearStreamingMarker(runConversationId);
updateConversationLatestRun('failed', endedAt);
setMessages((curr) => {
const finalized = curr.find((m) => m.id === assistantId);
if (finalized) persistMessage(finalized, { telemetryFinalized: true });
@ -1679,7 +1809,7 @@ export function ProjectView({
cancelSignal: cancelController.signal,
handlers,
projectId: project.id,
conversationId: activeConversationId,
conversationId: runConversationId,
assistantMessageId: assistantId,
clientRequestId: randomUUID(),
skillId: project.skillId ?? null,
@ -1694,16 +1824,22 @@ export function ProjectView({
updateMessageById(assistantId, (prev) => ({ ...prev, runId, runStatus: 'queued' }), true);
},
onRunStatus: (runStatus) => {
const endedAt = isTerminalRunStatus(runStatus) ? Date.now() : undefined;
updateMessageById(
assistantId,
(prev) => ({
...prev,
runStatus,
endedAt: isTerminalRunStatus(runStatus) ? prev.endedAt ?? Date.now() : prev.endedAt,
endedAt: endedAt === undefined ? prev.endedAt : prev.endedAt ?? endedAt,
}),
true,
runStatus === 'canceled' ? { telemetryFinalized: true } : undefined,
);
updateConversationLatestRun(runStatus, endedAt);
if (isTerminalRunStatus(runStatus)) {
clearActiveRunRefs(runConversationId, controller, cancelController);
clearStreamingMarker(runConversationId);
}
},
onRunEventId: (lastRunEventId) => {
updateMessageById(assistantId, (prev) => ({ ...prev, lastRunEventId }));
@ -1752,7 +1888,7 @@ export function ProjectView({
body: JSON.stringify({
userMessage: userText,
projectId: project.id,
conversationId: activeConversationId,
conversationId: runConversationId,
chatProvider: byokChatProvider,
}),
});
@ -1783,7 +1919,7 @@ export function ProjectView({
userMessage: userText,
assistantMessage: accumulatedAssistantText,
projectId: project.id,
conversationId: activeConversationId,
conversationId: runConversationId,
chatProvider: byokChatProvider,
}),
}).catch(() => {
@ -1812,6 +1948,9 @@ export function ProjectView({
persistMessageById,
patchAttachedStatuses,
updateMessageById,
markStreamingConversation,
clearStreamingMarker,
clearActiveRunRefs,
onProjectsRefresh,
],
);
@ -1998,22 +2137,10 @@ export function ProjectView({
}
reattachControllersRef.current.clear();
setStreaming(false);
streamingConversationIdRef.current = null;
setStreamingConversationId(null);
setMessages((curr) => {
const finalized: ChatMessage[] = [];
const next = curr.map((m) => {
if (m.role !== 'assistant') return m;
if (isActiveRunStatus(m.runStatus)) {
const updated = { ...m, runStatus: 'canceled' as const, endedAt: m.endedAt ?? stoppedAt };
finalized.push(updated);
return updated;
}
if (m.endedAt === undefined) {
const updated = { ...m, endedAt: stoppedAt };
finalized.push(updated);
return updated;
}
return m;
});
const { messages: next, finalized } = finalizeActiveAssistantMessagesOnStop(curr, stoppedAt);
for (const message of finalized) persistMessage(message, { telemetryFinalized: true });
return next;
});
@ -2039,6 +2166,8 @@ export function ProjectView({
// duplicate empty conversations before the effect resolves.
setMessages([]);
setStreaming(false);
streamingConversationIdRef.current = null;
setStreamingConversationId(null);
setMessagesConversationId(null);
messagesConversationIdRef.current = fresh.id;
setConversations((curr) => [fresh, ...curr]);
@ -2061,6 +2190,8 @@ export function ProjectView({
setAttachedComments([]);
setArtifact(null);
setStreaming(false);
streamingConversationIdRef.current = null;
setStreamingConversationId(null);
setMessagesConversationId(null);
setFailedMessagesConversationId(null);
setConversationLoadError(null);
@ -2417,22 +2548,7 @@ export function ProjectView({
return;
}
const launched = await terminalLauncher.open(project.id);
if (launched.kind === 'electron' && launched.ok) {
setProjectActionsToast({
message: 'Folder opened. Run `claude` in your terminal here and paste the prompt.',
details: null,
});
} else if (launched.kind === 'electron' && !launched.ok) {
setProjectActionsToast({
message: `Couldn't open the folder. Open your terminal at ${projectDir}, run \`claude\`, and paste the prompt.`,
details: null,
});
} else {
setProjectActionsToast({
message: `Open your terminal at ${projectDir}, run \`claude\`, and paste the prompt.`,
details: null,
});
}
setProjectActionsToast(buildContinueInCliToast(projectDir, launched));
}, [
project.id,
project.name,
@ -2837,10 +2953,57 @@ function isPhantomDaemonRunMessage(m: ChatMessage): boolean {
);
}
function isStoppableAssistantMessage(message: ChatMessage): boolean {
if (message.role !== 'assistant') return false;
if (isActiveRunStatus(message.runStatus)) return true;
return message.runStatus === undefined && message.endedAt === undefined && message.startedAt !== undefined;
}
export function resolveSucceededRunStatus(status: ChatMessage['runStatus']): ChatMessage['runStatus'] {
return status === 'failed' || status === 'canceled' ? status : 'succeeded';
}
export function clearStreamingConversationMarker(
currentConversationId: string | null,
completedConversationId?: string | null,
): string | null {
if (
completedConversationId !== undefined
&& completedConversationId !== null
&& currentConversationId !== completedConversationId
) {
return currentConversationId;
}
return null;
}
export function shouldClearActiveRunRefs(
currentConversationId: string | null,
completedConversationId: string,
): boolean {
return currentConversationId === completedConversationId;
}
export function finalizeActiveAssistantMessagesOnStop(
messages: ChatMessage[],
stoppedAt: number,
): { messages: ChatMessage[]; finalized: ChatMessage[] } {
const finalized: ChatMessage[] = [];
const next = messages.map((message) => {
if (!isStoppableAssistantMessage(message)) {
return message;
}
const updated = {
...message,
runStatus: 'canceled' as const,
endedAt: message.endedAt ?? stoppedAt,
};
finalized.push(updated);
return updated;
});
return { messages: next, finalized };
}
type BufferedTextUpdates = ReturnType<typeof createBufferedTextUpdates>;
function createBufferedTextUpdates({

View file

@ -14,6 +14,10 @@ import { navigate } from '../router';
type ProjectSummary = { id: string; name: string };
type RoutinesSectionProps = {
onClose?: () => void;
};
type ScheduleKind = RoutineSchedule['kind'];
const SCHEDULE_KINDS: { kind: ScheduleKind; label: string }[] = [
@ -346,7 +350,18 @@ function RunHistory({ routineId, refreshKey, onClose }: { routineId: string; ref
type="button"
className="routines-history-link"
onClick={() => {
navigate({ kind: 'project', projectId: r.projectId, fileName: null });
// Issue #1505: deep-link to this run's specific
// conversation, not just the project root. Without the
// conversation id, parallel runs that share a project
// (reuse mode) all resolve to the same default
// conversation in the project view, which made earlier
// runs look "absorbed" by the latest one.
navigate({
kind: 'project',
projectId: r.projectId,
conversationId: r.conversationId ?? null,
fileName: null,
});
onClose?.();
}}
title="Open the project this run wrote to"
@ -360,7 +375,7 @@ function RunHistory({ routineId, refreshKey, onClose }: { routineId: string; ref
);
}
export function RoutinesSection({ onClose }: { onClose?: () => void }) {
export function RoutinesSection({ onClose }: RoutinesSectionProps) {
const [routines, setRoutines] = useState<Routine[]>([]);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(true);
@ -571,6 +586,7 @@ export function RoutinesSection({ onClose }: { onClose?: () => void }) {
<fieldset className="routines-fieldset">
<legend>Project</legend>
<label className="routines-radio">
<input
type="radio"
@ -582,6 +598,7 @@ export function RoutinesSection({ onClose }: { onClose?: () => void }) {
<small>A fresh, isolated workspace per fire.</small>
</span>
</label>
<label className="routines-radio">
<input
type="radio"
@ -593,7 +610,8 @@ export function RoutinesSection({ onClose }: { onClose?: () => void }) {
<small>Each run lives as a new conversation inside the project.</small>
</span>
</label>
{form.mode === 'reuse' ? (
{form.mode === 'reuse' && (
<select
className="routines-project-select"
value={form.projectId}
@ -607,7 +625,7 @@ export function RoutinesSection({ onClose }: { onClose?: () => void }) {
</option>
))}
</select>
) : null}
)}
</fieldset>
<div className="routines-form-actions">

View file

@ -3348,6 +3348,7 @@ function OrbitSection({
navigateRoute({
kind: 'project',
projectId: payload.projectId,
conversationId: null,
fileName: null,
});
} catch {

View file

@ -877,6 +877,8 @@ export const ar: Dict = {
'fileViewer.exportZip': 'تحميل كـ zip.',
'fileViewer.exportHtml': 'تصدير كـ HTML مستقل',
'fileViewer.exportMd': 'تصدير كـ Markdown',
'fileViewer.exportImage': 'تصدير كصورة',
'fileViewer.exportImageFailed': 'فشل التقاط الصورة. يرجى المحاولة مرة أخرى أو استخدام أداة لقطة الشاشة في المتصفح.',
'fileViewer.exportJsx': 'تصدير كـ JSX',
'fileViewer.exportReactHtml': 'تصدير المعاينة كـ HTML',
'fileViewer.saveAsTemplate': 'حفظ كقالب...',

View file

@ -765,6 +765,8 @@ export const de: Dict = {
'fileViewer.exportZip': 'Als .zip herunterladen',
'fileViewer.exportHtml': 'Als eigenständiges HTML exportieren',
'fileViewer.exportMd': 'Als Markdown exportieren',
'fileViewer.exportImage': 'Als Bild exportieren',
'fileViewer.exportImageFailed': 'Bildaufnahme fehlgeschlagen. Bitte versuchen Sie es erneut oder verwenden Sie das Screenshot-Tool Ihres Browsers.',
'fileViewer.exportJsx': 'Als JSX exportieren',
'fileViewer.exportReactHtml': 'Vorschau als HTML exportieren',
'fileViewer.saveAsTemplate': 'Als Template speichern…',

View file

@ -973,6 +973,8 @@ export const en: Dict = {
'fileViewer.exportZip': 'Download as .zip',
'fileViewer.exportHtml': 'Export as standalone HTML',
'fileViewer.exportMd': 'Export as Markdown',
'fileViewer.exportImage': 'Export as image',
'fileViewer.exportImageFailed': 'Image capture failed. Please try again or use your browser\'s screenshot tool.',
'fileViewer.exportJsx': 'Export as JSX',
'fileViewer.exportReactHtml': 'Export preview as HTML',
'fileViewer.saveAsTemplate': 'Save as template…',

View file

@ -766,6 +766,8 @@ export const esES: Dict = {
'fileViewer.exportZip': 'Descargar como .zip',
'fileViewer.exportHtml': 'Exportar como HTML independiente',
'fileViewer.exportMd': 'Exportar como Markdown',
'fileViewer.exportImage': 'Exportar como imagen',
'fileViewer.exportImageFailed': 'Error al capturar la imagen. Inténtalo de nuevo o usa la herramienta de captura de pantalla de tu navegador.',
'fileViewer.exportJsx': 'Exportar como JSX',
'fileViewer.exportReactHtml': 'Exportar vista previa como HTML',
'fileViewer.saveAsTemplate': 'Guardar como plantilla…',

View file

@ -901,6 +901,8 @@ export const fa: Dict = {
'fileViewer.exportZip': 'دانلود به صورت .zip',
'fileViewer.exportHtml': 'صادرکردن به HTML مستقل',
'fileViewer.exportMd': 'صادرکردن به صورت Markdown',
'fileViewer.exportImage': 'صادرکردن به صورت تصویر',
'fileViewer.exportImageFailed': 'گرفتن تصویر ناموفق بود. لطفاً دوباره تلاش کنید یا از ابزار اسکرین‌شات مرورگرتان استفاده کنید.',
'fileViewer.exportJsx': 'صادرکردن به JSX',
'fileViewer.exportReactHtml': 'صادرکردن پیش‌نمایش به HTML',
'fileViewer.saveAsTemplate': 'ذخیره به عنوان قالب…',

View file

@ -877,6 +877,8 @@ export const fr: Dict = {
'fileViewer.exportZip': 'Télécharger en .zip',
'fileViewer.exportHtml': 'Exporter en HTML autonome',
'fileViewer.exportMd': 'Exporter en Markdown',
'fileViewer.exportImage': 'Exporter en image',
'fileViewer.exportImageFailed': 'La capture d\'image a échoué. Veuillez réessayer ou utiliser l\'outil de capture d\'écran de votre navigateur.',
'fileViewer.exportJsx': 'Exporter en JSX',
'fileViewer.exportReactHtml': 'Exporter l\'aperçu en HTML',
'fileViewer.saveAsTemplate': 'Enregistrer comme modèle…',

View file

@ -877,6 +877,8 @@ export const hu: Dict = {
'fileViewer.exportZip': 'Letöltés .zip-ként',
'fileViewer.exportHtml': 'Exportálás önálló HTML-ként',
'fileViewer.exportMd': 'Exportálás Markdown-ként',
'fileViewer.exportImage': 'Exportálás képként',
'fileViewer.exportImageFailed': 'A képrögzítés sikertelen. Kérjük, próbálja újra, vagy használja a böngészője képernyőkép eszközét.',
'fileViewer.exportJsx': 'Exportálás JSX-ként',
'fileViewer.exportReactHtml': 'Előnézet exportálása HTML-ként',
'fileViewer.saveAsTemplate': 'Mentés sablonként…',

View file

@ -996,6 +996,8 @@ export const id: Dict = {
'fileViewer.exportZip': 'Ekspor ZIP',
'fileViewer.exportHtml': 'Ekspor HTML',
'fileViewer.exportMd': 'Ekspor Markdown',
'fileViewer.exportImage': 'Ekspor gambar',
'fileViewer.exportImageFailed': 'Gagal menangkap gambar. Silakan coba lagi atau gunakan alat tangkapan layar browser Anda.',
'fileViewer.exportJsx': 'Ekspor JSX',
'fileViewer.exportReactHtml': 'Ekspor React HTML',
'fileViewer.saveAsTemplate': 'Simpan sebagai templat',

View file

@ -764,6 +764,8 @@ export const ja: Dict = {
'fileViewer.exportZip': '.zip としてダウンロード',
'fileViewer.exportHtml': 'スタンドアロン HTML としてエクスポート',
'fileViewer.exportMd': 'Markdown としてエクスポート',
'fileViewer.exportImage': '画像としてエクスポート',
'fileViewer.exportImageFailed': '画像のキャプチャに失敗しました。再試行するか、ブラウザのスクリーンショット機能をご利用ください。',
'fileViewer.exportJsx': 'JSX としてエクスポート',
'fileViewer.exportReactHtml': 'プレビューを HTML としてエクスポート',
'fileViewer.saveAsTemplate': 'テンプレートとして保存…',

View file

@ -877,6 +877,8 @@ export const ko: Dict = {
'fileViewer.exportZip': '.zip으로 다운로드',
'fileViewer.exportHtml': '독립 실행형 HTML로 내보내기',
'fileViewer.exportMd': 'Markdown으로 내보내기',
'fileViewer.exportImage': '이미지로 내보내기',
'fileViewer.exportImageFailed': '이미지 캡처에 실패했습니다. 다시 시도하거나 브라우저의 스크린샷 도구를 사용하세요.',
'fileViewer.exportJsx': 'JSX로 내보내기',
'fileViewer.exportReactHtml': '미리보기를 HTML로 내보내기',
'fileViewer.saveAsTemplate': '템플릿으로 저장…',

View file

@ -877,6 +877,8 @@ export const pl: Dict = {
'fileViewer.exportZip': 'Pobierz jako .zip',
'fileViewer.exportHtml': 'Eksportuj jako samodzielny HTML',
'fileViewer.exportMd': 'Eksportuj jako Markdown',
'fileViewer.exportImage': 'Eksportuj jako obraz',
'fileViewer.exportImageFailed': 'Przechwytywanie obrazu nie powiodło się. Spróbuj ponownie lub użyj narzędzia do zrzutów ekranu w przeglądarce.',
'fileViewer.exportJsx': 'Eksportuj jako JSX',
'fileViewer.exportReactHtml': 'Eksportuj podgląd jako HTML',
'fileViewer.saveAsTemplate': 'Zapisz jako szablon…',

View file

@ -900,6 +900,8 @@ export const ptBR: Dict = {
'fileViewer.exportZip': 'Baixar como .zip',
'fileViewer.exportHtml': 'Exportar como HTML independente',
'fileViewer.exportMd': 'Exportar como Markdown',
'fileViewer.exportImage': 'Exportar como imagem',
'fileViewer.exportImageFailed': 'Falha ao capturar a imagem. Tente novamente ou use a ferramenta de captura de tela do seu navegador.',
'fileViewer.exportJsx': 'Exportar como JSX',
'fileViewer.exportReactHtml': 'Exportar prévia como HTML',
'fileViewer.saveAsTemplate': 'Salvar como template…',

View file

@ -900,6 +900,8 @@ export const ru: Dict = {
'fileViewer.exportZip': 'Скачать как .zip',
'fileViewer.exportHtml': 'Экспорт как HTML',
'fileViewer.exportMd': 'Экспорт в Markdown',
'fileViewer.exportImage': 'Экспорт как изображение',
'fileViewer.exportImageFailed': 'Не удалось сделать снимок. Попробуйте ещё раз или воспользуйтесь инструментом скриншотов вашего браузера.',
'fileViewer.exportJsx': 'Экспорт как JSX',
'fileViewer.exportReactHtml': 'Экспорт предпросмотра как HTML',
'fileViewer.saveAsTemplate': 'Сохранить как шаблон…',

View file

@ -805,6 +805,8 @@ export const th: Dict = {
'fileViewer.exportZip': 'สูบทั้งหมดมาในรูป .zip',
'fileViewer.exportHtml': 'เอาไปแค่รูปไฟล์ HTML',
'fileViewer.exportMd': 'แปลงข้อความแบบฉบับเป็น Markdown',
'fileViewer.exportImage': 'ส่งออกเป็นรูปภาพ',
'fileViewer.exportImageFailed': 'การจับภาพล้มเหลว กรุณาลองอีกครั้งหรือใช้เครื่องมือจับภาพหน้าจอของเบราว์เซอร์',
'fileViewer.exportJsx': 'นำโค้ดในรูปแบบ React JSX ออก',
'fileViewer.exportReactHtml': 'แยกโหลดมาแค่โครง HTML เท่านั้น',
'fileViewer.saveAsTemplate': 'จัดเก็บในหมวดเทมเพลต…',

View file

@ -864,6 +864,8 @@ export const tr: Dict = {
'fileViewer.exportZip': 'ZIP olarak indir',
'fileViewer.exportHtml': 'Tekil HTML olarak dışa aktar',
'fileViewer.exportMd': 'Markdown olarak dışa aktar',
'fileViewer.exportImage': 'Görsel olarak dışa aktar',
'fileViewer.exportImageFailed': 'Görsel yakalama başarısız oldu. Lütfen tekrar deneyin veya tarayıcınızın ekran görüntüsü aracını kullanın.',
'fileViewer.exportJsx': 'JSX olarak dışa aktar',
'fileViewer.exportReactHtml': 'Önizlemeyi HTML olarak dışa aktar',
'fileViewer.saveAsTemplate': 'Şablon olarak kaydet…',

View file

@ -919,6 +919,8 @@ export const uk: Dict = {
'fileViewer.exportZip': 'Завантажити як .zip',
'fileViewer.exportHtml': 'Експортувати як самостійний HTML',
'fileViewer.exportMd': 'Експортувати як Markdown',
'fileViewer.exportImage': 'Експортувати як зображення',
'fileViewer.exportImageFailed': 'Не вдалося захопити зображення. Спробуйте ще раз або скористайтеся інструментом знімків екрана вашого браузера.',
'fileViewer.exportJsx': 'Експортувати як JSX',
'fileViewer.exportReactHtml': 'Експортувати попередній перегляд як HTML',
'fileViewer.saveAsTemplate': 'Зберегти як шаблон…',

View file

@ -960,6 +960,8 @@ export const zhCN: Dict = {
'fileViewer.exportZip': '下载为 .zip',
'fileViewer.exportHtml': '导出为独立 HTML',
'fileViewer.exportMd': '导出为 Markdown',
'fileViewer.exportImage': '导出为图片',
'fileViewer.exportImageFailed': '图片捕获失败,请重试或使用浏览器的截图工具。',
'fileViewer.exportJsx': '导出为 JSX',
'fileViewer.exportReactHtml': '导出预览 HTML',
'fileViewer.saveAsTemplate': '保存为模板…',

View file

@ -951,6 +951,8 @@ export const zhTW: Dict = {
'fileViewer.exportZip': '下載為 .zip',
'fileViewer.exportHtml': '匯出為獨立 HTML',
'fileViewer.exportMd': '匯出為 Markdown',
'fileViewer.exportImage': '匯出為圖片',
'fileViewer.exportImageFailed': '圖片擷取失敗,請重試或使用瀏覽器的截圖工具。',
'fileViewer.exportJsx': '匯出為 JSX',
'fileViewer.exportReactHtml': '匯出預覽 HTML',
'fileViewer.saveAsTemplate': '儲存為範本…',

View file

@ -1234,6 +1234,8 @@ export interface Dict {
'fileViewer.exportZip': string;
'fileViewer.exportHtml': string;
'fileViewer.exportMd': string;
'fileViewer.exportImage': string;
'fileViewer.exportImageFailed': string;
'fileViewer.exportJsx': string;
'fileViewer.exportReactHtml': string;
'fileViewer.saveAsTemplate': string;

View file

@ -910,7 +910,7 @@ a.avatar-item:visited {
padding: 4px 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-panel);
background-color: var(--bg-panel);
color: var(--text);
cursor: pointer;
}
@ -7211,6 +7211,7 @@ button.connector-action.is-loading {
.design-card-meta {
font-size: 11.5px;
color: var(--text-muted);
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@ -9931,6 +9932,93 @@ button.connector-action.is-loading {
z-index: 30;
overflow: hidden;
}
.comment-side-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel, #fff);
}
.comment-side-title {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 7px;
color: var(--text);
font-size: 12px;
font-weight: 700;
}
.comment-side-title span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-side-collapse {
width: 26px;
height: 26px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.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;
width: 42px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 12px 0;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-panel, #fff);
color: var(--text-muted);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
cursor: pointer;
}
.comment-side-rail:hover {
color: var(--text);
border-color: var(--border-strong);
background: var(--bg-subtle);
}
.comment-side-rail span {
writing-mode: vertical-rl;
text-orientation: mixed;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
}
.comment-side-rail strong {
min-width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
border-radius: 999px;
background: #ff5a3c;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.comment-side-list {
flex: 1;
overflow-y: auto;
@ -20834,7 +20922,8 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
/* Restore custom dropdown chevron for select elements in routines form.
The appearance:none above removes the native chevron, so we paint our own
via background-image (matching the global select styling). */
.routines-field select {
.routines-field select,
.routines-project-select {
padding-right: 32px;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2374716b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
@ -20842,12 +20931,14 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
background-size: 12px 12px;
}
[data-theme='dark'] .routines-field select {
[data-theme='dark'] .routines-field select,
[data-theme='dark'] .routines-project-select {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%239a9690' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E");
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) .routines-field select {
html:not([data-theme]) .routines-field select,
html:not([data-theme]) .routines-project-select {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%239a9690' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E");
}
}
@ -20921,28 +21012,74 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
flex-direction: column;
gap: 8px;
}
.routines-fieldset legend {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
padding: 0 6px;
}
.routines-radio {
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 8px 10px;
padding: 10px 8px;
border-radius: var(--radius);
cursor: pointer;
transition: background 120ms ease;
align-items: flex-start;
}
.routines-radio:hover { background: var(--bg-subtle); }
.routines-radio input { margin-top: 3px; }
.routines-radio span { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.routines-radio strong { font-size: 14px; color: var(--text); font-weight: 600; }
.routines-radio small { font-size: 12px; color: var(--text-muted); }
.routines-radio {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 14px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-panel);
cursor: pointer;
transition: border-color 120ms ease, background 120ms ease;
}
.routines-radio:hover {
background: var(--bg-subtle);
border-color: var(--border-strong);
}
.routines-radio:has(input:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.routines-radio input[type="radio"] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.routines-radio:has(input[type="radio"]:checked) {
border-color: var(--accent);
background: var(--accent-tint);
}
.routines-radio:has(input[type="radio"]:checked) strong {
color: var(--accent-strong);
}
.routines-radio span {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.routines-radio strong {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.routines-radio small {
font-size: 12px;
color: var(--text-muted);
}
.routines-form-actions {
display: flex;
gap: 8px;
@ -21215,4 +21352,4 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
border: 1px dashed var(--border-strong, var(--border)); border-radius: 4px;
}
.palette-tweaks-label { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.palette-tweaks-check { display: inline-flex; align-items: center; color: var(--text-muted); }
.palette-tweaks-check { display: inline-flex; align-items: center; color: var(--text-muted); }

View file

@ -0,0 +1,32 @@
import type { TerminalLaunchResult } from '../hooks/useTerminalLaunch';
export interface ContinueInCliToast {
message: string;
details: null;
}
const CLIPBOARD_PREFIX = 'Copied to clipboard. ';
export function buildContinueInCliToast(
projectDir: string,
launched: TerminalLaunchResult,
): ContinueInCliToast {
if (launched.kind === 'electron' && launched.ok) {
return {
message: `${CLIPBOARD_PREFIX}Folder opened. Run \`claude\` in your terminal here and paste the prompt.`,
details: null,
};
}
if (launched.kind === 'electron' && !launched.ok) {
return {
message: `${CLIPBOARD_PREFIX}Couldn't open the folder. Open your terminal at ${projectDir}, run \`claude\`, and paste the prompt.`,
details: null,
};
}
return {
message: `${CLIPBOARD_PREFIX}Open your terminal at ${projectDir}, run \`claude\`, and paste the prompt.`,
details: null,
};
}

View file

@ -32,43 +32,63 @@ export function parseProvenance(designMdText: string): ProvenanceFields | null {
projectId: extractField(body, /Project\s*ID[:\s]+([^\n]+)/i),
designSystemId: extractFieldOrNone(body, /Design\s*system[^:]*[:\s]+([^\n]+)/i),
currentArtifact: extractFieldOrNone(body, /Current\s*artifact[^:]*[:\s]+([^\n]+)/i),
transcriptMessageCount: extractNumber(body, /Transcript\s*message\s*count[:\s]+(\d+)/i),
generatedAt: extractDate(body, /Generated[^:\n]*[:\s]+(\S[^\n]*)/i),
transcriptMessageCount: extractNumber(body, /Transcript\s*message\s*count[^:]*[:\s]+([^\n]+)/i),
generatedAt: extractDate(body, /Generated[^:\n]*[:\s]+([^\n]+)/i),
};
}
function trimBullet(value: string): string {
// Lines look like "- Project ID: abc". The regex captures everything
// after the colon to the newline; strip incidental trailing whitespace.
return value.trim();
// #1580: Claude renders Provenance fields with Markdown-bold labels
// (`- **Field:** value`) per Markdown convention. The capture starts
// just after the label's `:`, so a leading `** ` (and any trailing
// emphasis if the value itself is wrapped) leaks into the value.
//
// PR #1584 review (lefarcen): narrow the strip to only consume
// Markdown residue, never literal `*`/`_` characters in the value:
// 1. Leading `*`/`_` tokens FOLLOWED BY WHITESPACE
// (the `** ` left over from `- **Field:** value`).
// 2. Trailing WHITESPACE followed by `*`/`_` tokens
// (mirror of step 1 if Claude closes after the value).
// 3. A single balanced wrap around the whole remaining value
// (`**X**` / `*X*` / `__X__` / `_X_`).
// Asymmetric literal `*`/`_` without a whitespace separator AND
// without a balanced closing token are preserved
// (e.g. `_draft.html`, `build_id_v1_`). Backticks are intentionally
// kept (the rendered clipboard text reads fine with them).
function stripMarkdownEmphasis(value: string): string {
let v = value.replace(/^[*_]+\s+/, '').replace(/\s+[*_]+$/, '');
const wrap = v.match(/^(\*\*|__|\*|_)(.+?)\1$/);
if (wrap && wrap[2]) v = wrap[2];
return v;
}
function extractField(body: string, re: RegExp): string | null {
function extractRawValue(body: string, re: RegExp): string | null {
const m = body.match(re);
if (!m || !m[1]) return null;
const value = trimBullet(m[1]);
const value = stripMarkdownEmphasis(m[1].trim());
return value.length > 0 ? value : null;
}
function extractField(body: string, re: RegExp): string | null {
return extractRawValue(body, re);
}
function extractFieldOrNone(body: string, re: RegExp): string | null {
const value = extractField(body, re);
const value = extractRawValue(body, re);
if (value === null) return null;
if (NONE_SENTINEL.test(value)) return null;
return value;
}
function extractNumber(body: string, re: RegExp): number | null {
const m = body.match(re);
if (!m || !m[1]) return null;
const n = Number.parseInt(m[1], 10);
const raw = extractRawValue(body, re);
if (raw === null) return null;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
}
function extractDate(body: string, re: RegExp): Date | null {
const m = body.match(re);
if (!m || !m[1]) return null;
const raw = trimBullet(m[1]);
if (raw.length === 0) return null;
const raw = extractRawValue(body, re);
if (raw === null) return null;
const d = new Date(raw);
return Number.isFinite(d.getTime()) ? d : null;
}

View file

@ -1,6 +1,6 @@
/**
* Daemon provider fetch-based SSE client for /api/runs. The daemon can
* emit three event streams depending on the agent's streamFormat:
* emit three event streams depending on the runtime adapter:
* - 'agent' : typed events emitted by Claude Code's stream-json parser
* (status, text_delta, thinking_delta, tool_use, tool_result,
* usage, raw). We forward these to the UI as AgentEvent items.

View file

@ -19,7 +19,19 @@ export type EntryHomeView =
export type Route =
| { kind: 'home'; view: EntryHomeView }
| { kind: 'project'; projectId: string; fileName: string | null }
| {
kind: 'project';
projectId: string;
/**
* Deep-link to a specific conversation inside the project. When
* present, the project view picks this conversation as the active
* one instead of defaulting to `list[0]`. Falls back to the
* default picker when the routed conversation no longer exists.
* Added for issue #1505 (Routines history specific conversation).
*/
conversationId?: string | null;
fileName: string | null;
}
| { kind: 'marketplace' }
| { kind: 'marketplace-detail'; pluginId: string };
@ -29,6 +41,20 @@ export function parseRoute(pathname: string): Route {
if (parts[0] === 'projects') {
if (parts[1]) {
const projectId = decodeURIComponent(parts[1]);
// /projects/:id/conversations/:cid[/files/...]
if (parts[2] === 'conversations' && parts[3]) {
const conversationId = decodeURIComponent(parts[3]);
if (parts[4] === 'files' && parts[5]) {
return {
kind: 'project',
projectId,
conversationId,
fileName: decodeURIComponent(parts.slice(5).join('/')),
};
}
return { kind: 'project', projectId, conversationId, fileName: null };
}
// /projects/:id/files/...
if (parts[2] === 'files' && parts[3]) {
return {
kind: 'project',
@ -78,14 +104,16 @@ export function buildPath(route: Route): string {
if (route.kind === 'marketplace') return '/marketplace';
if (route.kind === 'marketplace-detail') return `/marketplace/${encodeURIComponent(route.pluginId)}`;
const id = encodeURIComponent(route.projectId);
if (route.fileName) {
const file = route.fileName
.split('/')
.map((s) => encodeURIComponent(s))
.join('/');
return `/projects/${id}/files/${file}`;
const file = route.fileName
? route.fileName.split('/').map((s) => encodeURIComponent(s)).join('/')
: null;
if (route.conversationId) {
const cid = encodeURIComponent(route.conversationId);
return file
? `/projects/${id}/conversations/${cid}/files/${file}`
: `/projects/${id}/conversations/${cid}`;
}
return `/projects/${id}`;
return file ? `/projects/${id}/files/${file}` : `/projects/${id}`;
}
// Centralized navigation. Components call this instead of mutating

View file

@ -15,6 +15,7 @@
import { buildSrcdoc, type SrcdocOptions } from './srcdoc';
import { buildReactComponentSrcdoc } from './react-component';
import { buildZip } from './zip';
import { randomUUID } from '../utils/uuid';
const DESIGN_HANDOFF_FILENAME = 'DESIGN-HANDOFF.md';
const DESIGN_MANIFEST_FILENAME = 'DESIGN-MANIFEST.json';
@ -317,6 +318,82 @@ export function exportAsMd(source: string, title: string): void {
triggerDownload(blob, `${safeFilename(title, 'artifact')}.md`);
}
// ---------------------------------------------------------------------------
// Image screenshot export
// ---------------------------------------------------------------------------
/**
* Request a PNG screenshot of the current viewport from the snapshot bridge
* injected into a srcdoc preview iframe. Returns null if the bridge is not
* present (e.g. URL-load mode) or the capture times out.
*/
export function requestPreviewSnapshot(
iframe: HTMLIFrameElement,
timeout = 2500,
): Promise<{ dataUrl: string; w: number; h: number } | null> {
const win = iframe.contentWindow;
if (!win) return Promise.resolve(null);
const id = `snap-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return new Promise((resolve) => {
let done = false;
function onMsg(ev: MessageEvent) {
if (ev.source !== win) return;
const d = ev.data as {
type?: string;
id?: string;
dataUrl?: string;
w?: number;
h?: number;
error?: string;
} | null;
if (!d || d.type !== 'od:snapshot:result' || d.id !== id) return;
if (done) return;
done = true;
window.removeEventListener('message', onMsg);
if (d.dataUrl && d.w && d.h) resolve({ dataUrl: d.dataUrl, w: d.w, h: d.h });
else resolve(null);
}
window.addEventListener('message', onMsg);
try {
win.postMessage({ type: 'od:snapshot', id }, '*');
} catch {
/* sandboxed */
}
setTimeout(() => {
if (!done) {
done = true;
window.removeEventListener('message', onMsg);
resolve(null);
}
}, timeout);
});
}
/** Convert a data-URL to a Blob without re-encoding through canvas. */
function dataUrlToBlob(dataUrl: string): Blob {
if (!dataUrl.startsWith('data:')) {
throw new Error('Invalid data URL');
}
const [header, base64] = dataUrl.split(',');
const mime = header?.match(/:(.*?);/)?.[1] ?? 'image/png';
const bytes = atob(base64 ?? '');
const arr = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i);
return new Blob([arr], { type: mime });
}
/** Download a snapshot data-URL as a PNG file. */
export function exportAsImage(dataUrl: string, title: string): void {
try {
const blob = dataUrlToBlob(dataUrl);
triggerDownload(blob, `${safeFilename(title, 'artifact')}.png`);
} catch (err) {
console.warn('[exportAsImage] failed to convert snapshot:', err);
// Re-throw the error to allow the caller to handle UI feedback
throw err;
}
}
export type ProjectPdfExportResult = 'desktop' | 'fallback';
export async function exportProjectAsPdf(opts: {
@ -524,7 +601,7 @@ export async function exportAsPdf(
const sandboxedPreview = opts?.sandboxedPreview ?? true;
// Generate a per-export nonce so the print-ready handshake is resistant to
// spoofing by untrusted scripts inside the exported artifact.
const nonce = crypto.randomUUID();
const nonce = randomUUID();
let doc = buildSrcdoc(html, opts);
if (opts?.deck) doc = injectDeckPrintStylesheet(doc);
doc = injectPrintReadyHandshake(doc, nonce);

View file

@ -2,6 +2,7 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { useState } from 'react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
@ -21,6 +22,7 @@ vi.mock('../../src/state/projects', async () => {
});
import {
CommentSidePanel,
FileViewer,
LiveArtifactViewer,
LiveArtifactRefreshHistoryPanel,
@ -34,6 +36,7 @@ import {
import type { InspectOverrideMap } from '../../src/components/FileViewer';
import type { LiveArtifact, LiveArtifactWorkspaceEntry, ProjectFile } from '../../src/types';
import { I18nProvider } from '../../src/i18n';
import type { Dict } from '../../src/i18n/types';
afterEach(() => {
cleanup();
@ -970,6 +973,17 @@ describe('FileViewer SVG artifacts', () => {
});
describe('FileViewer tweaks toolbar', () => {
const t = (key: keyof Dict) => {
const labels: Partial<Record<keyof Dict, string>> = {
'chat.tabComments': 'Comments',
'chat.comments.emptySaved': 'No saved comments.',
'common.close': 'Close',
'preview.showSidebar': 'Show Comments',
'preview.hideSidebar': 'Hide Comments',
};
return labels[key] ?? key;
};
function htmlPreviewFile(): ProjectFile {
return baseFile({
name: 'preview.html',
@ -987,7 +1001,7 @@ describe('FileViewer tweaks toolbar', () => {
});
}
it('renders the toolbar Draw entry and no legacy picker/pod toggle', () => {
it('renders the toolbar Draw entry alongside restored Comment and Inspect entries', () => {
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
@ -995,13 +1009,12 @@ describe('FileViewer tweaks toolbar', () => {
);
expect(screen.getByTestId('palette-tweaks-toggle')).toBeTruthy();
expect(screen.getByTestId('board-mode-toggle')).toBeTruthy();
expect(screen.getByTestId('inspect-mode-toggle')).toBeTruthy();
expect(screen.getByTestId('draw-overlay-toggle')).toBeTruthy();
expect(screen.queryByPlaceholderText('Type anywhere to add a note')).toBeNull();
expect(screen.queryByTestId('board-mode-toggle')).toBeNull();
expect(screen.queryByTestId('comment-mode-toggle')).toBeNull();
expect(screen.queryByRole('button', { name: 'Pods' })).toBeNull();
expect(screen.queryByTestId('inspect-mode-toggle')).toBeNull();
expect(screen.queryByRole('button', { name: 'Inspect' })).toBeNull();
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
expect(screen.getByPlaceholderText('Type anywhere to add a note')).toBeTruthy();
@ -1066,6 +1079,64 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByRole('button', { name: 'Send' })).toBeNull();
expect(screen.queryByText('Queues while working')).toBeNull();
});
it('collapses the comment side panel into a narrow reopen rail', () => {
const onCollapseChange = vi.fn();
function Harness() {
const [collapsed, setCollapsed] = useState(false);
return (
<CommentSidePanel
comments={[
{
id: 'comment-1',
projectId: 'project-1',
conversationId: 'conversation-1',
filePath: 'preview.html',
elementId: 'button.sso-btn',
selector: '[data-od-id="button.sso-btn"]',
label: 'button.sso-btn',
text: 'GitHub',
htmlHint: '<button>GitHub</button>',
position: { x: 16, y: 24, width: 160, height: 48 },
note: '不要github换成微信',
status: 'open',
createdAt: Date.now(),
updatedAt: Date.now(),
},
]}
selectedIds={new Set(['comment-1'])}
collapsed={collapsed}
onCollapsedChange={(next) => {
onCollapseChange(next);
setCollapsed(next);
}}
onToggleSelect={() => {}}
onClearSelection={() => {}}
onReply={() => {}}
onSendSelected={() => {}}
sending={false}
t={t}
/>
);
}
render(<Harness />);
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
expect(screen.getByText('不要github换成微信')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /hide comments/i }));
expect(onCollapseChange).toHaveBeenLastCalledWith(true);
expect(screen.queryByText('不要github换成微信')).toBeNull();
expect(screen.queryByTestId('comment-side-selectbar')).toBeNull();
expect(screen.getByTestId('comment-side-collapsed-rail')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /show comments/i }));
expect(onCollapseChange).toHaveBeenLastCalledWith(false);
});
});
describe('applyInspectOverridesToSource', () => {

View file

@ -459,6 +459,104 @@ describe('MemorySection', () => {
expect(deletedUrls).toEqual(['/api/memory/user_ui_preferences']);
});
it('keeps the editor open when saving a memory entry fails', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({ extractions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/memory' && init?.method === 'POST') {
return new Response(JSON.stringify({ error: 'write failed' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
fireEvent.click(await screen.findByRole('button', { name: 'New memory' }));
fireEvent.change(screen.getByPlaceholderText('e.g. UI preferences'), {
target: { value: 'UI preferences' },
});
fireEvent.change(screen.getByPlaceholderText('One sentence — what is this memory about?'), {
target: { value: 'Persistent UI rendering preferences' },
});
fireEvent.change(
screen.getByPlaceholderText(/- Rule one[\s\S]*When to apply: optional scope/),
{
target: { value: '- Prefer dark mode\n- Prefer generous spacing' },
},
);
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(screen.getByDisplayValue('UI preferences')).toBeTruthy();
});
expect(screen.queryByText('✓ Memory created')).toBeNull();
expect(screen.queryByText('UI preferences')).toBeTruthy();
});
it('keeps unsaved index edits when saving the index fails', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n\n- Existing bullet\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({ extractions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/memory/index' && init?.method === 'PUT') {
return new Response(JSON.stringify({ error: 'disk full' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
fireEvent.click(await screen.findByText('MEMORY.md (index)'));
const indexArea = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.change(indexArea, {
target: { value: '# Memory\n\n- Existing bullet\n- New bullet\n' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save index' }));
await waitFor(() => {
expect(screen.getByText(/Unsaved changes/i)).toBeTruthy();
});
expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toContain('- New bullet');
expect(screen.queryByText('✓ Index saved')).toBeNull();
});
it('deletes a single extraction row without clearing the whole history', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
const deletedUrls: string[] = [];
@ -590,4 +688,119 @@ describe('MemorySection', () => {
expect(screen.getByText('Project brief')).toBeTruthy();
});
});
it('renders failed extraction rows with the error details', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({
extractions: [
{
id: 'ex-failed',
phase: 'failed',
kind: 'llm',
startedAt: Date.now(),
finishedAt: Date.now() + 2500,
userMessagePreview: 'Remember my dashboard preference',
error: 'provider returned 429 quota exceeded',
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
fireEvent.click(await screen.findByText('Extraction history'));
expect(await screen.findByText('Remember my dashboard preference')).toBeTruthy();
expect(screen.getByText('provider returned 429 quota exceeded')).toBeTruthy();
expect(screen.getByText('Failed')).toBeTruthy();
});
it('renders the disabled banner when memory starts disabled', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(JSON.stringify({
enabled: false,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({ extractions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
const banner = await screen.findByRole('status');
expect(banner.textContent).toContain('Memory is currently OFF.');
});
it('toggles memory injection off and persists the PATCH payload', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
const patchBodies: unknown[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({ extractions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/memory/config' && init?.method === 'PATCH') {
patchBodies.push(JSON.parse(String(init.body)));
return new Response(JSON.stringify({ enabled: false, extraction: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
const toggle = await screen.findByRole('checkbox', { name: 'Enable memory injection' }) as HTMLInputElement;
fireEvent.click(toggle);
await waitFor(() => {
expect(screen.getByRole('status').textContent).toContain('Memory is currently OFF.');
});
expect(patchBodies).toEqual([{ enabled: false }]);
});
});

View file

@ -2,7 +2,14 @@
import { cleanup, render, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ProjectView, resolveSucceededRunStatus } from '../../src/components/ProjectView';
import {
ProjectView,
clearStreamingConversationMarker,
finalizeActiveAssistantMessagesOnStop,
resolveSucceededRunStatus,
shouldClearActiveRunRefs,
} from '../../src/components/ProjectView';
import type { ChatMessage } from '../../src/types';
const listConversations = vi.fn();
const listMessages = vi.fn();
@ -184,6 +191,13 @@ describe('ProjectView daemon cleanup', () => {
// daemon run) used to stick the UI on "Waiting for first output —
// Working 24m+" forever. The reattach loop now self-heals by marking
// such a message as failed so the composer is interactive again.
//
// TODO(reconcile): re-add the three unit tests for
// finalizeActiveAssistantMessagesOnStop / clearStreamingConversationMarker /
// shouldClearActiveRunRefs that landed on main alongside this hunk —
// they were dropped at merge because their bodies sat on top of HEAD's
// self-heals fixture and the test body that follows uses the
// `startedAt` variable declared only in this `it()` opener.
it('self-heals running messages with no runId when daemon has no active run', async () => {
const startedAt = Date.now();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);

View file

@ -181,7 +181,66 @@ describe('ProjectView tab URL hydration', () => {
await waitFor(() => {
expect(mockedNavigate).toHaveBeenCalledWith(
{ kind: 'project', projectId: project.id, fileName: 'index.html' },
// The active conversation id is threaded into the URL alongside
// the active tab so a reload / share preserves the conversation
// segment of `/projects/:id/conversations/:cid/files/...`
// (PerishCode + Codex P1 on PR #1508).
{
kind: 'project',
projectId: project.id,
conversationId: 'conv-1',
fileName: 'index.html',
},
{ replace: true },
);
});
});
it('re-pushes /conversations/:cid when activeConversationId hydrates after the active tab has already synced (lefarcen P1 on PR #1508)', async () => {
// Race shape: `loadTabs` resolves and sets the active tab BEFORE
// `listConversations` resolves and sets `activeConversationId`.
// The first navigate fires with `conversationId: null` because
// the conversation hasn't loaded yet; the second navigate must
// fire with `conversationId: 'conv-1'` even though the active
// tab is identical. A ref guard that keys only on the file
// target skips the second call and the URL never gains the
// `/conversations/:cid` segment. The composite-key guard
// (`${activeConversationId}:${target}`) catches it.
let resolveConversations: (value: Conversation[]) => void = () => {};
const conversationsPromise = new Promise<Conversation[]>((resolve) => {
resolveConversations = resolve;
});
mockedListConversations.mockReturnValue(conversationsPromise);
mockedLoadTabs.mockResolvedValue({ tabs: ['index.html'], active: 'index.html' });
renderProjectView();
// First navigate: active tab synced, conversation still loading.
await waitFor(() => {
expect(mockedNavigate).toHaveBeenCalledWith(
{
kind: 'project',
projectId: project.id,
conversationId: null,
fileName: 'index.html',
},
{ replace: true },
);
});
// Now resolve the conversation list. The active tab is unchanged
// but `activeConversationId` flips from `null` to `'conv-1'`, so
// a second navigate must fire.
resolveConversations([conversation]);
await waitFor(() => {
expect(mockedNavigate).toHaveBeenCalledWith(
{
kind: 'project',
projectId: project.id,
conversationId: 'conv-1',
fileName: 'index.html',
},
{ replace: true },
);
});

View file

@ -417,7 +417,12 @@ describe('RoutinesSection', () => {
fireEvent.click(await screen.findByRole('button', { name: 'Open project' }));
expect(navigateSpy).toHaveBeenCalledWith(
{ kind: 'project', projectId: 'proj-run', fileName: null },
{
kind: 'project',
projectId: 'proj-run',
conversationId: 'conv-run',
fileName: null,
},
);
});
@ -465,4 +470,261 @@ describe('RoutinesSection', () => {
expect(await screen.findByText('No runs yet.')).toBeTruthy();
});
it('falls back to the empty history state when loading run history fails', async () => {
const routines = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1/runs?limit=10') {
return new Response(JSON.stringify({ error: 'history unavailable' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = (await screen.findByText('Morning briefing')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'History' }));
expect(await screen.findByText('No runs yet.')).toBeTruthy();
});
it('shows an error alert when the initial routines load fails', async () => {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/routines') {
return new Response(JSON.stringify({ error: 'boom' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects') {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.getByRole('alert').textContent).toContain('routines: 500');
});
it('shows an error alert when creating a routine fails', async () => {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines' && init?.method === 'POST') {
return new Response(JSON.stringify({ error: 'provider unavailable' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
fireEvent.click(await screen.findByRole('button', { name: 'New routine' }));
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'Weekly digest' },
});
fireEvent.change(screen.getByLabelText('Prompt'), {
target: { value: 'Summarize GitHub and design activity.' },
});
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
expect((await screen.findByRole('alert')).textContent).toContain('provider unavailable');
expect(screen.getByDisplayValue('Weekly digest')).toBeTruthy();
});
it('shows an error alert when running a routine now fails', async () => {
const routines: Routine[] = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1/run' && init?.method === 'POST') {
return new Response(JSON.stringify({ error: 'agent unavailable' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = await screen.findByText('Morning briefing');
const card = row.closest('li')!;
fireEvent.click(within(card).getByRole('button', { name: 'Run now' }));
expect((await screen.findByRole('alert')).textContent).toContain('agent unavailable');
expect(within(card).queryByRole('button', { name: 'Hide history' })).toBeNull();
});
it('shows an error alert when pausing a routine fails and keeps the current action', async () => {
const routines: Routine[] = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1' && init?.method === 'PATCH') {
return new Response(JSON.stringify({ error: 'scheduler unavailable' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = await screen.findByText('Morning briefing');
const card = row.closest('li')!;
fireEvent.click(within(card).getByRole('button', { name: 'Pause' }));
expect((await screen.findByRole('alert')).textContent).toContain('scheduler unavailable');
expect(within(card).getByRole('button', { name: 'Pause' })).toBeTruthy();
expect(within(card).queryByRole('button', { name: 'Resume' })).toBeNull();
});
it('shows an error alert when deleting a routine fails', async () => {
const routines: Routine[] = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
window.confirm = vi.fn(() => true);
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1' && init?.method === 'DELETE') {
return new Response(JSON.stringify({ error: 'delete failed upstream' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = (await screen.findByText('Morning briefing')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'Delete' }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.getByRole('alert').textContent).toContain('delete failed upstream');
expect(screen.getByText('Morning briefing')).toBeTruthy();
});
});

View file

@ -63,6 +63,48 @@ describe('AssistantMessage unfinished todo state', () => {
expect(screen.queryByRole('button', { name: 'Continue remaining tasks' })).toBeNull();
});
it('uses persisted usage duration for completed messages that do not have endedAt', () => {
render(
<AssistantMessage
message={{
id: 'assistant-duration',
role: 'assistant',
content: 'Done',
startedAt: 1_000,
runStatus: 'succeeded',
events: [{ kind: 'usage', outputTokens: 1439, durationMs: 32_000 }],
}}
streaming={false}
projectId="project-1"
isLast
/>,
);
expect(screen.getByText(/32s/)).toBeTruthy();
expect(screen.getByText(/1439 out/)).toBeTruthy();
});
it('does not synthesize a growing elapsed time for completed messages without endedAt', () => {
render(
<AssistantMessage
message={{
id: 'assistant-duration-missing',
role: 'assistant',
content: 'Done',
startedAt: 1_000,
runStatus: 'succeeded',
events: [{ kind: 'usage', outputTokens: 1439 }],
}}
streaming={false}
projectId="project-1"
isLast
/>,
);
expect(screen.getByText(/1439 out/)).toBeTruthy();
expect(screen.queryByText(/\d+m \d{2}s/)).toBeNull();
});
it('shows unfinished state and passes unfinished todos to the continue callback', () => {
const onContinue = vi.fn();
render(

View file

@ -15,8 +15,8 @@ if (typeof HTMLElement.prototype.scrollTo !== 'function') {
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChatPane } from '../../src/components/ChatPane';
import type { ChatMessage } from '../../src/types';
import { ChatPane, conversationMetaLabel, isAssistantMessageStreaming } from '../../src/components/ChatPane';
import type { ChatMessage, Conversation } from '../../src/types';
function renderChatPane(messages: ChatMessage[]) {
return render(
@ -89,4 +89,46 @@ describe('conversation timestamps', () => {
expect(screen.getAllByRole('separator')).toHaveLength(2);
});
it('does not treat a completed last assistant message as streaming just because another conversation is running', () => {
const message: ChatMessage = {
id: 'assistant-1',
role: 'assistant',
content: 'Done',
createdAt: 100,
startedAt: 100,
runStatus: 'succeeded',
};
expect(isAssistantMessageStreaming(message, true, 'assistant-1')).toBe(false);
expect(
isAssistantMessageStreaming(
{ ...message, id: 'assistant-2', runStatus: 'running' },
false,
'assistant-1',
),
).toBe(true);
});
it('shows fixed latest run duration in the conversation menu instead of live relative age', () => {
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: Conversation = {
id: 'conv-1',
projectId: 'project-1',
title: 'Done run',
createdAt: Date.parse('2025-01-15T12:00:00Z'),
updatedAt: Date.parse('2025-01-15T12:01:00Z'),
latestRun: {
status: 'succeeded',
startedAt: 1_000,
endedAt: 16_000,
durationMs: 15_000,
},
};
expect(conversationMetaLabel(conversation, t as never)).toBe('15s');
});
});

View file

@ -99,6 +99,52 @@ describe('useDesignMdState', () => {
expect(result.current.designSystemId).toBe('alphatrace');
});
// Issue #1580: the synthesis prompt does not pin field-label syntax,
// so Claude emits Provenance with Markdown-bold labels in practice.
// This end-to-end test through useDesignMdState pins the parser fix
// at the hook layer — a regression that re-introduces the `** ` leak
// would surface `transcriptMessageCount === null` and trip the
// `unknown-provenance` fail-closed path instead of the fresh path.
it('reads bold-labelled Provenance correctly (issue #1580 end-to-end)', async () => {
const olderMs = FRESH_GENERATED_MS - 60_000;
const boldDesignMd = `# DESIGN.md
## Provenance
- **Project ID:** \`p1\`
- **Design system:** \`alphatrace\`
- **Current artifact:** \`deck.html\`
- **Transcript message count:** 12
- **Generated UTC timestamp:** 2026-05-08T12:00:00Z
`;
installFetchMock({
files: {
body: {
files: [
{ name: 'DESIGN.md', size: 100, mtime: FRESH_GENERATED_MS, kind: 'text', mime: 'text/markdown' },
{ name: 'index.html', size: 10, mtime: olderMs, kind: 'html', mime: 'text/html' },
],
},
},
designMd: { body: boldDesignMd },
conversations: { body: { conversations: [] } },
});
const { result } = renderHook(() => useDesignMdState('p1'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.exists).toBe(true);
// Round 7 (mrcfps @ useDesignMdState.ts:160): a regression that
// leaks `** ` would null transcriptMessageCount / generatedAt and
// flip this to 'unknown-provenance'.
expect(result.current.staleReason).toBeNull();
expect(result.current.isStale).toBe(false);
expect(result.current.transcriptMessageCount).toBe(12);
// Backticks intentionally kept on the value (out of scope per
// #1580 spec); the `** ` bold prefix must be stripped.
expect(result.current.designSystemId).toBe('`alphatrace`');
});
it('marks stale with files-newer when a project file mtime exceeds generatedAt', async () => {
const newerMs = FRESH_GENERATED_MS + 60_000;
installFetchMock({

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { buildContinueInCliToast } from '../../src/lib/build-continue-in-cli-toast';
describe('buildContinueInCliToast', () => {
it('prefixes every success path with clipboard confirmation', () => {
expect(
buildContinueInCliToast('/work/acme', { kind: 'electron', ok: true }),
).toEqual({
message:
'Copied to clipboard. Folder opened. Run `claude` in your terminal here and paste the prompt.',
details: null,
});
expect(
buildContinueInCliToast('/work/acme', { kind: 'electron', ok: false }),
).toEqual({
message:
"Copied to clipboard. Couldn't open the folder. Open your terminal at /work/acme, run `claude`, and paste the prompt.",
details: null,
});
expect(
buildContinueInCliToast('/work/acme', { kind: 'web-fallback', ok: true }),
).toEqual({
message:
'Copied to clipboard. Open your terminal at /work/acme, run `claude`, and paste the prompt.',
details: null,
});
});
});

View file

@ -67,4 +67,139 @@ describe('parseProvenance', () => {
// Surrounding fields still populated.
expect(result!.transcriptMessageCount).toBe(42);
});
// Issue #1580: the daemon's synthesis prompt does not pin field-label
// syntax (finalize-design.ts:560-565), so Claude renders Provenance
// fields with Markdown-bold labels per Markdown convention. The
// pre-fix regexes' `[:\s]+` separator stops at the trailing `**`
// after the colon, leaking `** ` into every captured value and
// making transcriptMessageCount + generatedAt parse as null.
it('parses bold-labelled fields with backticked values (live DESIGN.md shape)', () => {
// Verbatim shape from a finalized DESIGN.md emitted by Claude
// against the prod synthesis prompt. UUID + filename are
// illustrative placeholders, not user data.
const text = `## Provenance
- **Project ID:** \`00000000-0000-0000-0000-000000000000\`
- **Design system:** \`default\` (Neutral Modern — not applied; wireframe overrides all tokens)
- **Current artifact:** \`prototype.html\` (single-file, 1,922 lines, 57KB)
- **Transcript message count:** 4
- **Generated UTC timestamp:** 2026-05-13T12:27:21.499Z
`;
const result = parseProvenance(text);
expect(result).not.toBeNull();
// Backticks may remain in the captured value (out of scope to
// strip per #1580 spec); the `** ` Markdown-bold prefix must not.
expect(result!.projectId).toBe('`00000000-0000-0000-0000-000000000000`');
expect(result!.designSystemId).toBe('`default` (Neutral Modern — not applied; wireframe overrides all tokens)');
expect(result!.currentArtifact).toBe('`prototype.html` (single-file, 1,922 lines, 57KB)');
expect(result!.transcriptMessageCount).toBe(4);
expect(result!.generatedAt).not.toBeNull();
expect(result!.generatedAt!.toISOString()).toBe('2026-05-13T12:27:21.499Z');
});
it('parses bold-labelled fields with plain values and a short "Generated:" label', () => {
const text = `## Provenance
- **Project ID:** abc-123
- **Design system:** my-system
- **Current artifact:** deck.html
- **Transcript message count:** 12
- **Generated:** 2026-05-08T11:55:00Z
`;
const result = parseProvenance(text);
expect(result).not.toBeNull();
expect(result!.projectId).toBe('abc-123');
expect(result!.designSystemId).toBe('my-system');
expect(result!.currentArtifact).toBe('deck.html');
expect(result!.transcriptMessageCount).toBe(12);
expect(result!.generatedAt).not.toBeNull();
expect(result!.generatedAt!.toISOString()).toBe('2026-05-08T11:55:00.000Z');
});
// PR #1584 review (lefarcen): the round-1 strip used `^[\s*_]+` /
// `[\s*_]+$`, which stripped a literal leading/trailing underscore
// from values like `_draft.html` (corrupting it to `draft.html`).
// Narrow the strip to only consume Markdown residue, never literal
// characters in the value itself.
it('preserves a literal leading underscore in a plain-label value (e.g. _draft.html)', () => {
const text = `## Provenance
- Project ID: abc-123
- Design system: alphatrace
- Current artifact: _draft.html
- Transcript message count: 7
- Generated UTC timestamp: 2026-05-08T00:00:00Z
`;
const result = parseProvenance(text);
expect(result).not.toBeNull();
// The whole filename must survive — no leading underscore strip.
expect(result!.currentArtifact).toBe('_draft.html');
});
it('preserves a literal trailing underscore in a plain-label id-like value', () => {
const text = `## Provenance
- Project ID: build_id_v1_
- Design system: alphatrace
- Current artifact: deck.html
- Transcript message count: 7
- Generated UTC timestamp: 2026-05-08T00:00:00Z
`;
const result = parseProvenance(text);
expect(result).not.toBeNull();
expect(result!.projectId).toBe('build_id_v1_');
});
it('preserves a literal leading underscore even when the label is Markdown-bold', () => {
const text = `## Provenance
- **Project ID:** abc-123
- **Design system:** alphatrace
- **Current artifact:** _draft.html
- **Transcript message count:** 7
- **Generated UTC timestamp:** 2026-05-08T00:00:00Z
`;
const result = parseProvenance(text);
expect(result).not.toBeNull();
// The bold-label residue (`** `) must be stripped, but the literal
// leading underscore on the filename must remain.
expect(result!.currentArtifact).toBe('_draft.html');
});
it('strips a balanced **value** wrap (residue case, no preceding bold-label residue)', () => {
const text = `## Provenance
- Project ID: **wrapped-id**
- Design system: alphatrace
- Current artifact: deck.html
- Transcript message count: 7
- Generated UTC timestamp: 2026-05-08T00:00:00Z
`;
const result = parseProvenance(text);
expect(result).not.toBeNull();
// **X** is unambiguously Markdown emphasis residue per the issue
// spec; strip the balanced wrap.
expect(result!.projectId).toBe('wrapped-id');
});
it('still treats "none" as the null sentinel after the bold-label prefix is stripped', () => {
const text = `## Provenance
- **Project ID:** abc-123
- **Design system:** none
- **Current artifact:** none
- **Transcript message count:** 7
- **Generated UTC timestamp:** 2026-05-08T00:00:00Z
`;
const result = parseProvenance(text);
expect(result).not.toBeNull();
expect(result!.projectId).toBe('abc-123');
// NONE_SENTINEL must trip on the value after emphasis is stripped,
// otherwise "** none" leaks through as a real design-system id.
expect(result!.designSystemId).toBeNull();
expect(result!.currentArtifact).toBeNull();
expect(result!.transcriptMessageCount).toBe(7);
expect(result!.generatedAt).not.toBeNull();
});
});

View file

@ -0,0 +1,114 @@
/**
* URL router round-trip tests (issue #1505).
*
* Pins the deep-link shape for the project route:
*
* / home
* /projects/:id project root
* /projects/:id/files/:path file view
* /projects/:id/conversations/:cid specific conversation
* /projects/:id/conversations/:cid/files/:path conversation + file
*
* The conversation segment was added to unblock the Routines history
* row: clicking "Open project" on a parallel run's row needs to land
* the user on that run's own conversation, not on whatever the
* project happens to default to.
*/
import { describe, expect, it } from 'vitest';
import { buildPath, parseRoute, type Route } from '../src/router';
function roundTrip(route: Route): Route {
return parseRoute(buildPath(route));
}
describe('parseRoute / buildPath (issue #1505)', () => {
it('parses the home route', () => {
expect(parseRoute('/')).toEqual({ kind: 'home' });
expect(parseRoute('')).toEqual({ kind: 'home' });
});
it('round-trips a bare project route', () => {
const route: Route = {
kind: 'project',
projectId: 'p-1',
conversationId: null,
fileName: null,
};
expect(roundTrip(route)).toEqual(route);
expect(buildPath(route)).toBe('/projects/p-1');
});
it('round-trips a project + file route (no conversation)', () => {
const route: Route = {
kind: 'project',
projectId: 'p-1',
conversationId: null,
fileName: 'src/index.tsx',
};
expect(roundTrip(route)).toEqual(route);
expect(buildPath(route)).toBe('/projects/p-1/files/src/index.tsx');
});
it('round-trips a project + conversation route', () => {
const route: Route = {
kind: 'project',
projectId: 'p-1',
conversationId: 'conv-abc',
fileName: null,
};
expect(roundTrip(route)).toEqual(route);
expect(buildPath(route)).toBe('/projects/p-1/conversations/conv-abc');
});
it('round-trips a project + conversation + file route', () => {
const route: Route = {
kind: 'project',
projectId: 'p-1',
conversationId: 'conv-abc',
fileName: 'index.html',
};
expect(roundTrip(route)).toEqual(route);
expect(buildPath(route)).toBe('/projects/p-1/conversations/conv-abc/files/index.html');
});
it('percent-encodes ids and file names with reserved characters', () => {
const route: Route = {
kind: 'project',
projectId: 'p/1 with space',
conversationId: 'conv/abc with space',
fileName: 'dir/file name.tsx',
};
const built = buildPath(route);
expect(built).toContain('p%2F1%20with%20space');
expect(built).toContain('conv%2Fabc%20with%20space');
// File path components are percent-encoded individually so the
// slash between segments survives.
expect(built.endsWith('/dir/file%20name.tsx')).toBe(true);
expect(roundTrip(route)).toEqual(route);
});
it('parses a legacy project + file URL with no conversation segment', () => {
expect(parseRoute('/projects/p-1/files/README.md')).toEqual({
kind: 'project',
projectId: 'p-1',
conversationId: null,
fileName: 'README.md',
});
});
it('parses a project + conversation URL with no file segment', () => {
expect(parseRoute('/projects/p-1/conversations/c-2')).toEqual({
kind: 'project',
projectId: 'p-1',
conversationId: 'c-2',
fileName: null,
});
});
it('falls back to home when the URL is unrecognized', () => {
expect(parseRoute('/something/else')).toEqual({ kind: 'home' });
expect(parseRoute('/projects')).toEqual({ kind: 'home' });
});
});

View file

@ -5,10 +5,12 @@ import {
buildDesignHandoffContent,
buildDesignManifestContent,
buildSandboxedPreviewDocument,
exportAsImage,
exportAsMd,
exportAsPdf,
exportProjectAsPdf,
openSandboxedPreviewInNewTab,
requestPreviewSnapshot,
} from '../../src/runtime/exports';
function mockResponse(headers: Record<string, string>): Response {
@ -524,3 +526,170 @@ describe('sandboxed preview Blob exports', () => {
expect(htmlArg).not.toContain('window.print()');
});
});
// ---------------------------------------------------------------------------
// Image screenshot export
// ---------------------------------------------------------------------------
describe('requestPreviewSnapshot', () => {
let listeners: Map<string, Set<(ev: unknown) => void>>;
beforeEach(() => {
listeners = new Map();
vi.stubGlobal('window', {
addEventListener: (type: string, fn: (ev: unknown) => void) => {
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type)!.add(fn);
},
removeEventListener: (type: string, fn: (ev: unknown) => void) => {
listeners.get(type)?.delete(fn);
},
dispatchEvent: (ev: { type: string }) => {
for (const fn of listeners.get(ev.type) ?? []) fn(ev);
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('returns null when the iframe has no contentWindow', async () => {
const iframe = { contentWindow: null } as unknown as HTMLIFrameElement;
const result = await requestPreviewSnapshot(iframe);
expect(result).toBeNull();
});
it('resolves with snapshot data when the bridge responds', async () => {
const postMessageMock = vi.fn();
const contentWindow = { postMessage: postMessageMock };
const iframe = { contentWindow } as unknown as HTMLIFrameElement;
const promise = requestPreviewSnapshot(iframe);
expect(postMessageMock).toHaveBeenCalledOnce();
const { id } = postMessageMock.mock.calls[0]![0] as { type: string; id: string };
// Simulate the bridge responding — source must match iframe.contentWindow
window.dispatchEvent(
{ type: 'message', source: contentWindow, data: { type: 'od:snapshot:result', id, dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 } } as unknown as Event,
);
const result = await promise;
expect(result).toEqual({ dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 });
});
it('resolves null when the bridge responds with an error', async () => {
const postMessageMock = vi.fn();
const contentWindow = { postMessage: postMessageMock };
const iframe = { contentWindow } as unknown as HTMLIFrameElement;
const promise = requestPreviewSnapshot(iframe);
const { id } = postMessageMock.mock.calls[0]![0] as { type: string; id: string };
window.dispatchEvent(
{ type: 'message', source: contentWindow, data: { type: 'od:snapshot:result', id, error: 'snapshot image failed' } } as unknown as Event,
);
const result = await promise;
expect(result).toBeNull();
});
it('resolves null on timeout', async () => {
vi.useFakeTimers();
const iframe = {
contentWindow: { postMessage: vi.fn() },
} as unknown as HTMLIFrameElement;
const promise = requestPreviewSnapshot(iframe, 100);
vi.advanceTimersByTime(150);
const result = await promise;
expect(result).toBeNull();
vi.useRealTimers();
});
it('ignores messages with a mismatched id', async () => {
vi.useFakeTimers();
const postMessageMock = vi.fn();
const contentWindow = { postMessage: postMessageMock };
const iframe = { contentWindow } as unknown as HTMLIFrameElement;
const promise = requestPreviewSnapshot(iframe, 100);
// Correct source but wrong id — should be ignored
window.dispatchEvent(
{ type: 'message', source: contentWindow, data: { type: 'od:snapshot:result', id: 'wrong-id', dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 } } as unknown as Event,
);
vi.advanceTimersByTime(150);
const result = await promise;
expect(result).toBeNull();
vi.useRealTimers();
});
it('ignores messages from a different source window', async () => {
vi.useFakeTimers();
const postMessageMock = vi.fn();
const contentWindow = { postMessage: postMessageMock };
const iframe = { contentWindow } as unknown as HTMLIFrameElement;
const promise = requestPreviewSnapshot(iframe, 100);
const { id } = postMessageMock.mock.calls[0]![0] as { type: string; id: string };
// Correct id but wrong source — should be ignored
window.dispatchEvent(
{ type: 'message', source: { other: true }, data: { type: 'od:snapshot:result', id, dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 } } as unknown as Event,
);
vi.advanceTimersByTime(150);
const result = await promise;
expect(result).toBeNull();
vi.useRealTimers();
});
});
describe('exportAsImage', () => {
let clickMock: ReturnType<typeof vi.fn>;
let anchors: Array<{ href: string; download: string; click: ReturnType<typeof vi.fn> }>;
beforeEach(() => {
clickMock = vi.fn();
anchors = [];
vi.stubGlobal('URL', { createObjectURL: () => 'blob:mock-url', revokeObjectURL: vi.fn() });
vi.stubGlobal('document', {
createElement: () => {
const el = { href: '', download: '', click: clickMock };
anchors.push(el);
return el;
},
body: {
appendChild: vi.fn(),
removeChild: vi.fn(),
},
});
// triggerDownload calls setTimeout for deferred revoke
vi.stubGlobal('setTimeout', (fn: () => void) => fn());
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('triggers a download with a .png filename', () => {
const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
exportAsImage(dataUrl, 'My Design');
expect(clickMock).toHaveBeenCalledOnce();
expect(anchors).toHaveLength(1);
expect(anchors[0]!.download).toBe('My-Design.png');
});
it('sanitizes the title into a safe filename', () => {
exportAsImage('data:image/png;base64,AA==', 'Hello <World> / Test!');
expect(anchors[0]!.download).toBe('Hello-World-Test.png');
});
});

View file

@ -59,6 +59,13 @@ sections, modules, badges, callouts, ribbons, banners, decorations or
chrome that aren't already in `example.html`. If a detail is not in
the example, it does not belong in your output.
**Identity guard.** Treat every person name or handle in `example.html`
as mock content only. Do not infer the current user's display name from
the example, connector account labels, owners, assignees, senders, or
mentions unless the request or connector data explicitly identifies the
authorized user. If no explicit current-user name is available, use
neutral wording such as `you`, `your`, `current user`, `我`, or `你`.
The body sections below are a **reference for the visual language and
tokens** — they are not a license to add features the example doesn't
already render.
@ -107,8 +114,8 @@ Connector icons must be monochrome line SVG (1.5 stroke).
## Page sections (top to bottom)
1. **Hero** — single row, ~80px tall.
Left: `☀ 你好, Eli` (Cormorant 38px, with `,` in `--orange`).
Right of name: `· 2026 年 5 月 6 日 · 星期三` (muted, 18px).
Left: `☀ 你好` (Cormorant 38px).
Right of greeting: `· 2026 年 5 月 6 日 · 星期三` (muted, 18px).
Far right: round avatar (40px) + small ⚙ + ✕ icons.
2. **KPI strip** — single row, ~120px tall, 5 columns equal width.

View file

@ -652,7 +652,7 @@
<line x1="33.9" y1="14.1" x2="38.14" y2="9.86"/>
</g>
</svg>
<h1>你好<span>Eli</span></h1>
<h1>你好</h1>
<div class="hero-date">2026 年 5 月 6 日 · 星期三</div>
</div>
@ -936,14 +936,14 @@
<div class="slack-avatar">MK</div>
<div class="slack-content">
<div class="slack-channel">#frontend</div>
<div class="slack-text"><span class="slack-sender">Mike</span> <span class="slack-highlight">@Eli</span> nav 组件有个 z-index 的问题,能看一下吗</div>
<div class="slack-text"><span class="slack-sender">Mike</span> <span class="slack-highlight">@you</span> nav 组件有个 z-index 的问题,能看一下吗</div>
</div>
</div>
<div class="slack-msg">
<div class="slack-avatar">LW</div>
<div class="slack-content">
<div class="slack-channel">#ship-it</div>
<div class="slack-text"><span class="slack-sender">Lisa</span> 权限 PR merge 后可以发 staging 了 <span class="slack-highlight">@Eli</span></div>
<div class="slack-text"><span class="slack-sender">Lisa</span> 权限 PR merge 后可以发 staging 了 <span class="slack-highlight">@you</span></div>
</div>
</div>
<div class="slack-msg">
@ -972,7 +972,7 @@
<div class="mail-body">
<div class="mail-from">Alex Wang</div>
<div class="mail-subject">Q3 roadmap feedback</div>
<div class="mail-snippet">Hi Eli, 看了你上周的 roadmap 草稿,有几个建议…</div>
<div class="mail-snippet">Hi there, 看了你上周的 roadmap 草稿,有几个建议…</div>
</div>
<div class="mail-time">09:14</div>
</div>
@ -1015,7 +1015,7 @@
Q3 产品路线图
</div>
<div class="notion-doc-excerpt">核心目标:权限系统 v2 上线、自助 billing 模块、onboarding 转化率提升 15%。时间线按 milestone 拆分…</div>
<div class="notion-doc-meta">Eli 编辑 · 3h ago</div>
<div class="notion-doc-meta">You edited · 3h ago</div>
</div>
<div class="notion-doc">
<div class="notion-doc-title">
@ -1299,4 +1299,4 @@
});
</script>
</body>
</html>
</html>

View file

@ -53,6 +53,13 @@ no extra event types, no extra badges, no extra chrome ornaments. If
something is not already present in `example.html`, it does not
belong in your output.
**Identity guard.** Treat every person name or handle in `example.html`
as mock content only. Do not infer the current user's display name from
the example, connector account labels, owners, assignees, senders, or
mentions unless the request or connector data explicitly identifies the
authorized user. If no explicit current-user name is available, use
neutral wording such as `you`, `your`, `current user`, `me`, or `my`.
The sections below are a **reference for tokens and visual language**
not a license to extend the page.

View file

@ -457,7 +457,7 @@
<!-- Action buttons -->
<div class="gh-header-actions">
<div class="gh-avatar-header">E</div>
<div class="gh-avatar-header">Y</div>
</div>
</header>
@ -538,7 +538,7 @@
<div class="reviewer-avatar" style="background:#cf222e;" title="jess — approved">J
<span class="reviewer-status approved"><svg viewBox="0 0 16 16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg></span>
</div>
<div class="reviewer-avatar" style="background:#8250df;" title="eli — pending">E
<div class="reviewer-avatar" style="background:#8250df;" title="you — pending">Y
<span class="reviewer-status pending"></span>
</div>
<div class="reviewer-avatar" style="background:#bf3989;" title="cody — pending">C

View file

@ -65,6 +65,13 @@ extra UI: no inbox listing, no left rail, no Categories tab strip,
no extra digest sections, no chrome ornaments. If a detail is not
already in `example.html`, it does not belong in your output.
**Identity guard.** Treat every person name or handle in `example.html`
as mock content only. Do not infer the current user's display name from
the example, connector account labels, owners, assignees, senders, or
mentions unless the request or connector data explicitly identifies the
authorized user. If no explicit current-user name is available, use
neutral wording such as `you`, `your`, `current user`, `我`, or `你`.
The sections below are a **reference for tokens and visual language**
not a license to extend the page.
@ -122,7 +129,7 @@ Type stack:
/ spacer / prev / next.
b. **Email subject area**`<h1 class="email-subject">` with the
digest subject (e.g. `☀ Eli, 你昨天的 6 封重要邮件 — Open Orbit
digest subject (e.g. `☀ 你昨天的 6 封重要邮件 — Open Orbit
Daily`) followed by an inline `Orbit` tag.
c. **Sender row** — round avatar `O` + `Open Orbit

View file

@ -27,7 +27,7 @@
})();</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Open Orbit · Gmail Daily Digest — Eli</title>
<title>Open Orbit · Gmail Daily Digest</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html { height: 100%; }
@ -405,7 +405,7 @@ body {
<span></span><span></span><span></span>
</div>
</div>
<div class="user-avatar">E</div>
<div class="user-avatar">Y</div>
</div>
</header>
@ -442,7 +442,7 @@ body {
<!-- Subject -->
<div class="email-subject-area">
<h1 class="email-subject">
Eli, 你昨天的 6 封重要邮件 — Open Orbit Daily
☀ 你昨天的 6 封重要邮件 — Open Orbit Daily
<span class="tag">Orbit</span>
</h1>
</div>
@ -467,7 +467,7 @@ body {
<div class="digest-body">
<div class="greeting">
你好 Eli 👋<br>
你好 👋<br>
以下是你昨天5月5日的 Gmail 简报。共 <strong>6 封值得关注</strong>,已按优先级分组。
</div>

View file

@ -54,6 +54,13 @@ labels, ages, assignees) so they read as "today", but you must
**not** add extra rail entries, extra groups, extra fields in the
preview pane, or any chrome ornaments not already in `example.html`.
**Identity guard.** Treat every person name or handle in `example.html`
as mock content only. Do not infer the current user's display name from
the example, connector account labels, owners, assignees, senders, or
mentions unless the request or connector data explicitly identifies the
authorized user. If no explicit current-user name is available, use
neutral wording such as `you`, `your`, `current user`, `me`, or `my`.
The sections below are a **reference for tokens and visual language**
not a license to extend the page.

View file

@ -286,7 +286,7 @@ body {
</div>
<div class="greeting">
<h1>Hello, Eli</h1>
<h1>Hello</h1>
<p>Tuesday, May 6, 2026 — here's what changed in Linear overnight.</p>
</div>
@ -346,8 +346,8 @@ body {
<div class="act-text"><strong>Sara</strong> added label <em>auth</em> <span class="t">· May 1</span></div>
</div>
<div class="act">
<div class="act-av">E</div>
<div class="act-text"><strong>Eli</strong> moved to In Progress <span class="t">· May 1</span></div>
<div class="act-av">Y</div>
<div class="act-text"><strong>You</strong> moved to In Progress <span class="t">· May 1</span></div>
</div>
</div>
</div>
@ -449,14 +449,14 @@ body {
<div class="issue-detail">
<p class="detail-desc">Sliding-window rate limiting on all public API endpoints. 120 req/min per API key, returns <code>429</code> with <code>Retry-After</code> header.</p>
<div class="props">
<span class="k">Assignee</span><span class="v">Eli</span>
<span class="k">Assignee</span><span class="v">You</span>
<span class="k">Priority</span><span class="v"><span class="pri"><i class="on"></i><i class="on"></i><i></i><i></i></span> Medium</span>
<span class="k">Cycle</span><span class="v">Cycle 12</span>
</div>
<div class="activity-label">Activity</div>
<div class="act">
<div class="act-av">E</div>
<div class="act-text"><strong>Eli</strong> marked as Done <span class="t">· 22:14</span></div>
<div class="act-av">Y</div>
<div class="act-text"><strong>You</strong> marked as Done <span class="t">· 22:14</span></div>
</div>
</div>
</div>
@ -568,4 +568,4 @@ document.querySelectorAll('.issue').forEach(issue => {
});
</script>
</body>
</html>
</html>

View file

@ -54,6 +54,13 @@ invent extra blocks: no extra H2 sections, no extra callouts, no
extra database columns, no extra emoji decorations. If a detail is
not in `example.html`, it does not belong in your output.
**Identity guard.** Treat every person name or handle in `example.html`
as mock content only. Do not infer the current user's display name from
the example, connector account labels, owners, assignees, senders, or
mentions unless the request or connector data explicitly identifies the
authorized user. If no explicit current-user name is available, use
neutral wording such as `you`, `your`, `current user`, `我`, or `你`.
The sections below are a **reference for tokens and visual language**
not a license to extend the page.
@ -120,7 +127,7 @@ column at ~720px max width with the rest as `--gray-light` rails.
4. **Page header inside content column** — emoji icon (60px) at top,
then page title `每日简报 · 2026 年 5 月 6 日 (Wed)` in 40px bold,
then a row of property chips (gray):
`🗂 Type: Daily Briefing · 👤 Owner: Eli · 📅 Created: 06:42`.
`🗂 Type: Daily Briefing · 👤 Owner: Current user · 📅 Created: 06:42`.
5. **Synopsis paragraph** — one sentence, italic muted:
*"Auto-generated by Open Orbit from yesterday's Notion activity.
@ -139,7 +146,7 @@ column at ~720px max width with the rest as `--gray-light` rails.
8. **Callout block** — required. `gray bg`, 16px padding, rounded 6px,
left side has a 24px emoji (e.g. 🌟 or 💡). Body:
*"Eli, 你昨天还有 3 条评论没回 — 周三例会前看一下?"*
*"你昨天还有 3 条评论没回 — 周三例会前看一下?"*
9. **H2 section: 🗄 数据库变更** — render as a Notion database
table view inline.

View file

@ -401,7 +401,7 @@
<!-- page content -->
<div class="page-content" data-od-id="page-body">
<h1 class="page-title" data-od-id="headline">📒 Notion · 昨日 3 条与你相关</h1>
<p class="page-subtitle">2026-05-06 你好Eli · 由 Open Orbit 自动生成</p>
<p class="page-subtitle">2026-05-06 你好 · 由 Open Orbit 自动生成</p>
<hr class="notion-divider" />

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 16 MiB

After

Width:  |  Height:  |  Size: 17 MiB

Some files were not shown because too many files have changed in this diff Show more