mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
commit
6c16283850
121 changed files with 8155 additions and 1141 deletions
68
.github/workflows/ci.yml
vendored
68
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
13
.github/workflows/contributor-card-bot.yml
vendored
13
.github/workflows/contributor-card-bot.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
72
.github/workflows/critique-conformance.yml
vendored
Normal file
72
.github/workflows/critique-conformance.yml
vendored
Normal 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
|
||||
2
.github/workflows/discord-resolved.yml
vendored
2
.github/workflows/discord-resolved.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
.github/workflows/landing-page-deploy.yml
vendored
1
.github/workflows/landing-page-deploy.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1
.github/workflows/metrics.yml
vendored
1
.github/workflows/metrics.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
1
.github/workflows/release-beta.yml
vendored
1
.github/workflows/release-beta.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
.github/workflows/release-stable.yml
vendored
1
.github/workflows/release-stable.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
104
apps/daemon/src/critique/__fixtures__/run-nightly.ts
Normal file
104
apps/daemon/src/critique/__fixtures__/run-nightly.ts
Normal 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);
|
||||
});
|
||||
133
apps/daemon/src/critique/conformance-history.ts
Normal file
133
apps/daemon/src/critique/conformance-history.ts
Normal 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;
|
||||
}
|
||||
268
apps/daemon/src/critique/ratchet.ts
Normal file
268
apps/daemon/src/critique/ratchet.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 5–10 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 5–10 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).
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? {
|
||||
|
|
|
|||
281
apps/daemon/src/runtimes/runtime-adapter.ts
Normal file
281
apps/daemon/src/runtimes/runtime-adapter.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
144
apps/daemon/tests/critique-conformance-history.test.ts
Normal file
144
apps/daemon/tests/critique-conformance-history.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
235
apps/daemon/tests/critique-ratchet.test.ts
Normal file
235
apps/daemon/tests/critique-ratchet.test.ts
Normal 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;
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
161
apps/daemon/tests/media-alias-capability.test.ts
Normal file
161
apps/daemon/tests/media-alias-capability.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>';
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
370
apps/daemon/tests/runtimes/runtime-adapter.test.ts
Normal file
370
apps/daemon/tests/runtimes/runtime-adapter.test.ts
Normal 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"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -3348,6 +3348,7 @@ function OrbitSection({
|
|||
navigateRoute({
|
||||
kind: 'project',
|
||||
projectId: payload.projectId,
|
||||
conversationId: null,
|
||||
fileName: null,
|
||||
});
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -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': 'حفظ كقالب...',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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': 'ذخیره به عنوان قالب…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'テンプレートとして保存…',
|
||||
|
|
|
|||
|
|
@ -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': '템플릿으로 저장…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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': 'Сохранить как шаблон…',
|
||||
|
|
|
|||
|
|
@ -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': 'จัดเก็บในหมวดเทมเพลต…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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': 'Зберегти як шаблон…',
|
||||
|
|
|
|||
|
|
@ -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': '保存为模板…',
|
||||
|
|
|
|||
|
|
@ -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': '儲存為範本…',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
32
apps/web/src/lib/build-continue-in-cli-toast.ts
Normal file
32
apps/web/src/lib/build-continue-in-cli-toast.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' }]);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
31
apps/web/tests/lib/build-continue-in-cli-toast.test.ts
Normal file
31
apps/web/tests/lib/build-continue-in-cli-toast.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
114
apps/web/tests/router.test.ts
Normal file
114
apps/web/tests/router.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue