diff --git a/scripts/agent-log-analysis.mjs b/scripts/agent-log-analysis.mjs index 0c204dc..8f857f7 100644 --- a/scripts/agent-log-analysis.mjs +++ b/scripts/agent-log-analysis.mjs @@ -36,8 +36,10 @@ function milestoneKinds(event) { const kinds = []; if (/\bnpm\s+(?:install|i)\b/.test(command)) kinds.push('installation'); if (/\bagent-context\b/.test(command)) kinds.push('contract'); - if (event.name === 'Write' && /(?:build-ops\.[cm]?js|ops\.json)$/.test(path)) - kinds.push('firstOperations'); + if (event.name === 'Write' && /(?:build[-_]ops\.(?:[cm]?js|py)|ops\.json)$/i.test(path)) { + const probe = /(?:^|[\/_.-])probe(?:[\/_.-]|$)/i.test(path); + kinds.push(probe ? 'firstProbeOperations' : 'firstOperations'); + } for (const kind of ['apply', 'validate', 'export']) if (new RegExp(`(?:deckuse|\\$CLI)\\s+${kind}\\b`).test(command)) kinds.push(kind); if (/(?:deckuse|\$CLI)\s+render\b/.test(command) && !command.includes('--preflight')) @@ -93,6 +95,7 @@ export function analyzeAgentLog(events) { [ 'installation', 'contract', + 'firstProbeOperations', 'firstOperations', 'apply', 'validate', @@ -129,7 +132,7 @@ export function analyzeAgentLog(events) { notes: [ 'Tool intervals are merged, not summed. Batched result timestamps do not identify the slow tool.', 'Unattributed time can include model output, reasoning, host scheduling and incomplete tool traces.', - 'Milestones identify attempts; return status alone cannot prove a validated artifact or visual review.', + 'Milestones identify attempts; return status alone cannot prove a validated artifact or visual review. Probe operations are identified by their path names.', 'Background notifications and summary backfills do not start tasks. No source payloads are included.', ], }; diff --git a/scripts/check-agent-package.mjs b/scripts/check-agent-package.mjs index ab838d0..1e1b746 100644 --- a/scripts/check-agent-package.mjs +++ b/scripts/check-agent-package.mjs @@ -147,6 +147,14 @@ if ( ) throw new Error('Authoring context probed preview or returned protocol schema'); const contract = compact.envelope.data.applyContract.examples; +const photo = join(staging, 'photo.png'); +await writeFile( + photo, + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/b1sAAAAASUVORK5CYII=', + 'base64', + ), +); const generatedOps = join(staging, 'compact-nine-page-ops.json'); const generatedNames = join(staging, 'compact-nine-page-names.json'); const inventory = ['Blank starter', ...Array.from({ length: 8 }, (_, i) => 'Page ' + (i + 2))]; @@ -157,6 +165,7 @@ await writeFile( ...(i ? [{ ...contract.slide, name: slide }] : []), { ...contract.nativeShape, slide }, { ...contract.richText, slide }, + ...(i === 0 ? [{ ...contract.image, slide, file: photo }] : []), ]), ), ); @@ -191,6 +200,10 @@ if ( const firstPage = cli(['list', 'shapes', '--slide', '1', '--workspace', compactDeck.workspace]); if (!JSON.stringify(firstPage).includes('产品 Overview')) throw new Error('Rich text was not preserved'); +if ( + !firstPage.data.items.some((item) => item.kind === 'picture' && item.name === contract.image.name) +) + throw new Error('Compact contract image did not produce a native picture'); await access(join(root, context.recipe.deliveryRunner.resource)); const ops = join(root, context.resource.example); @@ -223,15 +236,60 @@ if (generated.state !== 'structural-only' || !generated.sha256) await access(generated.output); const original = await readFile(generated.output); const runner = join(root, 'scripts/rebuild-generation.mjs'); +const maskedExit = join(staging, 'masked-preview-exit.mjs'); +await writeFile( + maskedExit, + ` + import processes from 'node:child_process'; + import { syncBuiltinESMExports } from 'node:module'; + const { spawnSync } = processes; + processes.spawnSync = (binary, argv, options) => { + const result = spawnSync(binary, argv, options); + return argv[1] === 'render' ? { ...result, status: 0 } : result; + }; + syncBuiltinESMExports(); +`, +); + const deniedPreview = spawnSync( process.execPath, - [runner, staging, ops, expectedNames, '--preview'], + ['--import', maskedExit, runner, staging, ops, expectedNames, '--preview'], { encoding: 'utf8' }, ); const blocked = JSON.parse(deniedPreview.stdout); if (deniedPreview.status !== 1 || blocked.state !== 'preview-blocked' || !blocked.output) throw Error('Unavailable preview did not preserve the structural artifact'); await access(blocked.output); +const previewProcess = JSON.parse( + await readFile(join(blocked.generation, 'preview.process.json'), 'utf8'), +); +if (previewProcess.status !== 0) throw Error('Preview wrapper did not mask the child exit'); +if ( + blocked.verification?.structure !== 'validated' || + blocked.verification.preview !== 'blocked' || + blocked.verification.visualReview !== 'not-performed' || + blocked.verification.nativeReview !== 'not-performed' || + blocked.previewFailure?.retryable !== false || + blocked.nextAction?.type !== 'deliver-validated-pptx' || + blocked.nextAction.path !== blocked.output +) + throw Error('Expected a machine-readable structural handoff for blocked preview'); +const resumedBlocked = spawnSync(process.execPath, [runner, '--resume', blocked.generation], { + encoding: 'utf8', + env: { ...process.env, DECKUSE_BROWSER_PROVIDER: 'chrome' }, +}); +const resumedReport = JSON.parse(resumedBlocked.stdout); +if ( + resumedBlocked.status !== 1 || + resumedReport.previewFailure?.code !== blocked.previewFailure.code || + resumedReport.nextAction?.path !== blocked.output || + resumedReport.sha256 !== blocked.sha256 || + (await readdir(blocked.generation)).filter((name) => + /^preview(?:\.attempt-\d+)?\.started\.json$/.test(name), + ).length !== 1 +) + throw Error('Changing a preview setting replayed a nonretryable preview or lost its artifact'); + const wrongNames = join(staging, 'wrong-slide-names.json'); await writeFile(wrongNames, JSON.stringify(['Blank starter', 'Unexpected'])); const invalidBuild = spawnSync(process.execPath, [runner, staging, ops, wrongNames], { diff --git a/scripts/rebuild-generation.mjs b/scripts/rebuild-generation.mjs index addb479..3e1c159 100644 --- a/scripts/rebuild-generation.mjs +++ b/scripts/rebuild-generation.mjs @@ -72,20 +72,6 @@ let config, checkpoint = {}, attempt, previewFailure; -const previewContext = () => - hash( - JSON.stringify( - Object.fromEntries( - Object.entries(process.env) - .filter(([key]) => - /^(DECKUSE_(BROWSER|DECK2HTML)|PLAYWRIGHT_|CHROME_PATH|CHROMIUM_PATH|DOTNET_BUNDLE_EXTRACT_BASE_DIR)/.test( - key, - ), - ) - .sort(), - ), - ), - ); async function nextBase(name) { if ( !(await exists(join(generation, name + '.started.json'))) && @@ -172,12 +158,22 @@ async function cli(argv, label = phase) { envelope?.ok !== true || (['validate', 'export'].includes(label) && envelope.data?.valid !== true) ) { - if (label === 'preview') + let previewMessage; + if (label === 'preview') { + const failures = (envelope?.data?.results ?? []).filter((page) => !page.ok); + const failedPage = + failures.find((page) => page.errorDetails?.retryable === false) ?? failures[0]; + const details = envelope?.error?.details ?? failedPage?.errorDetails; + previewMessage = failedPage?.error; previewFailure = { - context: previewContext(), - retryable: envelope.error?.details?.retryable ?? false, + code: envelope?.error?.code ?? failedPage?.errorCode ?? 'PREVIEW_FAILED', + stage: details?.stage, + retryable: + details?.retryable === true && + failures.every((page) => page.errorDetails?.retryable === true), }; - throw Error(envelope?.error?.message ?? label + ' failed'); + } + throw Error(envelope?.error?.message ?? previewMessage ?? label + ' failed'); } return { data: envelope.data, base }; } @@ -192,6 +188,18 @@ async function report(result) { revision, attempt, visualReview: 'not-performed', + verification: { + structure: artifact || validatedSnapshot ? 'validated' : 'unverified', + preview: + result.state === 'preview-ready' + ? 'generated' + : result.state === 'preview-blocked' + ? 'blocked' + : 'not-performed', + visualReview: 'not-performed', + nativeReview: 'not-performed', + }, + nextAction: { type: 'inspect-report', path }, ...result, }; await writeFile(path, JSON.stringify(data, null, 2) + '\n', { flag: 'wx' }); @@ -474,14 +482,12 @@ async function run() { } if (config.preview) { phase = 'preview'; - if ( - checkpoint.previewFailure?.retryable === false && - checkpoint.previewFailure.context === previewContext() && - !retryPreview - ) + if (checkpoint.previewFailure?.retryable === false && !retryPreview) { + previewFailure = checkpoint.previewFailure; throw Error( - 'Preview previously blocked in this context; fix the environment and pass --retry-preview explicitly', + 'Preview remains blocked. Deliver the validated PPTX and retry only after confirming host capability has changed.', ); + } const { data: result, base } = await cli([ 'render', ...(resume ? ['--since-revision', revision] : ['--all']), @@ -511,10 +517,18 @@ async function run() { state: config.preview ? 'preview-ready' : 'structural-only', validationReport: checkpoint.validationReport, ...(config.preview ? { previewInventory: checkpoint.previewReport } : {}), + nextAction: { + type: config.preview ? 'review-preview' : 'deliver-validated-pptx', + path: config.preview ? checkpoint.previewReport : artifact.output, + }, }); } catch (cause) { process.exitCode = 1; - if (previewFailure) await saveCheckpoint({ previewFailure }); + const previewBlocked = Boolean(artifact && phase === 'preview'); + if (previewBlocked) { + previewFailure ??= { code: failureKind ?? 'PREVIEW_FAILED', retryable: false }; + if (previewFailure !== checkpoint.previewFailure) await saveCheckpoint({ previewFailure }); + } let recoverableSnapshot; if (phase === 'export' && validatedSnapshot) { const candidate = await inspectSnapshot().catch(() => undefined); @@ -523,13 +537,21 @@ async function run() { await report({ ok: false, ...artifact, - state: - artifact && phase === 'preview' - ? 'preview-blocked' - : recoverableSnapshot - ? 'recoverable-snapshot' - : 'failed', + state: previewBlocked + ? 'preview-blocked' + : recoverableSnapshot + ? 'recoverable-snapshot' + : 'failed', ...(recoverableSnapshot ? { recoverableSnapshot } : {}), + ...(previewBlocked + ? { + previewFailure, + validationReport: checkpoint.validationReport, + nextAction: { type: 'deliver-validated-pptx', path: artifact.output }, + } + : phase === 'apply' + ? { nextAction: { type: 'resume-generation', path: generation } } + : {}), ...(failureKind ? { failureKind } : {}), error: cause instanceof Error ? cause.message : String(cause), }); diff --git a/skills/deckuse/scenarios/create.md b/skills/deckuse/scenarios/create.md index ccfc28d..94e020c 100644 --- a/skills/deckuse/scenarios/create.md +++ b/skills/deckuse/scenarios/create.md @@ -72,7 +72,7 @@ Use a revision-guarded patch for local changes. For a full replacement, run separate generation with its own workspace, exported PPTX and reports. Add `--preview` to render into that generation's preview directory and check the page inventory against the export. -A failed generation leaves all earlier workspaces and deliverables intact. The runner writes `run.json` and separate process results. Treat `preview-ready` as screenshots available for review and `preview-blocked` as structural delivery with visual review outstanding. After an error marked `retryable: false`, stop preview attempts until the host configuration changes. +A failed generation leaves all earlier workspaces and deliverables intact. The runner writes `run.json` and separate process results. Treat `preview-ready` as screenshots available for review and `preview-blocked` as structural delivery with visual review outstanding. For `preview-blocked`, follow `nextAction` to deliver the validated PPTX and report visual and native review as pending. Resume with `--retry-preview` only after confirming the required host capability has changed. The runner will not start another preview from a nonretryable checkpoint without that explicit flag. ### Default delivery and continuation diff --git a/src/agent-context.ts b/src/agent-context.ts index 3027edc..e2bd89e 100644 --- a/src/agent-context.ts +++ b/src/agent-context.ts @@ -220,6 +220,8 @@ export async function agentContext(options: { 'Append --patch ./patch.json for each ordered patch; inputs are saved and compiled into one guarded apply', resume: 'node /scripts/rebuild-generation.mjs --resume ', + previewBlocked: + 'Follow nextAction to deliver the validated PPTX. Use --retry-preview only after confirming host capability has changed.', cleanup: 'Workspace cleanup moves owned artifacts into .deckuse-trash by default. Set DECKUSE_CLEANUP_MODE=delete only when the host permits deletion.', }, @@ -275,6 +277,17 @@ export async function agentContext(options: { fill: { color: 'EDF4FA' }, stroke: { color: '345671', width: 1 }, }, + image: { + type: 'addShape', + slide: 'Overview', + shapeType: 'image', + name: 'Photo', + file: '/absolute/path/to/photo.png', + x: '0.75in', + y: '2in', + width: '6in', + height: '3in', + }, richText: { type: 'addShape', slide: 'Overview', diff --git a/test/cli/agent-log-analysis.test.ts b/test/cli/agent-log-analysis.test.ts index 574cb37..a6287b9 100644 --- a/test/cli/agent-log-analysis.test.ts +++ b/test/cli/agent-log-analysis.test.ts @@ -47,3 +47,24 @@ it('merges concurrent intervals and excludes summary/notification task boundarie ]), ).toBe(13); }); + +it('keeps probe and formal operations milestones separate', () => { + const result = analyzeAgentLog([ + { + timestamp: 100, + type: 'message', + role: 'user', + content: [{ text: 'Make a deck' }], + }, + ...['./probe-ops.json', './job/build_ops.py'].map((file_path, i) => ({ + timestamp: 200 + i * 100, + type: 'function_call', + callId: String(i), + name: 'Write', + arguments: JSON.stringify({ file_path }), + })), + ]); + expect(result.tasks[0].milestones.firstProbeOperations.startedMs).toBe(100); + expect(result.tasks[0].milestones.firstOperations.startedMs).toBe(200); + expect(JSON.stringify(result)).not.toContain('probe-ops.json'); +}); diff --git a/test/cli/rebuild-generation.test.ts b/test/cli/rebuild-generation.test.ts index 663f84d..1a25ac8 100644 --- a/test/cli/rebuild-generation.test.ts +++ b/test/cli/rebuild-generation.test.ts @@ -235,7 +235,12 @@ it('retains exported bytes and avoids repeating a blocked preview in the same co env, }); expect(resumed.status).toBe(1); - expect(JSON.parse(resumed.stdout).error).toContain('previously blocked'); + expect(JSON.parse(resumed.stdout)).toMatchObject({ + state: 'preview-blocked', + previewFailure: { retryable: false }, + verification: { structure: 'validated', preview: 'blocked', nativeReview: 'not-performed' }, + nextAction: { type: 'deliver-validated-pptx', path: failed.output }, + }); expect(await readFile(failed.output)).toEqual(before); const { readdir } = await import('node:fs/promises'); expect(