mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
[OPEND-2800][OPEND-4117] feat(od-next): integrate Discovery delivery and validated prototype fixes (#8804)
* fix(od-next): continue plans with keyed markers and honor explicit deliverables * fix(od-next): retire output contracts and hand off continued tasks * fix(od-next): record round settlement and recover production cold * fix(od-next): resume marker sessions with context deltas * fix(od-next): separate completion facts and honor plan-only intent * fix(od-next): preserve physical failures and legacy chat compatibility * test(web): align run recovery fixtures and await campaign mount * fix(od-next): tolerate terminal production marker formatting * fix(od-next): unblock follow-ups after a stop or unreadable history (#8423) Treat a previous task this build cannot read as absent so the follow-up starts a new task, and stop counting a Run with a pending cancel as holding its conversation. Add end-to-end coverage for the marker continuation flow and restore the remote Workspace directory gate cases. * feat(od-next): add deliverable discovery and ten-type routing * fix(od-next): refine discovery and live delivery evidence * feat(observability): expose actual task delivery receipts for evaluation * fix(observability): settle successful headless task telemetry * docs: clarify focused local validation Keep local daemon and web validation scoped to affected files and integration boundaries. Reserve full suites for PR CI unless the user explicitly requests them. * feat: add Motion Design skill and OD Next task route * fix(web): rename motion design entry and place it second * fix(web): reconcile motion design catalog consumers * feat(web): add motion design templates to Home and Community Five motion design example templates (3D procedural island, explainer, product launch, showreel, data story), chosen by community reach, under the Home motion-design chip and a new Community "Motion design" type. Each template follows the bundled example layout: an OD-generated example.html with its runtime libraries under assets/, an HTML preview baked by the existing preview pipeline, and a SKILL.md that carries example.html as the reference when the card is picked on the OD Next motion-design route. Home cards ship curated copy for every UI locale. Motion templates stay off the Video and HyperFrames chips. * fix(skills): keep motion-design procedural layers visible in video export The MP4 exporter seeks timelines with callbacks suppressed (renderSeek(t, { suppressEvents: true })), so content drawn only from a GSAP onUpdate callback is missing from exported video. Recommend a per-frame draw loop that reads the timeline clock, and state that visible content must not depend on timeline callbacks. The OD Next task asset is re-synced with scripts/sync-motion-design-skill.ts. * fix(motion): preserve reference assets and exported animation frames * feat(skills): integrate curated motion helpers with complete runtime files * feat(skills): add Huashu, Oil Motion and Motion.Lab adapters * test: classify animate design guidance in narration guard * feat(motion): refine five community templates and portable previews * fix(motion): refine launch transitions and center gallery previews * fix: allow free-form briefs on automatic motion task route * feat(motion): add template soundtracks and audio export * fix(motion): require rendered MP4 alongside editable video source * test(motion): align strategy activation expectation with package version * fix: scope motion delivery and make editable sources playable * fix(motion): bundle source dependencies and valid closing protocol * feat(motion): rebuild the five templates on the most-viewed community posts Re-select topics by single-post views in the latest awesome-opus-5.5-video snapshot and anchor each template to the top eligible post of its genre: camera-focus explainer, showreel, product launch, procedural island and a new wordless animated short that replaces the data story. All five example.html files are hand-authored canvas/Three.js films drawn by one render(t) from the GSAP master timeline, so preview, scrubbing and MP4 export produce identical frames. Each ships an original procedural soundtrack, updated SKILL.md and 19-locale copy; Home order follows reach. * fix(plugins): load bundled fonts in plugin previews Plugin previews blocked every bundled font: the preview CSP had no font-src, and relative url() references inside inline <style> blocks kept pointing at the preview URL instead of the plugin folder. Allow same-origin fonts, map inline-style url() references through the existing asset route (escapes, absolute and data: URLs stay untouched), and serve font files with font MIME types. * feat(community): make motion template Use match the Home example pick Community 「做同款」 on a motion design template now binds it as that type's own example card, carrying the type's project metadata, so the run takes the same OD Next motion-design route as a pick from Home's motion design list and delivers the MP4 together with the editable source. Other task types keep the existing plugin-pinning hand-off. Community use clicks now record use_source (card | detail_modal) and use_action (use | use_with_query); the details modal Use is tracked for the first time. The Home hand-off no longer records a second community_gallery click for uses Community already tracked. * fix(plugins): count the five motion templates in the official registry metadata * fix(media): keep HyperFrames audio filters compatible with FFmpeg 4.1 CI's bundled Linux FFmpeg 4.1 rejects adelay all=1 and amix normalize=0. Make each track stereo and delay both channels explicitly, skip amix for a single track, and undo amix averaging with a gain for several tracks. * fix(motion): keep template previews bakeable on slow CI renderers CI's software-GL baker skipped the 3D island (preview readiness timeout): under slow frames GSAP lag smoothing slowed the timeline, so the 8 s poster frame never arrived within the baker's 15 s wait. Play every template on the wall clock and move the island poster to 6 s. With simulated 300 ms frames all five now report ready within 10 s. * feat(motion): add the kinetic typography template Template selection now follows the motion design spec's four content types (brand and product films, kinetic typography, explainer animation, procedural art). Kinetic typography takes the fifth slot, replacing the animated short. The new template is a 15-second, 120 BPM type film with an original soundtrack and 19-locale copy. * fix(motion): let the project motion player fit template stages Inside a project the motion source player wraps the composition, fits it and adds its own controls. The templates also scaled their stage, so editable sources from Remix and Community use rendered at a fraction of the preview. Templates now keep their own fit and controls only outside that player. * feat(motion): restage the 3D island as a midday lagoon The island moves from golden hour to a bright tropical midday closer to its anchor post: a shader lagoon coloured by a baked depth map with ripples, shallow-water shimmer and shore foam, a wide white-sand beach, palms and instanced bushes, a village with a pier, soft sun shadows and a lower camera path. The CPU wave mesh is gone, so frames cost no more than before. Copy is updated in all 19 locales. * fix(motion): keep each soundtrack inside the composition root A Community use regenerated the island source tail and dropped the root <audio> element, so its MP4 had no sound. Templates now place the element inside the composition root next to the stage. * docs(motion): state the template selection rule as complete works * fix(motion): enforce video clocks and bound content verification * feat(motion): replace the motion templates with remakes of viral posts The five Motion design templates are now frame-faithful remakes of viral motion posts, so each preview shows the look users came for: the 15-second motion designer showreel, a high-energy motion reel (copy set in English), a one-shape UI motion piece, a pixel-art wizard and a 20-second electric-blue tech reel. Each ships open-licensed fonts, an original soundtrack, the creator's published prompt where one exists, and credits its source post. The explainer, product launch, 3D island and kinetic type templates are removed. Tests follow the new set; a backward-seek check now covers hidden WebGL layers. * feat(motion): feature The Future Is Already Here first and start preview sound with the film * chore: ensure PR CI * fix(motion): match The Future Is Already Here controls and reorder the motion templates * fix(motion): make the one-shape UI preview controls legible * fix(motion): hide The Future Is Already Here controls inside the project player * fix(od-next): maintain discovery prompt assets in English * fix(motion): plan suitable sound and synchronize source audio playback * feat(motion): keep remake soundtracks by default and fade exported audio where it stops * test(motion): cover audible source controls in Chromium * fix(test): use explicit ESM imports in audio player regression * fix(od-next): preserve planning intent and make agent CLI probes inert * fix(motion): align preview track discovery with video export * feat(motion): autoplay looping HTML without a player bar * fix(test): consume the built motion playback contract * docs(motion): state explicit soundtrack rules for make-same and the music line in replies * chore(motion): version the audible looping delivery profile * docs(motion): let template sound rules lead the general policy and freeze the audio contract * fix(motion): scope autoplay to declared video motion sources * fix(motion): reject audible MP4s under an explicit silent contract * docs(motion): drop the routine music note from make-same replies * test(motion): align delivery regressions with looping source playback * fix(od-next): route refreshable sources and preserve live templates * fix(od-next): distinguish source refresh from preview reload * fix(motion): isolate preview and audio export from legacy HyperFrames * fix(od-next): retain live list containers for empty source data * test(motion): retain the existing desktop legacy audio limitation * fix(motion): suspend audio when previews leave the active workspace * feat(motion): replace two motion templates with a product promo and a visual lesson; sign the showreel as Open Design * fix(discovery): distinguish reference recreation from new prototypes * fix(discovery): require skill reads before copying reference artifacts * feat(motion): give the showreel the Open Design palette and a full-size stacked wordmark * feat(motion): give the product promo a livelier mascot girl * refactor(motion): rebuild The Future Is Already Here as readable, editable scene code * fix(motion): pick gallery cover frames that survive the baker's capture delay * feat(motion): sharpen the showreel opener, depth, truchet and end card * fix(discovery): avoid implementation-only skills and false video delivery * feat(motion): make the product promo mascot talk, add a speaking score and the flow pop * fix(discovery): distinguish video rendering from editable animation delivery * feat(motion): give the visual lesson the original's paper-collage texture * fix(motion): bundle gallery covers for fresh branch checkouts * feat(motion): feature and refine Bring Ideas to Life Adapt the paper story around OpenDesign capabilities, with short rounded hands, clearer timing, continuous paper actions, and a larger audience invitation. Feature it first in Motion and synchronize localized names, metadata, cover, guidance, and regressions. Keep product promo audio music-only with sparse percussion. * fix(motion): stabilize artifact cards and capture declared covers * fix(discovery): retain WebGL intent and isolate reference-only skills * fix(discovery): complete authorized plans and verify audible delivery * fix(discovery): gate skill reads on current production authorization * fix(web): share creation type order with community * fix(discovery): stop required-model attempts on authentication failure * fix(discovery): retain required model provider and billing channel * fix(discovery): stop opaque video failures without diagnostic generations * fix(discovery): distinguish local state and tighten output adherence * fix(preview): preserve local forms and hash routes in strict sandbox * fix(prototype): keep validation feedback selectors consistent * fix(prototype): preserve native hidden feedback states * fix(discovery): align local routes and live filter states * fix(prototype): preserve dynamic controls and dialog visibility * fix(prototype): keep review and confirmation state coherent * fix(web): stabilize preview activation and retain scoped local progress * test(web): verify persistent previews through activation messages * feat(web): refine task type selection and keyboard navigation * fix(discovery): distinguish execution from requested delivery and expose recovery * fix(web): use final run delivery state in folded task history * revert(web): restore todo-based continuation controls * revert(od-next): remove experimental continuation recovery changes * fix(telemetry): bind Task traces to authenticated Vela identity * test(telemetry): establish accepted Task roots in bridge fixtures * test(discovery): align required CI fixtures with integrated behavior * fix(web): preserve succeeded messages when run probe is unavailable * test(discovery): verify selection clearing and production turn recovery --------- Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com> Co-authored-by: Cheems <hihicheems@gmail.com> Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com> Co-authored-by: zhouzheng <zhouzheng@refly.ai> Co-authored-by: William <zhouhaoran@refly.ai> Co-authored-by: open-design-crew[bot] <4197780+open-design-crew[bot]@users.noreply.github.com> Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
This commit is contained in:
co-authored by
bone3deep1962-collab
Cheems
Cheems
zhouzheng
William
open-design-crew[bot] <4197780+open-design-crew[bot]@users.noreply.github.com>
NJUHua
parent
dd43a3cef1
commit
3dfd41c6b3
@@ -702,31 +702,9 @@ function applyConfigValue(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What this installation's OD Next preference reads as when the field is there
|
||||
* but cannot be understood.
|
||||
*
|
||||
* Scoped deliberately narrow: this covers `odNextStrategyMode` holding a value
|
||||
* that is not one of the modes — a hand edit, a typo, a mode some other version
|
||||
* writes. Something was configured and we cannot read it, and since flipping
|
||||
* the default made unconfigured mean `active`, dropping it would turn "we
|
||||
* cannot read your choice" into "you chose OD Next".
|
||||
*
|
||||
* It deliberately does NOT cover a config file that fails to parse at all, or
|
||||
* one whose body is not an object. Those reset every preference to its default
|
||||
* — agent, telemetry, everything — and singling this one out to resolve against
|
||||
* its default would be inconsistent with the rest of the file and would opt
|
||||
* installations out of a rollout they never declined. A broken file is not
|
||||
* evidence of an opt-out; it is evidence of a broken file, and the user has
|
||||
* lost the whole config either way.
|
||||
*
|
||||
* The narrow case still has the property worth having: a user who never opted
|
||||
* out is unaffected, because a readable config keeps its value and a fresh
|
||||
* install has no key at all.
|
||||
*
|
||||
* This is a claim about one field, not about the user, so it is deliberately
|
||||
* not reported as a distinct mode source: `readOdNextRolloutPolicy` sees a
|
||||
* saved `off` and says `app_config`, which is true — a config is what decided.
|
||||
/** Legacy field normalization retained for config compatibility.
|
||||
* OD Next rollout ignores this retired Labs preference, including normalized off.
|
||||
* Operator environment controls remain the supported rollout override.
|
||||
*/
|
||||
const OD_NEXT_MODE_WHEN_CONFIG_UNREADABLE = 'off' as const;
|
||||
|
||||
@@ -904,21 +882,9 @@ export class InvalidAppConfigValueError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a write that names a control key with a value that is not one of its
|
||||
* modes.
|
||||
*
|
||||
* Every other preference here is sanitized by dropping what it cannot store,
|
||||
* and that is the right trade for a preference: the cost of a bad value is one
|
||||
* setting falling back to its default. `odNextStrategyMode` is not a
|
||||
* preference — it decides whether OD Next runs at all, and its default is
|
||||
* `active`, so dropping it is not a neutral outcome. It revokes an opt-out,
|
||||
* which means `od config set odNextStrategyMode of` would put the installation
|
||||
* back on OD Next while printing success, and the person who typed it would go
|
||||
* on believing they had opted out.
|
||||
*
|
||||
* So a typo fails loudly instead. `null` stays a legitimate value: clearing the
|
||||
* key IS the deliberate way to return to the default.
|
||||
/** Keep the legacy stored-value contract for older callers. The current rollout
|
||||
* ignores this user preference; only an engineering environment override can
|
||||
* change the new active default. Invalid legacy writes still fail explicitly.
|
||||
*/
|
||||
function assertWritableControlValues(partial: Record<string, unknown>): void {
|
||||
if (!Object.prototype.hasOwnProperty.call(partial, 'odNextStrategyMode')) return;
|
||||
|
||||
@@ -44,6 +44,7 @@ export async function finalizeSuccessfulRunDeliverable(input: {
|
||||
touchedPaths?: string[];
|
||||
baselineEntryFile?: string;
|
||||
allowIndependentOutput?: boolean;
|
||||
allowMultipleOutputCandidates?: boolean;
|
||||
syntaxFinalizerEnabled?: boolean;
|
||||
}): Promise<SuccessfulRunDeliverableFinalizationResult> {
|
||||
const deliverable = await validateRunDeliverable({
|
||||
@@ -57,6 +58,7 @@ export async function finalizeSuccessfulRunDeliverable(input: {
|
||||
...(input.touchedPaths ? { touchedPaths: input.touchedPaths } : {}),
|
||||
...(input.baselineEntryFile ? { baselineEntryFile: input.baselineEntryFile } : {}),
|
||||
...(input.allowIndependentOutput ? { allowIndependentOutput: true } : {}),
|
||||
...(input.allowMultipleOutputCandidates ? { allowMultipleOutputCandidates: true } : {}),
|
||||
});
|
||||
if (
|
||||
!deliverable.valid
|
||||
|
||||
+22
-11
@@ -637,13 +637,10 @@ function printStrategyHelp() {
|
||||
|
||||
Report which OD Next mode this daemon is running and which authority chose it.
|
||||
|
||||
OD Next runs by default; this installation opts out of it here:
|
||||
|
||||
od config set odNextStrategyMode off Opt out; takes effect next run.
|
||||
od config set odNextStrategyMode active Opt back in.
|
||||
|
||||
Status names the authority in effect (env / app_config / default), which the
|
||||
mode alone does not: leaving it unset and saving 'off' produce opposite routes.
|
||||
OD Next is active by default. Historical personal odNextStrategyMode values
|
||||
are ignored; saving 'off' does not opt out. Status reports the effective mode
|
||||
and its authority. Process-level OD_NEXT_STRATEGY_ROLLOUT controls engineering
|
||||
rollouts separately from personal settings.
|
||||
|
||||
Options:
|
||||
--json Emit the daemon response as JSON.
|
||||
@@ -683,8 +680,7 @@ async function runStrategy(args) {
|
||||
console.error(
|
||||
'od strategy rollout reset was removed: the daemon-instance stop latch it '
|
||||
+ 'cleared no longer exists, so there is nothing to reset.\n'
|
||||
+ 'OD Next now runs unless this installation opts out. To opt out:\n'
|
||||
+ ' od config set odNextStrategyMode off\n'
|
||||
+ 'OD Next is active by default; historical personal off values are ignored.\n'
|
||||
+ 'To check which authority decides the mode:\n'
|
||||
+ ' od strategy rollout status',
|
||||
);
|
||||
@@ -1971,7 +1967,7 @@ async function runMedia(args) {
|
||||
printMediaHelp();
|
||||
return;
|
||||
}
|
||||
if (sub !== 'generate' && sub !== 'wait' && sub !== 'scaffold') {
|
||||
if (sub !== 'generate' && sub !== 'wait' && sub !== 'scaffold' && sub !== 'models') {
|
||||
console.error(`unknown subcommand: od media ${sub}`);
|
||||
printMediaHelp();
|
||||
process.exit(1);
|
||||
@@ -1979,6 +1975,20 @@ async function runMedia(args) {
|
||||
|
||||
const idx = args.indexOf(sub);
|
||||
const subArgs = [...args.slice(0, idx), ...args.slice(idx + 1)];
|
||||
if (sub === 'models') {
|
||||
const flags = parseFlags(subArgs, { string: ['daemon-url', 'project', 'workspace', 'workspace-member'], boolean: ['json', 'cloud', 'help', 'h'] });
|
||||
if (flags.help || flags.h) { printMediaHelp(); return; }
|
||||
const daemonUrl = await cliDaemonUrl(flags);
|
||||
const token = process.env.OD_TOOL_TOKEN;
|
||||
const query = new URLSearchParams();
|
||||
if (flags.cloud) { query.set('cloud', 'true'); query.set('projectId', flags.project || process.env.OD_PROJECT_ID || ''); }
|
||||
const response = await fetch(`${daemonUrl.replace(/\/$/, '')}/api/media/models?${query}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : workspaceHeadersFromExplicitFlags(flags) ?? {},
|
||||
});
|
||||
if (!response.ok) throw new Error(`Media catalog request failed (${response.status})`);
|
||||
console.log(JSON.stringify(await response.json()));
|
||||
return;
|
||||
}
|
||||
if (sub === 'wait') return runMediaWait(subArgs);
|
||||
if (sub === 'scaffold') return runMediaScaffold(subArgs);
|
||||
return runMediaGenerate(subArgs);
|
||||
@@ -2477,7 +2487,8 @@ async function cliDaemonBaseUrl(flags) {
|
||||
}
|
||||
|
||||
function printMediaHelp() {
|
||||
console.log(`Usage: od media scaffold --composition-dir .hyperframes-cache/<id> [opts]
|
||||
console.log(`Usage: od media models [--cloud --project <id>] --json
|
||||
od media scaffold --composition-dir .hyperframes-cache/<id> [opts]
|
||||
od media generate --surface <image|video|audio> --model <id> [opts]
|
||||
"$OD_NODE_BIN" "$OD_BIN" media generate --surface <image|video|audio> --model <id> [opts]
|
||||
|
||||
|
||||
@@ -46,6 +46,12 @@ export function parseDaemonCliStartupArgs(
|
||||
argv: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): DaemonCliStartupParseResult {
|
||||
// An agent's bare CLI probe is help, not permission to boot a second daemon
|
||||
// against its inherited data root and reconcile the still-active parent run.
|
||||
if (argv.length === 0 && env.OD_DAEMON_URL?.trim()
|
||||
&& (env.OPEN_DESIGN_RUN_ID?.trim() || env.OD_TOOL_TOKEN?.trim())) {
|
||||
return { ok: false, kind: 'help' };
|
||||
}
|
||||
let port = Number(env.OD_PORT) || 7456;
|
||||
let host = normalizeDaemonBindHost(env.OD_BIND_HOST);
|
||||
let open = true;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { taskUpdateSink } from './observability/task-telemetry-receipt.js';
|
||||
import { discoveryObservationForRun } from './strategies/od-next/discovery-observation.js';
|
||||
import { codexTurnUsageFromEvents } from './observability/codex-turn-usage.js';
|
||||
import { enqueueObjectEvidence, taskObjectDeliveryEnabled, inheritFrozenAttachments, readObjectEvidence, enqueueFeedbackEvidence, drainEvidence } from './services/evidence-delivery.js';
|
||||
import { attachmentContext, buildEvalContext, evidenceMode } from './observability/eval-context.js';
|
||||
@@ -40,7 +42,6 @@ import {
|
||||
buildSafeRunQualityProjectionV1,
|
||||
buildTracePayload,
|
||||
readFeedbackTelemetrySinkConfig,
|
||||
readTaskTelemetrySinkConfig,
|
||||
readRunTelemetrySinkConfig,
|
||||
reportRunCompleted,
|
||||
reportRunFeedback,
|
||||
@@ -1507,11 +1508,13 @@ export async function buildSafeRunQualityProjectionFromDaemon(
|
||||
},
|
||||
...(run.strategyRolloutDecision ? { strategyRolloutDecision: run.strategyRolloutDecision } : {}),
|
||||
...(run.promptTelemetry ? { promptTelemetry: run.promptTelemetry } : {}),
|
||||
skillDiscovery: discoveryObservationForRun(run),
|
||||
...(deliverableSyntax ? { deliverableSyntax } : {}),
|
||||
};
|
||||
const event = buildTracePayload(context)[0] as { body: { input?: unknown; output?: unknown; metadata: Record<string, unknown> } };
|
||||
opts.onTraceProjection({ input: event.body.input, output: event.body.output, metadata: {
|
||||
...event.body.metadata,
|
||||
...discoveryObservationForRun(run),
|
||||
provider_reported_usage: analytics,
|
||||
provider_reported_usage_scope: run.agentId === 'codex' ? 'provider_session' : 'provider_run',
|
||||
input_truncated: Buffer.byteLength(context.message.prompt) > 64 * 1024,
|
||||
@@ -1787,6 +1790,7 @@ export async function reportRunCompletedFromDaemon(
|
||||
...(turn ? { turn } : {}),
|
||||
runtime,
|
||||
...(run.promptTelemetry ? { promptTelemetry: run.promptTelemetry } : {}),
|
||||
skillDiscovery: discoveryObservationForRun(run),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1905,7 +1909,7 @@ export async function reportRunFeedbackFromDaemon(
|
||||
// configured to ship the score to.
|
||||
const configuredAmrEnv = agentCliEnvForAgent(cfg.agentCliEnv, 'amr');
|
||||
const sink = opts.traceId
|
||||
? readTaskTelemetrySinkConfig(process.env)
|
||||
? taskUpdateSink(opts.dataDir, opts.traceId, process.env, configuredAmrEnv)
|
||||
: readFeedbackTelemetrySinkConfig(process.env, configuredAmrEnv);
|
||||
if (!sink) {
|
||||
return { status: 'skipped_no_sink' };
|
||||
|
||||
@@ -122,6 +122,11 @@ export interface LangfuseDeliveryState {
|
||||
langfuse_attempt_count?: number;
|
||||
/** Stable, non-secret identity supplied by the daemon delivery owner. */
|
||||
langfuse_idempotency_key?: string;
|
||||
/** Identity actually accepted by the selected transport, never guessed from config. */
|
||||
traceReceipt?: {
|
||||
traceId: string;
|
||||
transport: 'vela' | 'relay' | 'langfuse';
|
||||
};
|
||||
}
|
||||
|
||||
export type TelemetrySinkConfig =
|
||||
@@ -377,6 +382,8 @@ export interface ReportContext {
|
||||
runtime?: RuntimeInfo;
|
||||
/** Redacted section-level prompt diagnostics captured before agent spawn. */
|
||||
promptTelemetry?: PromptStackTelemetry;
|
||||
/** Bounded, content-free discovery evidence from actual runtime events. */
|
||||
skillDiscovery?: Record<string, unknown>;
|
||||
strategyRolloutDecision?: {
|
||||
requestedMode: string;
|
||||
effectiveMode: string;
|
||||
@@ -483,15 +490,15 @@ export function readTelemetrySinkConfig(
|
||||
}
|
||||
|
||||
/**
|
||||
* Task hierarchy delivery deliberately excludes Vela until that service
|
||||
* advertises and is verified against the versioned Task observation schema.
|
||||
* Keep this named seam separate from completed-run/feedback resolution so a
|
||||
* Vela control key can never mask a valid relay or direct Task sink.
|
||||
* Task hierarchies use the same authenticated identity as completed runs.
|
||||
* Older Vela servers may reject the versioned schema; delivery handles that
|
||||
* explicit compatibility rejection without changing the execution strategy.
|
||||
*/
|
||||
export function readTaskTelemetrySinkConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): TelemetrySinkConfig | null {
|
||||
return readTelemetrySinkConfig(env);
|
||||
configuredEnv: Record<string, string> = {},
|
||||
): RunTelemetrySinkConfig | null {
|
||||
return readRunTelemetrySinkConfig(env, configuredEnv);
|
||||
}
|
||||
|
||||
function isVelaTelemetryEnabled(env: NodeJS.ProcessEnv): boolean {
|
||||
@@ -2031,6 +2038,7 @@ export function buildTracePayload(
|
||||
// here. Fields are flat (Langfuse stores it as JSON but indexes shallow
|
||||
// keys best). All entries are anonymous — no PII, no credentials.
|
||||
const traceMetadata: Record<string, unknown> = {
|
||||
...ctx.skillDiscovery,
|
||||
...(ctx.evalContextV2 && wantsContent ? { eval_context_v2: wantsArtifacts ? ctx.evalContextV2 : {
|
||||
...ctx.evalContextV2,
|
||||
attachments: { turnDelta: { semantics: 'current_user_turn', entries: [] }, effectiveContext: { semantics: 'conversation_context_before_run', entries: [] } },
|
||||
@@ -2695,6 +2703,11 @@ function velaIdempotencyKey(envelope: VelaTelemetryEnvelope): string {
|
||||
return createHash('sha256').update(JSON.stringify(canonical), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function telemetryResponseRecord(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
async function postVelaBatch(
|
||||
config: VelaTelemetrySinkConfig,
|
||||
batch: unknown[],
|
||||
@@ -2702,6 +2715,7 @@ async function postVelaBatch(
|
||||
fetchImpl: typeof fetch,
|
||||
opts: {
|
||||
allowAnonymousAuthFallback?: boolean;
|
||||
allowTaskSchemaFallback?: boolean;
|
||||
deliveryIdempotencyKey?: string;
|
||||
fallbackConfig?: TelemetrySinkConfig | null;
|
||||
maxTotalAttempts?: number;
|
||||
@@ -2741,16 +2755,35 @@ async function postVelaBatch(
|
||||
},
|
||||
);
|
||||
if (response.status === 202) {
|
||||
const accepted = telemetryResponseRecord(await response.json().catch(() => null));
|
||||
const clientTraceId = envelope.events.find(event => event.kind === 'trace')?.data.id;
|
||||
const receipt = telemetryResponseRecord(accepted.receipt);
|
||||
return {
|
||||
langfuse_expected: true,
|
||||
langfuse_delivery_status: 'accepted',
|
||||
...(typeof clientTraceId === 'string'
|
||||
&& receipt?.clientTraceId === clientTraceId
|
||||
&& typeof receipt.scopedTraceId === 'string'
|
||||
&& /^[a-f0-9]{64}$/.test(receipt.scopedTraceId)
|
||||
? { traceReceipt: { traceId: receipt.scopedTraceId, transport: 'vela' as const } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
await response.text().catch(() => '');
|
||||
const rejection = telemetryResponseRecord(await response.json().catch(() => null));
|
||||
const issues = Array.isArray(rejection.issues) ? rejection.issues : [];
|
||||
const issue = telemetryResponseRecord(issues[0]);
|
||||
const unsupportedTask = opts.allowTaskSchemaFallback !== false
|
||||
&& response.status === 400
|
||||
&& envelope.events.some(event => event.kind === 'trace'
|
||||
&& event.data.name === 'open-design-strategy-task')
|
||||
&& rejection?.error === 'invalid_open_design_telemetry_payload'
|
||||
&& issues.length === 1
|
||||
&& issue.path === 'events.0.data.name'
|
||||
&& issue.message === 'trace name must be open-design-turn';
|
||||
if (
|
||||
allowAnonymousAuthFallback &&
|
||||
(response.status === 401 || response.status === 403)
|
||||
unsupportedTask || (allowAnonymousAuthFallback &&
|
||||
(response.status === 401 || response.status === 403))
|
||||
) {
|
||||
const fallback = opts.fallbackConfig === undefined
|
||||
? readTelemetrySinkConfig()
|
||||
@@ -2761,16 +2794,11 @@ async function postVelaBatch(
|
||||
...fallback,
|
||||
retries: Math.min(fallback.retries, fallbackAttempts - 1),
|
||||
};
|
||||
const serialized = JSON.stringify({ batch });
|
||||
return cappedFallback.kind === 'relay'
|
||||
? postRelayBatch(
|
||||
cappedFallback,
|
||||
serialized,
|
||||
fetchImpl,
|
||||
opts.onAttempt,
|
||||
opts.deliveryIdempotencyKey,
|
||||
)
|
||||
: postLangfuseBatch(cappedFallback, batch, fetchImpl, opts.onAttempt);
|
||||
return postLegacyTelemetryBatch(cappedFallback, batch, {
|
||||
fetchImpl,
|
||||
...(opts.onAttempt ? { onAttempt: opts.onAttempt } : {}),
|
||||
...(opts.deliveryIdempotencyKey ? { deliveryIdempotencyKey: opts.deliveryIdempotencyKey } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -2813,6 +2841,9 @@ async function postVelaBatch(
|
||||
}
|
||||
|
||||
export interface PostLegacyTelemetryBatchOptions {
|
||||
/** Updates must stay on the root trace's transport, even when auth expires. */
|
||||
allowAnonymousAuthFallback?: boolean;
|
||||
allowTaskSchemaFallback?: boolean;
|
||||
installationId?: string | null;
|
||||
fetchImpl?: typeof fetch;
|
||||
deliveryIdempotencyKey?: string;
|
||||
@@ -2836,21 +2867,24 @@ export async function postLegacyTelemetryBatch(
|
||||
options: PostLegacyTelemetryBatchOptions = {},
|
||||
): Promise<LangfuseDeliveryState> {
|
||||
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
||||
if (config.kind === 'langfuse') {
|
||||
return postLangfuseBatch(config, batch, fetchImpl, options.onAttempt);
|
||||
}
|
||||
if (config.kind === 'relay') {
|
||||
return postRelayBatch(
|
||||
config,
|
||||
JSON.stringify({ batch }),
|
||||
fetchImpl,
|
||||
options.onAttempt,
|
||||
options.deliveryIdempotencyKey,
|
||||
);
|
||||
if (config.kind !== 'vela') {
|
||||
const result = config.kind === 'langfuse'
|
||||
? await postLangfuseBatch(config, batch, fetchImpl, options.onAttempt)
|
||||
: await postRelayBatch(config, JSON.stringify({ batch }), fetchImpl,
|
||||
options.onAttempt, options.deliveryIdempotencyKey);
|
||||
const trace = asVelaSourceEvents(batch).find(event => event.type === 'trace-create');
|
||||
return {
|
||||
...result,
|
||||
...(result.langfuse_delivery_status === 'accepted' && typeof trace?.body.id === 'string'
|
||||
? { traceReceipt: { traceId: trace.body.id, transport: config.kind } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const installationId = options.installationId?.trim() ?? '';
|
||||
if (installationId) {
|
||||
return postVelaBatch(config, batch, installationId, fetchImpl, {
|
||||
allowAnonymousAuthFallback: options.allowAnonymousAuthFallback !== false,
|
||||
allowTaskSchemaFallback: options.allowTaskSchemaFallback !== false,
|
||||
...(options.deliveryIdempotencyKey
|
||||
? { deliveryIdempotencyKey: options.deliveryIdempotencyKey }
|
||||
: {}),
|
||||
@@ -2863,7 +2897,8 @@ export async function postLegacyTelemetryBatch(
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
const fallback = options.fallbackConfig === undefined
|
||||
const fallback = options.allowAnonymousAuthFallback === false ? null
|
||||
: options.fallbackConfig === undefined
|
||||
? readTelemetrySinkConfig()
|
||||
: options.fallbackConfig;
|
||||
if (!fallback) {
|
||||
@@ -2878,15 +2913,7 @@ export async function postLegacyTelemetryBatch(
|
||||
...fallback,
|
||||
retries: Math.min(fallback.retries, fallbackAttempts - 1),
|
||||
};
|
||||
return cappedFallback.kind === 'relay'
|
||||
? postRelayBatch(
|
||||
cappedFallback,
|
||||
JSON.stringify({ batch }),
|
||||
fetchImpl,
|
||||
options.onAttempt,
|
||||
options.deliveryIdempotencyKey,
|
||||
)
|
||||
: postLangfuseBatch(cappedFallback, batch, fetchImpl, options.onAttempt);
|
||||
return postLegacyTelemetryBatch(cappedFallback, batch, { ...options, fetchImpl });
|
||||
}
|
||||
|
||||
function waitBeforeRetry(attempt: number): Promise<void> {
|
||||
|
||||
@@ -585,6 +585,12 @@ export async function renderVelaVideo(
|
||||
}
|
||||
|
||||
const wireModel = wireModelForVela(input.model, input.wireModel);
|
||||
const available = await fetchVelaVideoModels(input.workspaceId, runCommand);
|
||||
if (!available.includes(wireModel)) {
|
||||
throw new VelaMediaError(`unknown or unavailable video model: ${wireModel}; no generation was submitted`, {
|
||||
code: 'MEDIA_MODEL_UNAVAILABLE', retryable: false,
|
||||
});
|
||||
}
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'open-design-vela-video-'));
|
||||
const outputPath = path.join(tempDir, 'result.mp4');
|
||||
const startedAt = Date.now();
|
||||
@@ -676,3 +682,15 @@ export async function renderVelaVideo(
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Account/workspace-filtered catalogue. Never treat a failed read as an empty catalogue. */
|
||||
export async function fetchVelaVideoModels(workspaceId?: string, runCommand: VelaCommandRunner = runVelaCommand): Promise<string[]> {
|
||||
const raw = await runCommand(['media', 'models', '--json'], {
|
||||
...velaWorkspaceCommandOptions(workspaceId), timeoutMs: VELA_MODELS_TIMEOUT_MS,
|
||||
});
|
||||
const response = parseJsonObject(raw, 'media models');
|
||||
if (!Array.isArray(response.models)) throw new Error('Vela media catalogue is unavailable: missing models');
|
||||
return response.models.filter((entry): entry is JsonRecord => isRecord(entry) && entry.kind === 'video'
|
||||
&& typeof entry.model === 'string' && Boolean(entry.model.trim()) && entry.enabled !== false)
|
||||
.map(entry => String(entry.model));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
postLangfuseBatch,
|
||||
type LangfuseConfig,
|
||||
type LangfuseDeliveryState,
|
||||
type TelemetrySinkConfig,
|
||||
type RunTelemetrySinkConfig,
|
||||
} from '../langfuse-trace.js';
|
||||
import {
|
||||
buildLegacyTaskObservationPayload,
|
||||
@@ -48,7 +48,7 @@ export interface TaskObservationExportOptions {
|
||||
config: TaskObservationExporterConfig | null;
|
||||
fetchImpl?: typeof fetch;
|
||||
deliveryIdempotencyKey?: string;
|
||||
onDeliveryAttempt?: () => void;
|
||||
onDeliveryAttempt?: (trace?: { traceId: string; protocol: 'legacy-v1' | 'otlp-v4' }) => void;
|
||||
context?: TaskObservationExportContextV1;
|
||||
/** Test-only retry delay override. Production callers should omit it. */
|
||||
retryDelayMs?: number;
|
||||
@@ -116,7 +116,7 @@ function parseMode(value: string | undefined): TaskObservationExporterMode {
|
||||
}
|
||||
|
||||
export function readTaskObservationExporterConfig(
|
||||
sink: TelemetrySinkConfig | null,
|
||||
sink: RunTelemetrySinkConfig | null,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): TaskObservationExporterConfig | null {
|
||||
// Consume the already-selected Task sink. Do not resolve again here: doing
|
||||
@@ -966,7 +966,7 @@ async function postOtlpTaskPayload(
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
attemptCount += 1;
|
||||
opts.onDeliveryAttempt?.();
|
||||
opts.onDeliveryAttempt?.({ traceId: payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.traceId, protocol: 'otlp-v4' });
|
||||
const response = await fetchImpl(`${config.baseUrl}${LANGFUSE_OTLP_TRACES_PATH}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -1097,7 +1097,8 @@ export async function exportTaskObservationAggregate(
|
||||
opts.fetchImpl ?? globalThis.fetch,
|
||||
() => {
|
||||
attemptCount += 1;
|
||||
opts.onDeliveryAttempt?.();
|
||||
const trace = (legacyBatch as Array<{ type: string; body: { id: string } }>).find(event => event.type === 'trace-create');
|
||||
opts.onDeliveryAttempt?.({ traceId: String(trace!.body.id), protocol: 'legacy-v1' });
|
||||
},
|
||||
);
|
||||
return withDiagnostics({
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { taskTraceReceipts } from './task-telemetry-receipt.js';
|
||||
import type { TaskTelemetryStatus } from '@open-design/contracts';
|
||||
import { evidenceStore, reconcileTaskObjectReasons } from '../services/evidence-delivery.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { taskRunUsage, projectTaskTrace, type TaskRunTraceProjection } from './task-trace-projection.js';
|
||||
import { preserveDiscoveryObservation } from '../strategies/od-next/discovery-observation.js';
|
||||
import { getConversation } from '../db.js';
|
||||
import { redactPromptText } from '../prompt-telemetry.js';
|
||||
import { evidenceMode, type EvalContextV2 } from './eval-context.js';
|
||||
@@ -167,6 +170,7 @@ export interface TaskObservationRolloutResult {
|
||||
export interface TaskObservationRolloutService {
|
||||
readonly config: TaskObservationRolloutConfig;
|
||||
diagnostic(): TaskObservationRolloutDiagnostic;
|
||||
telemetryForRun(runId: string): TaskTelemetryStatus | undefined;
|
||||
modeForRun(runId: string): TaskObservationRolloutMode;
|
||||
representationForRun(runId: string):
|
||||
| 'single_run'
|
||||
@@ -526,7 +530,7 @@ async function taskAggregate(
|
||||
? await buildSafeRunQualityProjectionFromDaemon({
|
||||
db: options.db,
|
||||
dataDir: options.dataDir,
|
||||
run: {
|
||||
run: preserveDiscoveryObservation(run, {
|
||||
...run,
|
||||
model: run.model ?? '',
|
||||
projectId: task.projectId,
|
||||
@@ -538,7 +542,7 @@ async function taskAggregate(
|
||||
error: run.error ?? null,
|
||||
errorCode: run.errorCode ?? null,
|
||||
events: run.events.map((event, index) => ({ id: index + 1, ...event })),
|
||||
},
|
||||
}),
|
||||
prefs: telemetry.prefs,
|
||||
exactPrompt: { ...exactFinalText, stage: mapping.inputStage },
|
||||
onTraceProjection: projection => traceProjections.set(run.id, { runId: run.id, ...projection }),
|
||||
@@ -711,6 +715,7 @@ export function createTaskObservationRolloutService(
|
||||
>();
|
||||
const taskAttemptOutcomes = new Map<string, TaskObservationRolloutResult>();
|
||||
|
||||
const traceReceipts = taskTraceReceipts(options.dataDir);
|
||||
const effectiveSink = (): RunTelemetrySinkConfig | null => {
|
||||
const sink = readTaskTelemetrySinkConfig(env);
|
||||
return sink ? capSinkRetries(sink) : null;
|
||||
@@ -1058,7 +1063,11 @@ export function createTaskObservationRolloutService(
|
||||
config: { ...direct, retries: Math.min(direct.retries, 1) },
|
||||
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
||||
deliveryIdempotencyKey: idempotencyKey,
|
||||
onDeliveryAttempt: onAttempt,
|
||||
onDeliveryAttempt: (trace) => {
|
||||
if (trace) traceReceipts.write({ ...trace, taskExecutionId: aggregate.root.taskExecutionId,
|
||||
idempotencyKey, transport: 'langfuse' });
|
||||
onAttempt();
|
||||
},
|
||||
context,
|
||||
});
|
||||
}
|
||||
@@ -1098,6 +1107,10 @@ export function createTaskObservationRolloutService(
|
||||
onAttempt();
|
||||
},
|
||||
});
|
||||
if (result.traceReceipt) traceReceipts.write({
|
||||
...result.traceReceipt, taskExecutionId: aggregate.root.taskExecutionId,
|
||||
idempotencyKey, protocol: 'legacy-v1',
|
||||
});
|
||||
return { ...result, langfuse_attempt_count: attemptCount };
|
||||
};
|
||||
|
||||
@@ -1433,6 +1446,22 @@ export function createTaskObservationRolloutService(
|
||||
return {
|
||||
config,
|
||||
diagnostic,
|
||||
telemetryForRun(runId) {
|
||||
try {
|
||||
const task = getStrategyTaskExecutionByRunId(options.db, runId);
|
||||
if (!task) return undefined;
|
||||
const row = readDeliveryRow(options.db, task.taskExecutionId);
|
||||
const receipt = row ? traceReceipts.read(task.taskExecutionId, row.idempotencyKey) : undefined;
|
||||
return {
|
||||
version: 1, taskExecutionId: task.taskExecutionId,
|
||||
status: row?.status === 'pending' && row.dropReason ? 'failed' : row?.status ?? 'unknown',
|
||||
traceId: receipt?.traceId ?? null, protocol: receipt?.protocol ?? null,
|
||||
transport: receipt?.transport ?? null, attemptCount: row?.attemptCount ?? null,
|
||||
reason: row?.dropReason ?? (row ? null : 'delivery_not_recorded'),
|
||||
environment: row?.environment ?? null, sampledAt: now(), remoteVerification: 'not_checked',
|
||||
};
|
||||
} catch { return undefined; }
|
||||
},
|
||||
modeForRun(runId) {
|
||||
const representation = ensureRepresentation(runId);
|
||||
if (!representation) return 'off';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { readTaskTelemetrySinkConfig, readTelemetrySinkConfig, type RunTelemetrySinkConfig } from '../langfuse-trace.js';
|
||||
|
||||
export interface TaskTraceReceipt {
|
||||
taskExecutionId: string;
|
||||
idempotencyKey: string;
|
||||
traceId: string;
|
||||
protocol: 'legacy-v1' | 'otlp-v4';
|
||||
transport: 'langfuse' | 'relay' | 'vela';
|
||||
}
|
||||
|
||||
/** Small non-secret exporter receipt under the caller's resolved daemon data root.
|
||||
* Missing/corrupt receipts must never be reconstructed from current config. */
|
||||
export function taskTraceReceipts(dataDir?: string) {
|
||||
const memory = new Map<string, TaskTraceReceipt>();
|
||||
const file = (id: string) => join(dataDir!, 'task-telemetry-receipts',
|
||||
createHash('sha256').update(id).digest('hex') + '.json');
|
||||
return {
|
||||
write(receipt: TaskTraceReceipt) {
|
||||
memory.set(receipt.taskExecutionId, receipt);
|
||||
if (!dataDir) return;
|
||||
try {
|
||||
mkdirSync(join(dataDir, 'task-telemetry-receipts'), { recursive: true, mode: 0o700 });
|
||||
const target = file(receipt.taskExecutionId);
|
||||
writeFileSync(target + '.tmp', JSON.stringify(receipt), { mode: 0o600 });
|
||||
renameSync(target + '.tmp', target);
|
||||
} catch { /* Observability cannot fail task execution or delivery. */ }
|
||||
},
|
||||
read(id: string, idempotencyKey?: string | null): TaskTraceReceipt | undefined {
|
||||
try {
|
||||
const r = memory.get(id) ?? (dataDir ? JSON.parse(readFileSync(file(id), 'utf8')) : undefined);
|
||||
if (r?.taskExecutionId === id && (idempotencyKey === undefined || r.idempotencyKey === idempotencyKey)
|
||||
&& typeof r.traceId === 'string' && r.traceId.length > 0
|
||||
&& ['legacy-v1', 'otlp-v4'].includes(r.protocol)
|
||||
&& ['langfuse', 'relay', 'vela'].includes(r.transport)) return r;
|
||||
} catch { /* Unknown is preferable to a guessed trace identity. */ }
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Never move feedback or manifest updates to another trace after an upgrade.
|
||||
* No receipt means delivery must wait for the Task root, not create an orphan. */
|
||||
export function taskUpdateSink(
|
||||
dataDir: string,
|
||||
clientTraceId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
configuredEnv: Record<string, string> = {},
|
||||
): RunTelemetrySinkConfig | null {
|
||||
if (!clientTraceId.startsWith('strategy-task:')) return null;
|
||||
const receipt = taskTraceReceipts(dataDir).read(clientTraceId.slice('strategy-task:'.length));
|
||||
if (!receipt) return null;
|
||||
const sink = receipt.transport === 'vela'
|
||||
? readTaskTelemetrySinkConfig(env, configuredEnv)
|
||||
: readTelemetrySinkConfig(env);
|
||||
return sink?.kind === receipt.transport ? sink : null;
|
||||
}
|
||||
@@ -35,6 +35,12 @@ export function projectTaskTrace(runs: TaskRunTraceProjection[], durationMs: num
|
||||
metadata[key] = runs.flatMap(run => Array.isArray(run.metadata[key]) ? run.metadata[key] as unknown[] : []);
|
||||
}
|
||||
Object.assign(metadata, taskObjectMetadata(runs.map(run => run.metadata)));
|
||||
metadata.skill_discovery_enabled = runs.some(run => run.metadata.skill_discovery_enabled === true);
|
||||
metadata.skill_ids_loaded = [...new Set(runs.flatMap(run =>
|
||||
Array.isArray(run.metadata.skill_ids_loaded) ? run.metadata.skill_ids_loaded as string[] : []))].sort();
|
||||
metadata.skill_discovery_policy_injected = runs.some(run => run.metadata.skill_discovery_policy_injected === true);
|
||||
metadata.skill_load_events = runs.flatMap(run => Array.isArray(run.metadata.skill_load_events) ? run.metadata.skill_load_events : []);
|
||||
metadata.skill_observation_status = runs.some(run => run.metadata.skill_observation_status === 'partial') ? 'partial' : metadata.skill_discovery_enabled ? 'complete' : 'unavailable';
|
||||
// Physical Run facts remain in run_metadata; they are not Task totals.
|
||||
for (const key of ['langfuse_trace_id', 'cost_usd', 'cost_breakdown', 'performance_diagnostics']) delete metadata[key];
|
||||
return { input: first.input, output: last.output, metadata };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { renderDeliverableSkillCatalog } from '@open-design/contracts';
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
@@ -101,7 +102,9 @@ export function loadBundledStrategyPromptAssetsV2(input: {
|
||||
binding: loaded.binding,
|
||||
coreStrategy: decode(loaded.corePath),
|
||||
generalOrchestration: decode(loaded.orchestrationPath),
|
||||
taskSkill: decode(loaded.selectedProfilePath),
|
||||
taskSkill: decode(loaded.selectedProfilePath)
|
||||
.replaceAll('{{SKILL_CATALOG}}', renderDeliverableSkillCatalog(path.join(input.plugin.fsPath, 'assets/task-profiles')))
|
||||
.replaceAll('{{SKILL_ROOT}}', path.join(input.plugin.fsPath, 'assets/task-profiles')),
|
||||
taskResources: loaded.selectedResourcePaths.map((resourcePath) => ({
|
||||
path: resourcePath,
|
||||
text: decode(resourcePath),
|
||||
|
||||
@@ -508,8 +508,14 @@ path is given.
|
||||
- **Image, default / no preference stated**: use an explicitly named model
|
||||
in the current user message, then the run-scoped BYOK image default, then the project metadata's
|
||||
\`imageModel\` if set; otherwise use \`vela/gpt-image-2\`
|
||||
- **Video, best quality**: use project metadata \`videoModel\` if set; otherwise
|
||||
\`doubao-seedance-2-0-260128\`
|
||||
- **Video**: preserve an explicitly requested model and provider. Otherwise
|
||||
prefer the configured run/project default if available. Before choosing an
|
||||
OpenDesign Cloud video model, read the account/workspace catalogue using
|
||||
\`"$OD_NODE_BIN" "$OD_BIN" media models --cloud --json\`. Choose only a returned
|
||||
video model. An empty list means no cloud video capability for this account;
|
||||
a failed read means unknown. Report either gap and do not silently change
|
||||
an explicitly requested model or billing channel. Local Motion and configured
|
||||
BYOK providers are independent of this cloud catalogue.
|
||||
|
||||
Default aspect ratio (use when \`aspectRatio\` is unknown):
|
||||
- Landscape/outdoor scenes, cinematic, widescreen → \`16:9\`
|
||||
|
||||
@@ -546,7 +546,7 @@ function renderMediaDispatchModelGuidance(defaults?: ByokMediaDefaults): string
|
||||
: 'For the best fal image model use `--model flux-pro-ultra`.';
|
||||
const videoPart = videoModel
|
||||
? `For video prefer your configured model: \`${videoModel}\`.`
|
||||
: 'For video use `--model veo-3-fal` or `--model wan-2.1-t2v`.';
|
||||
: 'For cloud video, read the current account/workspace catalogue with `od media models --cloud --json` before choosing a default. If it has no available video model, report the capability gap; never silently substitute an explicitly requested model or provider.';
|
||||
return `${imagePart} ${videoPart} Always pass \`--surface\` explicitly (\`image\`, \`video\`, or \`audio\`). Any \`fal-ai/*\` path (e.g. \`fal-ai/flux/schnell\`, \`fal-ai/wan-i2v\`) is also a valid \`--model\` value for image/video — pass it through as-is without substitution.`;
|
||||
}
|
||||
|
||||
@@ -573,9 +573,7 @@ function mediaDefaultsForRuntime(
|
||||
return {
|
||||
...defaults,
|
||||
imageModel: defaults?.imageModel?.trim() || 'vela/gpt-image-2',
|
||||
videoModel:
|
||||
defaults?.videoModel?.trim()
|
||||
|| 'vela/doubao-seedance-2-0-260128',
|
||||
...(defaults?.videoModel?.trim() ? { videoModel: defaults.videoModel.trim() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ import type {
|
||||
import type { AnalyticsContext } from '../analytics.js';
|
||||
import { defaultMediaExecutionPolicy, mediaPolicyDenial } from '../media/policy.js';
|
||||
import { formatMediaTaskDiagnostic } from '../media/diagnostics.js';
|
||||
import { findMediaModel } from '../media/models.js';
|
||||
import { findMediaModel, type MediaModel } from '../media/models.js';
|
||||
import { fetchVelaVideoModels } from '../media/vela.js';
|
||||
import type { CloudVideoModelsResponse } from '@open-design/contracts';
|
||||
import type { ImageGenerationRequestSummary } from '../media/image-generation-retry.js';
|
||||
import type { RouteDeps } from '../server-context.js';
|
||||
import type {
|
||||
@@ -676,7 +678,28 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
|
||||
// Analytics is best-effort and must not change the media task outcome.
|
||||
}
|
||||
};
|
||||
app.get('/api/media/models', (_req, res) => {
|
||||
app.get('/api/media/models', async (req, res) => {
|
||||
if (req.query.cloud === 'true') {
|
||||
if (!isLocalSameOrigin(req, getResolvedPort())) return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
const grant = optionalToolGrantFromRequest(req, { operation: 'media:generate' });
|
||||
const projectId = grant?.projectId ?? (typeof req.query.projectId === 'string' ? req.query.projectId : '');
|
||||
if (!projectId) return sendApiError(res, 400, 'BAD_REQUEST', 'projectId is required for the scoped cloud catalogue');
|
||||
if (grant) {
|
||||
if (!await ctx.authorizeProjectToolRequest(res, projectId, { mode: 'read' })) return;
|
||||
} else if (!await ctx.authorizeProjectRequest(req, res, projectId, { mode: 'read' })) return;
|
||||
try {
|
||||
const workspaceId = getWorkspaceProjectByProjectId(db, projectId)?.workspaceId?.trim() || undefined;
|
||||
const available = await fetchVelaVideoModels(workspaceId);
|
||||
const catalogue: CloudVideoModelsResponse = {
|
||||
video: VIDEO_MODELS.filter((model: MediaModel) => model.provider === 'vela'
|
||||
&& available.includes(model.id.replace(/^vela\//, ''))),
|
||||
source: 'account_workspace_catalogue',
|
||||
};
|
||||
return res.json(catalogue);
|
||||
} catch {
|
||||
return sendApiError(res, 503, 'MEDIA_CATALOG_UNAVAILABLE', 'Cloud video availability could not be verified; do not infer availability from the static catalogue.');
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
providers: MEDIA_PROVIDERS,
|
||||
image: IMAGE_MODELS,
|
||||
|
||||
@@ -113,7 +113,6 @@ import {
|
||||
} from '../strategies/od-next/task-input-snapshot.js';
|
||||
import {
|
||||
evaluateOdNextRollout,
|
||||
odNextTaskTypeForProjectScenarioBinding,
|
||||
readOdNextRolloutPolicy,
|
||||
type OdNextRolloutDecision,
|
||||
} from '../strategies/od-next/rollout.js';
|
||||
@@ -656,6 +655,7 @@ export interface RegisterRunRoutesDeps {
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
telemetry: {
|
||||
taskTelemetryForRun?: (runId: string) => ChatRunStatusResponse['taskTelemetry'];
|
||||
reportRunCompletionTelemetryFallback: (input: RunCreatedFallbackInput) => void;
|
||||
resolveRunProjectKindForAnalytics: (input: RunProjectKindInput) => string | null;
|
||||
runArtifactBaselines: RunArtifactBaselines;
|
||||
@@ -766,17 +766,21 @@ function toProjectFiles(value: unknown): ProjectFileEntry[] {
|
||||
// Intents the scenario-plugin fallback resolver is allowed to see. Mirrors the
|
||||
// `ProjectMetadata['intent']` contract union so an unknown/legacy string in a
|
||||
// stored project row never gets cast into the union.
|
||||
const SCENARIO_PROJECT_INTENTS: readonly NonNullable<ContractProjectMetadata['intent']>[] = [
|
||||
'live-artifact',
|
||||
'web-clone',
|
||||
'document',
|
||||
'marketing',
|
||||
'hyperframes',
|
||||
'motion-design',
|
||||
];
|
||||
const SCENARIO_PROJECT_INTENTS: Record<NonNullable<ContractProjectMetadata['intent']>, true> = {
|
||||
'live-artifact': true,
|
||||
'web-clone': true,
|
||||
document: true,
|
||||
'webgl-experience': true,
|
||||
'worker-visualizer': true,
|
||||
marketing: true,
|
||||
hyperframes: true,
|
||||
'motion-design': true,
|
||||
};
|
||||
|
||||
function toScenarioProjectIntent(value: unknown): ContractProjectMetadata['intent'] | undefined {
|
||||
return SCENARIO_PROJECT_INTENTS.find((intent) => intent === value);
|
||||
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(SCENARIO_PROJECT_INTENTS, value)
|
||||
? value as NonNullable<ContractProjectMetadata['intent']>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function toScenarioProjectMetadata(
|
||||
@@ -1045,6 +1049,8 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) {
|
||||
}
|
||||
}
|
||||
const status = design.runs.statusBody(run);
|
||||
const taskTelemetry = status.strategyTask ? ctx.telemetry.taskTelemetryForRun?.(run.id) : undefined;
|
||||
if (taskTelemetry) status.taskTelemetry = taskTelemetry;
|
||||
// A read can derive display identity from the validated task, but must not
|
||||
// stamp the source Run and bypass clarification's linked-snapshot witness.
|
||||
return projectedSnapshotId
|
||||
@@ -1629,7 +1635,16 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) {
|
||||
Array.isArray((rolloutProject?.metadata as ContractProjectMetadata | undefined)?.contextPlugins)
|
||||
&& (rolloutProject?.metadata as ContractProjectMetadata).contextPlugins!.length > 0
|
||||
);
|
||||
const explicitExecutablePlugin = Boolean(
|
||||
const officialTypeEntry = Boolean(defaultPluginId
|
||||
&& getInstalledPlugin(db, defaultPluginId)?.sourceKind === 'bundled'
|
||||
&& verifiedScenarioBinding?.pluginId === defaultPluginId
|
||||
&& (!suppliedPluginWasNamed || requestBody.pluginId === defaultPluginId)
|
||||
&& (!suppliedSnapshotWasNamed || requestBody.appliedPluginSnapshotId === rolloutProject?.appliedPluginSnapshotId));
|
||||
const officialTemplateEntry = Boolean(selectedExamplePlugin?.sourceKind === 'bundled'
|
||||
&& verifiedExampleBinding
|
||||
&& (!suppliedPluginWasNamed || requestBody.pluginId === selectedExamplePlugin.id)
|
||||
&& (!suppliedSnapshotWasNamed || requestBody.appliedPluginSnapshotId === rolloutProject?.appliedPluginSnapshotId));
|
||||
const explicitExecutablePlugin = !officialTypeEntry && !officialTemplateEntry && Boolean(
|
||||
suppliedSnapshotWasNamed
|
||||
|| suppliedPluginWasNamed
|
||||
|| (projectHasExplicitPin && !projectPinIsAutomaticDefault)
|
||||
@@ -1647,21 +1662,11 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) {
|
||||
explicitExecutablePlugin
|
||||
|| suppliedContextPluginWasNamed
|
||||
);
|
||||
// Read per request, not at boot: `odNextStrategyMode` is how a user opts
|
||||
// this installation out of OD Next, and "configure it and it takes
|
||||
// effect" has to mean the next run, not the next daemon restart.
|
||||
//
|
||||
// Deliberately uncaught. `readAppConfig` already answers `{}` for the
|
||||
// states that mean "nothing configured" — no file, unparseable file — and
|
||||
// only throws when the daemon genuinely cannot read its own config. That
|
||||
// is not the same as an opt-out, and swallowing it would silently run the
|
||||
// ordinary route (with no `agentCliEnv` either) while telling the
|
||||
// operator the installation was never opted in.
|
||||
// Subsequent requests use the current policy, including older conversations.
|
||||
// In-flight physical runs and idempotent retries keep their frozen bundle.
|
||||
const rolloutAppConfig = await readAppConfig(RUNTIME_DATA_DIR);
|
||||
const rolloutPolicy = readOdNextRolloutPolicy(process.env, rolloutAppConfig);
|
||||
const rolloutTaskType = previousStrategyTask
|
||||
? getSnapshot(db, previousStrategyTask.snapshotId)?.strategy?.selectedTaskProfile.taskType ?? null
|
||||
: odNextTaskTypeForProjectScenarioBinding(verifiedStrategyBinding ?? verifiedScenarioBinding);
|
||||
const rolloutTaskType = !explicitUserPlugin ? 'discovery' as const : null;
|
||||
const routeApplicability = explicitUserPlugin
|
||||
? 'explicit_user' as const
|
||||
: rolloutTaskType && snapshotConversationId
|
||||
|
||||
@@ -6,27 +6,15 @@ import {
|
||||
type OdNextRolloutAppConfig,
|
||||
} from '../strategies/od-next/rollout.js';
|
||||
|
||||
/**
|
||||
* Report which authority decides OD Next for this daemon.
|
||||
*
|
||||
* Read-only, and there is nothing here to write. This route used to expose a
|
||||
* reset for the stop latch — an automatic, daemon-wide disable a run could
|
||||
* raise, which then outranked the saved mode and survived restart. The latch is
|
||||
* gone, so the only thing that turns OD Next off for an installation is that
|
||||
* installation saving `off`, and `PUT /api/app-config` already owns that.
|
||||
*
|
||||
* Status stays because the mode alone does not say who chose it. The Labs
|
||||
* switch needs `requestedModeSource` to know whether the environment has taken
|
||||
* the decision away from the user, and an operator needs it to tell a daemon
|
||||
* that is on OD Next because nobody touched it from one that is off it because
|
||||
* somebody asked.
|
||||
/** Read-only engineering status: the active default or an environment override.
|
||||
* Legacy saved preferences remain readable for compatibility but no longer
|
||||
* control this daemon's rollout. No user Labs switch consumes this status.
|
||||
*/
|
||||
export function registerStrategyRolloutRoutes(app: Express, deps: {
|
||||
requireLocalDaemonRequest: RequestHandler;
|
||||
/**
|
||||
* The installation's saved OD Next preference. Injected rather than read
|
||||
* here so this route never resolves a daemon data path of its own, and so
|
||||
* status reflects a preference the user changed since the daemon started.
|
||||
* The legacy preference reader is retained without changing the route contract.
|
||||
* Current rollout policy ignores its value.
|
||||
*/
|
||||
readOdNextPreference: () => Promise<OdNextRolloutAppConfig>;
|
||||
}): void {
|
||||
|
||||
@@ -45,6 +45,7 @@ interface ValidateRunDeliverableInput {
|
||||
/** New marker tasks may deliver a separate file without changing the project entry.
|
||||
* Requires exact current-run touched paths; never enables project-wide inference. */
|
||||
allowIndependentOutput?: boolean;
|
||||
allowMultipleOutputCandidates?: boolean;
|
||||
}
|
||||
|
||||
export function inferBaselineHtmlEntry(projectRoot: string, paths: Iterable<string>): string | undefined {
|
||||
@@ -235,7 +236,19 @@ export async function validateRunDeliverable(
|
||||
|| !['entry_missing', 'entry_not_touched', 'type_mismatch'].includes(primary.validation)) {
|
||||
return primary;
|
||||
}
|
||||
return resolveDeliverable({ ...input, scope: 'run', independentOutput: true });
|
||||
const independent = await resolveDeliverable({ ...input, scope: 'run', independentOutput: true });
|
||||
if (!input.allowMultipleOutputCandidates || input.touchedPaths.length < 2) return independent;
|
||||
// Validate every current-run candidate separately. A stable representative is
|
||||
// used by the existing entry/syntax surface; it does not prove Query coverage
|
||||
// or visual quality for the other deliverables and never changes project metadata.
|
||||
const candidates = [...new Set(input.touchedPaths)].sort();
|
||||
let representative: RunDeliverableValidationResult | undefined;
|
||||
for (const candidate of candidates) {
|
||||
const result = await resolveDeliverable({ ...input, touchedPaths: [candidate], scope: 'run', independentOutput: true });
|
||||
if (!result.valid) return result;
|
||||
representative ??= result;
|
||||
}
|
||||
return representative ?? independent;
|
||||
}
|
||||
|
||||
async function resolveDeliverable(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { observeDiscoveryEvent } from './strategies/od-next/discovery-observation.js';
|
||||
import { assertCommentFileNotPending } from './collab/comment-file-retirements.js';
|
||||
import { captureJsonBodySize } from './http/json-body-size.js';
|
||||
|
||||
@@ -2581,8 +2582,8 @@ export function createFinalizedMessageTelemetryReporter({
|
||||
return reportFinalized;
|
||||
}
|
||||
|
||||
export function shouldReportRunCompletionTelemetryFallbackStatus(status: unknown): boolean {
|
||||
return status === 'failed' || status === 'canceled';
|
||||
export function shouldReportRunCompletionTelemetryFallbackStatus(status: unknown, taskOwned = false): boolean {
|
||||
return status === 'failed' || status === 'canceled' || (status === 'succeeded' && taskOwned);
|
||||
}
|
||||
|
||||
const PROJECT_PREVIEW_SCOPE_TTL_MS = 60 * 60 * 1000;
|
||||
@@ -8451,7 +8452,10 @@ export async function startServer({
|
||||
run: any;
|
||||
status: string;
|
||||
}) => {
|
||||
if (!shouldReportRunCompletionTelemetryFallbackStatus(status)) return;
|
||||
// Headless Task consumers do not save a final assistant message. Keep the UI
|
||||
// grace period and durable delivery gates, but also settle successful Tasks.
|
||||
const taskOwned = status === 'succeeded' && Boolean(getStrategyTaskExecutionByRunId(db, run.id));
|
||||
if (!shouldReportRunCompletionTelemetryFallbackStatus(status, taskOwned)) return;
|
||||
const timer = setTimeout(() => {
|
||||
terminalTelemetryFallbackTimers.delete(timer);
|
||||
if (reportedRuns.has(run.id)) return;
|
||||
@@ -11114,7 +11118,8 @@ export async function startServer({
|
||||
&& freeformDeckSignal === true;
|
||||
const isOdNextDeckRequest = odNextStrategyRecipe?.taskType === 'ppt'
|
||||
|| odNextDeckIntent;
|
||||
const hasSelectedDeckSeed = odNextStrategyRecipe?.taskType === 'ppt' && Boolean(
|
||||
const inspectDeckScaffold = isOdNextDeckRequest || odNextStrategyRecipe?.taskType === 'discovery';
|
||||
const hasSelectedDeckSeed = inspectDeckScaffold && Boolean(
|
||||
template?.files?.some((file) => /\.html?$/i.test(file.name))
|
||||
|| /(?:^|\/)assets\/template\.html\b/i.test(skillBody ?? '')
|
||||
|| frozenSkillPackage?.selections?.some((selection) =>
|
||||
@@ -11124,7 +11129,7 @@ export async function startServer({
|
||||
);
|
||||
let hasExistingDeckArtifact = false;
|
||||
if (
|
||||
odNextStrategyRecipe?.taskType === 'ppt'
|
||||
inspectDeckScaffold
|
||||
&& typeof projectId === 'string'
|
||||
&& projectId
|
||||
) {
|
||||
@@ -11157,7 +11162,9 @@ export async function startServer({
|
||||
// resolves to null and adds no bytes.
|
||||
planToolNote: planToolNoteForRuntime(agentId, streamFormat),
|
||||
executionProfile: executionProfileFromStreamFormat(streamFormat),
|
||||
deckIntent: odNextDeckIntent,
|
||||
// Discovery reads the staged framework through the selected PPT Skill.
|
||||
// Suppress the stable-context renderer's eager deck-intent fallback too.
|
||||
deckIntent: odNextStrategyRecipe.taskType !== 'discovery' && odNextDeckIntent,
|
||||
deckFrameworkMode: odNextDeckFrameworkMode,
|
||||
metadata,
|
||||
template,
|
||||
@@ -11441,6 +11448,17 @@ export async function startServer({
|
||||
}
|
||||
let persistedStrategyFinalText = strategyRunMapping?.finalText.text ?? null;
|
||||
const isOdNextInitialRun = Boolean(strategyTaskAtStart && isInitialStrategyTaskRun(strategyTaskAtStart, run.id));
|
||||
let discoveryPromptRecorded = false;
|
||||
const recordDiscoveryPromptDelivered = () => {
|
||||
if (discoveryPromptRecorded || !strategyTaskAtStart?.promptBundle.text.includes('skill_name="discovery"')) return;
|
||||
discoveryPromptRecorded = true;
|
||||
const data = { type: 'skill_discovery_policy', skillId: 'deliverable-discovery',
|
||||
injected: persistedStrategyFinalText?.includes('skill_name="discovery"') === true,
|
||||
skillRoot: path.join(BUNDLED_PLUGINS_DIR, 'scenarios/od-next-strategy/assets/task-profiles'),
|
||||
source: isOdNextInitialRun ? 'request' : 'production_continuation' };
|
||||
observeDiscoveryEvent(run, { event: 'diagnostic', data });
|
||||
design.runs.emit(run, 'diagnostic', data);
|
||||
};
|
||||
const hasExplicitCurrentPrompt = Object.prototype.hasOwnProperty.call(
|
||||
chatBody,
|
||||
'currentPrompt',
|
||||
@@ -13088,6 +13106,10 @@ export async function startServer({
|
||||
}
|
||||
};
|
||||
const send = (event, data) => {
|
||||
recordRunTelemetry('skill discovery', () => {
|
||||
if (event === 'agent') recordDiscoveryPromptDelivered();
|
||||
observeDiscoveryEvent(run, { event, data });
|
||||
});
|
||||
if (event === 'agent' && data?.type === 'usage') {
|
||||
physicalSessionUsage.observe(event, data);
|
||||
}
|
||||
@@ -16408,7 +16430,7 @@ export async function startServer({
|
||||
lifecycle.mark('model_call_start');
|
||||
lifecycle.mark('stdin_write_start');
|
||||
},
|
||||
onPromptSendEnd: () => lifecycle.mark('stdin_write_end'),
|
||||
onPromptSendEnd: () => { lifecycle.mark('stdin_write_end'); recordRunTelemetry('discovery prompt', recordDiscoveryPromptDelivered); },
|
||||
onTurnComplete: () => clearFirstOutputWatchdog(),
|
||||
});
|
||||
acpSession = codexSession;
|
||||
@@ -17197,6 +17219,7 @@ export async function startServer({
|
||||
await resolveRunArtifactOutcomeBeforeFinishAsync();
|
||||
const deliverableFinalization = await finalizeSuccessfulRunDeliverable({
|
||||
allowIndependentOutput: Boolean(strategyTaskAtStart),
|
||||
allowMultipleOutputCandidates: run.strategyRolloutDecision?.taskType === 'discovery',
|
||||
...(run.artifactOutcome?.diff && baselineEntryFile ? { baselineEntryFile } : {}),
|
||||
projectsRoot: PROJECTS_DIR,
|
||||
projectId: run.projectId ?? null,
|
||||
@@ -17534,7 +17557,7 @@ export async function startServer({
|
||||
// The complete plain-text prompt became the child's stdin at spawn
|
||||
// (runtimes/agent-process.ts), so there is nothing left to write. A
|
||||
// spawn that failed (no PID) never received it.
|
||||
if (typeof child.pid === 'number') recordPromptDeliveredAtSpawn(run, lifecycle);
|
||||
if (typeof child.pid === 'number') { recordPromptDeliveredAtSpawn(run, lifecycle); recordRunTelemetry('discovery prompt', recordDiscoveryPromptDelivered); }
|
||||
} else if (writePromptToChildStdin && child.stdin) {
|
||||
const promptInputFormat = def.promptInputFormat ?? 'text';
|
||||
lifecycle.mark('model_call_start');
|
||||
@@ -17542,6 +17565,7 @@ export async function startServer({
|
||||
const markStdinWriteEnd = (err?: Error | null) => {
|
||||
if (err) return;
|
||||
lifecycle.mark('stdin_write_end');
|
||||
recordRunTelemetry('discovery prompt', recordDiscoveryPromptDelivered);
|
||||
};
|
||||
// Plain-text prompts never get here: they are delivered at spawn.
|
||||
// stream-json is the one prompt still pumped through a live pipe; a
|
||||
@@ -17798,6 +17822,7 @@ export async function startServer({
|
||||
},
|
||||
},
|
||||
telemetry: {
|
||||
taskTelemetryForRun: (runId) => taskObservationRollout.telemetryForRun(runId),
|
||||
reportRunCompletionTelemetryFallback,
|
||||
resolveRunProjectKindForAnalytics,
|
||||
runArtifactBaselines,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { taskObjectMetadata } from '../observability/task-object-summary.js';
|
||||
import { taskUpdateSink } from '../observability/task-telemetry-receipt.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, chmod } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { TelemetryOutbox, type DeliveryAttempt, type OutboxJob } from '../storage/telemetry-outbox.js';
|
||||
import { agentCliEnvForAgent, readAppConfig } from '../app-config.js';
|
||||
import { buildFeedbackPayload, postLegacyTelemetryBatch, readTaskTelemetrySinkConfig, readFeedbackTelemetrySinkConfig, readRunTelemetrySinkConfig, reportRunCompleted, type FeedbackReportContext, type ReportContext, type LangfuseDeliveryState } from '../langfuse-trace.js';
|
||||
import { buildFeedbackPayload, postLegacyTelemetryBatch, readFeedbackTelemetrySinkConfig, readRunTelemetrySinkConfig, reportRunCompleted, type FeedbackReportContext, type ReportContext, type LangfuseDeliveryState } from '../langfuse-trace.js';
|
||||
import { buildTraceObjectManifests, type TraceObjectSource, type TraceObjectUploadManifests } from '../trace-object-manifest.js';
|
||||
import { readTelemetryEnvironment } from '../telemetry-environment.js';
|
||||
import { type AttachmentContextEntry, evidenceMode } from '../observability/eval-context.js';
|
||||
@@ -115,7 +116,10 @@ export function reconcileTaskObjectReasons(store: TelemetryOutbox, taskTraceId:
|
||||
}
|
||||
|
||||
async function publishTaskObjectManifests(store: TelemetryOutbox, payload: ObjectJob, dataDir: string, fetchImpl?: typeof fetch): Promise<DeliveryAttempt> {
|
||||
const sink = readTaskTelemetrySinkConfig(process.env);
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
const sink = payload.taskTraceId
|
||||
? taskUpdateSink(dataDir, payload.taskTraceId, process.env, agentCliEnvForAgent(cfg.agentCliEnv, 'amr'))
|
||||
: null;
|
||||
if (!sink || !payload.taskTraceId) return { status: 'retry', reason: 'task_sink_unavailable' };
|
||||
const manifests: TraceObjectUploadManifests[] = [];
|
||||
const runObjectMetadata: Record<string, unknown>[] = [];
|
||||
@@ -139,6 +143,7 @@ async function publishTaskObjectManifests(store: TelemetryOutbox, payload: Objec
|
||||
const objectMetadata = taskObjectMetadata(runObjectMetadata);
|
||||
reconcileTaskObjectReasons(store, payload.taskTraceId, objectMetadata);
|
||||
const metadata = {
|
||||
schema: 'open-design.strategy-task-observation/v1',
|
||||
artifact_manifest: manifests.flatMap(m => m.artifactManifest ?? []),
|
||||
attachment_manifest: manifests.flatMap(m => m.attachmentManifest ?? []),
|
||||
input_text_snapshot_manifest: manifests.flatMap(m => m.inputTextSnapshotManifest ?? []),
|
||||
@@ -148,8 +153,8 @@ async function publishTaskObjectManifests(store: TelemetryOutbox, payload: Objec
|
||||
const key = `task-objects:${sha(JSON.stringify([payload.taskTraceId, metadata]))}`;
|
||||
const result = await postLegacyTelemetryBatch(sink, [{
|
||||
id: key, type: 'trace-create', timestamp: new Date().toISOString(),
|
||||
body: { id: payload.taskTraceId, environment: payload.environment ?? readTelemetryEnvironment(), metadata },
|
||||
}], { deliveryIdempotencyKey: key, ...(payload.context.installationId ? { installationId: payload.context.installationId } : {}), ...(fetchImpl ? { fetchImpl } : {}) });
|
||||
body: { id: payload.taskTraceId, name: 'open-design-strategy-task', environment: payload.environment ?? readTelemetryEnvironment(), metadata },
|
||||
}], { allowAnonymousAuthFallback: false, allowTaskSchemaFallback: false, deliveryIdempotencyKey: key, ...(payload.context.installationId ? { installationId: payload.context.installationId } : {}), ...(fetchImpl ? { fetchImpl } : {}) });
|
||||
return result.langfuse_delivery_status === 'accepted'
|
||||
? { status: 'uploaded', reason: 'consumer_readback_pending' }
|
||||
: { status: 'retry', reason: result.langfuse_drop_reason ?? 'task_manifest_update_failed' };
|
||||
@@ -191,10 +196,12 @@ export async function drainEvidence(dataDir: string, fetchImpl?: typeof fetch):
|
||||
if (job.kind === 'feedback') {
|
||||
const payload = JSON.parse(job.payload) as FeedbackJob;
|
||||
const feedbackSink = payload.context.traceId
|
||||
? readTaskTelemetrySinkConfig(process.env)
|
||||
? taskUpdateSink(dataDir, payload.context.traceId, process.env, agentCliEnvForAgent(cfg.agentCliEnv, 'amr'))
|
||||
: readFeedbackTelemetrySinkConfig(process.env, agentCliEnvForAgent(cfg.agentCliEnv, 'amr'));
|
||||
if (!feedbackSink) return { status: 'retry', reason: 'feedback_sink_unavailable' };
|
||||
const result = await postLegacyTelemetryBatch(feedbackSink, payload.batch, {
|
||||
allowAnonymousAuthFallback: false,
|
||||
allowTaskSchemaFallback: false,
|
||||
...(payload.context.installationId ? { installationId: payload.context.installationId } : {}),
|
||||
deliveryIdempotencyKey: job.key, ...(fetchImpl ? { fetchImpl } : {}),
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
renderDeckFrameworkDirective,
|
||||
renderLegacyDeckCompatibilityDirective,
|
||||
OD_NEXT_DEVICE_FRAME_ROOT,
|
||||
OD_NEXT_MANAGED_RESOURCE_FILES,
|
||||
OD_NEXT_STRATEGY_ID,
|
||||
@@ -108,9 +110,18 @@ export async function loadOdNextTaskResourcesForSnapshot(input: {
|
||||
if (!resolved.ok) {
|
||||
throw new Error(`Bundled OD Next strategy is unavailable: ${resolved.errors.join('; ')}`);
|
||||
}
|
||||
return loadBundledStrategyPromptAssetsV2({ plugin: resolved.record, binding })
|
||||
.taskResources
|
||||
.map((resource) => ({ path: resource.path, text: resource.text }));
|
||||
const resources = loadBundledStrategyPromptAssetsV2({ plugin: resolved.record, binding })
|
||||
.taskResources.map((resource) => ({ path: resource.path, text: resource.text }));
|
||||
if (binding.selectedTaskProfile.taskType === 'discovery') {
|
||||
// Same host-owned source as explicitly selected PPT; load only when needed.
|
||||
resources.push({ path: 'deck-framework.md', text: [
|
||||
'# HTML deck host protocol',
|
||||
'Apply only to PPT outputs. If editing existing deck/template HTML, follow the compatibility section and preserve its scaffold. For a new deck use the canonical section. Do not create a deck solely because this file exists.',
|
||||
'## Existing deck or selected template', renderLegacyDeckCompatibilityDirective('filesystem'),
|
||||
'## New deck only', renderDeckFrameworkDirective('filesystem'),
|
||||
].join('\n\n') });
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
function digest(text: string): string {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { DELIVERABLE_SKILL_CATALOG_VERSION, DELIVERABLE_SKILLS } from '@open-design/contracts';
|
||||
import { singleSkillShellRead } from './skill-shell-read.js';
|
||||
|
||||
type Event = { event?: string; data?: unknown };
|
||||
type Load = { skill_id: string; version: string; tool_use_id: string; source: 'file_read' | 'shell_read'; status: 'requested' | 'loaded' | 'failed' | 'unknown'; content_coverage?: 'complete' | 'unknown' };
|
||||
const observers = new WeakMap<object, ReturnType<typeof createObserver>>();
|
||||
const MAX_LOADS = 128;
|
||||
|
||||
function createObserver() {
|
||||
const reads = new Map<string, Load>();
|
||||
const ranged = new Set<string>();
|
||||
let enabled = false;
|
||||
let injected = false;
|
||||
let partial = false;
|
||||
let skillRoot: string | undefined;
|
||||
return {
|
||||
push(event: Event) {
|
||||
if (!event.data || typeof event.data !== 'object') return;
|
||||
const data = event.data as Record<string, unknown>;
|
||||
if (data.type === 'skill_discovery_policy') {
|
||||
enabled = true;
|
||||
injected ||= data.injected === true;
|
||||
if (typeof data.skillRoot === 'string') skillRoot = data.skillRoot.replaceAll('\\', '/');
|
||||
}
|
||||
if (data.type === 'tool_use' && typeof data.id === 'string') {
|
||||
const input = data.input as Record<string, unknown> | undefined;
|
||||
const shellFile = ['Bash', 'exec_command'].includes(String(data.name))
|
||||
? singleSkillShellRead(input?.command ?? input?.cmd) : undefined;
|
||||
const rawFile = shellFile ?? input?.filePath ?? input?.file_path ?? input?.path;
|
||||
const file = typeof rawFile === 'string' ? rawFile.replaceAll('\\', '/') : undefined;
|
||||
const match = file ? /\/od-next-strategy\/assets\/task-profiles\/([a-z-]+)\.md$/.exec(file) : null;
|
||||
const skill = DELIVERABLE_SKILLS.find((candidate) => candidate.id === match?.[1]);
|
||||
if (skill && skillRoot && file === `${skillRoot}/${skill.id}.md` && (shellFile || ['Read', 'read', 'read_file', 'read_text_file'].includes(String(data.name)))) {
|
||||
if (reads.size >= MAX_LOADS && !reads.has(data.id)) { partial = true; return; }
|
||||
if (!reads.has(data.id)) reads.set(data.id, { skill_id: skill.id, version: skill.version,
|
||||
tool_use_id: data.id, source: shellFile ? 'shell_read' : 'file_read', status: 'requested' });
|
||||
if (input?.offset !== undefined || input?.limit !== undefined || input?.start_line !== undefined || input?.end_line !== undefined) ranged.add(data.id);
|
||||
} else if (JSON.stringify(input ?? {}).includes('/task-profiles/')) partial = true;
|
||||
}
|
||||
if (data.type === 'tool_result' && typeof data.toolUseId === 'string') {
|
||||
const read = reads.get(data.toolUseId);
|
||||
if (!read) return;
|
||||
read.status = data.isError === true ? 'failed'
|
||||
: typeof data.content !== 'string' || !data.content.trim() || ranged.has(data.toolUseId)
|
||||
? 'unknown' : 'loaded';
|
||||
// Some adapters truncate only the observability copy after a successful
|
||||
// native Read. Record that real event, but never claim full body coverage.
|
||||
read.content_coverage = read.status === 'loaded' && typeof data.content === 'string'
|
||||
&& !/\[truncated\]|output truncated|lines? omitted|\[REDACTED:/i.test(data.content)
|
||||
? 'complete' : 'unknown';
|
||||
}
|
||||
},
|
||||
metadata(): Record<string, unknown> {
|
||||
const events = [...reads.values()].map((read) => ({ ...read, status: read.status === 'requested' ? 'unknown' : read.status }));
|
||||
return {
|
||||
skill_discovery_enabled: enabled,
|
||||
skill_discovery_policy_injected: injected,
|
||||
skill_discovery_catalog_version: enabled ? DELIVERABLE_SKILL_CATALOG_VERSION : null,
|
||||
skill_load_events: events,
|
||||
skill_ids_loaded: [...new Set(events.filter((read) => read.status === 'loaded').map((read) => read.skill_id))].sort(),
|
||||
skill_ids_provided: [],
|
||||
// The marker protocol has no machine-readable selection claim.
|
||||
// Read events prove loading, not that a particular artifact was delivered.
|
||||
skill_observation_status: partial || events.some((read) => read.status === 'unknown') ? 'partial' : enabled ? 'complete' : 'unavailable',
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Bounded ID-only state survives the runtime's event-ring truncation, without a DB or body copy. */
|
||||
export function observeDiscoveryEvent(run: object, event: Event): void {
|
||||
let observer = observers.get(run);
|
||||
if (!observer) { observer = createObserver(); observers.set(run, observer); }
|
||||
observer.push(event);
|
||||
}
|
||||
|
||||
export function discoveryObservation(events: readonly Event[]): Record<string, unknown> {
|
||||
const observer = createObserver();
|
||||
events.forEach((event) => observer.push(event));
|
||||
return observer.metadata();
|
||||
}
|
||||
|
||||
/** Task exporters normalize Run objects; keep their bounded live evidence attached. */
|
||||
export function preserveDiscoveryObservation<T extends object>(source: object, target: T): T {
|
||||
const observer = observers.get(source);
|
||||
if (observer) observers.set(target, observer);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function discoveryObservationForRun(run: { events: readonly Event[] }): Record<string, unknown> {
|
||||
const observer = observers.get(run);
|
||||
if (!observer) return { ...discoveryObservation(run.events), skill_observation_status: 'partial' };
|
||||
// Includes terminal diagnostic events emitted after the live stream closed.
|
||||
run.events.filter((event) => event.event === 'diagnostic').forEach((event) => observer.push(event));
|
||||
return observer.metadata();
|
||||
}
|
||||
@@ -47,6 +47,9 @@ export function composeResumedRequest(task: Pick<StrategyTaskExecutionRecord, 'p
|
||||
const context = current.context;
|
||||
const parts = [
|
||||
'Follow the current user request. For a new design deliverable, finish an actionable plan before production; do not build in the planning turn. For an existing actionable plan, continue directly when requested.',
|
||||
current.sessionSkills.taskTypeSkill.skillName === 'discovery'
|
||||
? 'Reassess deliverables for this request using the existing discovery catalog. For a new deliverable type, use native file reading to load its main Skill before planning or editing. Reuse complete same-version bodies already in context; do not reload unrelated types. HTML is a format, not a reason to select prototype. A prior chat-only turn does not satisfy this prerequisite. Keep replies in the user\'s language; omit internal routing and tool commands unless requested.'
|
||||
: '',
|
||||
gate,
|
||||
contextDelta('Runtime capabilities', runtimeCapabilities(context.runtimeFacts), runtimeCapabilities(prior.context.runtimeFacts)),
|
||||
contextDelta('Runtime tool environment', context.runtimeToolEnvironment, prior.context.runtimeToolEnvironment),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DELIVERABLE_SKILLS, DELIVERABLE_PRODUCTION_ROUTES } from '@open-design/contracts';
|
||||
|
||||
type Availability = { id: string; available: boolean };
|
||||
|
||||
export interface OdNextIntakePreflightInput {
|
||||
@@ -9,6 +11,7 @@ export interface OdNextIntakePreflightInput {
|
||||
}
|
||||
|
||||
const DAEMON_OWNED_PRODUCTION_ROUTES = {
|
||||
discovery: new Set(DELIVERABLE_PRODUCTION_ROUTES),
|
||||
prototype: new Set(['html', 'prototype-html']),
|
||||
ppt: new Set(['ppt-html', 'html', 'deck-html']),
|
||||
marketing: new Set(['marketing-html', 'html', 'image-html']),
|
||||
@@ -17,6 +20,7 @@ const DAEMON_OWNED_PRODUCTION_ROUTES = {
|
||||
} as const;
|
||||
|
||||
const DAEMON_OWNED_OUTPUT_KINDS = {
|
||||
discovery: new Set(DELIVERABLE_SKILLS.flatMap(skill => skill.routes.map(route => route.kind))),
|
||||
prototype: new Set(['prototype', 'html', 'source']),
|
||||
ppt: new Set(['presentation', 'ppt', 'deck', 'html', 'source']),
|
||||
marketing: new Set(['image', 'marketing', 'html', 'source']),
|
||||
|
||||
@@ -15,7 +15,7 @@ export type {
|
||||
} from '@open-design/contracts';
|
||||
|
||||
/**
|
||||
* The single app-config field this policy consults. Structural on purpose: the
|
||||
* Legacy app-config shape accepted for source compatibility. The retired field is ignored. the
|
||||
* daemon's `AppConfigPrefs`, a partially read config, and a test literal all
|
||||
* satisfy it without this module depending on the config reader.
|
||||
*/
|
||||
@@ -41,6 +41,7 @@ const DEFAULT_TASK_TYPES: readonly OdNextRolloutTaskType[] = [
|
||||
'ppt',
|
||||
'marketing',
|
||||
'hyperframes',
|
||||
'discovery',
|
||||
'motion-design',
|
||||
];
|
||||
const DEFAULT_AGENTS = ['codex', 'claude', 'opencode', 'amr'] as const;
|
||||
@@ -60,51 +61,13 @@ function envMode(value: string | undefined): OdNextRolloutMode | null {
|
||||
return trimmed === 'observe' || trimmed === 'active' ? trimmed : 'off';
|
||||
}
|
||||
|
||||
function configuredMode(value: unknown): OdNextRolloutMode | null {
|
||||
return value === 'off' || value === 'observe' || value === 'active' ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which authority decides the requested mode, and what it decided.
|
||||
*
|
||||
* OD Next is the default route. An installation that configured nothing runs
|
||||
* `active`, so the strategy decides how a run behaves unless someone asked it
|
||||
* not to.
|
||||
*
|
||||
* That inverts which case is load-bearing. While the strategy was opt-in, the
|
||||
* question was whether anyone had asked for it, and an installation that lost
|
||||
* its saved mode simply kept the behaviour it already had. Now the question is
|
||||
* whether anyone asked against it, and an installation that loses its saved
|
||||
* mode is switched back on. So the invariant this function has to keep is:
|
||||
* an installation that opted out reads `off` through every later release.
|
||||
*
|
||||
* That rests on the config never reading as unconfigured unless it genuinely
|
||||
* is. `off` is a value the config carries, and the read path in `app-config.ts`
|
||||
* keeps three states apart rather than two: no file at all is the only one that
|
||||
* reaches the default below. A file that exists but cannot be believed —
|
||||
* malformed JSON, a non-object body, a mode this build does not recognise —
|
||||
* resolves to `off` before it gets here, because "we cannot read your choice"
|
||||
* must not become "you chose OD Next". See
|
||||
* `OD_NEXT_MODE_WHEN_CONFIG_UNREADABLE`.
|
||||
*
|
||||
* `assertWritableControlValues` covers the write path for the same reason, but
|
||||
* only the write path: it cannot do anything about a file that was already bad
|
||||
* on disk, hand-edited, or written by another version.
|
||||
*
|
||||
* `OD_NEXT_STRATEGY_ROLLOUT` outranks the saved `odNextStrategyMode` so that a
|
||||
* pinned process stays pinned: an operator debugging one daemon, a packaged
|
||||
* smoke run, and a test all set the mode for one process without overwriting
|
||||
* the user's choice, and without a user's saved choice overriding theirs. The
|
||||
* config is what survives a restart; the env var is what wins inside one.
|
||||
*/
|
||||
/** User Labs preference is retired. Process overrides remain an engineering rollback. */
|
||||
function resolveRequestedMode(
|
||||
env: NodeJS.ProcessEnv,
|
||||
appConfig: OdNextRolloutAppConfig | null | undefined,
|
||||
_appConfig: OdNextRolloutAppConfig | null | undefined,
|
||||
): { mode: OdNextRolloutMode; source: OdNextRolloutModeSource } {
|
||||
const fromEnv = envMode(env.OD_NEXT_STRATEGY_ROLLOUT);
|
||||
if (fromEnv) return { mode: fromEnv, source: 'env' };
|
||||
const fromConfig = configuredMode(appConfig?.odNextStrategyMode);
|
||||
if (fromConfig) return { mode: fromConfig, source: 'app_config' };
|
||||
return { mode: 'active', source: 'default' };
|
||||
}
|
||||
|
||||
@@ -114,7 +77,7 @@ export function readOdNextRolloutPolicy(
|
||||
): OdNextRolloutPolicy {
|
||||
const taskTypes = list(env.OD_NEXT_STRATEGY_TASK_TYPES).filter(
|
||||
(value): value is OdNextRolloutTaskType => (
|
||||
value === 'prototype' || value === 'ppt' || value === 'marketing' || value === 'hyperframes' || value === 'motion-design'
|
||||
value === 'prototype' || value === 'ppt' || value === 'marketing' || value === 'hyperframes' || value === 'discovery' || value === 'motion-design'
|
||||
),
|
||||
);
|
||||
const percent = Number(env.OD_NEXT_STRATEGY_ASSIGNMENT_PERCENT ?? '100');
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Parse only literal arguments, never evaluate commands or expand variables. */
|
||||
function literalArgs(command: string): string[] | null {
|
||||
const args: string[] = [];
|
||||
let word = '';
|
||||
let quote = '';
|
||||
let started = false;
|
||||
for (let i = 0; i < command.length; i += 1) {
|
||||
const c = command[i]!;
|
||||
if (quote === "'") {
|
||||
if (c === "'") quote = ''; else word += c;
|
||||
continue;
|
||||
}
|
||||
if (c === '$' || c === '`' || c === '\n' || c === '\r') return null;
|
||||
if (c === '\\') {
|
||||
const next = command[++i];
|
||||
if (!next || next === '\n' || next === '\r') return null;
|
||||
if (quote === '"' && !['\\', '"', '$', '`'].includes(next)) return null;
|
||||
word += next; started = true; continue;
|
||||
}
|
||||
if (quote === '"') {
|
||||
if (c === '"') quote = ''; else word += c;
|
||||
continue;
|
||||
}
|
||||
if (';&|<>()*?[]{}'.includes(c)) return null;
|
||||
if (c === "'" || c === '"') { quote = c; started = true; continue; }
|
||||
if (/\s/.test(c)) {
|
||||
if (started) { args.push(word); word = ''; started = false; }
|
||||
} else { word += c; started = true; }
|
||||
}
|
||||
if (quote) return null;
|
||||
if (started) args.push(word);
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Evidence for one complete cat, optionally in a native shell wrapper.
|
||||
* Pipelines, ranges, scripts, multiple files and substitutions stay unconfirmed.
|
||||
*/
|
||||
export function singleSkillShellRead(command: unknown): string | undefined {
|
||||
if (typeof command !== 'string' || command.length > 16_384) return undefined;
|
||||
let args = literalArgs(command);
|
||||
if (!args) return undefined;
|
||||
if (/^(?:\/bin\/|\/usr\/bin\/)?(?:zsh|bash|sh)$/.test(args[0] ?? '')) {
|
||||
if (args.length !== 3 || !['-lc', '-c'].includes(args[1]!)) return undefined;
|
||||
args = literalArgs(args[2]!);
|
||||
}
|
||||
if (!args || !['cat', '/bin/cat', '/usr/bin/cat'].includes(args[0] ?? '')) return undefined;
|
||||
const paths = args[1] === '--' ? args.slice(2) : args.slice(1);
|
||||
return paths.length === 1 && paths[0]!.startsWith('/') ? paths[0] : undefined;
|
||||
}
|
||||
@@ -233,6 +233,10 @@ export class StrategyTaskTransitionConflictError extends Error {
|
||||
}
|
||||
|
||||
export function migrateStrategyTaskStore(db: SqliteDb): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS strategy_motion_deliveries (
|
||||
task_execution_id TEXT PRIMARY KEY, declarations_json TEXT NOT NULL,
|
||||
frozen INTEGER NOT NULL DEFAULT 0 CHECK (frozen IN (0,1))
|
||||
)`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS strategy_task_executions (
|
||||
task_execution_id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -1343,13 +1343,7 @@ describe('app-config odNextStrategyMode', () => {
|
||||
// The read path stays fail-soft — a hand-edited or truncated file must not
|
||||
// take the daemon down, and the rest of the config still comes through.
|
||||
//
|
||||
// What changed is which answer is safe. This assertion used to read
|
||||
// `toBeUndefined()`, on the reasoning that "unconfigured is the safe answer
|
||||
// (`off`)". That reasoning was true only while the default was `off`. With
|
||||
// the default flipped, unconfigured is `active`, so the same fail-soft drop
|
||||
// would hand OD Next to an installation whose stored choice we just failed
|
||||
// to read. The mode now fails closed on its own; every other key keeps the
|
||||
// ordinary fail-soft behaviour.
|
||||
// Preserve legacy config normalization; rollout no longer uses this field.
|
||||
await writeFile(
|
||||
path.join(dataDir, 'app-config.json'),
|
||||
JSON.stringify({ agentId: 'codex', odNextStrategyMode: 'acive' }),
|
||||
@@ -1360,42 +1354,20 @@ describe('app-config odNextStrategyMode', () => {
|
||||
expect(cfg.agentId).toBe('codex');
|
||||
});
|
||||
|
||||
it('keeps an opt-out through the whole chain when the saved mode goes unreadable', async () => {
|
||||
// The join is where this guarantee actually lives, so assert it across the
|
||||
// join rather than in either half. `readAppConfig` reads the file and
|
||||
// `readOdNextRolloutPolicy` decides the mode; a mode that read as absent in
|
||||
// the first would resolve to `active` in the second, and nothing in between
|
||||
// would notice.
|
||||
await writeAppConfig(dataDir, { odNextStrategyMode: 'off' });
|
||||
expect(readOdNextRolloutPolicy({}, await readAppConfig(dataDir)))
|
||||
.toMatchObject({ requestedMode: 'off', requestedModeSource: 'app_config' });
|
||||
|
||||
// Same installation, same user, the mode rewritten to something this build
|
||||
// cannot read — a hand edit, or a value some other version writes.
|
||||
const saved = JSON.parse(
|
||||
await readFile(path.join(dataDir, 'app-config.json'), 'utf8'),
|
||||
) as Record<string, unknown>;
|
||||
await writeFile(
|
||||
path.join(dataDir, 'app-config.json'),
|
||||
JSON.stringify({ ...saved, odNextStrategyMode: 'OFF' }),
|
||||
'utf8',
|
||||
);
|
||||
expect(readOdNextRolloutPolicy({}, await readAppConfig(dataDir)))
|
||||
.toMatchObject({ requestedMode: 'off' });
|
||||
|
||||
// And the negative control on the same chain: a fresh installation with no
|
||||
// file must still reach the new default, or this guard has swallowed the
|
||||
// rollout it was meant to protect.
|
||||
const fresh = await mkdtemp(path.join(tmpdir(), 'od-appconfig-fresh-'));
|
||||
try {
|
||||
expect(readOdNextRolloutPolicy({}, await readAppConfig(fresh)))
|
||||
it('ignores retired saved modes across the config-to-rollout boundary', async () => {
|
||||
for (const mode of ['off', 'observe', 'active', 'OFF']) {
|
||||
await writeFile(path.join(dataDir, 'app-config.json'),
|
||||
JSON.stringify({ odNextStrategyMode: mode, agentId: 'codex' }), 'utf8');
|
||||
const config = await readAppConfig(dataDir);
|
||||
expect(config.agentId).toBe('codex');
|
||||
expect(readOdNextRolloutPolicy({}, config))
|
||||
.toMatchObject({ requestedMode: 'active', requestedModeSource: 'default' });
|
||||
} finally {
|
||||
await rm(fresh, { recursive: true, force: true });
|
||||
expect(readOdNextRolloutPolicy({ OD_NEXT_STRATEGY_ROLLOUT: 'off' }, config))
|
||||
.toMatchObject({ requestedMode: 'off', requestedModeSource: 'env' });
|
||||
}
|
||||
});
|
||||
|
||||
it('opts back out when the key is cleared', async () => {
|
||||
it('clears the retired key without changing the rollout default', async () => {
|
||||
await writeAppConfig(dataDir, { odNextStrategyMode: 'active' });
|
||||
await writeAppConfig(dataDir, { odNextStrategyMode: null });
|
||||
expect((await readAppConfig(dataDir)).odNextStrategyMode).toBeUndefined();
|
||||
|
||||
@@ -106,6 +106,7 @@ describe('/api/chat', () => {
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
let originalMemoryConfig: Awaited<ReturnType<typeof readMemoryConfig>> | null = null;
|
||||
const originalStrategyMode = process.env.OD_NEXT_STRATEGY_ROLLOUT;
|
||||
const originalPath = process.env.PATH;
|
||||
const originalAgentHome = process.env.OD_AGENT_HOME;
|
||||
const tempDirs: string[] = [];
|
||||
@@ -269,6 +270,8 @@ child.on('exit', (code, signal) => {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// These direct-chat fixtures exercise legacy runtime transport, not Task orchestration.
|
||||
process.env.OD_NEXT_STRATEGY_ROLLOUT = 'off';
|
||||
if (process.env.OD_DATA_DIR) {
|
||||
originalMemoryConfig = await readMemoryConfig(process.env.OD_DATA_DIR);
|
||||
await writeMemoryConfig(process.env.OD_DATA_DIR, {
|
||||
@@ -301,6 +304,8 @@ child.on('exit', (code, signal) => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (originalStrategyMode == null) delete process.env.OD_NEXT_STRATEGY_ROLLOUT;
|
||||
else process.env.OD_NEXT_STRATEGY_ROLLOUT = originalStrategyMode;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,21 @@ import { describe, expect, it } from 'vitest';
|
||||
import { parseDaemonCliStartupArgs } from '../src/daemon-startup.js';
|
||||
|
||||
describe('daemon startup CLI parsing', () => {
|
||||
it.each([
|
||||
{ OD_DAEMON_URL: 'http://127.0.0.1:7456', OPEN_DESIGN_RUN_ID: 'active-run' },
|
||||
{ OD_DAEMON_URL: 'http://127.0.0.1:7456', OD_TOOL_TOKEN: 'test-run-token' },
|
||||
])('shows help for a bare command inside an active agent environment', (env) => {
|
||||
expect(parseDaemonCliStartupArgs([], env)).toEqual({ ok: false, kind: 'help' });
|
||||
});
|
||||
|
||||
it('preserves an explicit startup and ordinary shell defaults', () => {
|
||||
const env = { OD_DAEMON_URL: 'http://127.0.0.1:7456', OPEN_DESIGN_RUN_ID: 'active-run' };
|
||||
expect(parseDaemonCliStartupArgs(['--port', '8123', '--no-open'], env)).toEqual({
|
||||
ok: true, config: { host: '127.0.0.1', open: false, port: 8123 },
|
||||
});
|
||||
expect(parseDaemonCliStartupArgs([], { OD_DAEMON_URL: env.OD_DAEMON_URL }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('parses the documented daemon startup flags', () => {
|
||||
expect(parseDaemonCliStartupArgs(['--host', '0.0.0.0', '--port', '8123', '--no-open'], {})).toEqual({
|
||||
ok: true,
|
||||
|
||||
@@ -139,6 +139,8 @@ describe('intent signals × stable prompt cache', () => {
|
||||
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-intent-cache-bin-'));
|
||||
const bin = await writeResumingOpencode(binDir);
|
||||
clearTelemetryEnv();
|
||||
// This suite covers legacy intent/memory stable sections; Discovery has its own prompt.
|
||||
process.env.OD_NEXT_STRATEGY_ROLLOUT = 'off';
|
||||
// Memory auto-extraction folds prior form answers into the stable-region
|
||||
// "Personal memory" block, which legitimately changes the stable hash —
|
||||
// disable it so the assertions isolate the intent-signal contribution.
|
||||
@@ -403,6 +405,7 @@ async function putConfig(url: string, patch: Record<string, unknown>): Promise<v
|
||||
|
||||
function snapshotEnv(): Record<string, string | undefined> {
|
||||
return {
|
||||
OD_NEXT_STRATEGY_ROLLOUT: process.env.OD_NEXT_STRATEGY_ROLLOUT,
|
||||
LANGFUSE_PUBLIC_KEY: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
LANGFUSE_SECRET_KEY: process.env.LANGFUSE_SECRET_KEY,
|
||||
LANGFUSE_BASE_URL: process.env.LANGFUSE_BASE_URL,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../src/langfuse-bridge.js';
|
||||
import { buildPromptStackTelemetry } from '../src/prompt-telemetry.js';
|
||||
import { readObjectEvidence } from '../src/services/evidence-delivery.js';
|
||||
import { taskTraceReceipts } from '../src/observability/task-telemetry-receipt.js';
|
||||
|
||||
interface FakeMessage {
|
||||
id: string;
|
||||
@@ -1455,11 +1456,23 @@ describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])('Task takeover freezes and retries without rereading live files (offline=%s)', async (offline) => {
|
||||
it.each([
|
||||
{ offline: false, transport: 'relay' as const },
|
||||
{ offline: true, transport: 'relay' as const },
|
||||
{ offline: false, transport: 'vela' as const },
|
||||
{ offline: true, transport: 'vela' as const },
|
||||
])('Task takeover freezes and retries without rereading live files (offline=$offline, transport=$transport)', async ({ offline, transport }) => {
|
||||
await writeAppCfg({ installationId: 'install-uuid-1', telemetry: { metrics: true, content: true, artifactManifest: true } });
|
||||
enableTestVelaTelemetry();
|
||||
vi.stubEnv('OPEN_DESIGN_OBJECT_OUTBOX_MODE', 'send');
|
||||
vi.stubEnv('OPEN_DESIGN_TELEMETRY_RELAY_URL', 'https://telemetry.open-design.ai/api/langfuse');
|
||||
// Manifest updates require an accepted root and must keep its transport,
|
||||
// even when the current configuration prefers authenticated Vela.
|
||||
taskTraceReceipts(dataDir).write({
|
||||
taskExecutionId: 'test', idempotencyKey: 'accepted-root',
|
||||
traceId: transport === 'vela' ? 'a'.repeat(64) : 'strategy-task:test',
|
||||
protocol: 'legacy-v1', transport,
|
||||
});
|
||||
const projectDir = path.join(dataDir, 'projects', 'proj-1');
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
const original = '<!doctype html><h1>frozen original</h1>';
|
||||
@@ -1499,6 +1512,15 @@ describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
|
||||
expect(first?.manifests?.artifacts?.[0]).toMatchObject({ status: 'ok', stored_in_open_design: true, size_bytes: original.length });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(offline ? 7 : 4);
|
||||
expect(velaTraceBody(fetchSpy.mock.calls[0] as [string, RequestInit]).metadata.registration_only).toBe(true);
|
||||
const manifestCall = fetchSpy.mock.calls.at(-1)!;
|
||||
expect(manifestCall[0]).toBe(transport === 'vela' ? TEST_VELA_TELEMETRY_URL : 'https://telemetry.open-design.ai/api/langfuse');
|
||||
const manifestTrace = transport === 'vela'
|
||||
? velaTraceBody(manifestCall as [string, RequestInit])
|
||||
: JSON.parse(manifestCall[1].body as string).batch[0].body;
|
||||
expect(manifestTrace).toMatchObject({
|
||||
id: 'strategy-task:test', name: 'open-design-strategy-task',
|
||||
metadata: { schema: 'open-design.strategy-task-observation/v1', manifest_completeness: 'complete' },
|
||||
});
|
||||
await writeFile(path.join(projectDir, 'index.html'), 'a later turn changed this file');
|
||||
const second = await buildSafeRunQualityProjectionFromDaemon(options);
|
||||
expect(second?.manifests).toEqual(first?.manifests);
|
||||
|
||||
@@ -98,6 +98,16 @@ function bodyOf(
|
||||
}
|
||||
|
||||
describe('readLangfuseConfig', () => {
|
||||
it('includes ID-only Discovery observations in the real trace payload with content disabled', () => {
|
||||
const discovery = { skill_discovery_enabled: true, skill_discovery_policy_injected: true,
|
||||
skill_ids_loaded: ['ppt'], deliverable_skill_ids: ['ppt', 'video'],
|
||||
deliverable_skill_mapping: [{ id: 'slides', skillId: 'ppt' }, { id: 'clip', skillId: 'video' }],
|
||||
skill_load_events: [{ skill_id: 'ppt', version: '1.0.0', tool_use_id: 'read-1', source: 'file_read', status: 'loaded' }],
|
||||
skill_observation_status: 'complete' };
|
||||
const trace = bodyOf(buildTracePayload(makeCtx({ skillDiscovery: discovery })), 'trace-create');
|
||||
expect(trace.metadata).toMatchObject(discovery);
|
||||
expect(trace.metadata.skill_load_events[0]).not.toHaveProperty('content');
|
||||
});
|
||||
it('returns null when keys are missing', () => {
|
||||
expect(readLangfuseConfig({})).toBeNull();
|
||||
expect(readLangfuseConfig({ LANGFUSE_PUBLIC_KEY: 'pk' })).toBeNull();
|
||||
|
||||
@@ -288,6 +288,9 @@ async function runTurn(plan: TurnPlan & { message?: string }): Promise<{
|
||||
|
||||
describe('a media generation the host watched fail reaches its run terminal', () => {
|
||||
beforeAll(async () => {
|
||||
// This suite exercises the legacy od-done protocol. OD Next's keyed
|
||||
// planning/production stages have separate coordinator and browser cases.
|
||||
vi.stubEnv('OD_NEXT_STRATEGY_ROLLOUT', 'off');
|
||||
// An OpenAI-compatible image endpoint that fails on demand. 400 is the
|
||||
// production status and is deliberately NOT in the retry set (429/503), so
|
||||
// the failure is one round-trip rather than a timing bet.
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
mediaTaskErrorFromFailure,
|
||||
registerMediaRoutes,
|
||||
} from '../../src/routes/media.js';
|
||||
import { fetchVelaVideoModels } from '../../src/media/vela.js';
|
||||
import { VIDEO_MODELS } from '../../src/media/models.js';
|
||||
|
||||
vi.mock('../../src/media/vela.js', () => ({ fetchVelaVideoModels: vi.fn() }));
|
||||
|
||||
const servers: http.Server[] = [];
|
||||
|
||||
@@ -41,6 +45,7 @@ function task(id: string, projectId: string) {
|
||||
async function startRouteServer(options: {
|
||||
generateMedia: ReturnType<typeof vi.fn>;
|
||||
workspaceBinding: { workspaceId: string; visibility: 'personal' | 'team' } | null;
|
||||
denyAccess?: boolean;
|
||||
}) {
|
||||
const toolGrant = {
|
||||
token: 'tool-token',
|
||||
@@ -80,6 +85,7 @@ async function startRouteServer(options: {
|
||||
requestProjectOverride: (left: string, right: string) => left !== right,
|
||||
}),
|
||||
media: functionProxy({
|
||||
VIDEO_MODELS,
|
||||
generateMedia: options.generateMedia,
|
||||
createMediaTask: task,
|
||||
persistMediaTask: noop,
|
||||
@@ -96,7 +102,10 @@ async function startRouteServer(options: {
|
||||
projectFiles: functionProxy(),
|
||||
conversations: functionProxy(),
|
||||
research: functionProxy({ ResearchError: class ResearchError extends Error {} }),
|
||||
authorizeProjectRequest: async () => true,
|
||||
authorizeProjectRequest: async (_req: unknown, res: express.Response) => {
|
||||
if (options.denyAccess) { res.status(403).json({ error: 'denied' }); return false; }
|
||||
return true;
|
||||
},
|
||||
authorizeProjectToolRequest: async () => true,
|
||||
} as unknown as Parameters<typeof registerMediaRoutes>[1];
|
||||
|
||||
@@ -135,6 +144,30 @@ async function postGenerate(
|
||||
}
|
||||
|
||||
describe('Vela media route Workspace attribution', () => {
|
||||
it('filters the static catalogue using the same workspace as generation, including an empty catalogue', async () => {
|
||||
const catalogue = vi.mocked(fetchVelaVideoModels);
|
||||
catalogue.mockResolvedValueOnce(['doubao-seedance-2-0-260128']).mockResolvedValueOnce([]);
|
||||
const url = await startRouteServer({ generateMedia: vi.fn(), workspaceBinding: { workspaceId: 'workspace-from-database', visibility: 'personal' } });
|
||||
const first = await fetch(`${url}/api/media/models?cloud=true&projectId=team-project`);
|
||||
expect(first.status).toBe(200);
|
||||
expect((await first.json() as { video: { id: string }[] }).video.map((m: { id: string }) => m.id)).toEqual(['vela/doubao-seedance-2-0-260128']);
|
||||
expect(catalogue).toHaveBeenLastCalledWith('workspace-from-database');
|
||||
const empty = await fetch(`${url}/api/media/models?cloud=true&projectId=team-project`);
|
||||
expect((await empty.json() as { video: unknown[] }).video).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a lookup failure separately from an empty catalogue and rejects denied project access', async () => {
|
||||
const catalogue = vi.mocked(fetchVelaVideoModels);
|
||||
catalogue.mockRejectedValueOnce(new Error('private upstream diagnostic'));
|
||||
const url = await startRouteServer({ generateMedia: vi.fn(), workspaceBinding: null });
|
||||
const failed = await fetch(`${url}/api/media/models?cloud=true&projectId=personal-project`);
|
||||
expect(failed.status).toBe(503);
|
||||
expect(await failed.text()).not.toContain('private upstream diagnostic');
|
||||
const calls = catalogue.mock.calls.length;
|
||||
const denied = await startRouteServer({ generateMedia: vi.fn(), workspaceBinding: null, denyAccess: true });
|
||||
expect((await fetch(`${denied}/api/media/models?cloud=true&projectId=personal-project`)).status).toBe(403);
|
||||
expect(catalogue).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
it('does not persist a numeric process exit code as a public media code', () => {
|
||||
const error = mediaTaskErrorFromFailure(
|
||||
Object.assign(new Error('renderer crashed'), { code: 1 }),
|
||||
|
||||
@@ -547,9 +547,18 @@ describe('Vela media provider', () => {
|
||||
await expect(stat(tempOutputDirs[0]!)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unavailable cloud video model before uploading a first frame or generating', async () => {
|
||||
runVelaCommandMock.mockResolvedValue(JSON.stringify({models:[{model:'gpt-image-2',kind:'image'}]}));
|
||||
await expect(generateMedia({...baseArgs(),surface:'video',model:'vela/doubao-seedance-2-0-260128',images:refs.slice(0,1),output:'clip.mp4',workspaceId:'scope-one'})).rejects.toThrow(/unknown or unavailable video model/);
|
||||
expect(runVelaCommandMock.mock.calls).toHaveLength(1);
|
||||
expect(runVelaCommandMock.mock.calls[0]![0]).toEqual(['media','models','--json']);
|
||||
expect(runVelaCommandMock.mock.calls[0]![1]).toMatchObject({configuredEnv:{VELA_WORKSPACE_ID:'scope-one'}});
|
||||
});
|
||||
|
||||
it('submits and polls video with first-frame/references, progress heartbeats, and one terminal download', async () => {
|
||||
const progress: string[] = [];
|
||||
runVelaCommandMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'media') return JSON.stringify({models:[{model:'doubao-seedance-2-0-260128',kind:'video'}]});
|
||||
if (args[0] === 'video' && args[1] === 'gen') {
|
||||
return JSON.stringify({ task_id: 'mt_test', status: 'queued' });
|
||||
}
|
||||
@@ -580,7 +589,7 @@ describe('Vela media provider', () => {
|
||||
expect(result.providerId).toBe('vela');
|
||||
expect(result.name).toBe('clip.mp4');
|
||||
expect(result.providerNote).not.toContain('mt_test');
|
||||
const submit = runVelaCommandMock.mock.calls[0]![0] as string[];
|
||||
const submit = runVelaCommandMock.mock.calls.find(([args])=>args[0]==='video' && args[1]==='gen')![0] as string[];
|
||||
expect(submit.slice(0, 2)).toEqual(['video', 'gen']);
|
||||
expect(submit).toContain('--no-wait');
|
||||
expect(submit).not.toContain('--wait');
|
||||
@@ -597,7 +606,7 @@ describe('Vela media provider', () => {
|
||||
expect(valueAfter(submit, '--resolution')).toBe('720p');
|
||||
expect(submit).not.toContain('--generate-audio');
|
||||
|
||||
const polls = runVelaCommandMock.mock.calls.slice(1).map(([args]) => args as string[]);
|
||||
const polls = runVelaCommandMock.mock.calls.filter(([args])=>args[0]==='video' && args[1]==='get').map(([args]) => args as string[]);
|
||||
expect(polls).toHaveLength(2);
|
||||
for (const poll of polls) {
|
||||
expect(poll.slice(0, 3)).toEqual(['video', 'get', 'mt_test']);
|
||||
@@ -613,6 +622,7 @@ describe('Vela media provider', () => {
|
||||
|
||||
it('preserves a failed video status and provider error', async () => {
|
||||
runVelaCommandMock
|
||||
.mockResolvedValueOnce(JSON.stringify({models:[{model:'doubao-seedance-2-0-260128',kind:'video'}]}))
|
||||
.mockResolvedValueOnce(JSON.stringify({ task_id: 'mt_failed', status: 'queued' }))
|
||||
.mockResolvedValueOnce(JSON.stringify({
|
||||
task_id: 'mt_failed',
|
||||
@@ -632,6 +642,7 @@ describe('Vela media provider', () => {
|
||||
it('times out with the last video status and cleans temporary output', async () => {
|
||||
process.env.OD_VELA_VIDEO_TIMEOUT_MS = '3';
|
||||
runVelaCommandMock
|
||||
.mockResolvedValueOnce(JSON.stringify({models:[{model:'doubao-seedance-2-0-260128',kind:'video'}]}))
|
||||
.mockResolvedValueOnce(JSON.stringify({ task_id: 'mt_slow', status: 'queued' }))
|
||||
.mockImplementation(async (args: string[]) => {
|
||||
tempOutputDirs.push(path.dirname(valueAfter(args, '--output')));
|
||||
|
||||
@@ -1104,6 +1104,7 @@ describe('task observation OTLP exporter', () => {
|
||||
|
||||
it('reads an explicit mode while reusing existing Langfuse auth and transport defaults', () => {
|
||||
const env = {
|
||||
OPEN_DESIGN_VELA_TELEMETRY: 'off',
|
||||
LANGFUSE_PUBLIC_KEY: 'pk-fixture',
|
||||
LANGFUSE_SECRET_KEY: 'sk-fixture',
|
||||
LANGFUSE_BASE_URL: 'https://self-host.example.test/',
|
||||
@@ -1126,6 +1127,7 @@ describe('task observation OTLP exporter', () => {
|
||||
);
|
||||
expect(readTaskObservationExporterConfig(null, {})).toBeNull();
|
||||
const relayAndDirect = {
|
||||
OPEN_DESIGN_VELA_TELEMETRY: 'off',
|
||||
LANGFUSE_PUBLIC_KEY: 'pk-fixture',
|
||||
LANGFUSE_SECRET_KEY: 'sk-fixture',
|
||||
OPEN_DESIGN_TELEMETRY_RELAY_URL: 'https://relay.example.test',
|
||||
@@ -1143,8 +1145,9 @@ describe('task observation OTLP exporter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('selects a Task-capable sink independently from Vela and exposes a versioned schema capability', () => {
|
||||
it('preserves anonymous exporters and exposes the versioned schema used by Vela', () => {
|
||||
const env = {
|
||||
OPEN_DESIGN_VELA_TELEMETRY: 'off',
|
||||
LANGFUSE_PUBLIC_KEY: 'pk-fixture',
|
||||
LANGFUSE_SECRET_KEY: 'sk-fixture',
|
||||
LANGFUSE_BASE_URL: 'https://self-host.example.test',
|
||||
@@ -1165,7 +1168,10 @@ describe('task observation OTLP exporter', () => {
|
||||
...configuredVela,
|
||||
LANGFUSE_EXPORTER_MODE: 'otlp',
|
||||
})).toMatchObject({ mode: 'otlp', baseUrl: 'https://self-host.example.test' });
|
||||
expect(readTaskTelemetrySinkConfig({})).toBeNull();
|
||||
expect(readTaskTelemetrySinkConfig({ OPEN_DESIGN_VELA_TELEMETRY: 'off' })).toBeNull();
|
||||
const authenticated = readTaskTelemetrySinkConfig({ ...env, OPEN_DESIGN_VELA_TELEMETRY: 'on' }, configuredVela);
|
||||
expect(authenticated?.kind).toBe('vela');
|
||||
expect(readTaskObservationExporterConfig(authenticated, { LANGFUSE_EXPORTER_MODE: 'otlp' })).toBeNull();
|
||||
expect(TASK_OBSERVATION_SCHEMA_CAPABILITY_V1).toMatchObject({
|
||||
schema: 'open-design.task-observation-schema-capability/v1',
|
||||
aggregateSchema: 'open-design.strategy-task-observation/v1',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { persistStrategyResumeText } from '../../src/strategies/task-store.js';
|
||||
import { observeDiscoveryEvent } from '../../src/strategies/od-next/discovery-observation.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -284,6 +285,21 @@ describe('task observation rollout', () => {
|
||||
`).run();
|
||||
}
|
||||
|
||||
it('exports live Skill IDs even when the bounded Run event ring no longer contains the Read', async () => {
|
||||
const run = syntheticRun();
|
||||
const skillRoot = '/bundled/scenarios/od-next-strategy/assets/task-profiles';
|
||||
observeDiscoveryEvent(run, { event: 'diagnostic', data: { type: 'skill_discovery_policy', injected: true, skillRoot } });
|
||||
observeDiscoveryEvent(run, { event: 'agent', data: { type: 'tool_use', id: 'read-doc', name: 'Read', input: { file_path: `${skillRoot}/document.md` } } });
|
||||
observeDiscoveryEvent(run, { event: 'agent', data: { type: 'tool_result', toolUseId: 'read-doc', content: 'Private body sentinel.' } });
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => acceptedResponse());
|
||||
await expect(service({ mode: 'send', dataDir: tempDir, fetchImpl, getRun: () => run }).finalizeForRun('run-1')).resolves.toMatchObject({ action: 'sent' });
|
||||
const batch = JSON.parse(String(fetchImpl.mock.calls[0]![1]!.body)).batch as Array<{ type: string; body: Record<string, unknown> }>;
|
||||
expect(batch.find(event => event.type === 'trace-create')?.body).toMatchObject({ metadata: {
|
||||
skill_discovery_enabled: true, skill_discovery_policy_injected: true, skill_ids_loaded: ['document'],
|
||||
} });
|
||||
expect(JSON.stringify(batch)).not.toContain('Private body sentinel.');
|
||||
});
|
||||
|
||||
it('keeps the mapped run version when restart finalization replaces single-run telemetry', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => acceptedResponse());
|
||||
const startedVersion = {
|
||||
@@ -504,6 +520,26 @@ describe('task observation rollout', () => {
|
||||
expect(deliveryRow().aggregateDigest).toMatch(/^[a-f0-9]{64}$/u);
|
||||
});
|
||||
|
||||
it.each(['legacy', 'otlp'] as const)('exposes actual %s payload trace ID after export and across restart', async (mode) => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => acceptedResponse());
|
||||
const rollout = service({ mode: 'send', env: { LANGFUSE_EXPORTER_MODE: mode }, dataDir: tempDir, fetchImpl });
|
||||
expect(rollout.telemetryForRun('run-1')?.traceId).toBeNull();
|
||||
await rollout.finalizeForRun('run-1');
|
||||
const payload = JSON.parse(String(fetchImpl.mock.calls[0]![1]!.body));
|
||||
const traceId = mode === 'otlp' ? payload.resourceSpans[0].scopeSpans[0].spans[0].traceId : payload.batch[0].body.id;
|
||||
expect(rollout.telemetryForRun('run-1')).toMatchObject({ status: 'accepted', traceId,
|
||||
protocol: mode === 'otlp' ? 'otlp-v4' : 'legacy-v1', remoteVerification: 'not_checked' });
|
||||
// Changing current exporter config cannot rewrite historical trace identity.
|
||||
const restarted = service({ mode: 'send', env: { LANGFUSE_EXPORTER_MODE: mode === 'otlp' ? 'legacy' : 'otlp' }, dataDir: tempDir });
|
||||
expect(restarted.telemetryForRun('run-1')?.traceId).toBe(traceId);
|
||||
});
|
||||
|
||||
it('does not fabricate trace identity for observe-only or missing exporter receipts', async () => {
|
||||
const rollout = service({ mode: 'observe', dataDir: tempDir });
|
||||
await rollout.finalizeForRun('run-1');
|
||||
expect(rollout.telemetryForRun('run-1')).toMatchObject({ status: 'observed', traceId: null, attemptCount: 0 });
|
||||
});
|
||||
|
||||
it('sends one task root with environment tags and never resends a finalized task', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => acceptedResponse());
|
||||
const rollout = service({ mode: 'send', fetchImpl });
|
||||
@@ -2040,19 +2076,25 @@ describe('task observation rollout', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('uses relay for Task hierarchy even when Vela is configured for single-Run', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => new Response('', { status: 202 }));
|
||||
it('uses authenticated Vela for Task hierarchy and persists its actual remote receipt', async () => {
|
||||
const remoteTraceId = 'b'.repeat(64);
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => Response.json({
|
||||
ok: true,
|
||||
receipt: { clientTraceId: 'strategy-task:task-1', scopedTraceId: remoteTraceId },
|
||||
}, { status: 202 }));
|
||||
const env = {
|
||||
...BASE_ENV,
|
||||
OD_NEXT_TASK_OBSERVABILITY_MODE: 'send',
|
||||
OPEN_DESIGN_VELA_TELEMETRY: 'on',
|
||||
OPEN_DESIGN_TELEMETRY_RELAY_URL: 'https://relay.example.test/private?key=secret',
|
||||
VELA_CONTROL_KEY: 'control-secret',
|
||||
VELA_API_URL: 'https://vela.example.test',
|
||||
};
|
||||
const configuredEnv = {
|
||||
VELA_CONTROL_KEY: 'control-secret',
|
||||
VELA_API_URL: 'https://vela.example.test',
|
||||
};
|
||||
expect(readTaskTelemetrySinkConfig(env)).toMatchObject({ kind: 'relay' });
|
||||
expect(readTaskTelemetrySinkConfig(env)).toMatchObject({ kind: 'vela' });
|
||||
expect(readRunTelemetrySinkConfig(env, configuredEnv)).toMatchObject({
|
||||
kind: 'vela',
|
||||
apiUrl: 'https://vela.example.test',
|
||||
@@ -2061,9 +2103,10 @@ describe('task observation rollout', () => {
|
||||
mode: 'send',
|
||||
fetchImpl,
|
||||
env,
|
||||
dataDir: tempDir,
|
||||
});
|
||||
expect(rollout.diagnostic()).toMatchObject({
|
||||
effectiveSink: { kind: 'relay', host: 'relay.example.test', protocol: 'https' },
|
||||
effectiveSink: { kind: 'vela', host: 'vela.example.test', protocol: 'https' },
|
||||
taskProtocol: 'legacy-v1',
|
||||
readyToSend: true,
|
||||
});
|
||||
@@ -2075,10 +2118,72 @@ describe('task observation rollout', () => {
|
||||
await expect(rollout.finalizeForRun('run-1')).resolves.toMatchObject({ action: 'sent' });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchImpl.mock.calls[0]![0])).toBe(
|
||||
'https://relay.example.test/private?key=secret',
|
||||
'https://vela.example.test/api/v1/open-design/telemetry',
|
||||
);
|
||||
expect((fetchImpl.mock.calls[0]![1]!.headers as Record<string, string>).Authorization)
|
||||
.toBeUndefined();
|
||||
.toBe('Bearer control-secret');
|
||||
const envelope = JSON.parse(String(fetchImpl.mock.calls[0]![1]!.body));
|
||||
expect(envelope.events[0]).toMatchObject({
|
||||
kind: 'trace',
|
||||
data: { name: 'open-design-strategy-task', id: 'strategy-task:task-1' },
|
||||
});
|
||||
expect(rollout.telemetryForRun('run-1')).toMatchObject({
|
||||
status: 'accepted', traceId: remoteTraceId, transport: 'vela',
|
||||
});
|
||||
const restarted = service({ mode: 'send', env, fetchImpl, dataDir: tempDir });
|
||||
expect(restarted.telemetryForRun('run-1')).toMatchObject({
|
||||
traceId: remoteTraceId, transport: 'vela',
|
||||
});
|
||||
await restarted.finalizeForRun('run-1');
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([null, { clientTraceId: 'another-task', scopedTraceId: 'a'.repeat(64) }])(
|
||||
'does not invent a remote identity or resend when the accepted receipt is invalid: %j', async (receipt) => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => Response.json({ ok: true, receipt }, { status: 202 }));
|
||||
const rollout = service({ mode: 'send', fetchImpl, dataDir: tempDir, env: {
|
||||
OPEN_DESIGN_VELA_TELEMETRY: 'on', VELA_CONTROL_KEY: 'synthetic-key', VELA_API_URL: 'https://vela.example.test',
|
||||
} });
|
||||
await rollout.finalizeForRun('run-1');
|
||||
expect(rollout.telemetryForRun('run-1')).toMatchObject({ status: 'accepted', traceId: null, transport: null });
|
||||
await rollout.finalizeForRun('run-1');
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not hide an unrelated Vela validation error with anonymous fallback', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => Response.json({
|
||||
error: 'invalid_open_design_telemetry_payload',
|
||||
issues: [{ path: 'events.0.data.id', message: 'invalid id' }],
|
||||
}, { status: 400 }));
|
||||
const rollout = service({ mode: 'send', fetchImpl, env: {
|
||||
OPEN_DESIGN_VELA_TELEMETRY: 'on', VELA_CONTROL_KEY: 'synthetic-key', VELA_API_URL: 'https://vela.example.test',
|
||||
OPEN_DESIGN_TELEMETRY_RELAY_URL: 'https://relay.example.test/ingest',
|
||||
} });
|
||||
expect(await rollout.finalizeForRun('run-1')).toMatchObject({ action: 'failed', delivery: { dropReason: 'vela_400' } });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back once for an older Vela Task schema and records the actual relay identity', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async (url) => String(url).includes('vela.example')
|
||||
? Response.json({
|
||||
error: 'invalid_open_design_telemetry_payload',
|
||||
issues: [{ path: 'events.0.data.name', message: 'trace name must be open-design-turn' }],
|
||||
}, { status: 400 })
|
||||
: new Response('', { status: 202 }));
|
||||
const rollout = service({
|
||||
mode: 'send', fetchImpl, dataDir: tempDir,
|
||||
env: {
|
||||
OPEN_DESIGN_VELA_TELEMETRY: 'on',
|
||||
VELA_CONTROL_KEY: 'control-secret', VELA_API_URL: 'https://vela.example.test',
|
||||
OPEN_DESIGN_TELEMETRY_RELAY_URL: 'https://relay.example.test/ingest',
|
||||
},
|
||||
});
|
||||
await expect(rollout.finalizeForRun('run-1')).resolves.toMatchObject({ action: 'sent' });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(rollout.telemetryForRun('run-1')).toMatchObject({
|
||||
traceId: 'strategy-task:task-1', transport: 'relay', attemptCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('never falls back through Vela when the selected Task relay rejects auth', async () => {
|
||||
@@ -2092,7 +2197,7 @@ describe('task observation rollout', () => {
|
||||
mode: 'send',
|
||||
fetchImpl,
|
||||
env: {
|
||||
OPEN_DESIGN_VELA_TELEMETRY: 'on',
|
||||
OPEN_DESIGN_VELA_TELEMETRY: 'off',
|
||||
OPEN_DESIGN_TELEMETRY_RELAY_URL: 'https://relay.example.test/ingest',
|
||||
OPEN_DESIGN_TELEMETRY_RETRIES: '9',
|
||||
},
|
||||
@@ -2116,7 +2221,7 @@ describe('task observation rollout', () => {
|
||||
mode: 'send',
|
||||
fetchImpl,
|
||||
env: {
|
||||
OPEN_DESIGN_VELA_TELEMETRY: 'on',
|
||||
OPEN_DESIGN_VELA_TELEMETRY: 'off',
|
||||
OPEN_DESIGN_TELEMETRY_RELAY_URL: 'https://relay.example.test/ingest',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,6 +6,20 @@ import { taskObjectDeliveryEnabled } from '../../src/services/evidence-delivery.
|
||||
import { redactPromptText } from '../../src/prompt-telemetry.js';
|
||||
|
||||
describe('complete Task trace evidence', () => {
|
||||
it('retains actual Discovery reads across production and does not invent a reused read', () => {
|
||||
const result = projectTaskTrace([
|
||||
{ runId: 'request', metadata: { skill_discovery_enabled: true, skill_discovery_policy_injected: true,
|
||||
skill_ids_loaded: ['ppt'], skill_load_events: [{ skill_id: 'ppt', tool_use_id: 'r1', status: 'loaded' }],
|
||||
skill_observation_status: 'partial' } },
|
||||
{ runId: 'production', metadata: { skill_discovery_enabled: true, skill_discovery_policy_injected: false,
|
||||
skill_ids_loaded: [], skill_load_events: [],
|
||||
skill_observation_status: 'complete' } },
|
||||
], 100);
|
||||
expect(result?.metadata).toMatchObject({ skill_discovery_enabled: true, skill_discovery_policy_injected: true,
|
||||
skill_ids_loaded: ['ppt'], skill_observation_status: 'partial' });
|
||||
expect(result?.metadata.skill_load_events).toHaveLength(1);
|
||||
expect(result?.metadata).not.toHaveProperty('deliverable_skill_mapping');
|
||||
});
|
||||
it('uses current Turn counters for stages without reclassifying cumulative cache counters', () => {
|
||||
const provider = { input_tokens: 10000, cache_read_input_tokens: 9000, cache_token_source: 'openai' as const, input_accounting_mode: 'inclusive' as const, token_count_source: 'provider_usage' as const, agent_reported_model: 'm' };
|
||||
const usage = taskRunUsage('codex', [{ event: 'agent', data: { type: 'usage', evaluationTurnUsage: { input: 20, output: 3, total: 23, modelCalls: 1 } } }], provider);
|
||||
|
||||
@@ -670,14 +670,7 @@ process.exit(127);
|
||||
}
|
||||
});
|
||||
|
||||
// ACCEPTANCE for the opt-out switch. Nothing configured takes the OD Next
|
||||
// route — that is the default this build ships. The SAME running daemon
|
||||
// takes the ordinary route on the next run once `odNextStrategyMode: 'off'`
|
||||
// is saved through the public app-config API. No restart, no environment
|
||||
// variable — that is what "configure it and it takes effect" has to mean for
|
||||
// a packaged install, where the saved mode is the only control a user has:
|
||||
// the packaged child environment allowlist carries no `OD_NEXT_*` key.
|
||||
it('marker protocol automatically resumes Codex once and preserves files despite a missing canonical entry', async () => {
|
||||
it('Discovery resumes Codex once and delivers multiple real files without the default canonical entry', async () => {
|
||||
const fixture = await createPublicRolloutFixture('marker-production', 'design');
|
||||
await verifyProductionHostProtocol(fixture, 'marker-production');
|
||||
});
|
||||
@@ -713,7 +706,9 @@ process.exit(127);
|
||||
expect(task.planContract).toBeUndefined();
|
||||
const production = await waitForRunTerminal(started.url, task.latestRunId);
|
||||
expect(production.status).toBe('succeeded');
|
||||
expect(production.strategyTask).toMatchObject({ outcome: 'completed', terminal: true, deliverableValid: false });
|
||||
// Discovery validates the current run's independent output candidates;
|
||||
// the homepage's default index.html is not required for this delivery.
|
||||
expect(production.strategyTask).toMatchObject({ outcome: 'completed', terminal: true, deliverableValid: true });
|
||||
expect(repeatPreparation).toBeTypeOf('function');
|
||||
const repeated = repeatPreparation!();
|
||||
expect(repeated.start).toBe(false);
|
||||
@@ -742,6 +737,7 @@ process.exit(127);
|
||||
expect(visibleText).not.toContain('<od-next');
|
||||
expect(await readFile(path.join(invocations[1]!.cwd, 'landing.html'), 'utf8')).toContain('Landing');
|
||||
expect(await readFile(path.join(invocations[1]!.cwd, 'deck.html'), 'utf8')).toContain('Deck');
|
||||
await expect(readFile(path.join(invocations[1]!.cwd, 'index.html'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
const response = await fetch(`${started.url}/api/projects/${fixture.projectId}/conversations/${fixture.conversationId}/messages`);
|
||||
const history = await response.json() as {
|
||||
messages: Array<{ content: string; strategyTaskDelivered?: boolean; events?: Array<{ kind: string; suggestions?: string[] }> }>;
|
||||
@@ -751,7 +747,7 @@ process.exit(127);
|
||||
const persistedSuggestions = history.messages.flatMap(message => (message.events ?? [])
|
||||
.filter(event => event.kind === 'next_steps').map(event => event.suggestions));
|
||||
expect(persistedSuggestions).toEqual(suggestionsExpected ? [PRODUCTION_FOLLOW_UP_SUGGESTIONS] : []);
|
||||
expect(history.messages.some(message => message.strategyTaskDelivered)).toBe(false);
|
||||
expect(history.messages.some(message => message.strategyTaskDelivered)).toBe(true);
|
||||
}
|
||||
|
||||
it.each(['missing-session', 'expired-session'] as const)('marker production cold-starts with its plan when %s', async mode => {
|
||||
@@ -784,34 +780,6 @@ process.exit(127);
|
||||
expect(settled.runs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(['identical', 'conflicting', 'malformed'] as const)(
|
||||
'produces once despite %s repeated retired runtime-state blocks', async mode => {
|
||||
const fixture = await createPublicRolloutFixture(`marker-duplicate-state-${mode}`, 'design');
|
||||
started = fixture.started; binDir = fixture.binDir;
|
||||
process.env.OD_NEXT_STRATEGY_LOCAL_SYNTHETIC_CANARY = '1';
|
||||
const created = await postRun(started.url, publicRunRequest(fixture,
|
||||
'Create a landing page from the plan.', `duplicate-state-${mode}`));
|
||||
const request = await waitForRunTerminal(started.url, created.runId as string);
|
||||
expect(request.status, JSON.stringify(request)).toBe('succeeded');
|
||||
const task = await waitForTask(created.strategyTask!.taskExecutionId, 'completed');
|
||||
expect(task.runs.map(run => run.inputStage)).toEqual(['request', 'production']);
|
||||
expect(task.planContract).toBeUndefined();
|
||||
const production = await waitForRunTerminal(started.url, task.latestRunId);
|
||||
expect(production.status, JSON.stringify(production)).toBe('succeeded');
|
||||
const calls = await readProjectInvocations(fixture.logPath, fixture.projectId);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1]!.argv).toContain('resume');
|
||||
expect(calls[1]!.stdin).toContain('This is the production turn');
|
||||
expect(await readFile(path.join(calls[1]!.cwd, 'landing.html'), 'utf8')).toContain('Landing');
|
||||
const response = await fetch(`${started.url}/api/projects/${fixture.projectId}/conversations/${fixture.conversationId}/messages`);
|
||||
const history = await response.json() as { messages: Array<{ content: string }> };
|
||||
expect(response.status).toBe(200);
|
||||
expect(history.messages.some(message => message.content.includes('Plan: create a blue landing page.'))).toBe(true);
|
||||
expect(JSON.stringify(history)).not.toContain('open-design-runtime-state');
|
||||
expect(JSON.stringify(history)).not.toContain('od-production-ready');
|
||||
}, 45_000,
|
||||
);
|
||||
|
||||
it('does not cold-replay production after a tool ran before resume failure', async () => {
|
||||
const fixture = await createPublicRolloutFixture('marker-expired-side-effect', 'design');
|
||||
started = fixture.started; binDir = fixture.binDir;
|
||||
@@ -974,6 +942,11 @@ process.exit(127);
|
||||
'Hold the run open and drain slowly after a stop.', 'draining-first'));
|
||||
const firstRunId = first.runId as string;
|
||||
await vi.waitFor(async () => expect((await getRun(started!.url, firstRunId)).status).toBe('running'));
|
||||
// A running registry entry precedes child initialization. Wait for the
|
||||
// fixture's installed SIGTERM handler before testing its drain window.
|
||||
await vi.waitFor(async () => {
|
||||
expect(await readFile(fixture.logPath, 'utf8')).toContain('\"drainReady\":true');
|
||||
});
|
||||
const stopping = fetch(`${started.url}/api/runs/${encodeURIComponent(firstRunId)}/cancel`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
|
||||
});
|
||||
@@ -1122,55 +1095,6 @@ process.exit(127);
|
||||
expect((await readProjectInvocations(fixture.logPath, fixture.projectId))).toHaveLength(2);
|
||||
}, 45_000);
|
||||
|
||||
it('sends compact historical context to the agent while still accepting another clarification form', async () => {
|
||||
const fixture = await createPublicRolloutFixture('marker-question', 'design');
|
||||
started = fixture.started; binDir = fixture.binDir;
|
||||
process.env.OD_NEXT_STRATEGY_LOCAL_SYNTHETIC_CANARY = '1';
|
||||
const original = 'Original handoff request: create a blue page with keyboard navigation.';
|
||||
const first = await postRun(started.url, publicRunRequest(fixture, original, 'history-context-first'));
|
||||
const previous = await waitForTask(first.strategyTask!.taskExecutionId, 'completed');
|
||||
await waitForRunTerminal(started.url, previous.latestRunId);
|
||||
const callsBefore = await readProjectInvocations(fixture.logPath, fixture.projectId);
|
||||
const body = '<!doctype html><html><body>' + 'STORED_PAGE_BODY_SENTINEL'.repeat(1000) + '</body></html>';
|
||||
await writeFile(path.join(callsBefore[0]!.cwd, 'history-page.html'), body);
|
||||
const prior = listMessages(database(), fixture.conversationId)
|
||||
.find(message => message.runId === previous.latestRunId)!;
|
||||
expect(prior).toBeDefined();
|
||||
upsertMessage(database(), fixture.conversationId, {
|
||||
...prior,
|
||||
content: 'Plan: keep the blue palette and keyboard navigation.\n'
|
||||
+ '<question-form id="scope">{"questions":[{"id":"audience","label":"Who is this for?"}]}</question-form>\n'
|
||||
+ `<artifact identifier="history-page" type="text/html" title="Existing page">${body}</artifact>`,
|
||||
producedFiles: [{ name: 'history-page.html', path: 'history-page.html', size: body.length,
|
||||
mtime: Date.now(), kind: 'html', mime: 'text/html',
|
||||
artifactManifest: { metadata: { identifier: 'history-page' } } }],
|
||||
});
|
||||
const followup = 'Continue the blue page. Ask for more detail if useful.';
|
||||
const next = await postRun(started.url, {
|
||||
...publicRunRequest(fixture, followup, 'history-context-second'),
|
||||
// Changed instructions require the full bundle rather than native incremental input.
|
||||
systemPrompt: 'Current instruction: retain keyboard navigation.',
|
||||
});
|
||||
const current = await waitForTask(next.strategyTask!.taskExecutionId, 'completed');
|
||||
expect((await waitForRunTerminal(started.url, current.latestRunId)).status).toBe('succeeded');
|
||||
const calls = await readProjectInvocations(fixture.logPath, fixture.projectId);
|
||||
expect(calls).toHaveLength(2);
|
||||
const context = parseOdNextPromptBundleV2(calls[1]!.stdin).context.priorTranscript!;
|
||||
for (const text of [original, 'blue palette', 'keyboard navigation', 'Who is this for?', 'history-page.html']) {
|
||||
expect(context).toContain(text);
|
||||
}
|
||||
expect(context).not.toContain('STORED_PAGE_BODY_SENTINEL');
|
||||
expect(context).not.toContain('<question-form');
|
||||
expect(context).not.toContain('already answered');
|
||||
expect(calls[1]!.stdin).toContain(followup);
|
||||
// The fixture agent asks the same question again. It remains an ordinary
|
||||
// succeeded reply: historical formatting must never become a repetition gate.
|
||||
const reply = listMessages(database(), fixture.conversationId)
|
||||
.find(message => message.runId === current.latestRunId)!;
|
||||
expect(reply.content).toContain('<question-form id="scope">');
|
||||
expect(reply.content).toContain('Who is this for?');
|
||||
}, 45_000);
|
||||
|
||||
it('rejects a handoff during an active run and continues under the current strategy after daemon restart', async () => {
|
||||
const fixture = await createPublicRolloutFixture('marker-handoff', 'design');
|
||||
started = fixture.started; binDir = fixture.binDir;
|
||||
@@ -1194,7 +1118,31 @@ process.exit(127);
|
||||
expect(task.continuedFromTaskExecutionId).toBe(first.strategyTask!.taskExecutionId);
|
||||
}, 45_000);
|
||||
|
||||
it('runs OD Next by default and leaves it on the next run once the installation opts out', async () => {
|
||||
it('keeps the automatic WebGL entry in Discovery when Query requests a presentation', async () => {
|
||||
const fixture = await createPublicRolloutFixture('webgl-query-priority', 'design');
|
||||
started = fixture.started;
|
||||
binDir = fixture.binDir;
|
||||
process.env.OD_NEXT_STRATEGY_ROLLOUT = 'active';
|
||||
process.env.OD_NEXT_STRATEGY_LOCAL_SYNTHETIC_CANARY = '1';
|
||||
const project = await createProjectForScenario(started.url, 'manual-webgl', {
|
||||
kind: 'prototype', intent: 'webgl-experience',
|
||||
});
|
||||
expect(project.metadata?.scenarioBinding).toMatchObject({
|
||||
provenance: 'automatic_default', pluginId: 'example-webgl-experience',
|
||||
});
|
||||
const run = await postRun(started.url, publicRunRequest(
|
||||
project, 'Make a three-slide community-service presentation, not a WebGL demo.',
|
||||
'webgl-query-priority',
|
||||
));
|
||||
expect(run.strategyTask).toMatchObject({ inputStage: 'request' });
|
||||
expect(await readDurableRunState(run.runId as string)).toMatchObject({
|
||||
strategyRolloutDecision: { decisionClass: 'active', taskType: 'discovery' },
|
||||
});
|
||||
await fetch(`${started.url}/api/runs/${encodeURIComponent(run.runId as string)}/cancel`, { method: 'POST' });
|
||||
await waitForRunTerminal(started.url, run.runId as string);
|
||||
});
|
||||
|
||||
it('keeps Discovery active for subsequent requests despite a historical off preference', async () => {
|
||||
const fixture = await createPublicRolloutFixture('app-config-opt-out', 'design');
|
||||
started = fixture.started;
|
||||
binDir = fixture.binDir;
|
||||
@@ -1208,7 +1156,7 @@ process.exit(127);
|
||||
));
|
||||
expect(beforeOptOut.strategyTask).toMatchObject({ inputStage: 'request', terminal: false });
|
||||
expect(await readDurableRunState(beforeOptOut.runId as string)).toMatchObject({
|
||||
strategyRolloutDecision: { decisionClass: 'active', taskType: 'prototype' },
|
||||
strategyRolloutDecision: { decisionClass: 'active', taskType: 'discovery' },
|
||||
});
|
||||
await fetch(
|
||||
`${started.url}/api/runs/${encodeURIComponent(beforeOptOut.runId as string)}/cancel`,
|
||||
@@ -1247,17 +1195,17 @@ process.exit(127);
|
||||
const invocationsBefore = (await readProjectInvocations(fixture.logPath, fixture.projectId)).length;
|
||||
const afterOptOut = await postRun(started.url, publicRunRequest(
|
||||
fixture,
|
||||
'Run after this installation opted out.',
|
||||
'Create a 2-page PPT after this installation opted out.',
|
||||
'app-config-opt-out-after',
|
||||
));
|
||||
expect(afterOptOut.strategyTask).toBeUndefined();
|
||||
expect(afterOptOut.pluginId).toBe('example-web-prototype');
|
||||
expect(afterOptOut.strategyTask).toMatchObject({ inputStage: 'request' });
|
||||
await waitForRunTerminal(started.url, afterOptOut.runId as string);
|
||||
const ordinaryInvocations = (await readProjectInvocations(fixture.logPath, fixture.projectId))
|
||||
.slice(invocationsBefore);
|
||||
expect(ordinaryInvocations).toHaveLength(1);
|
||||
expect(ordinaryInvocations[0]?.stdin).not.toContain('OD Next Strategy V2');
|
||||
expect(ordinaryInvocations[0]?.stdin).toContain('skill_name="discovery"');
|
||||
expect(ordinaryInvocations[0]?.stdin).not.toContain('open-design.strategy-state/v2');
|
||||
expect(ordinaryInvocations[0]?.stdin).not.toContain('Deck framework — DO NOT EDIT');
|
||||
|
||||
// The operator-facing surface names the authority that decided, so the
|
||||
// person who just configured the mode can confirm theirs is the one in
|
||||
@@ -1266,9 +1214,9 @@ process.exit(127);
|
||||
const status = await fetch(`${started.url}/api/strategies/od-next/rollout`);
|
||||
expect(status.status).toBe(200);
|
||||
expect((await status.json() as { status: unknown }).status).toMatchObject({
|
||||
requestedMode: 'off',
|
||||
requestedModeSource: 'app_config',
|
||||
effectiveMode: 'off',
|
||||
requestedMode: 'active',
|
||||
requestedModeSource: 'default',
|
||||
effectiveMode: 'active',
|
||||
});
|
||||
// Two full runs against a real server, plus config writes and a status
|
||||
// read — the heaviest case in this file, and the only one that drives more
|
||||
@@ -1363,7 +1311,7 @@ process.exit(127);
|
||||
}
|
||||
});
|
||||
|
||||
it('routes the four approved automatic profiles while ordinary Image remains media-only', async () => {
|
||||
it('converges official automatic and explicit task-type entries into Discovery', async () => {
|
||||
const fixture = await createPublicRolloutFixture('approved-profiles', 'design');
|
||||
started = fixture.started;
|
||||
binDir = fixture.binDir;
|
||||
@@ -1499,7 +1447,7 @@ process.exit(127);
|
||||
strategyRolloutDecision: {
|
||||
schemaVersion: 1,
|
||||
decisionClass: 'active',
|
||||
taskType: candidate.taskProfile,
|
||||
taskType: 'discovery',
|
||||
primaryReasonCode: 'od_next_rollout_eligible',
|
||||
},
|
||||
});
|
||||
@@ -1583,7 +1531,7 @@ process.exit(127);
|
||||
strategyRolloutDecision: {
|
||||
schemaVersion: 1,
|
||||
decisionClass: 'active',
|
||||
taskType: 'prototype',
|
||||
taskType: 'discovery',
|
||||
primaryReasonCode: 'od_next_rollout_eligible',
|
||||
},
|
||||
});
|
||||
@@ -1638,13 +1586,12 @@ process.exit(127);
|
||||
'Create an ordinary image.',
|
||||
'ordinary-image-default',
|
||||
));
|
||||
expect(imageRun.strategyTask).toBeUndefined();
|
||||
expect(imageRun.pluginId).toBe('od-media-generation');
|
||||
expect(imageRun.strategyTask).toMatchObject({ inputStage: 'request' });
|
||||
expect(await readDurableRunState(imageRun.runId as string)).toMatchObject({
|
||||
strategyRolloutDecision: {
|
||||
schemaVersion: 1,
|
||||
decisionClass: 'not_applicable',
|
||||
taskType: null,
|
||||
decisionClass: 'active',
|
||||
taskType: 'discovery',
|
||||
},
|
||||
});
|
||||
const imageStatus = await fetch(
|
||||
@@ -1654,8 +1601,8 @@ process.exit(127);
|
||||
expect(await imageStatus.json()).toMatchObject({
|
||||
strategyRolloutDecision: {
|
||||
schemaVersion: 1,
|
||||
decisionClass: 'not_applicable',
|
||||
taskType: null,
|
||||
decisionClass: 'active',
|
||||
taskType: 'discovery',
|
||||
},
|
||||
});
|
||||
await waitForRunTerminal(started.url, imageRun.runId as string);
|
||||
@@ -1684,13 +1631,12 @@ process.exit(127);
|
||||
'Create an ordinary image.',
|
||||
'ordinary-image-explicit',
|
||||
));
|
||||
expect(explicitImageRun.strategyTask).toBeUndefined();
|
||||
expect(explicitImageRun.pluginId).toBe('od-media-generation');
|
||||
expect(explicitImageRun.strategyTask).toMatchObject({ inputStage: 'request' });
|
||||
expect(await readDurableRunState(explicitImageRun.runId as string)).toMatchObject({
|
||||
strategyRolloutDecision: {
|
||||
schemaVersion: 1,
|
||||
decisionClass: 'explicit_user',
|
||||
taskType: null,
|
||||
decisionClass: 'active',
|
||||
taskType: 'discovery',
|
||||
},
|
||||
});
|
||||
await waitForRunTerminal(started.url, explicitImageRun.runId as string);
|
||||
@@ -1725,7 +1671,7 @@ process.exit(127);
|
||||
expect(await readDurableRunState(created.runId as string)).toMatchObject({
|
||||
strategyRolloutDecision: {
|
||||
decisionClass: 'active',
|
||||
taskType: 'prototype',
|
||||
taskType: 'discovery',
|
||||
},
|
||||
});
|
||||
await fetch(`${started.url}/api/runs/${encodeURIComponent(created.runId as string)}/cancel`, {
|
||||
@@ -1825,9 +1771,9 @@ process.exit(127);
|
||||
]);
|
||||
expect(savedResult.stderr).toBe('');
|
||||
expect((JSON.parse(savedResult.stdout) as { status: unknown }).status).toMatchObject({
|
||||
requestedMode: 'off',
|
||||
requestedModeSource: 'app_config',
|
||||
effectiveMode: 'off',
|
||||
requestedMode: 'active',
|
||||
requestedModeSource: 'default',
|
||||
effectiveMode: 'active',
|
||||
});
|
||||
|
||||
// The reset endpoint and its CLI subcommand are both gone, and gone the
|
||||
@@ -1858,7 +1804,9 @@ process.exit(127);
|
||||
// what replaced it — and gets a non-zero exit, because the recovery it
|
||||
// asked for neither happened nor can.
|
||||
expect(resetCli.stderr).toContain('od strategy rollout reset was removed');
|
||||
expect(resetCli.stderr).toContain('od config set odNextStrategyMode off');
|
||||
expect(resetCli.stderr).toContain('OD Next is active by default');
|
||||
expect(resetCli.stderr).toContain('od strategy rollout status');
|
||||
expect(resetCli.stderr).not.toContain('od config set odNextStrategyMode off');
|
||||
});
|
||||
|
||||
it('keeps active retry/task recipe-only while rollback lazily resolves the ordinary default', async () => {
|
||||
@@ -2239,7 +2187,7 @@ process.exit(127);
|
||||
expect(await readProjectInvocations(fixture.logPath, fixture.projectId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never overrides explicit plugin, snapshot, or existing project-pin authority', async () => {
|
||||
it('converges verified bundled task pins while protecting custom plugin and invalid snapshot authority', async () => {
|
||||
const fixture = await createPublicRolloutFixture(
|
||||
'authority',
|
||||
'design',
|
||||
@@ -2261,15 +2209,17 @@ process.exit(127);
|
||||
started.url,
|
||||
publicRunRequest(fixture, 'Use the pinned default.', 'pinned-authority'),
|
||||
);
|
||||
expect(pinned.strategyTask).toBeUndefined();
|
||||
expect(pinned.pluginId).toBe('example-web-prototype');
|
||||
expect(pinned.strategyTask).toMatchObject({ inputStage: 'request' });
|
||||
await fetch(`${started.url}/api/runs/${encodeURIComponent(pinned.runId as string)}/cancel`, { method: 'POST' });
|
||||
await waitForRunTerminal(started.url, pinned.runId as string);
|
||||
|
||||
const explicitDefault = await postRun(started.url, {
|
||||
...publicRunRequest(fixture, 'Use the explicit default.', 'explicit-default'),
|
||||
pluginId: 'example-web-prototype',
|
||||
});
|
||||
expect(explicitDefault.strategyTask).toBeUndefined();
|
||||
expect(explicitDefault.pluginId).toBe('example-web-prototype');
|
||||
expect(explicitDefault.strategyTask).toMatchObject({ inputStage: 'request' });
|
||||
await fetch(`${started.url}/api/runs/${encodeURIComponent(explicitDefault.runId as string)}/cancel`, { method: 'POST' });
|
||||
await waitForRunTerminal(started.url, explicitDefault.runId as string);
|
||||
|
||||
const invalidSnapshot = await fetch(`${started.url}/api/runs`, {
|
||||
method: 'POST',
|
||||
@@ -2302,11 +2252,12 @@ process.exit(127);
|
||||
source: 'community-collision-fixture',
|
||||
trust: 'restricted',
|
||||
});
|
||||
const collisionProject = await createProjectForScenario(started.url, 'collision-isolated', { kind: 'prototype' });
|
||||
const collidingId = await fetch(`${started.url}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...publicRunRequest(fixture, 'Use an explicit colliding id.', 'colliding-id'),
|
||||
...publicRunRequest(collisionProject, 'Use an explicit colliding id.', 'colliding-id'),
|
||||
pluginId: 'od-next-strategy',
|
||||
}),
|
||||
});
|
||||
@@ -2315,7 +2266,7 @@ process.exit(127);
|
||||
error: { code: 'capabilities-required' },
|
||||
});
|
||||
expect((database().prepare('SELECT COUNT(*) AS count FROM strategy_task_executions').get() as { count: number }).count)
|
||||
.toBe(strategyTaskCountAtStart);
|
||||
.toBe(strategyTaskCountAtStart + 2);
|
||||
|
||||
const restoredResult = await runOdCli([
|
||||
'project', 'restore-automatic-scenario', fixture.projectId,
|
||||
@@ -3009,6 +2960,7 @@ process.stdin.on('end', () => {
|
||||
if (stdin.includes('Hold the run open and drain slowly after a stop.') && !stdin.includes('Historical task context.')) {
|
||||
// Stopping answers only after the process exits; keep that window open.
|
||||
process.on('SIGTERM', () => setTimeout(() => process.exit(0), 2500));
|
||||
fs.appendFileSync(logPath, JSON.stringify({ drainReady: true }) + '\\n');
|
||||
setInterval(() => {}, 1 << 30);
|
||||
return;
|
||||
}
|
||||
@@ -3025,26 +2977,7 @@ process.stdin.on('end', () => {
|
||||
return;
|
||||
}
|
||||
let text = 'Ordinary public run completed.';
|
||||
if (${JSON.stringify(label)}.startsWith('marker-duplicate-state-')) {
|
||||
const mode = ${JSON.stringify(label)}.slice('marker-duplicate-state-'.length);
|
||||
if (stdin.includes('This is the production turn')) {
|
||||
fs.writeFileSync('landing.html', '<!doctype html><html><body>Landing</body></html>');
|
||||
text = 'Wrote landing.html.';
|
||||
} else {
|
||||
const key = /<od-production-ready key="([a-f0-9]+)"/.exec(stdin)?.[1];
|
||||
if (!key) throw new Error('Current host marker key was not injected');
|
||||
const state = {
|
||||
schema: 'open-design.strategy-state/v2', route: 'full_plan', inputStage: 'request',
|
||||
outcome: 'plan_ready', executionMode: 'simple', executionIntent: 'produce', reasonCodes: [],
|
||||
};
|
||||
const bodies = mode === 'malformed' ? ['{broken}', '{also broken}']
|
||||
: [JSON.stringify(state), JSON.stringify(mode === 'conflicting'
|
||||
? { ...state, executionMode: null, executionIntent: 'plan_only' } : state)];
|
||||
text = 'Plan: create a blue landing page.\\n'
|
||||
+ bodies.map(body => '<open-design-runtime-state>\\n' + body + '\\n</open-design-runtime-state>').join('\\n')
|
||||
+ '\\n<od-production-ready key="' + key + '" />';
|
||||
}
|
||||
} else if (${JSON.stringify(label)} === 'marker-handoff') {
|
||||
if (${JSON.stringify(label)} === 'marker-handoff') {
|
||||
if (stdin.includes('Historical task context.')) {
|
||||
fs.writeFileSync('index.html', '<!doctype html><html><body>Dog battle</body></html>');
|
||||
text = 'Delivered index.html.';
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('bundled OD Next strategy package identity', () => {
|
||||
]);
|
||||
expect(prototype.selectedTaskProfile).toEqual(expect.objectContaining({
|
||||
taskType: 'prototype',
|
||||
version: '2.3.2',
|
||||
version: '3.1.9',
|
||||
path: './assets/task-profiles/prototype.md',
|
||||
}));
|
||||
expect(hyperframes.packageHash).not.toBe(prototype.packageHash);
|
||||
|
||||
@@ -100,26 +100,6 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe('OD Next V2 request recipe wiring', () => {
|
||||
it('composes the canonical motion skill with Core and orchestration for the motion route', async () => {
|
||||
const binding = createBundledStrategyBindingV2({ plugin, taskType: 'motion-design' });
|
||||
const motionSnapshot = applyPlugin({
|
||||
plugin, inputs: {}, registry: EMPTY_REGISTRY, internalStrategyBinding: binding,
|
||||
}).result.appliedPlugin;
|
||||
const recipe = await resolveRecipe({ activeSnapshot: { ...motionSnapshot, snapshotId: 'snapshot-motion-recipe' } });
|
||||
if (!recipe) throw new Error('expected motion recipe');
|
||||
const prompt = composeSystemPrompt({ agentId: 'codex', sessionMode: 'design', odNextStrategyRecipe: recipe });
|
||||
const source = await readFile(path.resolve(BUNDLED_ROOT, '../../skills/motion-design/SKILL.md'), 'utf8');
|
||||
expect(prompt).toContain(source);
|
||||
expect(prompt).toContain('OD Next Core Strategy');
|
||||
expect(prompt).toContain('General Orchestration');
|
||||
expect(prompt).not.toContain('# OD Next Task Profile: HyperFrames');
|
||||
expect(prompt).not.toContain('<open-design-plan-contract>');
|
||||
expect(prompt).not.toContain('<open-design-runtime-state>');
|
||||
expect(prompt).not.toContain('planContractHash=');
|
||||
expect(prompt).toContain('keyed');
|
||||
expect(prompt).toContain('selected scenario or a loaded');
|
||||
});
|
||||
|
||||
it.each(['prototype', 'ppt', 'marketing', 'hyperframes'] as const)(
|
||||
'treats %s as guidance rather than overriding the current requested output', async (taskType) => {
|
||||
const binding = createBundledStrategyBindingV2({ plugin, taskType });
|
||||
@@ -136,10 +116,6 @@ describe('OD Next V2 request recipe wiring', () => {
|
||||
expect(prompt).not.toContain('propose a task-type\n switch and wait for confirmation');
|
||||
expect(prompt).not.toContain('is rejected as an invalid\ncanonical deliverable');
|
||||
expect(prompt).toContain('Apply this profile only to the parts of the current request');
|
||||
expect(prompt).not.toContain('<open-design-plan-contract>');
|
||||
expect(prompt).not.toContain('planContractHash=');
|
||||
expect(prompt).not.toContain('od-motion-audio');
|
||||
expect(prompt).not.toContain('media generate --project');
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 67931,
|
||||
"totalChars": 68130,
|
||||
},
|
||||
"ask-mode-full-context": {
|
||||
"sections": [
|
||||
@@ -50,7 +50,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 54489,
|
||||
"totalChars": 54999,
|
||||
},
|
||||
"critique-enabled": {
|
||||
"sections": [
|
||||
@@ -73,7 +73,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 67459,
|
||||
"totalChars": 67658,
|
||||
},
|
||||
"deck-kind-no-skill": {
|
||||
"sections": [
|
||||
@@ -90,7 +90,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 96225,
|
||||
"totalChars": 96424,
|
||||
},
|
||||
"deck-kind-with-skill-seed": {
|
||||
"sections": [
|
||||
@@ -107,7 +107,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 66987,
|
||||
"totalChars": 67186,
|
||||
},
|
||||
"design-ds-fixture-fallback": {
|
||||
"sections": [
|
||||
@@ -130,7 +130,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 91980,
|
||||
"totalChars": 92179,
|
||||
},
|
||||
"design-full-stack": {
|
||||
"sections": [
|
||||
@@ -160,7 +160,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 74612,
|
||||
"totalChars": 74811,
|
||||
},
|
||||
"design-minimal": {
|
||||
"sections": [
|
||||
@@ -177,7 +177,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 96181,
|
||||
"totalChars": 96380,
|
||||
},
|
||||
"design-no-ds-multitarget": {
|
||||
"sections": [
|
||||
@@ -194,7 +194,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 72553,
|
||||
"totalChars": 72752,
|
||||
},
|
||||
"example-prompt": {
|
||||
"sections": [
|
||||
@@ -211,7 +211,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 70800,
|
||||
"totalChars": 70999,
|
||||
},
|
||||
"freeform-other": {
|
||||
"sections": [
|
||||
@@ -229,7 +229,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 99877,
|
||||
"totalChars": 100076,
|
||||
},
|
||||
"freeform-other-no-deck-signal": {
|
||||
"sections": [
|
||||
@@ -245,7 +245,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 69723,
|
||||
"totalChars": 69922,
|
||||
},
|
||||
"media-image": {
|
||||
"sections": [
|
||||
@@ -259,7 +259,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 54487,
|
||||
"totalChars": 54997,
|
||||
},
|
||||
"memory-hooks-off": {
|
||||
"sections": [
|
||||
@@ -277,7 +277,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 97686,
|
||||
"totalChars": 97885,
|
||||
},
|
||||
"plan-mode": {
|
||||
"sections": [
|
||||
@@ -294,7 +294,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 73747,
|
||||
"totalChars": 73946,
|
||||
},
|
||||
"plugin-stages": {
|
||||
"sections": [
|
||||
@@ -313,7 +313,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 96277,
|
||||
"totalChars": 96476,
|
||||
},
|
||||
"skip-discovery-brief": {
|
||||
"sections": [
|
||||
@@ -330,7 +330,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 70311,
|
||||
"totalChars": 70510,
|
||||
},
|
||||
"slim-design-full-stack": {
|
||||
"sections": [
|
||||
@@ -357,7 +357,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 50648,
|
||||
"totalChars": 50847,
|
||||
},
|
||||
"slim-freeform-no-deck-signal": {
|
||||
"sections": [
|
||||
@@ -369,7 +369,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
|
||||
"comment-identity-edit",
|
||||
"role-marker-guard",
|
||||
],
|
||||
"totalChars": 42703,
|
||||
"totalChars": 42902,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -451,7 +451,7 @@ describe('composeSystemPrompt', () => {
|
||||
metadata: { kind: 'image', imageModel: 'vela/gpt-image-2' } as any,
|
||||
});
|
||||
expect(amrPrompt).toContain('Image model: `vela/gpt-image-2`');
|
||||
expect(amrPrompt).toContain(
|
||||
expect(amrPrompt).not.toContain(
|
||||
'Video model: `vela/doubao-seedance-2-0-260128`',
|
||||
);
|
||||
expect(amrPrompt).toContain('### OpenDesign Cloud media defaults');
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
// An unreadable app config must not be reported as an unconfigured one.
|
||||
//
|
||||
// `odNextStrategyMode` decides whether OD Next runs. `readAppConfig` already
|
||||
// answers `{}` for the two states that legitimately mean "nothing configured"
|
||||
// — no file, unparseable file — and throws only when the daemon genuinely
|
||||
// cannot read its own config. Substituting `{}` for that throw would tell an
|
||||
// operator the installation was never opted in, which is a claim about their
|
||||
// choice rather than about this daemon's disk.
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import express from 'express';
|
||||
@@ -34,8 +26,7 @@ describe('GET /api/strategies/od-next/rollout', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Env must not decide the mode for these cases; the saved preference is
|
||||
// the thing under test.
|
||||
// Historical preferences must not decide the new default.
|
||||
delete process.env.OD_NEXT_STRATEGY_ROLLOUT;
|
||||
});
|
||||
|
||||
@@ -44,13 +35,13 @@ describe('GET /api/strategies/od-next/rollout', () => {
|
||||
server = null;
|
||||
});
|
||||
|
||||
it('reports the saved mode and the authority that set it', async () => {
|
||||
await start(async () => ({ odNextStrategyMode: 'active' }));
|
||||
it.each(['off', 'observe', 'active'] as const)('ignores historical %s preference', async (mode) => {
|
||||
await start(async () => ({ odNextStrategyMode: mode }));
|
||||
const response = await fetch(`${baseUrl}/api/strategies/od-next/rollout`);
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json() as { status: unknown }).status).toMatchObject({
|
||||
requestedMode: 'active',
|
||||
requestedModeSource: 'app_config',
|
||||
requestedModeSource: 'default',
|
||||
effectiveMode: 'active',
|
||||
});
|
||||
});
|
||||
@@ -65,25 +56,9 @@ describe('GET /api/strategies/od-next/rollout', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the saved off an installation opted into, not the default', async () => {
|
||||
// `default` and a saved `off` now resolve to opposite modes, so the source
|
||||
// is what tells an operator whether this daemon is on OD Next because
|
||||
// nobody touched it or off it because somebody asked.
|
||||
await start(async () => ({ odNextStrategyMode: 'off' }));
|
||||
const response = await fetch(`${baseUrl}/api/strategies/od-next/rollout`);
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json() as { status: unknown }).status).toMatchObject({
|
||||
requestedMode: 'off',
|
||||
requestedModeSource: 'app_config',
|
||||
effectiveMode: 'off',
|
||||
});
|
||||
});
|
||||
|
||||
it('offers no way to change the mode from here', async () => {
|
||||
// The reset this route used to expose existed only to lift a stop latch.
|
||||
// With the latch gone there is nothing here to write, and the one way to
|
||||
// turn OD Next off is `PUT /api/app-config` — which is also the one place
|
||||
// a typo is refused rather than absorbed.
|
||||
// This endpoint remains read-only. Engineering env overrides own rollback.
|
||||
await start(async () => ({}));
|
||||
const response = await fetch(`${baseUrl}/api/strategies/od-next/rollout/reset`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -60,6 +60,15 @@ describe('run deliverable validation', () => {
|
||||
expect((await validateRunDeliverable(input)).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts multiple Discovery outputs only when each touched candidate is readable', async () => {
|
||||
const fixture = await projectFixture({ 'slides.html': '<main>Deck</main>', 'manual.html': '<main>Manual</main>' });
|
||||
const input = { ...fixture, runStatus: 'succeeded' as const, artifactCount: 2,
|
||||
touchedPaths: ['slides.html', 'manual.html'], allowIndependentOutput: true, allowMultipleOutputCandidates: true };
|
||||
expect(await validateRunDeliverable(input)).toMatchObject({ valid: true, entryFile: 'manual.html' });
|
||||
expect((await validateRunDeliverable({ ...input, touchedPaths: ['slides.html', 'missing.html'] })).valid).toBe(false);
|
||||
expect((await validateRunDeliverable({ ...input, touchedPaths: ['slides.html', '../outside.html'] })).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps HTML as the entry when the current run integrates an image into a page', async () => {
|
||||
const fixture = await projectFixture({ 'index.html': '<img src="dog.png">', 'dog.png': 'image' });
|
||||
expect(await validateRunDeliverable({ ...fixture, runStatus: 'succeeded', artifactCount: 2,
|
||||
@@ -462,6 +471,20 @@ describe('project deliverable validation', () => {
|
||||
|
||||
|
||||
describe('motion output facts without a planning-contract gate', () => {
|
||||
it('accepts Query-selected Motion output in a project entered through generic Video', async () => {
|
||||
const fixture = await projectFixture({
|
||||
'motion-source/film/index.html': '<main>Editable film source</main>',
|
||||
'film.mp4': 'video',
|
||||
});
|
||||
// Export bytes are checked by the exporter; completion must not demand a
|
||||
// Motion planning contract or rewrite the original project type.
|
||||
expect(await validateRunDeliverable({ ...fixture,
|
||||
projectMetadata: { kind: 'video' }, runStatus: 'succeeded', artifactCount: 2,
|
||||
allowIndependentOutput: true,
|
||||
touchedPaths: ['motion-source/film/index.html', 'film.mp4'],
|
||||
})).toMatchObject({ valid: true });
|
||||
});
|
||||
|
||||
it('recognizes nested editable source without requiring model-authored plan fields', async () => {
|
||||
const fixture = await projectFixture({ 'motion-source/film/index.html': '<main>Editable film source</main>', 'film.mp4': 'video' });
|
||||
expect(await validateRunDeliverable({ ...fixture, projectMetadata: { kind: 'video', intent: 'motion-design', videoModel: 'hyperframes-html' }, runStatus: 'succeeded', artifactCount: 2 })).toMatchObject({ valid: true, entryFile: 'motion-source/film/index.html' });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { taskTraceReceipts } from '../../src/observability/task-telemetry-receipt.js';
|
||||
import { writeAppConfig } from '../../src/app-config.js';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
@@ -31,23 +32,30 @@ it('inherits frozen attachments only within the exact conversation and refuses a
|
||||
} finally { (await evidenceStore(dir)).close(); await rm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it('publishes late object receipts and Task summary together across every stage', async () => {
|
||||
it.each(['relay', 'vela'] as const)('publishes late Task manifests on the accepted %s transport', async (transport) => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), 'od-late-task-receipts-'));
|
||||
vi.stubEnv('OPEN_DESIGN_TELEMETRY_RELAY_URL', 'https://telemetry.example.test/api/langfuse');
|
||||
vi.stubEnv('OPEN_DESIGN_VELA_TELEMETRY', 'off');
|
||||
vi.stubEnv('OPEN_DESIGN_VELA_TELEMETRY', transport === 'vela' ? 'on' : 'off');
|
||||
vi.stubEnv('VELA_CONTROL_KEY', 'synthetic-key');
|
||||
vi.stubEnv('VELA_API_URL', 'https://vela.example.test');
|
||||
vi.stubEnv('OPEN_DESIGN_OBJECT_OUTBOX_MODE', 'send');
|
||||
try {
|
||||
await writeAppConfig(dir, { telemetry: { metrics: true, content: true, artifactManifest: true } });
|
||||
const store = await evidenceStore(dir);
|
||||
taskTraceReceipts(dir).write({ taskExecutionId: 'late', idempotencyKey: 'late-root', traceId: 'strategy-task:late', transport, protocol: 'legacy-v1' });
|
||||
for (const runId of ['request', 'production']) {
|
||||
await enqueueObjectEvidence(dir, { ...context(runId), traceObjectSummary: { new_file_count: 1, modified_file_count: 0, recovered_file_count: 0, candidate_file_count: 1, uploaded_file_count: 0, skipped_file_count: 1, skip_reasons: { unavailable: 1 } } }, [{ objectClass: 'artifact', id: runId, filename: 'index.html', mime: 'text/html', source: 'run_artifact', body: Buffer.from(runId) }], 'strategy-task:late');
|
||||
await enqueueObjectEvidence(dir, { ...context(runId), installationId: 'synthetic-installation', traceObjectSummary: { new_file_count: 1, modified_file_count: 0, recovered_file_count: 0, candidate_file_count: 1, uploaded_file_count: 0, skipped_file_count: 1, skip_reasons: { unavailable: 1 } } }, [{ objectClass: 'artifact', id: runId, filename: 'index.html', mime: 'text/html', source: 'run_artifact', body: Buffer.from(runId) }], 'strategy-task:late');
|
||||
store.checkpointReceipt('object', `run:${runId}`, { completeness: 'complete', artifactManifest: [{ artifact_id: runId, object_class: 'artifact', status: 'ok', stored_in_open_design: true, storage_ref: `synthetic:${runId}` }] });
|
||||
}
|
||||
// A root trace may publish a failure before the outbox ever publishes anything.
|
||||
reconcileTaskObjectReasons(store, 'strategy-task:late', { trace_object_summary: { skip_reasons: { authorization_failed: 1 } } });
|
||||
const sent: Array<{ batch: Array<{ body: { metadata: Record<string, unknown> } }> }> = [];
|
||||
await drainEvidence(dir, (async (_url, init) => {
|
||||
sent.push(JSON.parse(String(init?.body)));
|
||||
const body = JSON.parse(String(init?.body));
|
||||
expect(String(_url)).toBe(transport === 'vela' ? 'https://vela.example.test/api/v1/open-design/telemetry' : 'https://telemetry.example.test/api/langfuse');
|
||||
const trace = transport === 'vela' ? body.events[0].data : body.batch[0].body;
|
||||
expect(trace).toMatchObject({ id: 'strategy-task:late', name: 'open-design-strategy-task', metadata: { schema: 'open-design.strategy-task-observation/v1' } });
|
||||
sent.push({ batch: [{ body: trace }] });
|
||||
return new Response('{}', { status: 202 });
|
||||
}) as typeof fetch);
|
||||
expect(sent).toHaveLength(2);
|
||||
@@ -67,6 +75,7 @@ it('clears historical skip reasons after recovery for receivers that deep-merge
|
||||
try {
|
||||
await writeAppConfig(dir, { telemetry: { metrics: true, content: true, artifactManifest: true } });
|
||||
const store = await evidenceStore(dir);
|
||||
taskTraceReceipts(dir).write({ taskExecutionId: 'recovery', idempotencyKey: 'recovery-root', traceId: 'strategy-task:recovery', transport: 'relay', protocol: 'legacy-v1' });
|
||||
for (const runId of ['first', 'second']) await enqueueObjectEvidence(dir, context(runId), [{ objectClass: 'artifact', id: runId, filename: 'index.html', mime: 'text/html', source: 'produced_file', body: Buffer.from(runId) }], 'strategy-task:recovery');
|
||||
const receipt = (runId: string) => ({ completeness: 'complete', artifactManifest: [{ artifact_id: runId, object_class: 'artifact', status: 'ok', stored_in_open_design: true, storage_ref: `synthetic:${runId}` }] });
|
||||
store.checkpointReceipt('object', 'run:first', receipt('first'));
|
||||
|
||||
@@ -484,6 +484,7 @@ describe('OD Next planning coordinator', () => {
|
||||
expect(resolveStrategyHandoff(db, input, [])).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
it.each([
|
||||
['a bundle written by the pre-reshape v2 composer', (target: Database.Database) => {
|
||||
// Same schema id as today's composer over a layout its parser no longer reads.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { expect, it } from 'vitest';
|
||||
import { assertOdNextPlanningBuildOnlyV2, DELIVERABLE_SKILLS, OD_NEXT_PLAN_OUTPUT_INSTRUCTIONS, resolveOdNextDeckFrameworkMode } from '@open-design/contracts';
|
||||
import { resolvePluginFolder } from '../../../src/plugins/registry.js';
|
||||
import { createBundledStrategyBindingV2, loadBundledStrategyPromptAssetsV2 } from '../../../src/plugins/strategy-package.js';
|
||||
import { loadOdNextTaskResourcesForSnapshot, materializeOdNextDeviceFrames } from '../../../src/strategies/od-next/device-frames.js';
|
||||
const bundledPluginsDir = path.resolve(import.meta.dirname, '../../../../../plugins/_official');
|
||||
const folder = path.join(bundledPluginsDir, 'scenarios/od-next-strategy');
|
||||
|
||||
it('loads the deck framework lazily for Discovery while preserving the existing PPT route', () => {
|
||||
expect(resolveOdNextDeckFrameworkMode({ taskType: 'discovery', deckIntent: true })).toBeUndefined();
|
||||
expect(resolveOdNextDeckFrameworkMode({ taskType: 'ppt' })).toBe('canonical');
|
||||
expect(resolveOdNextDeckFrameworkMode({ taskType: 'ppt', hasExistingDeckArtifact: true })).toBe('legacy_compatible');
|
||||
});
|
||||
|
||||
it('ships ten loadable main Skills without restoring the retired machine protocol', async () => {
|
||||
const resolved = await resolvePluginFolder({ folder, folderId: 'od-next-strategy', sourceKind: 'bundled', source: folder, trust: 'bundled' });
|
||||
if (!resolved.ok) throw Error(resolved.errors.join(';'));
|
||||
const binding = createBundledStrategyBindingV2({ plugin: resolved.record, taskType: 'discovery' });
|
||||
const assets = loadBundledStrategyPromptAssetsV2({ plugin: resolved.record, binding });
|
||||
for (const name of ['core-system-prompt.md', 'general-orchestration.md', 'task-profiles/discovery.md']) {
|
||||
const body = await readFile(path.join(folder, 'assets', name), 'utf8');
|
||||
expect(() => assertOdNextPlanningBuildOnlyV2(body, name)).not.toThrow();
|
||||
}
|
||||
expect(assets.taskSkill).toContain('Query');
|
||||
// The loaded prompt must gate native reads before presenting catalog choices.
|
||||
const opening = assets.taskSkill.split('\n\n')[1];
|
||||
expect(opening).toContain('Before choosing a deliverable type or calling a tool');
|
||||
expect(opening).toContain('text-only planning');
|
||||
expect(assets.taskSkill).toContain('Only for a request that authorizes producing or editing an artifact');
|
||||
expect(assets.taskSkill).toContain('production-ready');
|
||||
expect(assets.taskSkill).not.toMatch(/requiredDeliverables|runtime-state|planContractHash/);
|
||||
expect(DELIVERABLE_SKILLS).toHaveLength(11);
|
||||
for (const skill of DELIVERABLE_SKILLS) {
|
||||
expect(assets.taskSkill).toContain(`${folder}/assets/task-profiles/${skill.id}.md`);
|
||||
const body = await readFile(path.join(folder, `assets/task-profiles/${skill.id}.md`), 'utf8');
|
||||
expect(body).not.toMatch(/requiredDeliverables|resultRef=|outcome=completed/);
|
||||
expect(assets.taskResources.some(r => r.path.endsWith(`/${skill.id}.md`))).toBe(true);
|
||||
}
|
||||
expect(OD_NEXT_PLAN_OUTPUT_INSTRUCTIONS).toContain('Do not emit Plan Contract');
|
||||
const resources = await loadOdNextTaskResourcesForSnapshot({ bundledPluginsDir, snapshot: { pluginId: 'od-next-strategy', strategy: binding } });
|
||||
const cwd = await mkdtemp(path.join(os.tmpdir(), 'discovery-marker-'));
|
||||
try {
|
||||
await materializeOdNextDeviceFrames({ cwd, resources });
|
||||
const deck = await readFile(path.join(cwd, '.od-frames/deck-framework.md'), 'utf8');
|
||||
expect(deck).toContain('data-od-deck-protocol');
|
||||
expect(deck).toContain('Existing deck or selected template');
|
||||
expect(await readFile(path.join(cwd, '.od-frames/iphone.html'), 'utf8')).toContain('data-phone-shell');
|
||||
} finally { await rm(cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// Prompt wiring regression; semantic routing is validated by frozen live evals.
|
||||
it('exposes source-based recreation and fresh-design boundaries in the loaded Discovery prompt', async () => {
|
||||
const resolved = await resolvePluginFolder({ folder, folderId: 'od-next-strategy', sourceKind: 'bundled', source: folder, trust: 'bundled' });
|
||||
if (!resolved.ok) throw Error(resolved.errors.join(';'));
|
||||
const binding = createBundledStrategyBindingV2({ plugin: resolved.record, taskType: 'discovery' });
|
||||
const { taskSkill } = loadBundledStrategyPromptAssetsV2({ plugin: resolved.record, binding });
|
||||
const cloneEntry = taskSkill.split('\n').find(line => line.startsWith('- web-clone ('))!;
|
||||
const prototypeEntry = taskSkill.split('\n').find(line => line.startsWith('- prototype ('))!;
|
||||
expect(cloneEntry).toMatch(/HTML|source code/);
|
||||
expect(prototypeEntry).toContain('reference-page recreation');
|
||||
expect(taskSkill).toContain('Select web-clone, not prototype, when');
|
||||
expect(taskSkill).toContain('kind=prototype describes the output');
|
||||
expect(taskSkill).toContain('Style inspiration alone');
|
||||
expect(taskSkill).toContain('already in context');
|
||||
expect(taskSkill).toContain('Complete reference source is artifact input, not a loaded Skill');
|
||||
expect(taskSkill).toContain('Copying, translating, or saving that reference as a new output does not waive the read');
|
||||
});
|
||||
|
||||
// These assertions cover prompt delivery; frozen evals separately assess adherence.
|
||||
it('keeps output selection independent of shared HTML implementation and exposes video failure boundaries', async () => {
|
||||
const resolved = await resolvePluginFolder({ folder, folderId: 'od-next-strategy', sourceKind: 'bundled', source: folder, trust: 'bundled' });
|
||||
if (!resolved.ok) throw Error(resolved.errors.join(';'));
|
||||
const binding = createBundledStrategyBindingV2({ plugin: resolved.record, taskType: 'discovery' });
|
||||
const { taskSkill } = loadBundledStrategyPromptAssetsV2({ plugin: resolved.record, binding });
|
||||
expect(taskSkill).toContain('For authorized production, choose the smallest set of production Skills that covers the independently requested outputs before issuing any Skill reads');
|
||||
expect(taskSkill).toContain('An HTML presentation, including its layout and navigation, is one ppt output');
|
||||
expect(taskSkill).toContain('A separately requested website still needs prototype');
|
||||
const video = await readFile(path.join(folder, 'assets/task-profiles/video.md'), 'utf8');
|
||||
expect(video).toContain('An internal HTML composition for this renderer is part of the video task, not an independently requested HyperFrames deliverable');
|
||||
expect(taskSkill).toContain('not an internal renderer composition for MP4');
|
||||
expect(video).toContain('A failed render leaves the video undelivered');
|
||||
expect(video).toContain('Do not substitute an ad-hoc shell or ffmpeg animation');
|
||||
expect(video).toContain('A decodable container or successful encoder exit does not establish that the requested subject or motion is present');
|
||||
});
|
||||
|
||||
it('keeps Motion export checks out of the planning protocol and exposes unsupported local refresh limits', async () => {
|
||||
const core = await readFile(path.join(folder, 'assets/core-system-prompt.md'), 'utf8');
|
||||
const motion = await readFile(path.join(folder, 'assets/task-profiles/motion-design.md'), 'utf8');
|
||||
expect(core).toContain('actual video-motion film requested');
|
||||
expect(motion).toContain('not a model-output planning contract');
|
||||
expect(motion).not.toContain('media declare-motion');
|
||||
const live = await readFile(path.join(folder, 'assets/task-profiles/live-artifact.md'), 'utf8');
|
||||
expect(live).toContain('ask before converting or substituting the format');
|
||||
});
|
||||
|
||||
it('stops opaque media request failures without unrelated diagnostic generations', async () => {
|
||||
const video = await readFile(path.join(folder, 'assets/task-profiles/video.md'), 'utf8');
|
||||
expect(video).toContain('HTTP 400 invalid_request');
|
||||
expect(video).toContain('do not probe another media surface for connectivity');
|
||||
});
|
||||
|
||||
// Prompt transport only: adherence is checked separately on frozen live cases.
|
||||
it('delivers local-state, explicit-renderer and prototype action safeguards through Discovery', async () => {
|
||||
const resolved = await resolvePluginFolder({ folder, folderId: 'od-next-strategy', sourceKind: 'bundled', source: folder, trust: 'bundled' });
|
||||
if (!resolved.ok) throw Error(resolved.errors.join(';'));
|
||||
const binding = createBundledStrategyBindingV2({ plugin: resolved.record, taskType: 'discovery' });
|
||||
const assets = loadBundledStrategyPromptAssetsV2({ plugin: resolved.record, binding });
|
||||
expect(assets.taskSkill).toContain('Saving user edits or deriving totals from in-memory or browser-local state does not make a prototype a live-artifact');
|
||||
expect(assets.taskSkill).toContain('For explicitly requested HyperFrames source, read hyperframes only');
|
||||
const resources = await loadOdNextTaskResourcesForSnapshot({ bundledPluginsDir, snapshot: { pluginId: 'od-next-strategy', strategy: binding } });
|
||||
const prototype = resources.find(resource => resource.path.endsWith('/prototype.md'));
|
||||
const audio = resources.find(resource => resource.path.endsWith('/audio.md'));
|
||||
expect(prototype).toBeDefined();
|
||||
expect(audio).toBeDefined();
|
||||
const prototypeBody = prototype!.text;
|
||||
expect(prototypeBody).toContain('Commit each local mutation to the same state that renders the interface');
|
||||
expect(prototypeBody).toContain('Hot-search suggestions and typed searches use the same matching rules');
|
||||
expect(prototypeBody).toContain('Set both foreground and background on primary controls');
|
||||
expect(prototypeBody).toContain('do not relabel an unrelated image as the requested place or object');
|
||||
expect(prototypeBody).toContain('keep DOM lookup cardinality explicit');
|
||||
expect(prototypeBody).toMatch(/Results and their\s+empty state must never appear at the same time/);
|
||||
const audioBody = audio!.text;
|
||||
expect(audioBody).toContain('A request for an audio file alone does not authorize a local synthesis fallback');
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { discoveryObservation, discoveryObservationForRun, observeDiscoveryEvent, preserveDiscoveryObservation } from '../../../src/strategies/od-next/discovery-observation.js';
|
||||
import { singleSkillShellRead } from '../../../src/strategies/od-next/skill-shell-read.js';
|
||||
const skillRoot = '/bundle/scenarios/od-next-strategy/assets/task-profiles';
|
||||
const policy = { event: 'diagnostic', data: { type: 'skill_discovery_policy', injected: true, skillRoot } };
|
||||
const read = (id: string, skill: string, extra = {}) => ({ event: 'agent', data: { type: 'tool_use', name: 'Read', id, input: { file_path: `${skillRoot}/${skill}.md`, ...extra } } });
|
||||
const result = (id: string, content = 'Complete Skill body', isError = false) => ({ event: 'agent', data: { type: 'tool_result', toolUseId: id, content, isError } });
|
||||
|
||||
describe('Discovery observation is evidence, not selection or completion', () => {
|
||||
it('handles literal spaces and quoting but rejects shell substitutions and compound commands', () => {
|
||||
expect(singleSkillShellRead('cat -- "/bundle with spaces/document.md"')).toBe('/bundle with spaces/document.md');
|
||||
expect(singleSkillShellRead("/bin/zsh -lc 'cat -- \"/bundle with spaces/document.md\"'")).toBe('/bundle with spaces/document.md');
|
||||
for (const command of ['cat "$ROOT/document.md"', 'cat $(pwd)/document.md', 'cat /tmp/*.md',
|
||||
'/tmp/cat /bundle/document.md', 'cat /bundle/document.md > /tmp/result',
|
||||
"cat '/bundle/document.md", 'cat /bundle/document.md && true', 'cat /bundle/document.md\ntrue']) {
|
||||
expect(singleSkillShellRead(command)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it('observes a successful single-file cat from Codex without inferring arbitrary shell reads', () => {
|
||||
const shell = (id: string, command: string) => ({ event: 'agent', data: { type: 'tool_use', name: 'Bash', id, input: { command } } });
|
||||
const command = `/bin/zsh -lc 'cat "${skillRoot}/document.md"'`;
|
||||
expect(discoveryObservation([policy, shell('a', command), result('a')])).toMatchObject({
|
||||
skill_ids_loaded: ['document'], skill_load_events: [expect.objectContaining({ source: 'shell_read', status: 'loaded' })],
|
||||
});
|
||||
for (const command of [
|
||||
`echo 'cat ${skillRoot}/document.md'`,
|
||||
`cat ${skillRoot}/document.md; echo done`,
|
||||
`cat ${skillRoot}/document.md | head`,
|
||||
`sed -n '1,20p' ${skillRoot}/document.md`,
|
||||
`cat /user${skillRoot}/document.md`,
|
||||
`cat ${skillRoot}/document.md ${skillRoot}/ppt.md`,
|
||||
]) expect(discoveryObservation([policy, shell('b', command), result('b')]).skill_ids_loaded).toEqual([]);
|
||||
expect(discoveryObservation([policy, shell('c', command), result('c', 'not found', true)]).skill_ids_loaded).toEqual([]);
|
||||
});
|
||||
it('recognizes native Windows paths only under the trusted skill root', () => {
|
||||
const windowsRoot = 'C:\\bundle\\scenarios\\od-next-strategy\\assets\\task-profiles';
|
||||
const windowsPolicy = { ...policy, data: { ...policy.data, skillRoot: windowsRoot } };
|
||||
const windowsRead = read('a', 'document', { file_path: `${windowsRoot}\\document.md` });
|
||||
expect(discoveryObservation([windowsPolicy, windowsRead, result('a')]).skill_ids_loaded).toEqual(['document']);
|
||||
const fake = read('b', 'ppt', { file_path: `D:\\user\\scenarios\\od-next-strategy\\assets\\task-profiles\\ppt.md` });
|
||||
expect(discoveryObservation([windowsPolicy, fake, result('b')]).skill_ids_loaded).toEqual([]);
|
||||
});
|
||||
it('counts successful known reads once and separates injection from loading', () => {
|
||||
expect(discoveryObservation([policy])).toMatchObject({ skill_discovery_policy_injected: true, skill_ids_loaded: [] });
|
||||
const data = discoveryObservation([policy, read('a', 'ppt'), result('a'), read('b', 'document'), result('b'), read('c', 'ppt'), result('c')]);
|
||||
expect(data.skill_ids_loaded).toEqual(['document', 'ppt']);
|
||||
expect(data).not.toHaveProperty('deliverable_skill_mapping');
|
||||
expect(JSON.stringify(data)).not.toContain('Complete Skill body');
|
||||
});
|
||||
it('does not count failed, partial, missing or untrusted reads as loaded', () => {
|
||||
const fake = read('fake', 'ppt'); fake.data.input.file_path = `/user${fake.data.input.file_path}`;
|
||||
const data = discoveryObservation([policy, read('a', 'audio'), result('a', 'failed', true), read('b', 'ppt', { limit: 10 }), result('b'), read('c', 'video'), fake, result('fake')]);
|
||||
expect(data.skill_ids_loaded).toEqual([]);
|
||||
expect(data.skill_observation_status).toBe('partial');
|
||||
expect(data.skill_load_events).toEqual(expect.arrayContaining([expect.objectContaining({ status: 'failed' }), expect.objectContaining({ status: 'unknown' })]));
|
||||
});
|
||||
it('records a successful native read even when its telemetry copy is truncated', () => {
|
||||
const data = discoveryObservation([policy, read('a', 'prototype'), result('a', 'body …[truncated]')]);
|
||||
expect(data.skill_ids_loaded).toEqual(['prototype']);
|
||||
expect(data.skill_load_events).toEqual([expect.objectContaining({ status: 'loaded', content_coverage: 'unknown' })]);
|
||||
});
|
||||
it('retains bounded observations after event-ring truncation without storing a body', () => {
|
||||
const run = { events: [] }; [policy, read('a', 'prototype'), result('a')].forEach(e => observeDiscoveryEvent(run, e));
|
||||
expect(discoveryObservationForRun(run)).toMatchObject({ skill_ids_loaded: ['prototype'], skill_discovery_policy_injected: true });
|
||||
});
|
||||
it('preserves real evidence when task export normalizes a Run after its event ring is trimmed', () => {
|
||||
const run = { events: [] };
|
||||
[policy, read('a', 'ppt'), result('a'), read('b', 'document'), result('b')].forEach(e => observeDiscoveryEvent(run, e));
|
||||
const normalized = preserveDiscoveryObservation(run, { ...run, events: [] });
|
||||
expect(discoveryObservationForRun(normalized)).toMatchObject({
|
||||
skill_discovery_enabled: true, skill_ids_loaded: ['document', 'ppt'], skill_observation_status: 'complete',
|
||||
});
|
||||
expect(JSON.stringify(normalized)).not.toContain('Complete Skill body');
|
||||
});
|
||||
it('does not infer a reuse or new load merely from native continuation', () => {
|
||||
expect(discoveryObservation([{ ...policy, data: { ...policy.data, injected: false } }])).toMatchObject({ skill_discovery_enabled: true, skill_discovery_policy_injected: false, skill_ids_loaded: [] });
|
||||
});
|
||||
});
|
||||
@@ -115,32 +115,6 @@ describe('plan continuation marker', () => {
|
||||
expect(parsed.visibleText.trim()).toBe('Done.');
|
||||
expect(parsed.productionReady).toBe(false);
|
||||
});
|
||||
it.each(['identical', 'conflicting', 'malformed'] as const)(
|
||||
'ignores %s repeated legacy states and uses only the current production marker', mode => {
|
||||
const state = {
|
||||
schema: 'open-design.strategy-state/v2', route: 'full_plan', inputStage: 'request',
|
||||
outcome: 'plan_ready', executionMode: 'simple', executionIntent: 'produce', reasonCodes: [],
|
||||
};
|
||||
const bodies = mode === 'malformed'
|
||||
? ['{broken}', '{also broken}']
|
||||
: [JSON.stringify(state), JSON.stringify(mode === 'conflicting'
|
||||
? { ...state, executionMode: null, executionIntent: 'plan_only' }
|
||||
: state)];
|
||||
const legacy = bodies.map(body => `<open-design-runtime-state>\n${body}\n</open-design-runtime-state>`).join('\n');
|
||||
for (const ready of [false, true]) {
|
||||
const text = `Plan: create a blue landing page.\n${legacy}\n${ready ? marker : ''}`;
|
||||
// Streaming chunk boundaries must not turn retired metadata into a
|
||||
// completion gate or let it supply production intent.
|
||||
const stream = createOdNextRunProtocol(null, key);
|
||||
let visible = '';
|
||||
for (const char of text) visible += stream.push(char);
|
||||
const result = stream.finish();
|
||||
expect(result.parsed.productionReady).toBe(ready);
|
||||
expect((visible + result.visibleTail).trim()).toBe('Plan: create a blue landing page.');
|
||||
expect(result.parsed.visibleText).toBe(visible + result.visibleTail);
|
||||
}
|
||||
},
|
||||
);
|
||||
it('bounds a very long line without treating its suffix as a new control line', () => {
|
||||
const stream = createOdNextRunProtocol(null, key);
|
||||
const text = 'x'.repeat(5000) + marker;
|
||||
|
||||
@@ -41,6 +41,20 @@ it('sends new query, key, attachments and runtime context without repeating stab
|
||||
expect(text).not.toContain(parseOdNextPromptBundleV2(previous.promptBundle.text).sessionSkills.generalOrchestrationSkill.body);
|
||||
});
|
||||
|
||||
it('reminds a resumed discovery session to load a newly requested type without replaying its catalog', () => {
|
||||
const discovery = (bundle: ReturnType<typeof parseOdNextPromptBundleV2>) => {
|
||||
bundle.sessionSkills.taskTypeSkill.skillName = 'discovery';
|
||||
bundle.sessionSkills.taskTypeSkill.body = 'Full catalog sentinel.';
|
||||
};
|
||||
const current = task('bbb', bundle => { discovery(bundle); bundle.userFirstPrompt = '现在做一份说明书'; });
|
||||
const text = composeResumedRequest(current, task('aaa', discovery))!;
|
||||
expect(text).toContain('new deliverable type');
|
||||
expect(text).toContain('native file');
|
||||
expect(text).toContain('already in context');
|
||||
expect(text).not.toContain('Full catalog sentinel.');
|
||||
expect(composeResumedRequest(task('bbb'), task('aaa'))).not.toContain('new deliverable type');
|
||||
});
|
||||
|
||||
it.each(['core', 'skill', 'configuration', 'custom'] as const)('requires full input when %s instructions change', kind => {
|
||||
const previous = task('aaa');
|
||||
const current = task('bbb', bundle => {
|
||||
|
||||
@@ -17,12 +17,12 @@ function syntheticPolicy() {
|
||||
}
|
||||
|
||||
describe('OD Next controlled rollout', () => {
|
||||
it('owns all five task types by default, and honours a mode that was named', () => {
|
||||
it('owns the legacy profiles and discovery by default, and honours a mode that was named', () => {
|
||||
const policy = readOdNextRolloutPolicy({ OD_NEXT_STRATEGY_ROLLOUT: 'active' });
|
||||
expect(policy).toMatchObject({
|
||||
requestedMode: 'active',
|
||||
requestedModeSource: 'env',
|
||||
eligibleTaskTypes: ['prototype', 'ppt', 'marketing', 'hyperframes', 'motion-design'],
|
||||
eligibleTaskTypes: ['prototype', 'ppt', 'marketing', 'hyperframes', 'discovery', 'motion-design'],
|
||||
productionActiveApproved: true,
|
||||
assignmentPercent: 100,
|
||||
});
|
||||
@@ -42,7 +42,7 @@ describe('OD Next controlled rollout', () => {
|
||||
]).toEqual(['prototype', 'ppt', 'marketing', 'hyperframes']);
|
||||
expect(odNextTaskTypeForProjectScenarioBinding({ provenance: 'explicit_user', taskProfile: 'prototype' })).toBeNull();
|
||||
expect(odNextTaskTypeForProjectScenarioBinding({ provenance: 'legacy_unknown', taskProfile: 'ppt' })).toBeNull();
|
||||
for (const taskType of ['prototype', 'ppt', 'marketing', 'hyperframes'] as const) {
|
||||
for (const taskType of ['prototype', 'ppt', 'marketing', 'hyperframes', 'discovery'] as const) {
|
||||
expect(evaluateOdNextRollout({
|
||||
policy,
|
||||
assignmentIdentity: `default:${taskType}`,
|
||||
@@ -56,18 +56,12 @@ describe('OD Next controlled rollout', () => {
|
||||
});
|
||||
|
||||
describe('choosing a mode for one installation', () => {
|
||||
it('takes the saved mode when the environment names none', () => {
|
||||
expect(readOdNextRolloutPolicy({}, { odNextStrategyMode: 'active' })).toMatchObject({
|
||||
requestedMode: 'active',
|
||||
requestedModeSource: 'app_config',
|
||||
});
|
||||
expect(readOdNextRolloutPolicy({}, { odNextStrategyMode: 'observe' }).requestedMode)
|
||||
.toBe('observe');
|
||||
// An empty variable is not a choice; it is how a shell exports nothing.
|
||||
expect(readOdNextRolloutPolicy(
|
||||
{ OD_NEXT_STRATEGY_ROLLOUT: ' ' },
|
||||
{ odNextStrategyMode: 'active' },
|
||||
)).toMatchObject({ requestedMode: 'active', requestedModeSource: 'app_config' });
|
||||
it('retires all saved Labs preferences in favor of the rollout default', () => {
|
||||
for (const saved of ['off', 'observe', 'active'] as const) {
|
||||
expect(readOdNextRolloutPolicy({}, { odNextStrategyMode: saved })).toMatchObject({
|
||||
requestedMode: 'active', requestedModeSource: 'default',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('lets the environment pin a mode over the one the installation saved', () => {
|
||||
@@ -84,7 +78,7 @@ describe('OD Next controlled rollout', () => {
|
||||
)).toMatchObject({ requestedMode: 'active', requestedModeSource: 'env' });
|
||||
});
|
||||
|
||||
it('treats an absent preference, and only an absent one, as unconfigured', () => {
|
||||
it('ignores absent and invalid retired preferences', () => {
|
||||
// `null` and `undefined` are the two shapes of "nobody has chosen", and
|
||||
// they reach the default.
|
||||
for (const saved of [null, undefined] as unknown[]) {
|
||||
@@ -94,13 +88,8 @@ describe('OD Next controlled rollout', () => {
|
||||
)).toMatchObject({ requestedMode: 'active', requestedModeSource: 'default' });
|
||||
}
|
||||
|
||||
// A value that is not a mode never arrives here in production: the read
|
||||
// path in `app-config.ts` resolves an unreadable config to `off` before
|
||||
// this function sees it, because unconfigured now means `active` and
|
||||
// "we could not read your choice" must not become "you chose OD Next".
|
||||
// The end-to-end guarantee is asserted across that join in
|
||||
// `tests/app-config.test.ts`; this function stays a pure reader of what
|
||||
// it is handed.
|
||||
// Both raw invalid values and the config reader's normalized off are
|
||||
// retired preferences; neither overrides the new default.
|
||||
for (const saved of ['acive', '', 'true', 1, {}] as unknown[]) {
|
||||
expect(readOdNextRolloutPolicy(
|
||||
{},
|
||||
@@ -109,33 +98,12 @@ describe('OD Next controlled rollout', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps an installation that opted out off, whatever the default becomes', () => {
|
||||
// The one guarantee the default owes users who were here before it
|
||||
// flipped. It holds only because opting out stores `off` rather than
|
||||
// clearing the key: a cleared key reads as unconfigured, and unconfigured
|
||||
// is `active`. If this ever goes red because the switch was "simplified"
|
||||
// into deleting the key, every opted-out installation was just switched
|
||||
// back on without being asked.
|
||||
expect(readOdNextRolloutPolicy({}, { odNextStrategyMode: 'off' })).toMatchObject({
|
||||
requestedMode: 'off',
|
||||
requestedModeSource: 'app_config',
|
||||
});
|
||||
expect(readOdNextRolloutPolicy({}, { odNextStrategyMode: 'observe' })).toMatchObject({
|
||||
requestedMode: 'observe',
|
||||
requestedModeSource: 'app_config',
|
||||
});
|
||||
// And the decision that follows has to actually leave the strategy
|
||||
// unused: a preserved `off` that still evaluated to `active` would keep
|
||||
// this guarantee only on paper.
|
||||
it('admits discovery even when the retired Labs preference was off', () => {
|
||||
expect(evaluateOdNextRollout({
|
||||
policy: readOdNextRolloutPolicy({}, { odNextStrategyMode: 'off' }),
|
||||
assignmentIdentity: 'project:conversation',
|
||||
taskType: 'prototype',
|
||||
agentId: 'codex',
|
||||
agentVersion: 'codex-e2e 0.0.0',
|
||||
sourceKind: 'bundled',
|
||||
runtimeCapabilityVerified: true,
|
||||
})).toMatchObject({ requestedMode: 'off', effectiveMode: 'off', eligible: false });
|
||||
assignmentIdentity: 'project:conversation', taskType: 'discovery', agentId: 'amr',
|
||||
agentVersion: '0.0.38', sourceKind: 'bundled', runtimeCapabilityVerified: true,
|
||||
})).toMatchObject({ requestedMode: 'active', effectiveMode: 'active', eligible: true });
|
||||
});
|
||||
|
||||
it('admits an eligible task on an installation that stayed on the default', () => {
|
||||
@@ -167,11 +135,11 @@ describe('OD Next controlled rollout', () => {
|
||||
effectiveMode: 'active',
|
||||
});
|
||||
expect(readOdNextRolloutControlStatus({}, { odNextStrategyMode: 'off' }))
|
||||
.toMatchObject({ requestedMode: 'off', requestedModeSource: 'app_config', effectiveMode: 'off' });
|
||||
.toMatchObject({ requestedMode: 'active', requestedModeSource: 'default', effectiveMode: 'active' });
|
||||
expect(readOdNextRolloutControlStatus({}, { odNextStrategyMode: 'active' }))
|
||||
.toMatchObject({
|
||||
requestedMode: 'active',
|
||||
requestedModeSource: 'app_config',
|
||||
requestedModeSource: 'default',
|
||||
effectiveMode: 'active',
|
||||
});
|
||||
// Status takes no database, because there is no longer any stored state
|
||||
|
||||
@@ -948,6 +948,26 @@ describe('durable strategy task store', () => {
|
||||
expect(() => getStrategyTaskExecution(db, poisoned.taskExecutionId)).toThrow();
|
||||
});
|
||||
|
||||
it('reads stored delivery-recovery beta facts without changing the task outcome', () => {
|
||||
const task = createTask(db, snapshot, 'run-beta-facts');
|
||||
const facts = {
|
||||
physicalStatus: 'succeeded',
|
||||
outputGoal: ['video'],
|
||||
deliveryState: 'incomplete',
|
||||
productionSignal: 'missing',
|
||||
hostDeliveryFailure: true,
|
||||
};
|
||||
db.prepare('UPDATE strategy_task_runs SET settlement_facts_json = ? WHERE run_id = ?')
|
||||
.run(JSON.stringify(facts), 'run-beta-facts');
|
||||
|
||||
closeDatabase();
|
||||
db = openDatabase(tempDir, { dataDir: tempDir });
|
||||
const reloaded = getStrategyTaskExecution(db, task.taskExecutionId);
|
||||
expect(reloaded?.outcome).toBe(task.outcome);
|
||||
expect(reloaded?.activeRunId).toBe(task.activeRunId);
|
||||
expect(reloaded?.runs.at(-1)?.settlementFacts).toEqual(facts);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['running', 'completed'],
|
||||
['canceled', 'canceled'],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { taskTraceReceipts, taskUpdateSink } from '../src/observability/task-telemetry-receipt.js';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { evidenceStore } from '../src/services/evidence-delivery.js';
|
||||
import { evidenceStore, enqueueFeedbackEvidence, drainEvidence } from '../src/services/evidence-delivery.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildFeedbackPayload, readFeedbackTelemetrySinkConfig, type FeedbackReportContext } from '../src/langfuse-trace.js';
|
||||
import { reportRunFeedbackFromDaemon } from '../src/langfuse-bridge.js';
|
||||
@@ -42,16 +43,34 @@ describe('Task-owned feedback', () => {
|
||||
expect(repeated).toEqual(bodies.map(body => body.id));
|
||||
});
|
||||
|
||||
it('uses the same Task relay even when Vela credentials are configured', async () => {
|
||||
it.each(['relay', 'vela'] as const)('keeps feedback on its accepted %s Task transport', async (transport) => {
|
||||
expect(readFeedbackTelemetrySinkConfig()?.kind).toBe('vela');
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('{}', { status: 202 }));
|
||||
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'od-task-feedback-'));
|
||||
try {
|
||||
taskTraceReceipts(dataDir).write({ taskExecutionId: 'synthetic-feedback-task', idempotencyKey: 'root-key', traceId: transport === 'vela' ? 'a'.repeat(64) : taskId, protocol: 'legacy-v1', transport });
|
||||
expect(await reportRunFeedbackFromDaemon({ dataDir, ...context(), fetchImpl })).toMatchObject({ deliveryStatus: 'queued' });
|
||||
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1));
|
||||
expect(fetchImpl.mock.calls[0]?.[0]).toBe('https://relay.example/api/langfuse');
|
||||
expect(fetchImpl.mock.calls[0]?.[0]).toBe(transport === 'vela' ? 'https://vela.example/api/v1/open-design/telemetry' : 'https://relay.example/api/langfuse');
|
||||
const payload = JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body));
|
||||
expect(payload.batch[0].body.traceId).toBe(taskId);
|
||||
expect(transport === 'vela' ? payload.events[0].data.traceId : payload.batch[0].body.traceId).toBe(taskId);
|
||||
} finally { (await evidenceStore(dataDir)).close(); await rm(dataDir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
it('does not choose a new transport when the root receipt is missing', () => {
|
||||
expect(taskUpdateSink('/synthetic-missing-receipt', taskId)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not anonymously publish feedback when Vela authentication expires', async () => {
|
||||
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'od-task-auth-'));
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('{}', { status: 401 }));
|
||||
try {
|
||||
taskTraceReceipts(dataDir).write({ taskExecutionId: 'synthetic-feedback-task', idempotencyKey: 'root-key', traceId: 'a'.repeat(64), protocol: 'legacy-v1', transport: 'vela' });
|
||||
await enqueueFeedbackEvidence(dataDir, context());
|
||||
await drainEvidence(dataDir, fetchImpl);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(fetchImpl.mock.calls[0]?.[0]).toBe('https://vela.example/api/v1/open-design/telemetry');
|
||||
} finally { (await evidenceStore(dataDir)).close(); await rm(dataDir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,12 @@ describe('Langfuse message finalization gate', () => {
|
||||
expect(shouldReportRunCompletionTelemetryFallbackStatus('running')).toBe(false);
|
||||
});
|
||||
|
||||
it('also schedules successful Task-owned runs without a client final-message write', () => {
|
||||
expect(shouldReportRunCompletionTelemetryFallbackStatus('succeeded', true)).toBe(true);
|
||||
expect(shouldReportRunCompletionTelemetryFallbackStatus('succeeded', false)).toBe(false);
|
||||
expect(shouldReportRunCompletionTelemetryFallbackStatus('running', true)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the explicit current prompt for telemetry instead of the full transcript', () => {
|
||||
expect(
|
||||
telemetryPromptFromRunRequest(
|
||||
|
||||
@@ -82,10 +82,13 @@ describe('思考流也要过剧场语法剥离', () => {
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
let binDir: string;
|
||||
const originalStrategyMode = process.env.OD_NEXT_STRATEGY_ROLLOUT;
|
||||
const originalPath = process.env.PATH;
|
||||
const originalTransport = process.env.OD_CODEX_TRANSPORT;
|
||||
|
||||
beforeAll(async () => {
|
||||
// These direct-chat fixtures exercise legacy runtime transport, not Task orchestration.
|
||||
process.env.OD_NEXT_STRATEGY_ROLLOUT = 'off';
|
||||
process.env.OD_CODEX_TRANSPORT = 'exec-json';
|
||||
binDir = await mkdtemp(join(tmpdir(), 'od-thinking-grammar-'));
|
||||
const bin = join(binDir, 'codex');
|
||||
@@ -119,6 +122,8 @@ setTimeout(() => process.exit(0), 10);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (originalStrategyMode == null) delete process.env.OD_NEXT_STRATEGY_ROLLOUT;
|
||||
else process.env.OD_NEXT_STRATEGY_ROLLOUT = originalStrategyMode;
|
||||
if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(binDir, { recursive: true, force: true });
|
||||
if (originalPath == null) delete process.env.PATH;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { upstreamActivityAt } from "../runtime/chat/upstream-activity";
|
||||
import type { RecordFileScope } from "../runtime/chat/record-file-open";
|
||||
import { FileOpsSummary } from "./FileOpsSummary";
|
||||
import { messageArtifactRefs } from "../runtime/chat/artifact-refs";
|
||||
import { liveArtifactDeliveries } from "../runtime/live-artifact-delivery";
|
||||
import { assistantMessageNeverHadARun } from "../runtime/chat/host-authored-message";
|
||||
import {
|
||||
renderMarkdown,
|
||||
@@ -731,6 +732,7 @@ function AssistantMessageImpl({
|
||||
[displayEvents, fileOpScope],
|
||||
);
|
||||
const rawProduced = message.producedFiles ?? [];
|
||||
const liveDeliveries = useMemo(() => liveArtifactDeliveries(message.events), [message.events]);
|
||||
/**
|
||||
* 这一轮 agent 自己声明的「显示什么」—— `<od-focus …/>` 的 `show`。
|
||||
*
|
||||
@@ -864,7 +866,7 @@ function AssistantMessageImpl({
|
||||
* 用意,原样保留)。第 1 条不受这道闸约束 —— 工具行本身带 `pending` 态,
|
||||
* 边跑边出卡是设计要的(D37)。
|
||||
*/
|
||||
const turnArtifactPanelEntries = useMemo(() => {
|
||||
const rawTurnArtifactPanelEntries = useMemo(() => {
|
||||
/*
|
||||
* 两条支各自已经在消费点做过同一套边界处理
|
||||
* (`summaryArtifactOps` / `declaredArtifactFiles`):有 `show` 就按声明收窄,
|
||||
@@ -875,6 +877,9 @@ function AssistantMessageImpl({
|
||||
if (streaming) return [];
|
||||
return orderArtifactCards(producedFilesAsFileOps(declaredArtifactFiles), artifactFocus);
|
||||
}, [artifactFocus, declaredArtifactFiles, streaming, summaryArtifactOps]);
|
||||
const turnArtifactPanelEntries = liveDeliveries.length
|
||||
? rawTurnArtifactPanelEntries.filter(entry => entry.path !== 'template.html')
|
||||
: rawTurnArtifactPanelEntries;
|
||||
// The single artifact the "next step" affordance anchors to: prefer the HTML
|
||||
// produced by THIS turn; if the final turn emitted none (a summary / continue
|
||||
// message) fall back to the most recently modified HTML in the project so
|
||||
@@ -1355,6 +1360,11 @@ function AssistantMessageImpl({
|
||||
)}
|
||||
{/* 状态行 / 插件候选不属于执行记录,也没有流里的位置 —— 收在最后 */}
|
||||
{restBlocks.map((b, i) => renderOuterBlock(b, `rest-${i}`))}
|
||||
{liveDeliveries.map(item => (
|
||||
<Button key={item.id} onClick={() => onRequestOpenFile?.(item.tabId)}>
|
||||
{item.title || t('tasks.liveArtifact')}
|
||||
</Button>
|
||||
))}
|
||||
{/* #5517 shape: the collapsible tool-op summary lists only the ops the
|
||||
turn actually emitted, and the produced-files list stays its own
|
||||
flat block below it (name / size / Open / Download). Folding the
|
||||
|
||||
@@ -1122,11 +1122,9 @@ export function foldStrategyTaskTurns(messages: ChatMessage[]): FoldedChatMessag
|
||||
// internal step, not the turn ending.
|
||||
runId: message.runId ?? head.runId,
|
||||
runStatus: message.runStatus ?? head.runStatus,
|
||||
// Likewise the task verdict: only the final Run of the chain carries it,
|
||||
// and the folded turn is what the pinned todo card reads.
|
||||
...(message.strategyTaskDelivered
|
||||
? { strategyTaskDelivered: message.strategyTaskDelivered }
|
||||
: {}),
|
||||
// Completion belongs to the final physical Run, including false or
|
||||
// absent legacy values. Never inherit the planning Run's verdict.
|
||||
strategyTaskDelivered: message.strategyTaskDelivered,
|
||||
...(message.endedAt ? { endedAt: message.endedAt } : {}),
|
||||
...(message.resultDeliveryState
|
||||
? { resultDeliveryState: message.resultDeliveryState }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { previewStorageKey, readPreviewStorage, savePreviewStorageMessage } from '../runtime/preview-storage';
|
||||
import { commentSelectorsMatch, commentUsesIdentity, ownerCommentNeedsLocation, resolveOwnerCommentAnchor } from '../comments/owner-anchor';
|
||||
import { indexCommentTargets } from '../comments/target-index';
|
||||
import { useShareSourceGuard } from './share/useShareSourceGuard';
|
||||
@@ -248,6 +249,7 @@ import {
|
||||
hasTweaksTemplate,
|
||||
hasUrlModeBridge,
|
||||
htmlNeedsFocusGuard,
|
||||
htmlNeedsLocalInteractionBridge,
|
||||
htmlNeedsPoweredPreview,
|
||||
htmlNeedsRedirectGuard,
|
||||
htmlNeedsSandboxShim,
|
||||
@@ -10454,6 +10456,10 @@ function HtmlViewer({
|
||||
const s = routingHtmlSource;
|
||||
return s != null && htmlNeedsSandboxShim(s);
|
||||
}, [passiveLargeHtmlPreview, routingHtmlSource]);
|
||||
const needsLocalInteractionBridge = useMemo(() => {
|
||||
if (passiveLargeHtmlPreview) return false;
|
||||
return routingHtmlSource != null && htmlNeedsLocalInteractionBridge(routingHtmlSource);
|
||||
}, [passiveLargeHtmlPreview, routingHtmlSource]);
|
||||
const needsFocusGuard = useMemo(() => {
|
||||
if (passiveLargeHtmlPreview) return false;
|
||||
const s = routingHtmlSource;
|
||||
@@ -10466,6 +10472,7 @@ function HtmlViewer({
|
||||
const s = routingHtmlSource;
|
||||
return s != null && htmlNeedsRedirectGuard(s);
|
||||
}, [passiveLargeHtmlPreview, routingHtmlSource]);
|
||||
const needsPersistentStorage = Boolean(routingHtmlSource && /\blocalStorage\b/.test(routingHtmlSource));
|
||||
const urlDocumentGuardsAvailable =
|
||||
liveHtml === undefined && file.size <= PREVIEW_URL_GUARD_MAX_HTML_BYTES;
|
||||
// Set by the injected guard's `od:redirect-loop-blocked` postMessage. The
|
||||
@@ -10618,8 +10625,9 @@ function HtmlViewer({
|
||||
urlModeBridge,
|
||||
inspectMode,
|
||||
drawMode: drawOverlayOpen,
|
||||
forceInline: forceInline && !needsPowered,
|
||||
forceInline: (forceInline || needsPersistentStorage) && !needsPowered,
|
||||
needsSandboxShim: needsSandboxShim && !needsPowered,
|
||||
needsLocalInteractionBridge: needsLocalInteractionBridge && !needsPowered,
|
||||
// Daemon guards wrap the settled on-disk response. Streaming/in-memory
|
||||
// HTML has no matching URL representation, so keep it on srcDoc until it
|
||||
// lands on disk instead of showing stale bytes from the raw route.
|
||||
@@ -10656,6 +10664,12 @@ function HtmlViewer({
|
||||
srcDocNavigationCommittedRef.current = null;
|
||||
srcDocContentReadyRef.current = null;
|
||||
}
|
||||
// Reset before the shell can announce readiness. Its ready message may
|
||||
// precede the first load; resetting in onLoad would activate it twice.
|
||||
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
|
||||
srcDocFrameDedupeResetForRef.current = frame;
|
||||
activatedSrcDocTransportHtmlRef.current = null;
|
||||
}
|
||||
srcDocPreviewIframeRef.current = frame;
|
||||
if (frame) bindMotionPreviewActivity(frame);
|
||||
// A ref callback is the only synchronous witness when React replaces or
|
||||
@@ -11231,6 +11245,7 @@ function HtmlViewer({
|
||||
effectiveDeck ? 'deck' : 'html',
|
||||
srcDocBaseHref,
|
||||
].join('\0');
|
||||
const previewStorageScopeKey = previewStorageKey(sourceAuthorizationScopeKey, projectId, file.name);
|
||||
const candidateSrcDocTransport = useMemo(
|
||||
() => {
|
||||
const sourceFingerprint = previewSource === null
|
||||
@@ -11253,6 +11268,7 @@ function HtmlViewer({
|
||||
generation,
|
||||
srcDoc: previewSource ? buildSrcdoc(previewSource, {
|
||||
motionPreview: true,
|
||||
previewStorage: Boolean(previewStorageScopeKey),
|
||||
deck: effectiveDeck,
|
||||
baseHref: srcDocBaseHref,
|
||||
initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0,
|
||||
@@ -11286,6 +11302,7 @@ function HtmlViewer({
|
||||
previewSource,
|
||||
effectiveDeck,
|
||||
srcDocTransportIdentity,
|
||||
previewStorageScopeKey,
|
||||
previewStateKey,
|
||||
reloadKey,
|
||||
transportPreviewMeasurementDocumentEpoch,
|
||||
@@ -11309,6 +11326,14 @@ function HtmlViewer({
|
||||
const srcDoc = srcDocTransport.srcDoc;
|
||||
const expectedSrcDocTransportGenerationRef = useRef(srcDocTransportGeneration);
|
||||
expectedSrcDocTransportGenerationRef.current = srcDocTransportGeneration;
|
||||
useEffect(() => {
|
||||
const onStorage = (event: MessageEvent) => {
|
||||
savePreviewStorageMessage(event, srcDocPreviewIframeRef.current?.contentWindow ?? null,
|
||||
expectedSrcDocTransportGenerationRef.current, previewStorageScopeKey);
|
||||
};
|
||||
window.addEventListener('message', onStorage);
|
||||
return () => window.removeEventListener('message', onStorage);
|
||||
}, [previewStorageScopeKey]);
|
||||
const readySrcDocTransportRef = useRef<{
|
||||
frame: HTMLIFrameElement;
|
||||
generation: string;
|
||||
@@ -12061,6 +12086,7 @@ function HtmlViewer({
|
||||
// visibility swap rather than a remount and reload.
|
||||
const useLazySrcDocTransport =
|
||||
usesPersistentSrcDocTransport
|
||||
|| (needsPersistentStorage && !needsPowered)
|
||||
|| srcDocRecoveryGeneration === srcDocTransportGeneration
|
||||
|| (!manualEditRequiresSrcDoc && !captureModeActive && useUrlLoadPreview && !srcDocMaterialized);
|
||||
// Park on a static "loop detected" document once the guard reports a runaway
|
||||
@@ -12233,12 +12259,14 @@ function HtmlViewer({
|
||||
type: 'od:srcdoc-transport-activate',
|
||||
html: srcDocActivationContent,
|
||||
generation: srcDocTransportGeneration,
|
||||
previewStorage: previewStorageScopeKey ? readPreviewStorage(previewStorageScopeKey) : undefined,
|
||||
}, '*');
|
||||
activatedSrcDocTransportHtmlRef.current = srcDocActivationContent;
|
||||
return true;
|
||||
}, [
|
||||
boardMode,
|
||||
srcDocActivationContent,
|
||||
previewStorageScopeKey,
|
||||
srcDocMaterialized,
|
||||
srcDocShellReady,
|
||||
srcDocTransportGeneration,
|
||||
@@ -12260,11 +12288,13 @@ function HtmlViewer({
|
||||
type: 'od:srcdoc-transport-activate',
|
||||
html: srcDocActivationContent,
|
||||
generation: srcDocTransportGeneration,
|
||||
previewStorage: previewStorageScopeKey ? readPreviewStorage(previewStorageScopeKey) : undefined,
|
||||
}, '*');
|
||||
activatedSrcDocTransportHtmlRef.current = srcDocActivationContent;
|
||||
return true;
|
||||
}, [
|
||||
srcDocActivationContent,
|
||||
previewStorageScopeKey,
|
||||
srcDocMaterialized,
|
||||
srcDocTransportGeneration,
|
||||
useLazySrcDocTransport,
|
||||
@@ -12278,9 +12308,10 @@ function HtmlViewer({
|
||||
type: 'od:srcdoc-transport-activate',
|
||||
html: srcDocActivationContent,
|
||||
generation: srcDocTransportGeneration,
|
||||
previewStorage: previewStorageScopeKey ? readPreviewStorage(previewStorageScopeKey) : undefined,
|
||||
}, '*');
|
||||
return true;
|
||||
}, [srcDocActivationContent, srcDocTransportGeneration]);
|
||||
}, [srcDocActivationContent, srcDocTransportGeneration, previewStorageScopeKey]);
|
||||
function verifyLoadedSrcDocTransport(target: HTMLIFrameElement | null) {
|
||||
if (!target || target !== srcDocPreviewIframeRef.current) return;
|
||||
const generation = srcDocTransportGeneration;
|
||||
@@ -18079,40 +18110,6 @@ function HtmlViewer({
|
||||
transportPreviewMeasurementDocumentEpoch;
|
||||
}
|
||||
if (!useUrlLoadPreview) beginDesktopPreviewContentMeasurementGeneration(frame);
|
||||
// Reset the activation dedupe exactly ONCE per
|
||||
// freshly mounted iframe DOM node, never on the
|
||||
// subsequent load events that the same node
|
||||
// emits during normal srcDoc rendering.
|
||||
//
|
||||
// The iframe's load event fires twice for one
|
||||
// successful activation: once when the lazy
|
||||
// transport shell HTML loads, and again when
|
||||
// our own document.open/write/close inside the
|
||||
// shell finishes. PR #2699 reset the dedupe on
|
||||
// every load so that switching
|
||||
// preview -> source -> preview (which remounts
|
||||
// this iframe as a fresh DOM node) would
|
||||
// re-activate the new shell. But resetting on
|
||||
// every load also re-activated on the SECOND
|
||||
// load of a non-remounted frame, which
|
||||
// re-triggered document.open/write/close, which
|
||||
// re-fired the load event, ad infinitum. The
|
||||
// dedupe ref oscillated between null and the
|
||||
// current srcDoc thousands of times per render
|
||||
// and each iteration restarted every CSS
|
||||
// animation from its `from` keyframe. Designs
|
||||
// using `animation-fill-mode: both` with
|
||||
// `from { opacity: 0 }` stayed at opacity 0
|
||||
// forever and the preview read as blank.
|
||||
// That is issue #2361.
|
||||
//
|
||||
// Tracking the last frame we reset for lets us
|
||||
// keep PR #2699's "remount after Source toggle"
|
||||
// fix while breaking the loop on plain renders.
|
||||
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
|
||||
srcDocFrameDedupeResetForRef.current = frame;
|
||||
activatedSrcDocTransportHtmlRef.current = null;
|
||||
}
|
||||
if (useLazySrcDocTransport) setSrcDocShellReady(true);
|
||||
const activatedTransport = activateLoadedSrcDocTransport(frame);
|
||||
if (frame && activatedTransport) {
|
||||
|
||||
@@ -91,6 +91,7 @@ import { ContextChipHoverCard } from './ContextChipHoverCard';
|
||||
import { workspaceContextDetailLine, workspaceContextKindLabel } from './workspace-context';
|
||||
import { FigmaHelpModal } from './FigmaHelpModal';
|
||||
import { TemplatePicker } from './home-hero/TemplatePicker';
|
||||
import { TypePillRow } from './home-hero/TypePillRow';
|
||||
import { LibraryPicker } from './LibraryPicker';
|
||||
import { assetTitle } from './LibraryAssetMeta';
|
||||
import { libraryAssetRawUrl } from '../providers/registry';
|
||||
@@ -1511,7 +1512,7 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
|
||||
rather than hard-coding a lead/tail pair per language. The nouns reuse the
|
||||
composer's own type labels, so the headline can never name a kind the type
|
||||
row does not offer. */
|
||||
const titleTemplate = t('homeHero.title');
|
||||
const titleTemplate = activeChipId ? t('homeHero.title') : t('homeHero.titleUnselected');
|
||||
const titleSlotIndex = titleTemplate.indexOf(TITLE_WORD_SLOT);
|
||||
const titleParts =
|
||||
titleSlotIndex === -1
|
||||
@@ -1575,7 +1576,7 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
|
||||
<h1
|
||||
className={
|
||||
'home-hero__title' +
|
||||
(endsWithFullWidthPunctuation(t('homeHero.title'))
|
||||
(endsWithFullWidthPunctuation(titleTemplate)
|
||||
? ' home-hero__title--optical-trim'
|
||||
: '')
|
||||
}
|
||||
@@ -1593,7 +1594,7 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
|
||||
{titleParts.tail}
|
||||
</>
|
||||
) : (
|
||||
t('homeHero.title')
|
||||
titleTemplate
|
||||
)}
|
||||
</h1>
|
||||
{/* One quiet line under the headline (per product). */}
|
||||
@@ -2352,13 +2353,14 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
|
||||
{/* Task type follows the two icon controls: it picks WHAT is being
|
||||
made, and the pair before it answers "with what" / "in what
|
||||
style". */}
|
||||
<TemplatePicker
|
||||
{activeChipId || isDock ? <TemplatePicker
|
||||
templates={templateChips}
|
||||
onPick={handlePickTaskChip}
|
||||
onClear={onClearActiveChip}
|
||||
disabled={pluginsLoading || typeSelectionPending || pendingChipId !== null || pendingPluginId !== null}
|
||||
activeChipId={activeChipId}
|
||||
labelFor={(id) => homeHeroChipLabel(id, t)}
|
||||
/>
|
||||
/> : null}
|
||||
{libraryPickerOpen ? (
|
||||
<LibraryPicker
|
||||
onClose={() => setLibraryPickerOpen(false)}
|
||||
@@ -2587,6 +2589,16 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
|
||||
question the pill had just answered. The examples below now show the
|
||||
picked type's full set. */}
|
||||
|
||||
{!isDock && !activeChipId ? (
|
||||
<TypePillRow
|
||||
chips={templateChips}
|
||||
activeChipId={null}
|
||||
disabled={pluginsLoading || typeSelectionPending || pendingChipId !== null || pendingPluginId !== null}
|
||||
labelFor={(id) => homeHeroChipLabel(id, t)}
|
||||
onPick={handlePickTaskChip}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isDock ? null : recommendationSlot}
|
||||
|
||||
{isDock ? null : examplePluginPresets.length > 0 && activeChipId ? (
|
||||
|
||||
@@ -1975,37 +1975,8 @@ export function HomeView({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pendingChipRestore, pluginsLoading, plugins, active, pendingPluginUseHandoff]);
|
||||
|
||||
// Seed only the page's first untouched visit. Restored drafts and host
|
||||
// handoffs own their selection; a later clear must not re-run this default.
|
||||
const [defaultTypeSettled, setDefaultTypeSettled] = useState(false);
|
||||
const defaultTypePending = ownsComposerDraft && !defaultTypeSettled && !active;
|
||||
useEffect(() => {
|
||||
if (!ownsComposerDraft || defaultTypeSettled) return;
|
||||
if (active || promptHandoff || pendingPluginUseHandoff || hasPendingHomeChip(variant)) {
|
||||
setDefaultTypeSettled(true);
|
||||
return;
|
||||
}
|
||||
if (pluginsLoading || pendingChipRestore) return;
|
||||
setDefaultTypeSettled(true);
|
||||
const chip = findChip('prototype');
|
||||
if (chip?.action.kind !== 'apply-scenario') return;
|
||||
const action = chip.action;
|
||||
const record = plugins.find((plugin) => plugin.id === action.pluginId);
|
||||
// A missing catalog entry must not lock the composer or invent a plugin.
|
||||
// Explicit picks continue to report the normal missing-scenario error.
|
||||
if (!record) return;
|
||||
void usePlugin(record, undefined, {
|
||||
chipId: chip.id,
|
||||
projectKind: chip.action.projectKind,
|
||||
inputs: chip.action.inputs,
|
||||
projectMetadata: chip.action.projectMetadata ?? null,
|
||||
suppressPromptUpdate: true,
|
||||
focusPrompt: false,
|
||||
deferApply: true,
|
||||
});
|
||||
// usePlugin reads this render's catalog/context; it is not an effect trigger.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ownsComposerDraft, defaultTypeSettled, active, promptHandoff, pendingPluginUseHandoff, variant, pluginsLoading, pendingChipRestore, plugins]);
|
||||
// Natural-language entry has no implicit task selection.
|
||||
const defaultTypePending = false;
|
||||
|
||||
function addPluginContext(record: InstalledPluginRecord, nextPrompt: string | null) {
|
||||
setSelectedPluginContexts((prev) => {
|
||||
|
||||
@@ -1,483 +1,13 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import type {
|
||||
OdNextRolloutControlResponse,
|
||||
OdNextRolloutMode,
|
||||
TrackingLabsOptOutReason,
|
||||
} from '@open-design/contracts';
|
||||
|
||||
import { trackLabsItemToggled } from '../analytics/events';
|
||||
import { useAnalytics } from '../analytics/provider';
|
||||
import { useT } from '../i18n';
|
||||
import { Icon } from './Icon';
|
||||
import styles from './LabsSection.module.css';
|
||||
|
||||
/**
|
||||
* Why this component reads and writes on its own instead of going through
|
||||
* `cfg` / `setCfg` like the other Settings sections:
|
||||
*
|
||||
* `apps/web/src/types.ts`'s `AppConfig` is a web-local projection that does
|
||||
* not carry `odNextStrategyMode`, and `syncConfigToDaemon` serialises an
|
||||
* explicit allow-list of fields. Threading this switch through that pipeline
|
||||
* means three edits, and forgetting the allow-list one fails silently — the
|
||||
* toggle would look saved and never reach the daemon. A self-contained read
|
||||
* and a single-field PUT avoid the whole class of bug, and keep the Labs
|
||||
* surface deletable in one piece when the experiment converges.
|
||||
*/
|
||||
|
||||
type SwitchLock = 'env' | 'unreadable';
|
||||
|
||||
interface LabsHarnessState {
|
||||
on: boolean;
|
||||
lock: SwitchLock | null;
|
||||
}
|
||||
|
||||
const LOADING: LabsHarnessState | null = null;
|
||||
|
||||
/** `PUT /api/app-config` merges per key, so a single-field body is safe. */
|
||||
async function writeHarnessMode(mode: OdNextRolloutMode): Promise<void> {
|
||||
const response = await fetch('/api/app-config', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ odNextStrategyMode: mode }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`app-config write failed (${response.status})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the daemon's three-valued rollout mode onto the switch.
|
||||
*
|
||||
* `observe` reads as off because it does not run the new harness — but the
|
||||
* switch never rewrites it on its own: it is a developer-set diagnostic mode,
|
||||
* and silently clearing it would break someone's debugging session. The user's
|
||||
* next deliberate toggle resolves it to `active` / `off` naturally.
|
||||
*
|
||||
* The environment is the only authority that locks the switch. A mode set
|
||||
* through `OD_NEXT_STRATEGY_ROLLOUT` outranks the installation's saved value,
|
||||
* so the single-field PUT this component makes would be written and then
|
||||
* ignored — the lock is what stops the switch from claiming an edit the
|
||||
* daemon will not act on. Every other status is a plain, operable value.
|
||||
*/
|
||||
export function harnessStateFromStatus(
|
||||
status: OdNextRolloutControlResponse['status'],
|
||||
): LabsHarnessState {
|
||||
const on = status.requestedMode === 'active';
|
||||
if (status.requestedModeSource === 'env') return { on, lock: 'env' };
|
||||
return { on, lock: null };
|
||||
}
|
||||
|
||||
function LabsTooltip({ label, body, scope }: { label: string; body: string; scope: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const tooltipId = useId();
|
||||
return (
|
||||
<span className={styles.tooltipHost}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tooltipTrigger}
|
||||
aria-label={label}
|
||||
aria-describedby={open ? tooltipId : undefined}
|
||||
aria-expanded={open}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
// The trigger explains, it does not act. Swallow the click so it
|
||||
// cannot reach the row and flip the switch by accident.
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
<Icon name="help-circle" size={14} aria-hidden />
|
||||
</button>
|
||||
{open ? (
|
||||
<span className={styles.tooltip} role="tooltip" id={tooltipId}>
|
||||
<span className={styles.tooltipBody}>{body}</span>
|
||||
<span className={styles.tooltipScope}>{scope}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the reason panel waits before recording a non-answer.
|
||||
*
|
||||
* Long enough that it is not a trick question, short enough that the row does
|
||||
* not stay in a feedback state for the rest of the session. Not shown as a
|
||||
* countdown: a visible timer turns a question into a deadline.
|
||||
*/
|
||||
const OPT_OUT_PROMPT_TTL_MS = 120_000;
|
||||
|
||||
/** How long the saved confirmation stays up before the pill returns to idle. */
|
||||
const SAVED_PILL_TTL_MS = 3_000;
|
||||
|
||||
/** Free-text cap. Long enough for a real sentence, short of an essay. */
|
||||
const CUSTOM_REASON_MAX = 200;
|
||||
|
||||
const OPT_OUT_CHOICES: ReadonlyArray<{ reason: TrackingLabsOptOutReason; labelKey: 'labs.optOutWorseOutput' | 'labs.optOutTooSlow' | 'labs.optOutNotWhatIWanted' }> = [
|
||||
{ reason: 'worse_output', labelKey: 'labs.optOutWorseOutput' },
|
||||
{ reason: 'too_slow', labelKey: 'labs.optOutTooSlow' },
|
||||
{ reason: 'not_what_i_wanted', labelKey: 'labs.optOutNotWhatIWanted' },
|
||||
];
|
||||
|
||||
interface OptOutPanelProps {
|
||||
onAnswer: (answer: { reason: TrackingLabsOptOutReason[]; customReason?: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks why, once, right after the user turns an experiment off.
|
||||
*
|
||||
* Inline rather than a dialog: the user just declined something, and a modal
|
||||
* asking them to justify it reads as friction. It resolves exactly once —
|
||||
* every path out (a choice, skip, the timeout, or leaving the page) reports,
|
||||
* so the share of people who declined to answer is visible rather than missing.
|
||||
*/
|
||||
function OptOutPanel({ onAnswer }: OptOutPanelProps) {
|
||||
const t = useT();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [text, setText] = useState('');
|
||||
const answerRef = useRef(onAnswer);
|
||||
answerRef.current = onAnswer;
|
||||
|
||||
useEffect(() => {
|
||||
// Picking "other" is an explicit intent to write something. Taking the
|
||||
// panel away mid-sentence would be hostile, so the clock stops there —
|
||||
// a discrete state change, not hover-tracking.
|
||||
if (expanded) return undefined;
|
||||
const timer = window.setTimeout(() => {
|
||||
answerRef.current({ reason: ['skipped'] });
|
||||
}, OPT_OUT_PROMPT_TTL_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [expanded]);
|
||||
|
||||
const trimmed = text.trim();
|
||||
return (
|
||||
<div className={styles.optOut}>
|
||||
<span className={styles.optOutPrompt}>{t('labs.optOutPrompt')}</span>
|
||||
<div className={styles.optOutChoices}>
|
||||
{OPT_OUT_CHOICES.map((choice) => (
|
||||
<button
|
||||
key={choice.reason}
|
||||
type="button"
|
||||
className={styles.optOutChip}
|
||||
onClick={() => onAnswer({ reason: [choice.reason] })}
|
||||
>
|
||||
{t(choice.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.optOutChip}${expanded ? ` ${styles.optOutChipActive}` : ''}`}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
{t('labs.optOutOther')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.optOutSkip}
|
||||
onClick={() => onAnswer({ reason: ['skipped'] })}
|
||||
>
|
||||
{t('labs.optOutSkip')}
|
||||
</button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className={styles.optOutInputRow}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.optOutInput}
|
||||
maxLength={CUSTOM_REASON_MAX}
|
||||
placeholder={t('labs.optOutOtherPlaceholder')}
|
||||
aria-label={t('labs.optOutOtherPlaceholder')}
|
||||
value={text}
|
||||
autoFocus
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' || !trimmed) return;
|
||||
onAnswer({ reason: ['other'], customReason: trimmed });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.optOutSubmit}
|
||||
disabled={!trimmed}
|
||||
onClick={() => onAnswer({ reason: ['other'], customReason: trimmed })}
|
||||
>
|
||||
{t('labs.optOutSubmit')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dialog's save indicator, borrowed for one write.
|
||||
*
|
||||
* It is a single shared surface: every Settings section drives the same pill.
|
||||
* This switch writes immediately instead of through the dialog's debounced
|
||||
* autosave, and its request can outlive the section — so a claim id, not a
|
||||
* status alone, is what crosses the await. Comparing the pill's current value
|
||||
* would not be enough: a newer section can legitimately be showing the same
|
||||
* `saving` this write left behind.
|
||||
*/
|
||||
export interface LabsAutosaveController {
|
||||
/** Take the indicator for one write and report it as saving. */
|
||||
claim(): number;
|
||||
/** Settle that claim. A no-op once a newer writer has taken the indicator. */
|
||||
settle(claim: number, status: 'saved' | 'error' | 'idle'): void;
|
||||
}
|
||||
export interface LabsSectionProps { autosave?: LabsAutosaveController }
|
||||
|
||||
export interface LabsSectionProps {
|
||||
autosave?: LabsAutosaveController;
|
||||
}
|
||||
|
||||
export function LabsSection({ autosave }: LabsSectionProps) {
|
||||
export function LabsSection(_props: LabsSectionProps) {
|
||||
const t = useT();
|
||||
const analytics = useAnalytics();
|
||||
const [state, setState] = useState<LabsHarnessState | null>(LOADING);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const noticeId = useId();
|
||||
// Non-null while the reason panel is up. Held in a ref as well as state so
|
||||
// the unmount path can settle it without re-rendering a dying component.
|
||||
const [askingReason, setAskingReason] = useState(false);
|
||||
const reasonPendingRef = useRef(false);
|
||||
// Guards against a slow earlier write re-applying UI after a later toggle,
|
||||
// and against setState landing on an unmounted section.
|
||||
const writeTokenRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
// `busy` drives the disabled styling, but it cannot gate re-entry: a second
|
||||
// click in the same tick reads the pre-render closure, where `busy` is still
|
||||
// false and `state.on` is still the old value, so it would start a second
|
||||
// write from a stale baseline. The ref flips synchronously and is what the
|
||||
// guard actually reads.
|
||||
const writeInFlightRef = useRef(false);
|
||||
// The dialog's autosave pill is a shared surface with no timer of its own —
|
||||
// whoever sets it owns clearing it. Without this the confirmation sat there
|
||||
// for the rest of the session, following the user into every other section.
|
||||
const savedPillTimerRef = useRef<number | null>(null);
|
||||
// The claim this section currently holds on the shared indicator, captured
|
||||
// when the write starts rather than read back when it finishes.
|
||||
const autosaveClaimRef = useRef<number | null>(null);
|
||||
const autosaveRef = useRef(autosave);
|
||||
autosaveRef.current = autosave;
|
||||
|
||||
const settleAutosave = useCallback(
|
||||
(claim: number | null, status: 'saved' | 'error' | 'idle') => {
|
||||
if (claim == null) return;
|
||||
autosaveRef.current?.settle(claim, status);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reportSaved = useCallback((claim: number | null) => {
|
||||
settleAutosave(claim, 'saved');
|
||||
if (savedPillTimerRef.current != null) window.clearTimeout(savedPillTimerRef.current);
|
||||
savedPillTimerRef.current = window.setTimeout(() => {
|
||||
savedPillTimerRef.current = null;
|
||||
settleAutosave(claim, 'idle');
|
||||
}, SAVED_PILL_TTL_MS);
|
||||
}, [settleAutosave]);
|
||||
|
||||
useEffect(() => () => {
|
||||
// Leaving the section takes the confirmation with it; it describes an edit
|
||||
// the user can no longer see.
|
||||
if (savedPillTimerRef.current == null) return;
|
||||
window.clearTimeout(savedPillTimerRef.current);
|
||||
savedPillTimerRef.current = null;
|
||||
settleAutosave(autosaveClaimRef.current, 'idle');
|
||||
}, [settleAutosave]);
|
||||
|
||||
/**
|
||||
* Read the mode this daemon is in.
|
||||
*
|
||||
* Split out of the mount effect so a failed read is not permanent. An
|
||||
* unreachable daemon must not blank the page, so the row renders locked with
|
||||
* the reason spelled out — but a lock that only lifts on remount means a
|
||||
* transient failure leaves the user staring at a switch that does nothing,
|
||||
* and this switch is the only control a packaged install has over OD Next.
|
||||
* `toggle` calls this again instead of ignoring the click, so the dead state
|
||||
* becomes its own retry.
|
||||
*/
|
||||
const readStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/strategies/od-next/rollout');
|
||||
if (!response.ok) throw new Error(`rollout status failed (${response.status})`);
|
||||
const body = (await response.json()) as OdNextRolloutControlResponse;
|
||||
if (!mountedRef.current) return;
|
||||
setState(harnessStateFromStatus(body.status));
|
||||
} catch {
|
||||
if (!mountedRef.current) return;
|
||||
setState({ on: false, lock: 'unreadable' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void readStatus();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [readStatus]);
|
||||
|
||||
const answerOptOut = useCallback(
|
||||
(answer: { reason: TrackingLabsOptOutReason[]; customReason?: string }) => {
|
||||
// Exactly once. A chip click that races the timeout, or a timeout that
|
||||
// races unmount, must not produce two reason rows for one opt-out.
|
||||
if (!reasonPendingRef.current) return;
|
||||
reasonPendingRef.current = false;
|
||||
const custom = answer.customReason?.trim() ?? '';
|
||||
trackLabsItemToggled(analytics.track, {
|
||||
item_id: 'design_harness',
|
||||
to: 'off',
|
||||
source: 'settings',
|
||||
reason: answer.reason,
|
||||
has_custom_reason: custom.length > 0,
|
||||
...(custom ? { custom_reason: custom } : {}),
|
||||
});
|
||||
if (mountedRef.current) setAskingReason(false);
|
||||
},
|
||||
[analytics.track],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
// Leaving the page with the question still open is the same signal as
|
||||
// letting it time out: the user moved on. Recording it keeps every opt-out
|
||||
// paired with exactly one reason row, so the share who declined to answer
|
||||
// is a number rather than a gap.
|
||||
if (reasonPendingRef.current) answerOptOut({ reason: ['skipped'] });
|
||||
}, [answerOptOut]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (writeInFlightRef.current) return;
|
||||
// A read that failed is the one lock the user can do something about, so
|
||||
// spend the click on trying again rather than swallowing it. The
|
||||
// environment lock stays inert on purpose: the daemon would ignore the
|
||||
// write, and pretending otherwise is worse than doing nothing.
|
||||
if (state?.lock === 'unreadable') {
|
||||
setState(LOADING);
|
||||
void readStatus();
|
||||
return;
|
||||
}
|
||||
if (!state || state.lock) return;
|
||||
const next = !state.on;
|
||||
const previous = state.on;
|
||||
const token = ++writeTokenRef.current;
|
||||
writeInFlightRef.current = true;
|
||||
setState({ ...state, on: next });
|
||||
setBusy(true);
|
||||
const claim = autosaveRef.current?.claim() ?? null;
|
||||
autosaveClaimRef.current = claim;
|
||||
void (async () => {
|
||||
try {
|
||||
// `'off'` rather than clearing the key: an absent value reads as
|
||||
// "never touched", and the two are worth telling apart later.
|
||||
await writeHarnessMode(next ? 'active' : 'off');
|
||||
// Only staleness ends the write here. `mountedRef` guards this
|
||||
// component's own state and nothing else: leaving Labs mid-write is
|
||||
// the ordinary case — flip the switch, click another section — and the
|
||||
// preference is on the machine either way. Letting the guard reach
|
||||
// this far turned every one of those into a missing event and left the
|
||||
// dialog's pill on "Saving" for an edit that had already landed.
|
||||
if (token !== writeTokenRef.current) return;
|
||||
// After the write, not on click: a failed write rolls the switch back,
|
||||
// and an event for a preference the machine does not hold is worse
|
||||
// than a missing one.
|
||||
trackLabsItemToggled(analytics.track, {
|
||||
item_id: 'design_harness',
|
||||
to: next ? 'on' : 'off',
|
||||
source: 'settings',
|
||||
});
|
||||
if (next) {
|
||||
// Turning it back on retracts the question. Left open, a fumbled
|
||||
// off/on/off would report two opt-outs against a single reason row
|
||||
// and leave the panel asking about a switch that is now on. Settling
|
||||
// here keeps the invariant every count depends on: one reported
|
||||
// opt-out, one reason row.
|
||||
answerOptOut({ reason: ['skipped'] });
|
||||
} else if (mountedRef.current) {
|
||||
// The opt-out itself is already reported above; the reason arrives
|
||||
// as a second event once the user answers. Counting opt-outs from
|
||||
// the first and reasons from the second keeps the opt-out count
|
||||
// whole even when nobody answers.
|
||||
reasonPendingRef.current = true;
|
||||
setAskingReason(true);
|
||||
} else {
|
||||
// Nobody is left to ask, and the unmount settler has already run.
|
||||
// Recording the non-answer here keeps the same invariant: an
|
||||
// opt-out reported without a reason row would never be paired.
|
||||
reasonPendingRef.current = true;
|
||||
answerOptOut({ reason: ['skipped'] });
|
||||
}
|
||||
reportSaved(claim);
|
||||
} catch {
|
||||
if (token !== writeTokenRef.current) return;
|
||||
if (mountedRef.current) {
|
||||
setState((current) => (current ? { ...current, on: previous } : current));
|
||||
}
|
||||
// Reported even after unmount: the pill belongs to the dialog, which
|
||||
// outlives this section, and a failed save it never hears about stays
|
||||
// on "Saving" forever. Still gated on the claim — a newer section's
|
||||
// save must not be relabelled as this one's failure.
|
||||
settleAutosave(claim, 'error');
|
||||
} finally {
|
||||
if (token === writeTokenRef.current) {
|
||||
writeInFlightRef.current = false;
|
||||
if (mountedRef.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [analytics.track, answerOptOut, readStatus, reportSaved, settleAutosave, state]);
|
||||
|
||||
const lockNoticeKey = state?.lock === 'env'
|
||||
? 'labs.envOverrideNotice'
|
||||
: state?.lock === 'unreadable'
|
||||
? 'labs.loadFailedNotice'
|
||||
: null;
|
||||
|
||||
const on = state?.on ?? false;
|
||||
// A section that has not resolved yet is not operable either — treating the
|
||||
// pending read as locked keeps the switch from accepting a click it would
|
||||
// immediately overwrite.
|
||||
const locked = state == null || state.lock != null;
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<p className={styles.pageDesc}>{t('labs.pageDesc')}</p>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowText}>
|
||||
<span className={styles.rowTitle}>
|
||||
<span className={styles.rowName}>{t('labs.harnessName')}</span>
|
||||
<LabsTooltip
|
||||
label={t('labs.itemAbout', { name: t('labs.harnessName') })}
|
||||
body={t('labs.harnessTooltip')}
|
||||
scope={t('labs.harnessScope')}
|
||||
/>
|
||||
</span>
|
||||
<span className={styles.rowHint}>{t('labs.harnessHint')}</span>
|
||||
{lockNoticeKey ? (
|
||||
<span className={styles.rowNotice} role="status" id={noticeId}>
|
||||
<Icon name="info" size={12} aria-hidden />
|
||||
{t(lockNoticeKey)}
|
||||
</span>
|
||||
) : null}
|
||||
{askingReason ? <OptOutPanel onAnswer={answerOptOut} /> : null}
|
||||
</div>
|
||||
{/* `aria-disabled` rather than `disabled`: a disabled button leaves the
|
||||
tab order, so a screen-reader user never reaches the sentence that
|
||||
explains why the switch will not move. `toggle` refuses the click
|
||||
on its own. */}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
aria-label={t('labs.harnessName')}
|
||||
aria-disabled={locked || busy}
|
||||
aria-describedby={lockNoticeKey ? noticeId : undefined}
|
||||
className={`${styles.switch}${on ? ` ${styles.switchOn}` : ''}`}
|
||||
onClick={toggle}
|
||||
data-testid="labs-harness-switch"
|
||||
>
|
||||
<span className={styles.switchKnob} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
return <section className="settings-section"><p className={styles.pageDesc}>{t('labs.pageDesc')}</p></section>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withLiveArtifactDeliveries } from '../runtime/live-artifact-delivery';
|
||||
import { readRetriedErrorSurface, retriedErrorSurfaceKey, writeRetriedErrorSurface } from '../runtime/chat/retried-error-surface';
|
||||
import type { ObservedPublicShareLink, ObservedShareUpdateRequest } from './share/observed-public-share-link';
|
||||
import type { ShareLoginReturn } from './share/share-login-return';
|
||||
@@ -701,13 +702,9 @@ function mergeServerMessageWithLocal(
|
||||
if ((local.content?.length ?? 0) > (server.content?.length ?? 0)) {
|
||||
merged.content = local.content;
|
||||
}
|
||||
if (
|
||||
!(server.strategyTaskBlocked === false && local.strategyTaskBlocked === true)
|
||||
&& (
|
||||
(local.events?.length ?? 0) > (server.events?.length ?? 0)
|
||||
|| localThinkingExtendsServerSnapshot(server, local)
|
||||
)
|
||||
) {
|
||||
if (!(server.strategyTaskBlocked === false && local.strategyTaskBlocked === true)
|
||||
&& ((local.events?.length ?? 0) > (server.events?.length ?? 0)
|
||||
|| localThinkingExtendsServerSnapshot(server, local))) {
|
||||
merged.events = local.events;
|
||||
}
|
||||
}
|
||||
@@ -6823,6 +6820,8 @@ export function ProjectView({
|
||||
?? await fetchChatRunStatus(runId, projectRunWorkspaceContext);
|
||||
if (cancelled) return;
|
||||
if (!physicalStatus) {
|
||||
// A missing status response cannot invalidate persisted success.
|
||||
if (message.runStatus === 'succeeded') continue;
|
||||
// A local retry seal is not a daemon terminal verdict. Keep this
|
||||
// run's receipts for the existing manual reconnect action.
|
||||
// `fetchChatRunStatus` returns null on ANY non-OK response or fetch
|
||||
@@ -7178,7 +7177,7 @@ export function ProjectView({
|
||||
// OPEND-2588 (2026-09-04): a turn that finishes while we are
|
||||
// replaying it opens ALL of its primary artifacts, same as a live
|
||||
// completion — the user cannot tell the two apart.
|
||||
const turnArtifacts = selectAutoOpenTurnArtifacts(produced, nextFiles, {
|
||||
const turnArtifacts = withLiveArtifactDeliveries(selectAutoOpenTurnArtifacts(produced, nextFiles, {
|
||||
...autoOpenArtifactOptions,
|
||||
preTurnFileNames: beforeFileNames,
|
||||
turnStartedAt: status.createdAt || message.startedAt || message.createdAt || null,
|
||||
@@ -7189,7 +7188,7 @@ export function ProjectView({
|
||||
project.id,
|
||||
projectDetail.resolvedDir,
|
||||
),
|
||||
});
|
||||
}), message.events);
|
||||
if (turnArtifacts.focused && !userTookOverPreviewRef.current) {
|
||||
requestOpenTurnArtifacts(turnArtifacts.open, turnArtifacts.focused);
|
||||
}
|
||||
@@ -7715,7 +7714,7 @@ export function ProjectView({
|
||||
);
|
||||
// OPEND-2588 (2026-09-04): see the replay path above — a run
|
||||
// that lands while reattached is still a turn finishing.
|
||||
const turnArtifacts = selectAutoOpenTurnArtifacts(produced, nextFiles, {
|
||||
const turnArtifacts = withLiveArtifactDeliveries(selectAutoOpenTurnArtifacts(produced, nextFiles, {
|
||||
...autoOpenArtifactOptions,
|
||||
preTurnFileNames: beforeFileNames,
|
||||
turnStartedAt: status.createdAt || message.startedAt || message.createdAt || null,
|
||||
@@ -7729,7 +7728,7 @@ export function ProjectView({
|
||||
project.id,
|
||||
projectDetail.resolvedDir,
|
||||
),
|
||||
});
|
||||
}), needsFullReplay ? replayedEvents : message.events);
|
||||
if (turnArtifacts.focused && !userTookOverPreviewRef.current) {
|
||||
requestOpenTurnArtifacts(turnArtifacts.open, turnArtifacts.focused);
|
||||
}
|
||||
@@ -9599,7 +9598,10 @@ export function ProjectView({
|
||||
if (ev.kind === 'live_artifact') {
|
||||
setLiveArtifactEvents((prev) => appendLiveArtifactEventItem(prev, ev));
|
||||
void refreshLiveArtifacts().then(() => {
|
||||
if (ev.action !== 'deleted') requestOpenFile(liveArtifactTabId(ev.artifactId));
|
||||
if (ev.action !== 'deleted' && !userTookOverPreviewRef.current) {
|
||||
completionSelectedAutoOpen = true;
|
||||
requestOpenFile(liveArtifactTabId(ev.artifactId));
|
||||
}
|
||||
});
|
||||
onProjectsRefresh();
|
||||
return;
|
||||
@@ -10061,7 +10063,7 @@ export function ProjectView({
|
||||
// `turnArtifacts.focused` is byte-for-byte the old
|
||||
// `selectAutoOpenTurnArtifact` answer, so the focused tab below
|
||||
// is decided exactly as it was before; only `.open` is new.
|
||||
const turnArtifacts = selectAutoOpenTurnArtifacts(produced, nextFiles, {
|
||||
const turnArtifacts = withLiveArtifactDeliveries(selectAutoOpenTurnArtifacts(produced, nextFiles, {
|
||||
...autoOpenArtifactOptions,
|
||||
preTurnFileNames: beforeFileNames,
|
||||
turnStartedAt: startedAt,
|
||||
@@ -10075,9 +10077,11 @@ export function ProjectView({
|
||||
project.id,
|
||||
projectDetail.resolvedDir,
|
||||
),
|
||||
});
|
||||
}), latestAssistantMsg.events);
|
||||
const turnArtifactToOpen = turnArtifacts.focused;
|
||||
const producedArtifactToOpen = selectAutoOpenProducedArtifact(
|
||||
const producedArtifactToOpen = turnArtifactToOpen && isLiveArtifactTabId(turnArtifactToOpen)
|
||||
? turnArtifactToOpen
|
||||
: selectAutoOpenProducedArtifact(
|
||||
[
|
||||
...provenTraceTouchedFiles(),
|
||||
...(turnArtifactToOpen
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface UrlLoadDecision {
|
||||
forceInline: boolean;
|
||||
/** The artifact needs the opaque-origin Web Storage/history sandbox shim. */
|
||||
needsSandboxShim?: boolean;
|
||||
/** Local forms/hash routers require the current client interaction bridge. */
|
||||
needsLocalInteractionBridge?: boolean;
|
||||
/** The daemon-served URL response includes the sandbox shim. */
|
||||
urlSandboxGuard?: boolean;
|
||||
/**
|
||||
@@ -120,6 +122,7 @@ export function shouldUrlLoadHtmlPreview(d: UrlLoadDecision): boolean {
|
||||
// the artifact ships a `.tw-panel`.
|
||||
if (d.tweaksBridge) return false;
|
||||
if (d.forceInline) return false;
|
||||
if (d.needsLocalInteractionBridge) return false;
|
||||
if (d.needsSandboxShim && !d.urlSandboxGuard) return false;
|
||||
if (d.needsFocusGuard && !d.urlFocusGuard) return false;
|
||||
// A self-redirecting document needs the redirect-loop guard on whichever
|
||||
@@ -255,6 +258,10 @@ export function htmlNeedsPoweredPreview(source: string | null | undefined): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
export function htmlNeedsLocalInteractionBridge(source: string): boolean {
|
||||
return /<form\b/i.test(source) || /\bhref\s*=\s*["']#(?:\/|!)/i.test(source);
|
||||
}
|
||||
|
||||
export function htmlNeedsSandboxShim(source: string): boolean {
|
||||
// Quote-optional: HTML5 permits unquoted attribute values
|
||||
// (`<script type=text/babel src=app.jsx>`). The trailing `\b` rejects
|
||||
|
||||
@@ -36,3 +36,38 @@
|
||||
background: color-mix(in srgb, var(--bg-subtle) 78%, var(--bg-panel));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.picker[data-type] { padding-inline: 12px; }
|
||||
.picker[data-type] :global(.home-hero__template-trigger) { cursor: default; gap: 8px; }
|
||||
.picker .switcher { gap: 7px; min-width: 0; }
|
||||
.picker .clear {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
/* Keep the 24px click target while giving its 18px glyph the same visual
|
||||
slot and spacing as the type entry buttons. */
|
||||
margin-inline: -3px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--brand-ink);
|
||||
box-shadow: none;
|
||||
}
|
||||
.picker .clear:hover:not(:disabled) { background: color-mix(in srgb, var(--brand-ink) 8%, transparent); }
|
||||
.picker .clear:disabled { background: transparent; opacity: 0.5; }
|
||||
.typeGlyph, .clearGlyph {
|
||||
transition: opacity 140ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.clearGlyph { position: absolute; opacity: 0; }
|
||||
.picker:hover .clear:not(:disabled) .typeGlyph,
|
||||
.picker:focus-within .clear:not(:disabled) .typeGlyph { opacity: 0; }
|
||||
.picker:hover .clear:not(:disabled) .clearGlyph,
|
||||
.picker:focus-within .clear:not(:disabled) .clearGlyph { opacity: 1; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.typeGlyph, .clearGlyph { transition: none; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Selected creation type with category-switch controls.
|
||||
// Selected creation type can be cleared; the unselected dock offers a picker.
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { Button } from '@open-design/components';
|
||||
import type { HomeHeroChip } from './chips';
|
||||
import { Icon } from '../Icon';
|
||||
import { useT } from '../../i18n';
|
||||
@@ -10,6 +11,7 @@ interface Props {
|
||||
templates: HomeHeroChip[];
|
||||
activeChipId: string | null;
|
||||
onPick?: (chip: HomeHeroChip) => void;
|
||||
onClear?: () => void;
|
||||
disabled?: boolean;
|
||||
// Localized label for a chip id (reuses HomeHero's chip copy).
|
||||
labelFor: (chipId: string) => string;
|
||||
@@ -19,6 +21,7 @@ export function TemplatePicker({
|
||||
templates,
|
||||
activeChipId,
|
||||
onPick,
|
||||
onClear,
|
||||
disabled = false,
|
||||
labelFor,
|
||||
}: Props) {
|
||||
@@ -66,20 +69,29 @@ export function TemplatePicker({
|
||||
<div
|
||||
className="home-hero__footer-select-trigger home-hero__template-trigger"
|
||||
data-testid="home-hero-template-trigger"
|
||||
title={t('homeHero.templatePicker.label')}
|
||||
>
|
||||
{active ? <span className="home-hero__footer-option-icon home-hero__footer-option-icon--compact" aria-hidden="true">
|
||||
<Icon name={active.icon} size={16} className="home-hero__template-icon-glyph" />
|
||||
</span> : null}
|
||||
<button type="button" ref={triggerRef} className={styles.switcher}
|
||||
{active ? <>
|
||||
{onClear ? <Button
|
||||
className={styles.clear}
|
||||
aria-label={t('chat.removeAria', { name: valueLabel })}
|
||||
title={t('chat.removeAria', { name: valueLabel })}
|
||||
data-testid="home-hero-template-clear"
|
||||
disabled={disabled}
|
||||
onClick={() => { setOpen(false); onClear(); }}
|
||||
>
|
||||
<Icon name={active.icon} size={18} className={styles.typeGlyph} />
|
||||
<Icon name="close" size={18} className={styles.clearGlyph} />
|
||||
</Button> : <Icon name={active.icon} size={18} />}
|
||||
<span className="home-hero__footer-select-label">{valueLabel}</span>
|
||||
</> : <button type="button" ref={triggerRef} className={styles.switcher}
|
||||
aria-label={t('homeHero.templatePicker.label')} aria-haspopup="listbox"
|
||||
aria-expanded={open} aria-controls={open ? menuId : undefined} disabled={disabled}
|
||||
onClick={() => setOpen((value) => !value)}>
|
||||
<span className="home-hero__footer-select-label">{valueLabel}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="6 6 12 12" fill="currentColor" aria-hidden="true"><path d="M12 15.0006L7.75732 10.758L9.17154 9.34375L12 12.1722L14.8284 9.34375L16.2426 10.758L12 15.0006Z" /></svg>
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
{open ? <div id={menuId} role="listbox" aria-label={t('homeHero.templatePicker.label')}
|
||||
{open && !active ? <div id={menuId} role="listbox" aria-label={t('homeHero.templatePicker.label')}
|
||||
className="home-hero__footer-select-menu" data-testid="home-hero-template-menu">
|
||||
{templates.map((chip) => <button key={chip.id} type="button" role="option" data-chip={chip.id}
|
||||
aria-selected={chip.id === activeChipId}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
.row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 820px;
|
||||
margin: 18px auto 2px;
|
||||
}
|
||||
.pill, .menuItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
box-shadow: none;
|
||||
}
|
||||
.pill:hover:not(:disabled), .menuItem:hover:not(:disabled), .menuItem:focus-visible:not(:disabled) {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.menuItem:focus-visible:not(:disabled) { outline: none; }
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
}
|
||||
.more { padding-inline: 15px; gap: 10px; }
|
||||
.moreAnchor { position: relative; display: flex; }
|
||||
.menu {
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
top: calc(100% + 8px);
|
||||
inset-inline-end: 0;
|
||||
/* More can wrap onto a centered row. Keep the menu within a 16px viewport
|
||||
gutter while preserving its alignment with the trigger's end edge. */
|
||||
width: min(220px, calc(50vw + 50% - 16px));
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.menu[data-side='top'] { top: auto; bottom: calc(100% + 8px); }
|
||||
.menuList {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-auto-rows: 42px;
|
||||
gap: 4px;
|
||||
/* Four full rows, four gaps and half of the fifth row hint at more types. */
|
||||
max-height: 205px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.menuList::-webkit-scrollbar { display: none; }
|
||||
.menuItem { height: 42px; min-height: 42px; border: 0; border-radius: 8px; justify-content: flex-start; padding: 7px 12px; }
|
||||
@media (max-width: 480px) {
|
||||
.row { gap: 6px; }
|
||||
.pill { padding-inline: 10px; }
|
||||
.more { padding-inline: 12px; }
|
||||
}
|
||||
@@ -1,24 +1,21 @@
|
||||
// Capsule type row — the pill replacement for the fanned type carousel.
|
||||
// ONE line exactly as wide as the composer card below it, EVERY gap identical
|
||||
// (8px). The row's membership is a fixed product decision rather than a width
|
||||
// computation (2026-08-31): `HOME_TYPE_ROW_IDS` render inline, the 更多 button
|
||||
// follows, and `HOME_TYPE_ROW_MORE_IDS` live in its popover. A narrow viewport
|
||||
// can still push inline pills into the popover — fit is computed against an
|
||||
// invisible probe row that always lays out the full inline set at natural
|
||||
// size, so showing / hiding pills can never feed back into its own
|
||||
// measurement. Selection state stays with the caller via `onPick`.
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
// Unselected Home entry: three fixed types, with all remaining types in More.
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@open-design/components';
|
||||
import type { HomeHeroChip } from './chips';
|
||||
import { HOME_TYPE_ROW_IDS, HOME_TYPE_ROW_MORE_IDS } from './chips';
|
||||
import { Icon } from '../Icon';
|
||||
import { useT } from '../../i18n';
|
||||
import { smoothScrollTo } from '../../utils/smoothScrollToTop';
|
||||
import styles from './TypePillRow.module.css';
|
||||
|
||||
// Must match .home-hero__type-pills-wrap's CSS gap.
|
||||
const PILL_GAP = 8;
|
||||
const MENU_LIST_HEIGHT = 205; // Four 42px rows, four 4px gaps and half a row.
|
||||
const MENU_FRAME_HEIGHT = 18; // Two 8px paddings and two 1px borders.
|
||||
const MENU_GAP = 8;
|
||||
const VIEWPORT_GUTTER = 16;
|
||||
const KEYBOARD_SCROLL_MS = 120;
|
||||
|
||||
interface Props {
|
||||
chips: HomeHeroChip[];
|
||||
/** Currently selected chip id (null → nothing selected yet). */
|
||||
activeChipId: string | null;
|
||||
disabled?: boolean;
|
||||
labelFor: (chipId: string) => string;
|
||||
@@ -27,159 +24,152 @@ interface Props {
|
||||
|
||||
export function TypePillRow({ chips, activeChipId, disabled, labelFor, onPick }: Props) {
|
||||
const t = useT();
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const probeRef = useRef<HTMLDivElement | null>(null);
|
||||
const tailRef = useRef<HTMLDivElement | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const pendingFocusRef = useRef<'first' | 'last' | null>(null);
|
||||
const cancelScrollRef = useRef<(() => void) | null>(null);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
// Two fixed sets, ordered by the product lists rather than by catalog order.
|
||||
const byId = (ids: readonly string[]) =>
|
||||
ids
|
||||
.map((id) => chips.find((chip) => chip.id === id))
|
||||
.filter((chip): chip is HomeHeroChip => Boolean(chip));
|
||||
const flowing = byId(HOME_TYPE_ROW_IDS);
|
||||
const [placement, setPlacement] = useState({ side: 'bottom', maxHeight: MENU_LIST_HEIGHT });
|
||||
const menuId = useId();
|
||||
const byId = (ids: readonly string[]) => ids
|
||||
.map((id) => chips.find((chip) => chip.id === id))
|
||||
.filter((chip): chip is HomeHeroChip => Boolean(chip));
|
||||
const inlineChips = byId(HOME_TYPE_ROW_IDS);
|
||||
const moreChips = byId(HOME_TYPE_ROW_MORE_IDS);
|
||||
const measurementSignature = [
|
||||
...flowing.map((chip) => `${chip.id}:${labelFor(chip.id)}`),
|
||||
`more:${t('homeHero.subTypeMore')}`,
|
||||
].join('\0');
|
||||
// How many inline pills fit; anything squeezed out joins the 更多 popover.
|
||||
const [fitCount, setFitCount] = useState(flowing.length);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const wrap = wrapRef.current;
|
||||
const probe = probeRef.current;
|
||||
const tail = tailRef.current;
|
||||
if (!wrap || !probe || !tail) return undefined;
|
||||
const measure = () => {
|
||||
// jsdom (unit tests) lays out nothing — keep every pill "fitting"
|
||||
// there rather than collapsing the row to zero.
|
||||
if (wrap.clientWidth === 0) {
|
||||
setFitCount(flowing.length);
|
||||
return;
|
||||
}
|
||||
// The tail (the 更多 trigger) is part of the visible row, so its width —
|
||||
// including its own leading gap — is reserved up front.
|
||||
const available = wrap.clientWidth - tail.offsetWidth - PILL_GAP;
|
||||
let used = 0;
|
||||
let count = 0;
|
||||
for (const el of probe.children) {
|
||||
const width = (el as HTMLElement).offsetWidth;
|
||||
const next = used + (count > 0 ? PILL_GAP : 0) + width;
|
||||
if (next > available) break;
|
||||
used = next;
|
||||
count += 1;
|
||||
}
|
||||
setFitCount((prev) => (prev === count ? prev : count));
|
||||
if (!moreOpen) return;
|
||||
const updatePlacement = () => {
|
||||
const trigger = triggerRef.current;
|
||||
const list = listRef.current;
|
||||
if (!trigger || !list) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const above = Math.max(0, rect.top - MENU_GAP - VIEWPORT_GUTTER);
|
||||
const below = Math.max(0, window.innerHeight - rect.bottom - MENU_GAP - VIEWPORT_GUTTER);
|
||||
const desiredHeight = Math.min(MENU_LIST_HEIGHT, list.scrollHeight) + MENU_FRAME_HEIGHT;
|
||||
const side = below < desiredHeight && above > below ? 'top' : 'bottom';
|
||||
const maxHeight = Math.max(0, Math.min(MENU_LIST_HEIGHT,
|
||||
(side === 'top' ? above : below) - MENU_FRAME_HEIGHT));
|
||||
setPlacement((current) => current.side === side && current.maxHeight === maxHeight
|
||||
? current : { side, maxHeight });
|
||||
};
|
||||
measure();
|
||||
// jsdom has no ResizeObserver; the initial measure above still ran.
|
||||
if (typeof ResizeObserver === 'undefined') return undefined;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(wrap);
|
||||
observer.observe(tail);
|
||||
for (const child of probe.children) observer.observe(child);
|
||||
return () => observer.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flowing.length, measurementSignature]);
|
||||
|
||||
// Dismiss the popover on outside press / Escape.
|
||||
useEffect(() => {
|
||||
if (!moreOpen) return undefined;
|
||||
const onPointer = (event: MouseEvent) => {
|
||||
if (wrapRef.current?.contains(event.target as Node)) return;
|
||||
setMoreOpen(false);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMoreOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
updatePlacement();
|
||||
window.addEventListener('resize', updatePlacement);
|
||||
window.addEventListener('scroll', updatePlacement, true);
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updatePlacement);
|
||||
if (rootRef.current) observer?.observe(rootRef.current);
|
||||
if (triggerRef.current) observer?.observe(triggerRef.current);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('resize', updatePlacement);
|
||||
window.removeEventListener('scroll', updatePlacement, true);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [moreOpen, moreChips.length]);
|
||||
|
||||
function focusItem(item: HTMLButtonElement | undefined) {
|
||||
const list = listRef.current;
|
||||
if (!item || !list) return;
|
||||
cancelScrollRef.current?.();
|
||||
cancelScrollRef.current = null;
|
||||
// Native focus scrolling may center off-screen items and jump several
|
||||
// rows. Keep the page still and reveal only the clipped part of this row.
|
||||
item.focus({ preventScroll: true });
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
const top = list.getBoundingClientRect().top + list.clientTop;
|
||||
const bottom = top + list.clientHeight;
|
||||
const delta = itemRect.top < top ? itemRect.top - top
|
||||
: itemRect.bottom > bottom ? itemRect.bottom - bottom : 0;
|
||||
if (delta !== 0) {
|
||||
const target = Math.max(0, Math.min(list.scrollHeight - list.clientHeight, list.scrollTop + delta));
|
||||
cancelScrollRef.current = smoothScrollTo(list, target, KEYBOARD_SCROLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function focusMenuItem(edge: 'first' | 'last') {
|
||||
const items = listRef.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)');
|
||||
focusItem(items?.[edge === 'first' ? 0 : items.length - 1]);
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (moreOpen && pendingFocusRef.current) focusMenuItem(pendingFocusRef.current);
|
||||
pendingFocusRef.current = null;
|
||||
return () => {
|
||||
cancelScrollRef.current?.();
|
||||
cancelScrollRef.current = null;
|
||||
};
|
||||
}, [moreOpen]);
|
||||
|
||||
if (flowing.length === 0 && moreChips.length === 0) return null;
|
||||
useEffect(() => {
|
||||
if (!moreOpen) return;
|
||||
const dismiss = (event: PointerEvent) => {
|
||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setMoreOpen(false);
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') { setMoreOpen(false); triggerRef.current?.focus(); }
|
||||
};
|
||||
document.addEventListener('pointerdown', dismiss);
|
||||
document.addEventListener('keydown', escape);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', dismiss);
|
||||
document.removeEventListener('keydown', escape);
|
||||
};
|
||||
}, [moreOpen]);
|
||||
if (disabled && moreOpen) setMoreOpen(false);
|
||||
|
||||
const inlineChips = flowing.slice(0, fitCount);
|
||||
// 更多 always holds its own fixed set; pills squeezed out by a narrow
|
||||
// viewport join them at the front so nothing becomes unreachable.
|
||||
const popoverChips = [...flowing.slice(fitCount), ...moreChips];
|
||||
|
||||
const pillButton = (chip: HomeHeroChip, inPopover: boolean) => {
|
||||
const isActive = chip.id === activeChipId;
|
||||
return (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
className={`home-hero__type-pill${isActive ? ' is-active' : ''}`}
|
||||
disabled={disabled}
|
||||
data-testid={`home-hero-type-pill-${chip.id}${inPopover ? '-more' : ''}`}
|
||||
// Selector hook for tests and measurement — the stylesheet keys no
|
||||
// colour on it (OPEND-3103: every type chip is neutral).
|
||||
data-chip={chip.id}
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
if (chip.id !== activeChipId) onPick(chip);
|
||||
}}
|
||||
>
|
||||
<Icon name={chip.icon} size={14} />
|
||||
<span>{labelFor(chip.id)}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
const pill = (chip: HomeHeroChip, inMenu = false) => (
|
||||
<Button key={chip.id}
|
||||
className={inMenu ? styles.menuItem : styles.pill}
|
||||
role={inMenu ? 'option' : undefined}
|
||||
aria-selected={inMenu ? chip.id === activeChipId : undefined}
|
||||
disabled={disabled}
|
||||
data-chip={chip.id}
|
||||
data-testid={`home-hero-type-pill-${chip.id}${inMenu ? '-more' : ''}`}
|
||||
onClick={() => { setMoreOpen(false); if (chip.id !== activeChipId) onPick(chip); }}>
|
||||
<span className={styles.icon}><Icon name={chip.icon} size={18} /></span>
|
||||
<span>{labelFor(chip.id)}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="home-hero__type-pills-wrap"
|
||||
ref={wrapRef}
|
||||
role="listbox"
|
||||
aria-label="type"
|
||||
data-testid="home-hero-type-pills"
|
||||
>
|
||||
{inlineChips.map((chip) => pillButton(chip, false))}
|
||||
{/* Tail: the 更多 trigger, one measured unit so the fit computation
|
||||
reserves its width. The trigger is ALWAYS mounted — geometry that
|
||||
came and went with the overflow set would oscillate the measurement
|
||||
at boundary widths. */}
|
||||
<div className="home-hero__type-pills-tail" ref={tailRef}>
|
||||
<div className="home-hero__type-pills-more">
|
||||
<button
|
||||
type="button"
|
||||
className={`home-hero__type-pill home-hero__type-pills-more-btn${moreOpen ? ' is-open' : ''}`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={moreOpen}
|
||||
disabled={disabled}
|
||||
data-testid="home-hero-type-pills-more"
|
||||
onClick={() => setMoreOpen((v) => !v)}
|
||||
>
|
||||
<span>{t('homeHero.subTypeMore')}</span>
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
{moreOpen ? (
|
||||
<div
|
||||
className="home-hero__type-pills-popover"
|
||||
role="listbox"
|
||||
aria-label={t('homeHero.subTypeMore')}
|
||||
data-testid="home-hero-type-pills-popover"
|
||||
>
|
||||
{popoverChips.map((chip) => pillButton(chip, true))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Invisible probe: the FULL flowing set at natural size, used only to
|
||||
measure pill widths — never interactive, never painted. */}
|
||||
<div className="home-hero__type-pills-probe" ref={probeRef} aria-hidden>
|
||||
{flowing.map((chip) => (
|
||||
<span key={chip.id} className="home-hero__type-pill" data-chip={chip.id}>
|
||||
<Icon name={chip.icon} size={14} />
|
||||
<span>{labelFor(chip.id)}</span>
|
||||
</span>
|
||||
))}
|
||||
<div ref={rootRef} className={styles.row} role="group"
|
||||
aria-label={t('homeHero.templatePicker.label')} data-testid="home-hero-type-pills">
|
||||
{inlineChips.map((chip) => pill(chip))}
|
||||
<div className={styles.moreAnchor}>
|
||||
<Button ref={triggerRef} className={`${styles.pill} ${styles.more}`}
|
||||
aria-haspopup="listbox" aria-expanded={moreOpen}
|
||||
aria-controls={moreOpen ? menuId : undefined} disabled={disabled}
|
||||
data-testid="home-hero-type-pills-more"
|
||||
onKeyDown={(event) => {
|
||||
if (disabled || !['ArrowDown', 'ArrowUp'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const edge = event.key === 'ArrowDown' ? 'first' : 'last';
|
||||
if (moreOpen) focusMenuItem(edge);
|
||||
else {
|
||||
pendingFocusRef.current = edge;
|
||||
setMoreOpen(true);
|
||||
}
|
||||
}}
|
||||
onClick={(event) => {
|
||||
pendingFocusRef.current = !moreOpen && event.detail === 0 ? 'first' : null;
|
||||
setMoreOpen((value) => !value);
|
||||
}}>
|
||||
{t('homeHero.subTypeMore')}<Icon name="chevron-down" size={14} />
|
||||
</Button>
|
||||
{moreOpen ? <div className={styles.menu} data-side={placement.side} data-testid="home-hero-type-pills-popover">
|
||||
<div ref={listRef} id={menuId} className={styles.menuList} role="listbox"
|
||||
style={{ maxHeight: placement.maxHeight }}
|
||||
aria-label={t('homeHero.subTypeMore')}
|
||||
onKeyDown={(event) => {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button'));
|
||||
const index = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
|
||||
: Math.max(0, Math.min(items.length - 1, index + (event.key === 'ArrowDown' ? 1 : -1)));
|
||||
if (items[next] !== document.activeElement) focusItem(items[next]);
|
||||
}}>{moreChips.map((chip) => pill(chip, true))}</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -410,10 +410,10 @@ export { CREATION_TYPE_ORDER as CREATE_RAIL_ORDER } from '../creation-type-order
|
||||
// other create type in this exact order, so no artifact kind loses its
|
||||
// discoverable entry to the fold. The two lists together cover
|
||||
// `CREATE_RAIL_ORDER`; `TypePillRow.more-order.test.tsx` pins both.
|
||||
export const HOME_TYPE_ROW_IDS: readonly string[] = ['prototype', 'deck', 'document'];
|
||||
export const HOME_TYPE_ROW_IDS: readonly string[] = ['prototype', 'motion-design', 'deck'];
|
||||
export const HOME_TYPE_ROW_MORE_IDS: readonly string[] = [
|
||||
'image',
|
||||
'motion-design',
|
||||
'document',
|
||||
'hyperframes',
|
||||
'web-clone',
|
||||
'video',
|
||||
|
||||
@@ -982,6 +982,7 @@ export const ar: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'تفاصيل الإضافة',
|
||||
'workspaceTabs.marketplace': 'المتجر',
|
||||
'homeHero.title': 'لننشئ {word}',
|
||||
"homeHero.titleUnselected": "ماذا تريد أن تنشئ؟",
|
||||
'homeHero.titleWords': 'نماذج أولية,عروضًا تقديمية,مستندات',
|
||||
'homeHero.subtitle': 'العمل الجيد يبدأ من هنا',
|
||||
'homeHero.startWithTemplate': 'ابدأ بقالب…',
|
||||
@@ -5496,7 +5497,7 @@ export const ar: Dict = {
|
||||
'labs.title': "مختبر Open Design",
|
||||
'labs.itemAbout': "حول {name}",
|
||||
'labs.navHint': "جرّب الإمكانات قبل إطلاقها.",
|
||||
'labs.pageDesc': "احصل على وصول مبكر إلى إمكانات لم تُطلق رسمياً بعد. غالباً ما تكون أفضل من السلوك الحالي، وإيقافها يعيد الوضع الحالي فوراً.",
|
||||
'labs.pageDesc': "استكشف الميزات التجريبية. قد تتغير الميزات ومدى توفرها بين الإصدارات.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "عند التفعيل، ستستخدم عملية التوليد التالية أحدث استراتيجية من OpenDesign بنتائج أكثر إتقاناً بوضوح (تجريبي)",
|
||||
'labs.harnessTooltip': "يبدّل إلى agent harness الذي بناه Open Design لأعمال التصميم. يتحسن التخطيط والبنية ومستوى الاكتمال في المخرجات بشكل ملحوظ.",
|
||||
|
||||
@@ -982,6 +982,7 @@ export const de: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'Plugin-Details',
|
||||
'workspaceTabs.marketplace': 'Marketplace',
|
||||
'homeHero.title': 'Lass uns {word} erstellen',
|
||||
"homeHero.titleUnselected": "Was möchtest du erstellen?",
|
||||
'homeHero.titleWords': 'Prototypen,Präsentationen,Dokumente',
|
||||
'homeHero.subtitle': 'Gute Arbeit beginnt hier',
|
||||
'homeHero.startWithTemplate': 'Mit einer Vorlage beginnen…',
|
||||
@@ -5496,7 +5497,7 @@ export const de: Dict = {
|
||||
'labs.title': "Open Design Labs",
|
||||
'labs.itemAbout': "Über {name}",
|
||||
'labs.navHint': "Funktionen testen, bevor sie erscheinen.",
|
||||
'labs.pageDesc': "Teste Funktionen, die noch nicht offiziell verfügbar sind. Sie sind meist besser als das aktuelle Verhalten, und ein Ausschalten stellt den bisherigen Stand sofort wieder her.",
|
||||
'labs.pageDesc': "Entdecke experimentelle Funktionen. Funktionen und Verfügbarkeit können sich mit neuen Versionen ändern.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "Aktiviert nutzt die nächste Generierung die neueste Strategie von OpenDesign — merklich feinere Ergebnisse (Beta)",
|
||||
'labs.harnessTooltip': "Wechselt zu dem Agent-Harness, das Open Design für Designarbeit gebaut hat. Layout, Struktur und Vollständigkeit der Ergebnisse werden spürbar besser.",
|
||||
|
||||
@@ -982,6 +982,7 @@ export const en: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'Plugin details',
|
||||
'workspaceTabs.marketplace': 'Marketplace',
|
||||
'homeHero.title': 'Let’s create {word}',
|
||||
"homeHero.titleUnselected": "What would you like to create?",
|
||||
'homeHero.titleWords': 'prototypes,slide decks,documents',
|
||||
'homeHero.subtitle': 'Good work starts here',
|
||||
'homeHero.startWithTemplate': 'Start with a template…',
|
||||
@@ -5511,7 +5512,7 @@ export const en: Dict = {
|
||||
'labs.title': "Open Design Labs",
|
||||
'labs.itemAbout': "About {name}",
|
||||
'labs.navHint': "Try capabilities before they ship.",
|
||||
'labs.pageDesc': "Get early access to capabilities that have not shipped yet. They are usually better than what you have today, and turning one off restores the current behaviour right away.",
|
||||
'labs.pageDesc': "Explore experimental features. Features and availability may change between versions.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "Your next generation will use OpenDesign's latest strategy, with noticeably more polished results (beta)",
|
||||
'labs.harnessTooltip': "Switches to the agent harness Open Design built for design work. Output is noticeably better in layout, structure, and completeness.",
|
||||
|
||||
@@ -982,6 +982,7 @@ export const esES: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'Detalles del plugin',
|
||||
'workspaceTabs.marketplace': 'Marketplace',
|
||||
'homeHero.title': 'Vamos a crear {word}',
|
||||
"homeHero.titleUnselected": "¿Qué te gustaría crear?",
|
||||
'homeHero.titleWords': 'prototipos,presentaciones,documentos',
|
||||
'homeHero.subtitle': 'El buen trabajo empieza aquí',
|
||||
'homeHero.startWithTemplate': 'Empezar con una plantilla…',
|
||||
@@ -5496,7 +5497,7 @@ export const esES: Dict = {
|
||||
'labs.title': "Open Design Labs",
|
||||
'labs.itemAbout': "Acerca de {name}",
|
||||
'labs.navHint': "Prueba funciones antes de su lanzamiento.",
|
||||
'labs.pageDesc': "Accede antes que nadie a funciones que aún no se han lanzado. Suelen ser mejores que el comportamiento actual y, al desactivarlas, todo vuelve al estado actual de inmediato.",
|
||||
'labs.pageDesc': "Explora funciones experimentales. Las funciones y su disponibilidad pueden cambiar entre versiones.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "Al activarlo, la próxima generación usará la última estrategia de OpenDesign, con resultados mucho más cuidados (beta)",
|
||||
'labs.harnessTooltip': "Cambia al harness de agente que Open Design creó para trabajo de diseño. La maquetación, la estructura y el nivel de acabado mejoran de forma notable.",
|
||||
|
||||
@@ -982,6 +982,7 @@ export const fa: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'جزئیات افزونه',
|
||||
'workspaceTabs.marketplace': 'بازارچه',
|
||||
'homeHero.title': 'بیایید {word} بسازیم',
|
||||
"homeHero.titleUnselected": "چه چیزی میخواهید بسازید؟",
|
||||
'homeHero.titleWords': 'نمونههای اولیه,اسلایدها,اسناد',
|
||||
'homeHero.subtitle': 'کار خوب از اینجا آغاز میشود',
|
||||
'homeHero.startWithTemplate': 'با یک قالب شروع کنید…',
|
||||
@@ -5491,7 +5492,7 @@ export const fa: Dict = {
|
||||
'labs.title': "آزمایشگاه Open Design",
|
||||
'labs.itemAbout': "درباره {name}",
|
||||
'labs.navHint': "قابلیتها را پیش از انتشار امتحان کنید.",
|
||||
'labs.pageDesc': "به قابلیتهایی که هنوز رسماً منتشر نشدهاند زودتر دسترسی پیدا کنید. معمولاً از رفتار فعلی بهترند و با خاموش کردن، وضعیت فعلی بلافاصله برمیگردد.",
|
||||
'labs.pageDesc': "قابلیتهای آزمایشی را تجربه کنید. قابلیتها و دسترسی به آنها ممکن است در نسخههای مختلف تغییر کند.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "با فعالسازی، تولید بعدی از جدیدترین استراتژی OpenDesign استفاده میکند و خروجی محسوساً پرداختهتر است (بتا)",
|
||||
'labs.harnessTooltip': "به agent harness ای که Open Design برای کارهای طراحی ساخته سوئیچ میکند. چیدمان، ساختار و کامل بودن خروجی بهطور محسوس بهتر میشود.",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const fr: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'Détails du plugin',
|
||||
'workspaceTabs.marketplace': 'Place de marché',
|
||||
'homeHero.title': 'Créons {word}',
|
||||
"homeHero.titleUnselected": "Que souhaitez-vous créer ?",
|
||||
'homeHero.titleWords': 'des prototypes,des présentations,des documents',
|
||||
'homeHero.subtitle': 'Le beau travail commence ici',
|
||||
'homeHero.startWithTemplate': 'Commencer avec un modèle…',
|
||||
@@ -5487,7 +5488,7 @@ export const fr: Dict = {
|
||||
'labs.title': "Open Design Labs",
|
||||
'labs.itemAbout': "À propos de {name}",
|
||||
'labs.navHint': "Essayez des fonctionnalités avant leur sortie.",
|
||||
'labs.pageDesc': "Accédez en avant-première à des fonctionnalités qui ne sont pas encore publiées. Elles sont en général meilleures que le comportement actuel, et les désactiver rétablit immédiatement l état actuel.",
|
||||
'labs.pageDesc': "Découvrez les fonctionnalités expérimentales. Les fonctionnalités et leur disponibilité peuvent varier selon les versions.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "Une fois activé, la prochaine génération utilise la dernière stratégie d'OpenDesign, pour un rendu nettement plus soigné (bêta)",
|
||||
'labs.harnessTooltip': "Bascule vers le harness d agent conçu par Open Design pour le design. La mise en page, la structure et le niveau de finition des productions sont nettement meilleurs.",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const hu: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'Plugin részletei',
|
||||
'workspaceTabs.marketplace': 'Marketplace',
|
||||
'homeHero.title': 'Készítsünk {word}',
|
||||
"homeHero.titleUnselected": "Mit szeretnél létrehozni?",
|
||||
'homeHero.titleWords': 'prototípusokat,prezentációkat,dokumentumokat',
|
||||
'homeHero.subtitle': 'A jó munka itt kezdődik',
|
||||
'homeHero.startWithTemplate': 'Kezdés sablonnal…',
|
||||
@@ -5487,7 +5488,7 @@ export const hu: Dict = {
|
||||
'labs.title': "Open Design Labs",
|
||||
'labs.itemAbout': "{name} névjegye",
|
||||
'labs.navHint': "Próbáld ki a funkciókat a megjelenés előtt.",
|
||||
'labs.pageDesc': "Korai hozzáférés olyan képességekhez, amelyek még nem jelentek meg. Általában jobbak a jelenlegi működésnél, és kikapcsolva azonnal visszaáll a mostani állapot.",
|
||||
'labs.pageDesc': "Fedezd fel a kísérleti funkciókat. A funkciók és elérhetőségük verziónként változhat.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "Bekapcsolva a következő generálás az OpenDesign legújabb stratégiáját használja, érezhetően igényesebb eredménnyel (béta)",
|
||||
'labs.harnessTooltip': "Átvált arra az agent harness re, amelyet az Open Design tervezői munkához épített. Az elrendezés, a szerkezet és a kidolgozottság érezhetően jobb lesz.",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const id: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'Detail plugin',
|
||||
'workspaceTabs.marketplace': 'Marketplace',
|
||||
'homeHero.title': 'Mari membuat {word}',
|
||||
"homeHero.titleUnselected": "Apa yang ingin Anda buat?",
|
||||
'homeHero.titleWords': 'prototipe,presentasi,dokumen',
|
||||
'homeHero.subtitle': 'Karya yang baik dimulai di sini',
|
||||
'homeHero.startWithTemplate': 'Mulai dengan templat…',
|
||||
@@ -5487,7 +5488,7 @@ export const id: Dict = {
|
||||
'labs.title': "Open Design Labs",
|
||||
'labs.itemAbout': "Tentang {name}",
|
||||
'labs.navHint': "Coba fitur sebelum dirilis.",
|
||||
'labs.pageDesc': "Dapatkan akses awal ke kemampuan yang belum dirilis. Umumnya lebih baik dari perilaku saat ini, dan mematikannya langsung mengembalikan keadaan sekarang.",
|
||||
'labs.pageDesc': "Jelajahi fitur eksperimental. Fitur dan ketersediaannya dapat berubah antarversi.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "Setelah diaktifkan, generasi berikutnya memakai strategi terbaru OpenDesign dengan hasil yang jauh lebih rapi (beta)",
|
||||
'labs.harnessTooltip': "Beralih ke agent harness yang dibangun Open Design untuk pekerjaan desain. Tata letak, struktur, dan kelengkapan hasil terasa jauh lebih baik.",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const it: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'Dettagli del plugin',
|
||||
'workspaceTabs.marketplace': 'Marketplace',
|
||||
'homeHero.title': 'Creiamo {word}',
|
||||
"homeHero.titleUnselected": "Cosa vorresti creare?",
|
||||
'homeHero.titleWords': 'prototipi,presentazioni,documenti',
|
||||
'homeHero.subtitle': 'Il bel lavoro inizia qui',
|
||||
'homeHero.startWithTemplate': 'Inizia con un modello…',
|
||||
@@ -5487,7 +5488,7 @@ export const it: Dict = {
|
||||
'labs.title': "Open Design Labs",
|
||||
'labs.itemAbout': "Informazioni su {name}",
|
||||
'labs.navHint': "Prova le funzionalità prima del rilascio.",
|
||||
'labs.pageDesc': "Accedi in anteprima a funzionalità non ancora rilasciate. Di solito sono migliori del comportamento attuale e disattivarle ripristina subito lo stato corrente.",
|
||||
'labs.pageDesc': "Esplora le funzionalità sperimentali. Le funzionalità e la loro disponibilità possono cambiare tra le versioni.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "Se attivo, la prossima generazione usa la strategia più recente di OpenDesign, con risultati molto più curati (beta)",
|
||||
'labs.harnessTooltip': "Passa all harness dell agente che Open Design ha creato per il lavoro di design. Impaginazione, struttura e livello di completezza migliorano in modo evidente.",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const ja: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'プラグインの詳細',
|
||||
'workspaceTabs.marketplace': 'マーケットプレイス',
|
||||
'homeHero.title': '{word}をつくろう',
|
||||
"homeHero.titleUnselected": "何を作りたいですか?",
|
||||
'homeHero.titleWords': 'プロトタイプ,スライド,ドキュメント',
|
||||
'homeHero.subtitle': '良い作品は、ここから始まる',
|
||||
'homeHero.startWithTemplate': 'テンプレートから始める…',
|
||||
@@ -5487,7 +5488,7 @@ export const ja: Dict = {
|
||||
'labs.title': "Open Design ラボ",
|
||||
'labs.itemAbout': "{name} について",
|
||||
'labs.navHint': "未リリースの機能をひと足先に。",
|
||||
'labs.pageDesc': "まだ正式リリースされていない機能をひと足先に試せます。多くの場合は現在の挙動より優れており、オフにすればすぐに元の状態へ戻ります。",
|
||||
'labs.pageDesc': "試験的な機能を体験できます。機能と利用可能な範囲はバージョンによって変わる場合があります。",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "オンにすると、次回の生成から OpenDesign の最新生成戦略を使用します。仕上がりがより洗練されます(ベータ)",
|
||||
'labs.harnessTooltip': "Open Design がデザイン用途に構築した agent harness に切り替えます。レイアウト・構成・完成度が目に見えて良くなります。",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const ko: Dict = {
|
||||
'workspaceTabs.pluginDetails': '플러그인 세부 정보',
|
||||
'workspaceTabs.marketplace': '마켓플레이스',
|
||||
'homeHero.title': '함께 {word} 만들기',
|
||||
"homeHero.titleUnselected": "무엇을 만들고 싶으신가요?",
|
||||
'homeHero.titleWords': '프로토타입,슬라이드,문서',
|
||||
'homeHero.subtitle': '좋은 작업은 여기서 시작됩니다',
|
||||
'homeHero.startWithTemplate': '템플릿으로 시작하기…',
|
||||
@@ -5487,7 +5488,7 @@ export const ko: Dict = {
|
||||
'labs.title': "Open Design 랩",
|
||||
'labs.itemAbout': "{name} 정보",
|
||||
'labs.navHint': "아직 출시되지 않은 기능을 미리 사용해 보세요.",
|
||||
'labs.pageDesc': "아직 정식 출시되지 않은 기능을 먼저 사용해 볼 수 있습니다. 대체로 현재 동작보다 낫고, 언제든 꺼서 원래대로 되돌릴 수 있습니다.",
|
||||
'labs.pageDesc': "실험적인 기능을 체험하세요. 기능과 사용 가능 범위는 버전에 따라 달라질 수 있습니다.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "켜면 다음 생성부터 OpenDesign 최신 생성 전략을 사용해 결과물이 한층 정교해집니다 (베타)",
|
||||
'labs.harnessTooltip': "Open Design 이 디자인 작업을 위해 만든 agent harness 로 전환합니다. 레이아웃, 구조, 완성도가 눈에 띄게 좋아집니다.",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const pl: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'Szczegóły wtyczki',
|
||||
'workspaceTabs.marketplace': 'Marketplace',
|
||||
'homeHero.title': 'Stwórzmy {word}',
|
||||
"homeHero.titleUnselected": "Co chcesz stworzyć?",
|
||||
'homeHero.titleWords': 'prototypy,prezentacje,dokumenty',
|
||||
'homeHero.subtitle': 'Dobra praca zaczyna się tutaj',
|
||||
'homeHero.startWithTemplate': 'Zacznij od szablonu…',
|
||||
@@ -5487,7 +5488,7 @@ export const pl: Dict = {
|
||||
'labs.title': "Open Design Labs",
|
||||
'labs.itemAbout': "Informacje o {name}",
|
||||
'labs.navHint': "Wypróbuj funkcje przed premierą.",
|
||||
'labs.pageDesc': "Uzyskaj wcześniejszy dostęp do funkcji, które nie zostały jeszcze wydane. Zwykle są lepsze od obecnego działania, a wyłączenie natychmiast przywraca stan bieżący.",
|
||||
'labs.pageDesc': "Poznaj funkcje eksperymentalne. Funkcje i ich dostępność mogą się zmieniać między wersjami.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "Po włączeniu następna generacja użyje najnowszej strategii OpenDesign — wyraźnie staranniejsze efekty (beta)",
|
||||
'labs.harnessTooltip': "Przełącza na agent harness, który Open Design zbudował do pracy projektowej. Układ, struktura i kompletność wyników są zauważalnie lepsze.",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const ptBR: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'Detalhes do plugin',
|
||||
'workspaceTabs.marketplace': 'Marketplace',
|
||||
'homeHero.title': 'Vamos criar {word}',
|
||||
"homeHero.titleUnselected": "O que você gostaria de criar?",
|
||||
'homeHero.titleWords': 'protótipos,apresentações,documentos',
|
||||
'homeHero.subtitle': 'Um bom trabalho começa aqui',
|
||||
'homeHero.startWithTemplate': 'Começar com um modelo…',
|
||||
@@ -5487,7 +5488,7 @@ export const ptBR: Dict = {
|
||||
'labs.title': "Open Design Labs",
|
||||
'labs.itemAbout': "Sobre {name}",
|
||||
'labs.navHint': "Teste recursos antes do lançamento.",
|
||||
'labs.pageDesc': "Tenha acesso antecipado a recursos que ainda não foram lançados. Costumam ser melhores que o comportamento atual, e desligar restaura o estado atual na hora.",
|
||||
'labs.pageDesc': "Explore recursos experimentais. Os recursos e sua disponibilidade podem mudar entre versões.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "Ao ativar, a próxima geração usará a estratégia mais recente da OpenDesign, com resultados bem mais caprichados (beta)",
|
||||
'labs.harnessTooltip': "Passa a usar o harness de agente que a Open Design criou para trabalho de design. Layout, estrutura e nível de acabamento ficam visivelmente melhores.",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const ru: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'Сведения о плагине',
|
||||
'workspaceTabs.marketplace': 'Маркетплейс',
|
||||
'homeHero.title': 'Давайте создадим {word}',
|
||||
"homeHero.titleUnselected": "Что вы хотите создать?",
|
||||
'homeHero.titleWords': 'прототипы,презентации,документы',
|
||||
'homeHero.subtitle': 'Хорошая работа начинается здесь',
|
||||
'homeHero.startWithTemplate': 'Начать с шаблона…',
|
||||
@@ -5487,7 +5488,7 @@ export const ru: Dict = {
|
||||
'labs.title': "Open Design Labs",
|
||||
'labs.itemAbout': "О {name}",
|
||||
'labs.navHint': "Пробуйте возможности до релиза.",
|
||||
'labs.pageDesc': "Ранний доступ к возможностям, которые ещё не вышли. Обычно они лучше текущего поведения, а выключение сразу возвращает всё как было.",
|
||||
'labs.pageDesc': "Попробуйте экспериментальные функции. Функции и их доступность могут меняться от версии к версии.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "После включения следующая генерация использует новейшую стратегию OpenDesign — результат заметно аккуратнее (бета)",
|
||||
'labs.harnessTooltip': "Переключает на agent harness, созданный Open Design для дизайнерских задач. Вёрстка, структура и завершённость результата заметно лучше.",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const th: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'รายละเอียดปลั๊กอิน',
|
||||
'workspaceTabs.marketplace': 'Marketplace',
|
||||
'homeHero.title': 'มาสร้าง{word}กัน',
|
||||
"homeHero.titleUnselected": "คุณอยากสร้างอะไร?",
|
||||
'homeHero.titleWords': 'ต้นแบบ,สไลด์,เอกสาร',
|
||||
'homeHero.subtitle': 'ผลงานที่ดีเริ่มต้นที่นี่',
|
||||
'homeHero.startWithTemplate': 'เริ่มต้นด้วยเทมเพลต…',
|
||||
@@ -5487,7 +5488,7 @@ export const th: Dict = {
|
||||
'labs.title': "Open Design Labs",
|
||||
'labs.itemAbout': "เกี่ยวกับ {name}",
|
||||
'labs.navHint': "ทดลองความสามารถก่อนเปิดตัวจริง",
|
||||
'labs.pageDesc': "เข้าถึงความสามารถที่ยังไม่เปิดตัวอย่างเป็นทางการก่อนใคร โดยทั่วไปดีกว่าการทำงานปัจจุบัน และปิดเมื่อไหร่ก็กลับสู่สถานะเดิมทันที",
|
||||
'labs.pageDesc': "ลองใช้ฟีเจอร์ทดลอง ฟีเจอร์และความพร้อมใช้งานอาจเปลี่ยนแปลงในแต่ละเวอร์ชัน",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "เมื่อเปิด การสร้างครั้งถัดไปจะใช้กลยุทธ์ล่าสุดของ OpenDesign ให้ผลงานประณีตขึ้นชัดเจน (เบต้า)",
|
||||
'labs.harnessTooltip': "สลับไปใช้ agent harness ที่ Open Design สร้างขึ้นสำหรับงานออกแบบ ผลลัพธ์จะดีขึ้นชัดเจนทั้งการจัดวาง โครงสร้าง และความสมบูรณ์",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const tr: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'Plugin ayrıntıları',
|
||||
'workspaceTabs.marketplace': 'Pazar Yeri',
|
||||
'homeHero.title': 'Hadi {word} oluşturalım',
|
||||
"homeHero.titleUnselected": "Ne oluşturmak istersiniz?",
|
||||
'homeHero.titleWords': 'prototipler,sunumlar,belgeler',
|
||||
'homeHero.subtitle': 'İyi işler burada başlar',
|
||||
'homeHero.startWithTemplate': 'Bir şablonla başla…',
|
||||
@@ -5487,7 +5488,7 @@ export const tr: Dict = {
|
||||
'labs.title': "Open Design Labs",
|
||||
'labs.itemAbout': "{name} hakkında",
|
||||
'labs.navHint': "Yayınlanmadan önce özellikleri deneyin.",
|
||||
'labs.pageDesc': "Henüz yayınlanmamış özelliklere erken erişin. Genellikle mevcut davranıştan daha iyidir ve kapattığınızda her şey anında eski haline döner.",
|
||||
'labs.pageDesc': "Deneysel özellikleri keşfedin. Özellikler ve kullanılabilirlik sürümler arasında değişebilir.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "Açtığınızda bir sonraki üretim OpenDesign'ın en yeni stratejisini kullanır, çıktı belirgin şekilde daha özenli olur (beta)",
|
||||
'labs.harnessTooltip': "Open Design in tasarım işi için geliştirdiği agent harness a geçer. Çıktının yerleşimi, yapısı ve tamamlanmışlığı gözle görülür şekilde daha iyi olur.",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const uk: Dict = {
|
||||
'workspaceTabs.pluginDetails': 'Деталі плагіна',
|
||||
'workspaceTabs.marketplace': 'Маркетплейс',
|
||||
'homeHero.title': 'Створімо {word}',
|
||||
"homeHero.titleUnselected": "Що ви хочете створити?",
|
||||
'homeHero.titleWords': 'прототипи,презентації,документи',
|
||||
'homeHero.subtitle': 'Гарна робота починається тут',
|
||||
'homeHero.startWithTemplate': 'Почати з шаблону…',
|
||||
@@ -5487,7 +5488,7 @@ export const uk: Dict = {
|
||||
'labs.title': "Open Design Labs",
|
||||
'labs.itemAbout': "Про {name}",
|
||||
'labs.navHint': "Спробуйте можливості до релізу.",
|
||||
'labs.pageDesc': "Ранній доступ до можливостей, які ще не вийшли. Зазвичай вони кращі за поточну поведінку, а вимкнення одразу повертає все як було.",
|
||||
'labs.pageDesc': "Спробуйте експериментальні функції. Функції та їхня доступність можуть змінюватися між версіями.",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "Після ввімкнення наступна генерація використає найновішу стратегію OpenDesign — результат помітно охайніший (бета)",
|
||||
'labs.harnessTooltip': "Перемикає на agent harness, створений Open Design для дизайнерських задач. Верстка, структура та завершеність результату помітно кращі.",
|
||||
|
||||
@@ -976,6 +976,7 @@ export const zhCN: Dict = {
|
||||
"workspaceTabs.pluginDetails": "插件详情",
|
||||
"workspaceTabs.marketplace": "插件市场",
|
||||
"homeHero.title": "让我们创建{word}",
|
||||
"homeHero.titleUnselected": "你想创作什么?",
|
||||
"homeHero.titleWords": "原型,幻灯片,文档",
|
||||
"homeHero.subtitle": "好的作品,从这里开始",
|
||||
"homeHero.startWithTemplate": "从模板开始…",
|
||||
@@ -5931,7 +5932,7 @@ export const zhCN: Dict = {
|
||||
'labs.title': "Open Design 实验室",
|
||||
'labs.itemAbout': "关于 {name}",
|
||||
'labs.navHint': "抢先体验尚未正式发布的能力。",
|
||||
'labs.pageDesc': "抢先体验尚未正式发布的能力,通常已优于当前版本;随时关闭即可恢复现状。",
|
||||
'labs.pageDesc': "抢先体验尚在探索中的功能。功能及可用范围可能随版本调整。",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "开启后,下次生成将使用 OpenDesign 最新生成策略,生成效果更精美(内测中)",
|
||||
'labs.harnessTooltip': "开启后改用 Open Design 为设计场景专门打造的 agent harness,产物在版式、结构和完成度上会明显更好。",
|
||||
|
||||
@@ -978,6 +978,7 @@ export const zhTW: Dict = {
|
||||
"workspaceTabs.pluginDetails": "外掛詳細資料",
|
||||
"workspaceTabs.marketplace": "市集",
|
||||
"homeHero.title": "我們來設計點{word}",
|
||||
"homeHero.titleUnselected": "你想創作什麼?",
|
||||
"homeHero.titleWords": "原型,簡報,文件",
|
||||
"homeHero.subtitle": "好的作品,從這裡開始",
|
||||
"homeHero.startWithTemplate": "從範本開始…",
|
||||
@@ -5938,7 +5939,7 @@ export const zhTW: Dict = {
|
||||
'labs.title': "Open Design 實驗室",
|
||||
'labs.itemAbout': "關於 {name}",
|
||||
'labs.navHint': "搶先體驗尚未正式發布的能力。",
|
||||
'labs.pageDesc': "搶先體驗尚未正式發布的能力,通常已優於目前版本;隨時關閉即可恢復現狀。",
|
||||
'labs.pageDesc': "搶先體驗仍在探索中的功能。功能及可用範圍可能隨版本調整。",
|
||||
'labs.harnessName': "Design Harness",
|
||||
'labs.harnessHint': "開啟後,下次生成將使用 OpenDesign 最新生成策略,生成效果更精緻(內測中)",
|
||||
'labs.harnessTooltip': "開啟後改用 Open Design 為設計場景打造的 agent harness,產物在版面、結構與完成度上會明顯更好。",
|
||||
|
||||
@@ -1337,6 +1337,7 @@ export interface Dict {
|
||||
|
||||
// Entry home
|
||||
'homeHero.title': string;
|
||||
'homeHero.titleUnselected': string;
|
||||
'homeHero.titleWords': string;
|
||||
'homeHero.subtitle': string;
|
||||
'homeHero.startWithTemplate': string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MediaExecutionPolicy } from '@open-design/contracts';
|
||||
import { HYPERFRAMES_VIDEO_MODEL, type MediaExecutionPolicy } from '@open-design/contracts';
|
||||
import type { ProjectMetadata } from '../types';
|
||||
|
||||
function cleanModel(model: unknown): string {
|
||||
@@ -9,6 +9,19 @@ export function mediaExecutionPolicyForProjectMetadata(
|
||||
metadata: ProjectMetadata | null | undefined,
|
||||
): MediaExecutionPolicy | undefined {
|
||||
if (!metadata) return undefined;
|
||||
const automaticRoute = metadata.strategyBinding?.provenance === 'automatic_default';
|
||||
const automaticMotionRenderer = automaticRoute
|
||||
&& metadata.intent === 'motion-design'
|
||||
&& metadata.videoModel === HYPERFRAMES_VIDEO_MODEL
|
||||
&& metadata.strategyBinding?.taskProfile === 'motion-design';
|
||||
const hasSelectedModel = cleanModel(metadata.imageModel)
|
||||
|| cleanModel(metadata.videoModel) || cleanModel(metadata.audioModel);
|
||||
// A creation type is a preference, not media authorization. On the automatic
|
||||
// Discovery route, a Query can request another medium or several outputs.
|
||||
// Preserve explicit model policies and let the host enforce real access.
|
||||
if (automaticRoute && (!hasSelectedModel || automaticMotionRenderer)) {
|
||||
return { mode: 'enabled' };
|
||||
}
|
||||
if (metadata.kind === 'image') {
|
||||
const model = cleanModel(metadata.imageModel);
|
||||
return model
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { liveArtifactTabId, type AgentEvent } from '../types';
|
||||
|
||||
/** Only daemon registration/refresh events establish a delivered live artifact. */
|
||||
export function liveArtifactDeliveries(events: readonly AgentEvent[] = []) {
|
||||
const artifacts = new Map<string, { id: string; title: string; tabId: string }>();
|
||||
for (const event of events) {
|
||||
if (event.kind === 'live_artifact') {
|
||||
if (event.action === 'deleted') artifacts.delete(event.artifactId);
|
||||
else artifacts.set(event.artifactId, { id: event.artifactId, title: event.title ?? '', tabId: liveArtifactTabId(event.artifactId) });
|
||||
} else if (event.kind === 'live_artifact_refresh' && event.phase === 'succeeded') {
|
||||
artifacts.set(event.artifactId, { id: event.artifactId, title: event.title ?? artifacts.get(event.artifactId)?.title ?? '', tabId: liveArtifactTabId(event.artifactId) });
|
||||
}
|
||||
}
|
||||
return [...artifacts.values()];
|
||||
}
|
||||
|
||||
/** template.html is the live input, not the daemon-rendered result. Source stays
|
||||
* available in Design Files; independent HTML outputs remain in the turn. */
|
||||
export function withLiveArtifactDeliveries(
|
||||
selection: { open: readonly string[]; focused: string | null },
|
||||
events: readonly AgentEvent[] = [],
|
||||
): { open: string[]; focused: string | null } {
|
||||
const live = liveArtifactDeliveries(events);
|
||||
if (!live.length) return { open: [...selection.open], focused: selection.focused };
|
||||
const files = selection.open.filter(name => name !== 'template.html');
|
||||
return {
|
||||
open: [...new Set([...files, ...live.map(item => item.tabId)])],
|
||||
focused: selection.focused && selection.focused !== 'template.html' ? selection.focused : live.at(-1)!.tabId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Local preview progress only. Never exposes the host's storage namespace. */
|
||||
export const PREVIEW_STORAGE_MAX_CHARS = 256 * 1024;
|
||||
const PREFIX = 'od:artifact-local-storage:v1:';
|
||||
export type PreviewStorageSnapshot = Record<string, string>;
|
||||
|
||||
export function previewStorageKey(authority: string | null, project: string, file: string): string | null {
|
||||
return authority ? PREFIX + JSON.stringify([authority, project, file]) : null;
|
||||
}
|
||||
|
||||
export function validatePreviewStorage(value: unknown): PreviewStorageSnapshot | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const result = Object.create(null) as PreviewStorageSnapshot;
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (typeof item !== 'string') return null;
|
||||
result[key] = item;
|
||||
}
|
||||
return JSON.stringify(result).length <= PREVIEW_STORAGE_MAX_CHARS ? result : null;
|
||||
}
|
||||
|
||||
export function readPreviewStorage(key: string | null): PreviewStorageSnapshot {
|
||||
if (!key) return {};
|
||||
try {
|
||||
return validatePreviewStorage(JSON.parse(window.localStorage.getItem(key) ?? '{}')) ?? {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
export function savePreviewStorageMessage(
|
||||
event: MessageEvent,
|
||||
frameWindow: Window | null,
|
||||
generation: string,
|
||||
key: string | null,
|
||||
): boolean {
|
||||
if (!key || !frameWindow || event.source !== frameWindow) return false;
|
||||
const message = event.data;
|
||||
if (message?.type !== 'od:preview-local-storage' || message.generation !== generation) return false;
|
||||
const snapshot = validatePreviewStorage(message.values);
|
||||
if (!snapshot) return false;
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(snapshot));
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Runs before authored scripts, after the shell receives this frame's seed. */
|
||||
export function buildPreviewStorageBridge(): string {
|
||||
return `<script data-od-preview-storage>(function(){
|
||||
var seed = window.__odPreviewStorageSeed;
|
||||
try { delete window.__odPreviewStorageSeed; } catch (_) {}
|
||||
if (!seed || typeof seed.generation !== 'string' || !seed.values) return;
|
||||
var values = Object.assign(Object.create(null), seed.values);
|
||||
function commit(next){
|
||||
if (JSON.stringify(next).length > ${PREVIEW_STORAGE_MAX_CHARS}) {
|
||||
throw new DOMException('Preview storage quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
values = next;
|
||||
window.parent.postMessage({type:'od:preview-local-storage',generation:seed.generation,values:values}, '*');
|
||||
}
|
||||
var storage = {
|
||||
getItem:function(key){key=String(key);return Object.prototype.hasOwnProperty.call(values,key)?values[key]:null;},
|
||||
setItem:function(key,value){var next=Object.assign(Object.create(null),values);next[String(key)]=String(value);commit(next);},
|
||||
removeItem:function(key){var next=Object.assign(Object.create(null),values);delete next[String(key)];commit(next);},
|
||||
clear:function(){commit(Object.create(null));},
|
||||
key:function(index){return Object.keys(values)[index] || null;}
|
||||
};
|
||||
Object.defineProperty(storage,'length',{get:function(){return Object.keys(values).length;}});
|
||||
Object.defineProperty(window,'localStorage',{configurable:true,value:storage});
|
||||
})();</script>`;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildPreviewStorageBridge } from './preview-storage';
|
||||
import { injectMotionSourcePlayer, markMotionHostedPreview } from '@open-design/contracts/runtime/motion-source-player';
|
||||
/**
|
||||
* Wrap an artifact's HTML for a sandboxed iframe. Corresponds to
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
PREVIEW_RUNTIME_STATE_VERSION,
|
||||
} from '@open-design/contracts/runtime/preview-runtime-state';
|
||||
import {
|
||||
buildPreviewSandboxShim,
|
||||
PREVIEW_REDIRECT_GUARD_MAX_HOPS,
|
||||
PREVIEW_REDIRECT_GUARD_SELF_REFRESH_MIN_DELAY_MS,
|
||||
PREVIEW_REDIRECT_GUARD_WINDOW_MS,
|
||||
@@ -70,6 +72,8 @@ import { ANNOTATED_SELECTOR_HELPERS } from './annotated-selector';
|
||||
import { COMMENT_IDENTITY_HELPERS } from '@open-design/contracts';
|
||||
|
||||
export type SrcdocOptions = {
|
||||
/** Host-scoped local progress, seeded before authored scripts by the lazy shell. */
|
||||
previewStorage?: boolean;
|
||||
/** Live embedded motion preview only; do not enable for exports/captures. */
|
||||
motionPreview?: boolean;
|
||||
deck?: boolean;
|
||||
@@ -371,7 +375,7 @@ export function buildSrcdoc(
|
||||
const withDeferredFonts = options.deferFontStylesheets
|
||||
? deferTrustedFontStylesheets(withPreviewBaseBridge)
|
||||
: withPreviewBaseBridge;
|
||||
const withShim = injectSandboxShim(withDeferredFonts);
|
||||
const withShim = injectSandboxShim(withDeferredFonts, options.previewStorage);
|
||||
const blockLoadTimeScriptRedirect = htmlHasLoadTimeLocationNavigation(withPreviewBaseBridge);
|
||||
// Always on: a redirect loop can freeze ANY previewed artifact, and the guard
|
||||
// is inert on documents that never self-redirect. Injected right after the
|
||||
@@ -502,6 +506,7 @@ export function buildLazySrcdocTransport(options: { quirksMode?: boolean } = {})
|
||||
} catch (_) { /* sandboxed parent — host falls back to onLoad */ }
|
||||
}
|
||||
function handleTransportMessage(ev){
|
||||
if (ev.source !== window.parent) return;
|
||||
var data = ev && ev.data;
|
||||
if (data && data.type === 'od:srcdoc-transport-shell-probe') {
|
||||
announceReady();
|
||||
@@ -513,6 +518,9 @@ export function buildLazySrcdocTransport(options: { quirksMode?: boolean } = {})
|
||||
window.removeEventListener('message', handleTransportMessage);
|
||||
}
|
||||
document.open();
|
||||
window.__odPreviewStorageSeed = data.previewStorage
|
||||
? { generation: data.generation, values: data.previewStorage }
|
||||
: null;
|
||||
document.write(data.html);
|
||||
document.close();
|
||||
}
|
||||
@@ -1529,103 +1537,14 @@ function escapeAttr(value: string): string {
|
||||
// try/catch — when it throws, the whole script aborts and the deck
|
||||
// becomes a static, unnavigable preview. We install a same-origin
|
||||
// in-memory shim BEFORE any user script runs so those decks degrade
|
||||
// gracefully (position just doesn't persist across reloads).
|
||||
// gracefully. Live host previews may additionally seed isolated local progress;
|
||||
// exports and unhosted documents retain the in-memory fallback.
|
||||
// allow-popups and allow-popups-to-escape-sandbox are needed for
|
||||
// links with target="_blank" to work in the sandboxed preview.
|
||||
// Empty hrefs and hash only hrefs will be intercepted and ignored.
|
||||
// hrefs leading to an id on the page will be scrolled into view.
|
||||
function injectSandboxShim(doc: string): string {
|
||||
const shim = `<script data-od-sandbox-shim>(function(){
|
||||
function makeStore(){
|
||||
var data = {};
|
||||
var api = {
|
||||
getItem: function(k){ return Object.prototype.hasOwnProperty.call(data, k) ? data[k] : null; },
|
||||
setItem: function(k, v){ data[k] = String(v); },
|
||||
removeItem: function(k){ delete data[k]; },
|
||||
clear: function(){ data = {}; },
|
||||
key: function(i){ return Object.keys(data)[i] || null; }
|
||||
};
|
||||
Object.defineProperty(api, 'length', { get: function(){ return Object.keys(data).length; } });
|
||||
return api;
|
||||
}
|
||||
function tryShim(name){
|
||||
var works = false;
|
||||
try { works = !!window[name] && typeof window[name].getItem === 'function'; void window[name].length; }
|
||||
catch (_) { works = false; }
|
||||
if (works) return;
|
||||
try { Object.defineProperty(window, name, { configurable: true, value: makeStore() }); }
|
||||
catch (_) { try { window[name] = makeStore(); } catch (__) {} }
|
||||
}
|
||||
tryShim('localStorage');
|
||||
tryShim('sessionStorage');
|
||||
// A <base href> pointing at the artifact's real /raw/ URL (injectBaseHref,
|
||||
// below) is what lets relative asset paths resolve inside this opaque
|
||||
// srcDoc document. That same base is what breaks history.pushState /
|
||||
// replaceState: a bare-hash call like replaceState(null, '', '#/3') — the
|
||||
// exact pattern generated decks use for their own slide routing — resolves
|
||||
// against the <base> into a real http(s) URL, and the browser throws
|
||||
// SecurityError because the DOCUMENT's own origin is the opaque "null" of
|
||||
// about:srcdoc, which can never match a concrete origin. A deck whose own
|
||||
// navigation function calls replaceState before it finishes updating the
|
||||
// slide DOM (ordering varies by generated template) aborts mid-navigation
|
||||
// on that throw, leaving the main canvas blank until a manual reload —
|
||||
// the thumbnail rail is unaffected because it does not run the deck's
|
||||
// script per-thumbnail. Wrap both methods so that failure degrades to a
|
||||
// no-op instead of interrupting the caller: the iframe's address bar was
|
||||
// never visible to the user anyway, so losing the hash update here is
|
||||
// invisible.
|
||||
function shimHistoryMethod(name){
|
||||
try {
|
||||
var h = window.history;
|
||||
var original = h && h[name];
|
||||
if (typeof original !== 'function') return;
|
||||
h[name] = function(state, title, url){
|
||||
try {
|
||||
return original.call(h, state, title, url);
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
} catch (_) {}
|
||||
}
|
||||
shimHistoryMethod('pushState');
|
||||
shimHistoryMethod('replaceState');
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target || !(e.target instanceof Element)) return;
|
||||
var link = e.target.closest('a[href]');
|
||||
if (!link) return;
|
||||
var href = link.getAttribute('href');
|
||||
if (href === null) return;
|
||||
var isAnchor = href.startsWith('#') || href === '';
|
||||
if (isAnchor) {
|
||||
e.preventDefault();
|
||||
if (href === '' || href === '#') {
|
||||
window.scrollTo({ top: 0 });
|
||||
history.replaceState(null, '', ' ');
|
||||
} else {
|
||||
var targetId = href.slice(1);
|
||||
var target = targetId ? document.getElementById(targetId) : null;
|
||||
if (target) {
|
||||
target.scrollIntoView();
|
||||
location.hash === href && history.replaceState(null, '', ' ');
|
||||
location.hash = href;
|
||||
}
|
||||
}
|
||||
} else if (link.getAttribute('target') === '_blank') {
|
||||
e.preventDefault();
|
||||
let safe = false;
|
||||
try {
|
||||
var url = new URL(href, location.href);
|
||||
safe =
|
||||
url.protocol === 'http:' ||
|
||||
url.protocol === 'https:' ||
|
||||
url.protocol === 'mailto:';
|
||||
} catch (_) {}
|
||||
safe && window.open(href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
});
|
||||
})();</script>`;
|
||||
return injectAtDocumentStart(doc, shim);
|
||||
// Empty hrefs return to the top; element anchors scroll into view.
|
||||
// Hash routers keep their same-document route and browser history.
|
||||
function injectSandboxShim(doc: string, persistStorage = false): string {
|
||||
return injectAtDocumentStart(doc, buildPreviewSandboxShim() + (persistStorage ? buildPreviewStorageBridge() : ''));
|
||||
}
|
||||
|
||||
function injectPreviewFocusGuard(doc: string): string {
|
||||
|
||||
@@ -69,21 +69,26 @@ function prefersReducedMotion(): boolean {
|
||||
}
|
||||
|
||||
export function smoothScrollToTop(container: HTMLElement): void {
|
||||
inFlight.get(container)?.();
|
||||
|
||||
container.scrollLeft = 0;
|
||||
const from = container.scrollTop;
|
||||
if (from <= 0) return;
|
||||
|
||||
if (prefersReducedMotion() || typeof requestAnimationFrame !== 'function') {
|
||||
container.scrollTop = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = Math.min(
|
||||
MAX_DURATION_MS,
|
||||
MIN_DURATION_MS + from / DISTANCE_PER_MS,
|
||||
MIN_DURATION_MS + container.scrollTop / DISTANCE_PER_MS,
|
||||
);
|
||||
smoothScrollTo(container, 0, duration);
|
||||
}
|
||||
|
||||
// A bounded-duration scroll for keyboard navigation. Returns a cancellation
|
||||
// handle so closing the owning menu cannot leave a detached element animating.
|
||||
export function smoothScrollTo(container: HTMLElement, top: number, duration: number): () => void {
|
||||
inFlight.get(container)?.();
|
||||
|
||||
const from = container.scrollTop;
|
||||
if (from === top) return () => {};
|
||||
|
||||
if (prefersReducedMotion() || typeof requestAnimationFrame !== 'function') {
|
||||
container.scrollTop = top;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
let start: number | null = null;
|
||||
@@ -100,7 +105,7 @@ export function smoothScrollToTop(container: HTMLElement): void {
|
||||
const step = (now: number) => {
|
||||
if (start === null) start = now;
|
||||
const t = Math.min(1, (now - start) / duration);
|
||||
container.scrollTop = from * (1 - EASE_OUT(t));
|
||||
container.scrollTop = from + (top - from) * EASE_OUT(t);
|
||||
if (t < 1) {
|
||||
frame = requestAnimationFrame(step);
|
||||
} else {
|
||||
@@ -112,4 +117,5 @@ export function smoothScrollToTop(container: HTMLElement): void {
|
||||
container.addEventListener('touchstart', cancel, { passive: true });
|
||||
inFlight.set(container, cancel);
|
||||
frame = requestAnimationFrame(step);
|
||||
return cancel;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
* `f.name.toLowerCase()` 上炸掉整个会话视图。
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AssistantMessage } from '../../src/components/AssistantMessage';
|
||||
@@ -216,3 +216,17 @@ describe('OPEND-2608 · 视频产物已归档时,消息底部要出视频卡', (
|
||||
expect(screen.queryByTestId(`artifact-card-${VIDEO_FILE.name}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('registered live artifact delivery', () => {
|
||||
it('opens the rendered live view instead of presenting the input template as the result', () => {
|
||||
const template = { ...AUDIO_FILE, name: 'template.html', path: 'template.html', kind: 'html', mime: 'text/html' } as ProjectFile;
|
||||
const message = mediaTurn({ producedFiles: [template] });
|
||||
message.events = [...(message.events ?? []), { kind: 'live_artifact', action: 'created', projectId: PROJECT_ID, artifactId: 'la-board', title: '任务看板' }];
|
||||
const open = vi.fn();
|
||||
render(<CollabProvider value={projectCollabValue()}><AssistantMessage message={message} streaming={false} projectId={PROJECT_ID} projectFiles={[template]} onRequestOpenFile={open} isLast /></CollabProvider>);
|
||||
fireEvent.click(screen.getByRole('button', { name: '任务看板' }));
|
||||
expect(open).toHaveBeenCalledWith('live:la-board');
|
||||
expect(screen.queryByTestId('artifact-card-template.html')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('OPEND-3740 completion belongs to the final physical Run', () => {
|
||||
it.each([false, undefined])('does not inherit a planning verdict when the final verdict is %s', verdict => {
|
||||
const rows = taskRuns('The page still needs work.');
|
||||
rows[0] = { ...rows[0]!, strategyTaskDelivered: true };
|
||||
rows[1] = { ...rows[1]!, strategyTaskDelivered: verdict };
|
||||
const message = foldedReply(rows);
|
||||
expect(message.strategyTaskDelivered).toBe(verdict);
|
||||
renderReply(message);
|
||||
expectRemainingWork();
|
||||
});
|
||||
|
||||
it('keeps a production completion after folding planning and production', () => {
|
||||
const rows = taskRuns(`<od-done key="${PRODUCTION_KEY}"/>The page is delivered.`);
|
||||
// The same successful production must not acquire remaining work on reload.
|
||||
|
||||
@@ -177,12 +177,17 @@ describe('FileViewer manual edit history regressions', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const initialFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
const post = vi.spyOn(initialFrame.contentWindow!, 'postMessage');
|
||||
await enterManualEditMode();
|
||||
await selectManualEditTarget();
|
||||
|
||||
const editFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
expect(editFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
|
||||
expect(editFrame.srcdoc).toContain('data-od-edit-bridge');
|
||||
fireEvent.load(editFrame);
|
||||
const activation = post.mock.calls.map(([message]) => message)
|
||||
.find((message) => message.type === 'od:srcdoc-transport-activate');
|
||||
expect(activation?.html).toContain('data-od-edit-bridge');
|
||||
|
||||
// Exiting edit mode is the toolbar toggle's job — the panel's own close
|
||||
// button only collapses the inspector and stays in edit.
|
||||
@@ -192,8 +197,9 @@ describe('FileViewer manual edit history regressions', () => {
|
||||
const previewFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
expect(previewFrame).toBe(editFrame);
|
||||
expect(previewFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
|
||||
expect(previewFrame.srcdoc).toContain('Hero');
|
||||
expect(previewFrame.srcdoc).toContain('data-od-edit-bridge');
|
||||
expect(activation?.html).toContain('Hero');
|
||||
expect(activation?.html).toContain('data-od-edit-bridge');
|
||||
expect(post.mock.calls.filter(([message]) => message.type === 'od:srcdoc-transport-activate')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@ function htmlFile(overrides: Partial<ProjectFile> = {}): ProjectFile {
|
||||
}
|
||||
|
||||
function srcDocHtml(label: string): string {
|
||||
// localStorage forces the same sandbox-shim/srcDoc transport used by the
|
||||
// sessionStorage forces the same non-persistent sandbox-shim/srcDoc transport used by the
|
||||
// artifact in the reported diagnostics bundle.
|
||||
return `<html><body><main>${label}</main><script>localStorage.setItem('deepflow', 'monthly')</script></body></html>`;
|
||||
return `<html><body><main>${label}</main><script>sessionStorage.setItem('deepflow', 'monthly')</script></body></html>`;
|
||||
}
|
||||
|
||||
function transportGeneration(frame: HTMLIFrameElement): string {
|
||||
@@ -64,6 +64,55 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('FileViewer srcDoc file-watch refresh recovery', () => {
|
||||
it('seeds fresh local progress on reload without navigating the current document on writes', () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 })));
|
||||
const view = () => <FileViewer projectId="persistent-checklist" projectKind="prototype"
|
||||
file={htmlFile()} liveHtml="<html><body>Checklist<script>localStorage.getItem('progress')</script></body></html>" />;
|
||||
const first = render(view());
|
||||
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
expect(frame.srcdoc).toContain('data-od-lazy-srcdoc-transport');
|
||||
const post = vi.spyOn(frame.contentWindow!, 'postMessage');
|
||||
fireEvent.load(frame);
|
||||
const activation = post.mock.calls.find(([msg]) => msg.type === 'od:srcdoc-transport-activate')![0];
|
||||
expect(activation.html).toContain('data-od-preview-storage');
|
||||
act(() => window.dispatchEvent(new MessageEvent('message', {
|
||||
source: frame.contentWindow, data: {type:'od:preview-local-storage', generation:activation.generation, values:{progress:'2/15'}},
|
||||
})));
|
||||
expect(post.mock.calls.filter(([msg]) => msg.type === 'od:srcdoc-transport-activate')).toHaveLength(1);
|
||||
first.unmount();
|
||||
render(view());
|
||||
const reopened = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
const next = vi.spyOn(reopened.contentWindow!, 'postMessage');
|
||||
fireEvent.load(reopened);
|
||||
expect(next).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type:'od:srcdoc-transport-activate', previewStorage:{progress:'2/15'},
|
||||
}), '*');
|
||||
});
|
||||
|
||||
it('activates an Electron shell only once when ready arrives before its first load', () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 })));
|
||||
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('OpenDesign Electron/41.3.0');
|
||||
const original = URL.createObjectURL;
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true, value: vi.fn(() => 'blob:http://localhost/ready-before-load'),
|
||||
});
|
||||
try {
|
||||
render(<FileViewer projectId="ready-project" projectKind="prototype"
|
||||
file={htmlFile()} liveHtml={srcDocHtml('ready-first')} />);
|
||||
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
const post = vi.spyOn(frame.contentWindow!, 'postMessage');
|
||||
act(() => window.dispatchEvent(new MessageEvent('message', {
|
||||
source: frame.contentWindow, data: { type: 'od:srcdoc-transport-ready' },
|
||||
})));
|
||||
const activations = () => post.mock.calls.filter(([msg]) => msg.type === 'od:srcdoc-transport-activate');
|
||||
expect(activations()).toHaveLength(1);
|
||||
fireEvent.load(frame);
|
||||
expect(activations()).toHaveLength(1);
|
||||
} finally {
|
||||
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: original });
|
||||
}
|
||||
});
|
||||
|
||||
it('remounts once when a refreshed srcDoc revision never acknowledges activation', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 })));
|
||||
|
||||
@@ -597,7 +597,8 @@ describe('FileViewer srcDoc reload — prevSourceBeforeReloadRef race conditions
|
||||
// reload must keep that transport stable while the source probe is pending.
|
||||
// ---------------------------------------------------------------------------
|
||||
it('keeps the guarded URL iframe active while source is null after Reload', async () => {
|
||||
// localStorage requires the sandbox shim, but settled on-disk HTML can now
|
||||
// sessionStorage requires the sandbox shim; persistent localStorage uses srcDoc.
|
||||
// Settled on-disk HTML can
|
||||
// receive that shim from the daemon without taking the srcDoc transport.
|
||||
const shimFile: ProjectFile = {
|
||||
name: 'app.html',
|
||||
@@ -609,7 +610,7 @@ describe('FileViewer srcDoc reload — prevSourceBeforeReloadRef race conditions
|
||||
mime: 'text/html',
|
||||
};
|
||||
const shimHtml =
|
||||
'<html><body><script>localStorage.setItem("key","val");</script><p>loaded</p></body></html>';
|
||||
'<html><body><script>sessionStorage.setItem("key","val");</script><p>loaded</p></body></html>';
|
||||
|
||||
// Step 1: mount with shimHtml and wait for the guarded URL lane.
|
||||
vi.stubGlobal('fetch', fetchReturning(shimHtml));
|
||||
@@ -1137,7 +1138,7 @@ describe('FileViewer srcDoc reload — prevSourceBeforeReloadRef race conditions
|
||||
it('saves a Manual Edit patch while Reload exits through the URL standby', async () => {
|
||||
const initialSource =
|
||||
'<!doctype html><html><body>' +
|
||||
'<script>localStorage.setItem("k","v");</script>' +
|
||||
'<script>sessionStorage.setItem("k","v");</script>' +
|
||||
'<h1 data-od-id="hero">Hello</h1>' +
|
||||
'</body></html>';
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user