diff --git a/packages/api/src/domains/cats/services/agents/providers/l0-compiler.ts b/packages/api/src/domains/cats/services/agents/providers/l0-compiler.ts index 551e3831fd..8c273d13d7 100644 --- a/packages/api/src/domains/cats/services/agents/providers/l0-compiler.ts +++ b/packages/api/src/domains/cats/services/agents/providers/l0-compiler.ts @@ -21,13 +21,25 @@ */ import { spawn as nodeSpawn } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveProfileDir } from '../../profile/profile-dir.js'; const SCRIPT_BASENAME = 'compile-system-prompt-l0.mjs'; +/** + * F257 #2 — one L-series (L1-L7) segment as rendered by the actual L0 compiler. + * The raw per-segment content is the authoritative artifact the provider delivers; + * hash/token/version formatting is derived downstream (see l0-manifest-trace.ts). + */ +export interface L0SegmentContent { + segmentId: string; + content: string; +} + // ── L0 cache ──────────────────────────────────────────────────────── // The compiled L0 depends on static inputs (shared-rules.md, cat config, // teammate roster) that don't change during a session. Caching avoids @@ -35,6 +47,12 @@ const SCRIPT_BASENAME = 'compile-system-prompt-l0.mjs'; // startup via warmL0Cache() and invalidated on hot-reload via clearL0Cache(). const l0Cache = new Map(); +// F257 #2 — per-segment L1-L7 manifest, populated in LOCKSTEP with l0Cache inside +// doCompileL0() (same subprocess, same generation guard, cleared together). This +// lets the injection trace be sourced from the SAME compiled artifact the provider +// delivers, instead of an out-of-band reconstruction that can diverge. +const l0ManifestCache = new Map(); + // In-flight Promise dedup — Phase G AC-G10 (砚砚 Design Gate position 1). // Without this, two concurrent calls on a cold cache (e.g. invoke provider // + Prompt X-Ray capture inside the same invocation hot path) both spawn @@ -70,6 +88,7 @@ function isL0GenerationCurrent(catId: string, generation: { global: number; cat: export function clearL0Cache(catId?: string): void { if (catId) { l0Cache.delete(catId); + l0ManifestCache.delete(catId); bumpL0Generation(catId); // Also drop any in-flight promise — next call will re-spawn fresh. The // generation guard prevents the older promise from repopulating l0Cache @@ -77,6 +96,7 @@ export function clearL0Cache(catId?: string): void { l0InflightPromises.delete(catId); } else { l0Cache.clear(); + l0ManifestCache.clear(); bumpL0Generation(); l0InflightPromises.clear(); } @@ -228,7 +248,20 @@ async function doCompileL0( // write (routes) MUST resolve identically or the nurturing loop silently breaks (a primer // written to one path while the injector reads another). const profileDir = resolveProfileDir(cwd, scriptPath); - const args = [scriptPath, '--cat', catId, '--profile-dir', profileDir, ...(outPath ? ['--out', outPath] : [])]; + // F257 #2: always request the per-segment L1-L7 manifest to a temp file so the + // injection trace can be sourced from this exact compiled artifact. Orthogonal to + // --out; the string compile stays authoritative + fail-closed. + const manifestPath = join(tmpdir(), `cat-cafe-l0-manifest-${catId}-${randomUUID()}.json`); + const args = [ + scriptPath, + '--cat', + catId, + '--profile-dir', + profileDir, + '--manifest-out', + manifestPath, + ...(outPath ? ['--out', outPath] : []), + ]; const stdout = await new Promise((resolvePromise, rejectPromise) => { const child = spawnFn(process.execPath, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -271,8 +304,56 @@ async function doCompileL0( } } + // F257 #2: read the per-segment manifest (best-effort — a manifest failure must + // NEVER fail the critical fail-closed string compile above). An empty manifest is a + // visible "L not observed" signal downstream, not a silent healthy-zero. + const manifest = readL0Manifest(manifestPath); + if (isL0GenerationCurrent(catId, compileGeneration)) { l0Cache.set(catId, result); + l0ManifestCache.set(catId, manifest); } return result; } + +/** Parse + clean up the temp L-segment manifest written by the compiler. */ +function readL0Manifest(manifestPath: string): L0SegmentContent[] { + try { + if (!existsSync(manifestPath)) return []; + const parsed: unknown = JSON.parse(readFileSync(manifestPath, 'utf8')); + if (!Array.isArray(parsed)) return []; + return parsed + .filter( + (e): e is { id: string; content: string } => + !!e && + typeof e === 'object' && + typeof (e as { id?: unknown }).id === 'string' && + typeof (e as { content?: unknown }).content === 'string', + ) + .map((e) => ({ segmentId: e.id, content: e.content })); + } catch { + return []; + } finally { + try { + if (existsSync(manifestPath)) unlinkSync(manifestPath); + } catch { + /* temp cleanup best-effort */ + } + } +} + +/** + * F257 #2 — get the per-segment L1-L7 manifest for a cat, sourced from the SAME + * compiled artifact the provider delivers. Cache-first; on a cold cache it triggers + * the shared subprocess compile (which populates both string + manifest caches under + * the generation guard), so the provider's later compile is a cache hit — no + * redundant work. Returns [] when the compile produced no manifest (visible signal). + */ +export async function getL0ManifestViaSubprocess(options: CompileL0Options): Promise { + const cached = l0ManifestCache.get(options.catId); + if (cached) return cached; + // Manifest cold ⇒ string cold too (they are set together in doCompileL0), so this + // actually compiles rather than hitting compileL0ViaSubprocess's string cache-first. + await compileL0ViaSubprocess(options); + return l0ManifestCache.get(options.catId) ?? []; +} diff --git a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts index 7e176a15c4..9bfc5aaba8 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts @@ -29,6 +29,7 @@ import { prepareGuideContext, } from '../../../../guides/GuideRoutingInterceptor.js'; import { triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; +import { persistNativeL0SessionTrace } from '../../../../prompt-hooks/native-l0-trace.js'; import { drainCapturedTraces, refreshOverrideSnapshot } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js'; // F257: Pipeline trace bridge — richer per-hook traces, replaces redundant v0 re-collection @@ -397,33 +398,48 @@ export async function* routeParallel( const traceStore = getTraceStore(); if (traceStore && !preTraceSignal?.aborted) { const traceTurnId = crypto.randomUUID(); - // F257: try pipeline bridge first — richer per-hook data - const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, { - turnId: traceTurnId, - threadId, - catId: catId as string, - hasNativeL0, - }); - if (bridgeResult) { - traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => { - log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)'); + if (hasNativeL0) { + // F257 #2: native-L0 identity (L1-L7) is delivered by the native L0 compiler, + // not the session pipeline. Source the trace from that ACTUAL compiled artifact + // (cache-first, shares the provider's compile) — fire-and-forget so it never + // taxes the model critical path; visible warning if the manifest is empty. + void persistNativeL0SessionTrace({ + traceStore, + catId: catId as string, + threadId, + turnId: traceTurnId, + turnResult: pipelineTurnTrace.turn, + log, }); } else { - // v0 fallback: re-collect traces via annotateSegments (legacy path) - const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; - const traceTurnContent = [invocationContext, traceModePrompt, bootstrapCtx, mcpInstructions] - .filter(Boolean) - .join('\n\n---\n\n'); - const collected = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { - mcpAvailable, - packBlocks, - }); - const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; - const summary = buildTraceSummary(collected, traceMeta); - const detail = buildTraceDetail(collected, traceMeta); - traceStore.persist(summary, detail).catch((err) => { - log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); + // Non-native: session identity IS pipeline-delivered (S-series captured trace). + const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, { + turnId: traceTurnId, + threadId, + catId: catId as string, + hasNativeL0, }); + if (bridgeResult) { + traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => { + log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)'); + }); + } else { + // v0 fallback: re-collect traces via annotateSegments (legacy/unknown-cat path) + const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; + const traceTurnContent = [invocationContext, traceModePrompt, bootstrapCtx, mcpInstructions] + .filter(Boolean) + .join('\n\n---\n\n'); + const collected = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { + mcpAvailable, + packBlocks, + }); + const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; + const summary = buildTraceSummary(collected, traceMeta); + const detail = buildTraceDetail(collected, traceMeta); + traceStore.persist(summary, detail).catch((err) => { + log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); + }); + } } } // v0 collectTrace → buildStaticIdentity(annotateSegments: true) re-populates diff --git a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts index 68fe50ac2e..3430aa9910 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts @@ -79,6 +79,7 @@ import { prepareGuideContext, } from '../../../../guides/GuideRoutingInterceptor.js'; import { triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; +import { persistNativeL0SessionTrace } from '../../../../prompt-hooks/native-l0-trace.js'; import { drainCapturedTraces, refreshOverrideSnapshot } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js'; // F257: Pipeline trace bridge — richer per-hook traces, replaces redundant v0 re-collection @@ -918,33 +919,48 @@ export async function* routeSerial( const traceStore = getTraceStore(); if (traceStore) { const traceTurnId = crypto.randomUUID(); - // F257: try pipeline bridge first — richer per-hook data - const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, { - turnId: traceTurnId, - threadId, - catId: catId as string, - hasNativeL0, - }); - if (bridgeResult) { - traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => { - log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)'); + if (hasNativeL0) { + // F257 #2: native-L0 identity (L1-L7) is delivered by the native L0 compiler, + // not the session pipeline. Source the trace from that ACTUAL compiled artifact + // (cache-first, shares the provider's compile) — fire-and-forget so it never + // taxes the model critical path; visible warning if the manifest is empty. + void persistNativeL0SessionTrace({ + traceStore, + catId: catId as string, + threadId, + turnId: traceTurnId, + turnResult: pipelineTurnTrace.turn, + log, }); } else { - // v0 fallback: re-collect traces via annotateSegments (legacy path) - const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; - const traceTurnContent = [invocationContext, traceModePrompt, bootstrapContext, mcpInstructions] - .filter(Boolean) - .join('\n\n---\n\n'); - const traceV0 = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { - mcpAvailable, - packBlocks, - }); - const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; - const summary = buildTraceSummary(traceV0, traceMeta); - const detail = buildTraceDetail(traceV0, traceMeta); - traceStore.persist(summary, detail).catch((err) => { - log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); + // Non-native: session identity IS pipeline-delivered (S-series captured trace). + const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, { + turnId: traceTurnId, + threadId, + catId: catId as string, + hasNativeL0, }); + if (bridgeResult) { + traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => { + log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)'); + }); + } else { + // v0 fallback: re-collect traces via annotateSegments (legacy/unknown-cat path) + const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? ''; + const traceTurnContent = [invocationContext, traceModePrompt, bootstrapContext, mcpInstructions] + .filter(Boolean) + .join('\n\n---\n\n'); + const traceV0 = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, { + mcpAvailable, + packBlocks, + }); + const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string }; + const summary = buildTraceSummary(traceV0, traceMeta); + const detail = buildTraceDetail(traceV0, traceMeta); + traceStore.persist(summary, detail).catch((err) => { + log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); + }); + } } } // v0 collectTrace → buildStaticIdentity(annotateSegments: true) re-populates diff --git a/packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts b/packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts new file mode 100644 index 0000000000..f06657bfcb --- /dev/null +++ b/packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts @@ -0,0 +1,84 @@ +/** + * F257 #2 — L0 manifest → session trace adapter. + * + * Converts the per-segment L1-L7 manifest emitted by the ACTUAL L0 compiler + * (`getL0ManifestViaSubprocess`) into a session `PipelineResult`, so the existing + * trace bridge (`buildFromPipeline` → `eventsToSegments`) persists it as per-segment + * `ObservedSegment`s — no second persistence format. + * + * Why this and not the (rejected) `collectNativeL0SessionTrace`: that reran the API + * hook pipeline (a separate code path) and could report OVERRIDDEN L content the + * override-blind native compiler never delivered. This adapter's input IS the compiled + * artifact, so hash/char/token describe exactly what the provider received. Version is + * the only field not in the artifact; it resolves from the hook registry (same source + * the segment lifeline uses), defaulting to 1 for the always-on L hooks. + */ + +import type { TraceEvent, TraceEventFired } from '@cat-cafe/shared'; +import { estimateTokens } from '../../utils/token-counter.js'; +import type { L0SegmentContent } from '../cats/services/agents/providers/l0-compiler.js'; +import type { PipelineResult } from './HookPipeline.js'; +import { getCachedRegistry } from './PipelinePromptBuilder.js'; +import { hashContent } from './trace-collector.js'; + +/** The native L0 identity is exactly these segments, in this order (compiler-emitted). */ +const CANONICAL_L_SEGMENTS = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7'] as const; + +/** + * F257 #2 (2b R2 P1-1): validate the manifest as ONE atomic L1-L7 artifact. + * Returns null when valid, else a human-readable reason. + * + * The native L0 identity is delivered as a whole, so the trace must trust it atomically: + * a partial / empty / reordered / foreign / duplicate / blank-content manifest means the + * producer (compiler / CLI) regressed, and recording it as healthy `fired` data would + * recreate the original incident (Console shows an apparently-injected iron-law segment + * that was actually dropped or empty). Any violation → reject the WHOLE manifest into the + * visible producer-failure path, never a partial success. + */ +export function validateL0Manifest(manifest: readonly L0SegmentContent[]): string | null { + if (manifest.length !== CANONICAL_L_SEGMENTS.length) { + return `expected exactly ${CANONICAL_L_SEGMENTS.length} L segments, got ${manifest.length}`; + } + for (let i = 0; i < CANONICAL_L_SEGMENTS.length; i++) { + const seg = manifest[i]; + if (!seg || seg.segmentId !== CANONICAL_L_SEGMENTS[i]) { + // Catches missing / duplicate / foreign / reordered in one canonical-order check. + return `segment[${i}] must be ${CANONICAL_L_SEGMENTS[i]}, got "${seg?.segmentId}"`; + } + if (typeof seg.content !== 'string' || seg.content.trim().length === 0) { + return `${seg.segmentId} has blank content`; + } + } + return null; +} + +/** + * Build a session-stage `PipelineResult` from the real L0 compiler manifest, or null when + * the manifest fails atomic validation (see validateL0Manifest) — callers then emit a + * visible "L not observed" signal instead of persisting a partial/false healthy trace. + */ +export function l0ManifestToSessionResult(manifest: readonly L0SegmentContent[]): PipelineResult | null { + if (validateL0Manifest(manifest) !== null) return null; + const registry = getCachedRegistry(); + const timestamp = Date.now(); + + const patches = manifest.map((seg, i) => ({ + hookId: seg.segmentId, + content: seg.content, + order: (i + 1) * 100, + })); + + const events: TraceEvent[] = manifest.map( + (seg): TraceEventFired => ({ + hookId: seg.segmentId, + stage: 'session-init', + timestamp, + status: 'fired', + version: registry?.getHook(seg.segmentId)?.manifest.version ?? 1, + contentHash: hashContent(seg.content), + tokenEstimate: estimateTokens(seg.content), + }), + ); + + return { patches, events }; +} diff --git a/packages/api/src/domains/prompt-hooks/native-l0-trace.ts b/packages/api/src/domains/prompt-hooks/native-l0-trace.ts new file mode 100644 index 0000000000..2c7b40e029 --- /dev/null +++ b/packages/api/src/domains/prompt-hooks/native-l0-trace.ts @@ -0,0 +1,71 @@ +/** + * F257 #2 — native-L0 session trace persistence (shared by route-serial + route-parallel). + * + * Persists the L1-L7 session trace from the ACTUAL L0 compiler manifest + * (`getL0ManifestViaSubprocess`), bridged through the existing `buildFromPipeline`. + * + * Fully fire-and-forget: call WITHOUT awaiting so it never taxes the model critical + * path (sol 2b R1 P2-1). The manifest is cache-first; a cold cache shares the provider's + * own compile via the l0-compiler in-flight dedup — no redundant full-stage run. An empty + * manifest emits a visible producer warning rather than silently persisting D-only, so + * "L 系列无数据" is distinguishable from a healthy zero. + * + * Centralizing here (vs. inlining in two large route functions) is also sol 2b R1 P2-2: + * one producer seam, unit-testable without driving a whole route. + */ + +import type { InjectionTraceDetail, InjectionTraceSummary } from '@cat-cafe/shared'; +import { getL0ManifestViaSubprocess } from '../cats/services/agents/providers/l0-compiler.js'; +import type { PipelineResult } from './HookPipeline.js'; +import { l0ManifestToSessionResult, validateL0Manifest } from './l0-manifest-trace.js'; +import { buildFromPipeline } from './trace-bridge.js'; + +interface TraceSink { + persist(summary: InjectionTraceSummary, detail: InjectionTraceDetail): Promise; +} + +interface TraceLogger { + warn(obj: Record, msg: string): void; +} + +export interface PersistNativeL0Params { + traceStore: TraceSink; + catId: string; + threadId: string; + turnId: string; + /** The already-drained per-turn (D-series) pipeline trace for this invocation. */ + turnResult: PipelineResult | null; + log: TraceLogger; +} + +export async function persistNativeL0SessionTrace(params: PersistNativeL0Params): Promise { + const { traceStore, catId, threadId, turnId, turnResult, log } = params; + try { + const manifest = await getL0ManifestViaSubprocess({ catId }); + // 2b R2 P1-1: reject the manifest atomically. A partial/foreign/blank/reordered manifest + // is a producer regression — surface WHY (visible signal), never persist a partial success. + const rejectReason = validateL0Manifest(manifest); + if (rejectReason) { + log.warn( + { catId, threadId, reason: rejectReason }, + '[F257] native L0 manifest rejected — L1-L7 not observed this turn (producer signal)', + ); + } + const sessionResult = l0ManifestToSessionResult(manifest); // null iff rejectReason + const bridge = buildFromPipeline(sessionResult, turnResult, { + turnId, + threadId, + catId, + hasNativeL0: true, + sessionFromNativeCompiler: sessionResult !== null, + }); + if (bridge) { + await traceStore.persist(bridge.summary, bridge.detail); + } + } catch (err) { + log.warn( + { err: err instanceof Error ? err.message : String(err), catId, threadId }, + '[F257] native L0 session trace failed (fire-and-forget)', + ); + } +} diff --git a/packages/api/src/domains/prompt-hooks/trace-bridge.ts b/packages/api/src/domains/prompt-hooks/trace-bridge.ts index fdaadee610..5e270bcc8a 100644 --- a/packages/api/src/domains/prompt-hooks/trace-bridge.ts +++ b/packages/api/src/domains/prompt-hooks/trace-bridge.ts @@ -34,6 +34,12 @@ export interface TraceBridgeMeta { threadId: string; catId: string; hasNativeL0: boolean; + /** + * F257 #2: the session result is the native L0 compiler's L1-L7 manifest (delivered + * via `--system-prompt-file` / native carrier), so the session-stage delivery channel + * is `native-l0`, not `pack-only` (which stays correct for actual pack blocks). + */ + sessionFromNativeCompiler?: boolean; } /** @@ -61,7 +67,7 @@ export function buildFromPipeline( const sessionChars = sumChars(sessionResult); const turnChars = sumChars(turnResult); - const delivery = buildDelivery(sessionResult, turnResult, meta.hasNativeL0); + const delivery = buildDelivery(sessionResult, turnResult, meta.hasNativeL0, meta.sessionFromNativeCompiler ?? false); const timestamp = Date.now(); const summary: InjectionTraceSummary = { @@ -193,16 +199,26 @@ function buildDelivery( sessionResult: PipelineResult | null, turnResult: PipelineResult | null, hasNativeL0: boolean, + sessionFromNativeCompiler: boolean, ): StageDeliveryDecision[] { - const sessionChannel: DeliveryChannel = hasNativeL0 ? 'pack-only' : 'message-prepend'; + // F257 #2: L1-L7 sourced from the native compiler manifest → 'native-l0'. Only the + // pack-blocks path (no compiler manifest) stays 'pack-only'. + const sessionChannel: DeliveryChannel = sessionFromNativeCompiler + ? 'native-l0' + : hasNativeL0 + ? 'pack-only' + : 'message-prepend'; + const sessionReason = sessionFromNativeCompiler + ? 'Pipeline bridge: L1-L7 delivered via native L0 compiler artifact' + : hasNativeL0 + ? 'Pipeline bridge: pack-only for native L0' + : 'Pipeline bridge: content assembled for message-prepend'; return [ { stage: 'session-init' as InjectionStage, contentAssembled: sessionResult !== null && sessionResult.patches.length > 0, channel: sessionChannel, - reason: hasNativeL0 - ? 'Pipeline bridge: pack-only for native L0' - : 'Pipeline bridge: content assembled for message-prepend', + reason: sessionReason, }, { stage: 'per-turn' as InjectionStage, diff --git a/packages/api/test/f257-l0-manifest-cli.test.js b/packages/api/test/f257-l0-manifest-cli.test.js new file mode 100644 index 0000000000..c4955c46bb --- /dev/null +++ b/packages/api/test/f257-l0-manifest-cli.test.js @@ -0,0 +1,102 @@ +/** + * F257 #2 (2b R2 P2-1) — REAL L0 compiler ↔ manifest contract (no fake spawn). + * + * The unit tests use a fake spawn that assumes --manifest-out writes correct JSON; this + * guards the actual producer so it can't silently stop writing or diverge while the unit + * tests stay green. Proves: compileL0WithManifest emits exactly L1-L7 and each manifest + * content appears byte-for-byte in the compiled prompt, and the CLI's --manifest-out is + * orthogonal to --out (file + stdout modes). + * + * Test-data isolation (2b R2 P2-2): the compiler is ALWAYS pointed at a dedicated EMPTY + * temp profile dir (--profile-dir / options.profileDir), so it never reads real user + * capsule/primer data; every temp dir (profile + compile outputs) is tracked and removed. + */ + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { after, before, describe, test } from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const testDir = dirname(fileURLToPath(import.meta.url)); +// packages/api/test → up 3 → repo root → scripts/compile-system-prompt-l0.mjs +const scriptPath = resolve(testDir, '..', '..', '..', 'scripts', 'compile-system-prompt-l0.mjs'); +const L_IDS = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7']; +const CAT = 'opus'; + +const tmpDirs = []; +function mkTmp(prefix) { + const d = mkdtempSync(join(tmpdir(), prefix)); + tmpDirs.push(d); + return d; +} + +describe('F257 #2 — real compiler manifest contract (2b R2 P2-1)', () => { + let mjs; + let profileDir; + + before(async () => { + mjs = await import(pathToFileURL(scriptPath).href); + // Dedicated EMPTY profile dir — isolates the compile from any real user profile data. + profileDir = mkTmp('l0-profile-'); + }); + + after(() => { + for (const d of tmpDirs.splice(0)) { + try { + rmSync(d, { recursive: true, force: true }); + } catch { + /* best-effort temp cleanup */ + } + } + }); + + test('compileL0WithManifest: exactly L1-L7, each content byte-for-byte in the compiled prompt', async () => { + const { compiled, lSegments } = await mjs.compileL0WithManifest({ catId: CAT, profileDir }); + assert.deepEqual( + lSegments.map((s) => s.id), + L_IDS, + 'manifest is exactly L1-L7 in canonical order', + ); + for (const seg of lSegments) { + assert.ok(seg.content.trim().length > 0, `${seg.id} non-blank`); + assert.ok(compiled.includes(seg.content), `${seg.id} content appears byte-for-byte in the compiled prompt`); + } + }); + + test('CLI --manifest-out is orthogonal to --out (file mode + stdout mode)', () => { + const dir = mkTmp('l0-cli-'); + + // File mode: --out writes the prompt, --manifest-out writes the manifest. + const outPath = join(dir, 'prompt.md'); + const mPath = join(dir, 'manifest.json'); + execFileSync( + process.execPath, + [scriptPath, '--cat', CAT, '--profile-dir', profileDir, '--out', outPath, '--manifest-out', mPath], + { stdio: ['ignore', 'ignore', 'inherit'] }, + ); + const fileManifest = JSON.parse(readFileSync(mPath, 'utf8')); + assert.deepEqual( + fileManifest.map((s) => s.id), + L_IDS, + ); + const filePrompt = readFileSync(outPath, 'utf8'); + for (const seg of fileManifest) assert.ok(filePrompt.includes(seg.content), `${seg.id} in --out prompt`); + + // Stdout mode: no --out → prompt on stdout; --manifest-out still writes the manifest. + const mPath2 = join(dir, 'manifest2.json'); + const stdout = execFileSync( + process.execPath, + [scriptPath, '--cat', CAT, '--profile-dir', profileDir, '--manifest-out', mPath2], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }, + ); + const stdoutManifest = JSON.parse(readFileSync(mPath2, 'utf8')); + assert.deepEqual( + stdoutManifest.map((s) => s.id), + L_IDS, + ); + for (const seg of stdoutManifest) assert.ok(stdout.includes(seg.content), `${seg.id} in stdout prompt`); + }); +}); diff --git a/packages/api/test/f257-l0-manifest.test.js b/packages/api/test/f257-l0-manifest.test.js new file mode 100644 index 0000000000..a09a996fd7 --- /dev/null +++ b/packages/api/test/f257-l0-manifest.test.js @@ -0,0 +1,120 @@ +/** + * F257 #2 — L0 compiler manifest boundary (foundation). + * + * Proves getL0ManifestViaSubprocess() sources the per-segment L1-L7 manifest from the + * SAME subprocess compile that produces the delivered prompt string, riding the SAME + * cache/generation lifecycle (lockstep with l0Cache), and fails open (empty manifest, + * never a throw that would break the fail-closed string compile). Uses a fake spawn + * that writes the --manifest-out file exactly as the real compiler CLI does. + */ + +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + clearL0Cache, + compileL0ViaSubprocess, + getL0ManifestViaSubprocess, +} from '../dist/domains/cats/services/agents/providers/l0-compiler.js'; + +function makeRoot() { + const root = mkdtempSync(join(tmpdir(), 'l0-manifest-')); + mkdirSync(join(root, 'scripts'), { recursive: true }); + writeFileSync(join(root, 'scripts', 'compile-system-prompt-l0.mjs'), '// fake'); + return root; +} + +/** + * Fake spawn that mimics the real CLI: writes the compiled string to --out (or + * emits it on stdout), and writes the JSON manifest to --manifest-out. + */ +function buildManifestSpawn({ compiled = 'COMPILED-L0', manifest = [], exitCode = 0 }) { + const fn = function fakeSpawn(cmd, args, opts) { + fn.calls.push({ cmd, args, opts }); + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setImmediate(() => { + const outIdx = args.indexOf('--out'); + if (outIdx >= 0 && args[outIdx + 1]) writeFileSync(args[outIdx + 1], compiled, 'utf8'); + const mIdx = args.indexOf('--manifest-out'); + if (mIdx >= 0 && args[mIdx + 1] && manifest !== null) { + writeFileSync(args[mIdx + 1], JSON.stringify(manifest), 'utf8'); + } + if (outIdx < 0) child.stdout.emit('data', Buffer.from(compiled)); + child.emit('close', exitCode); + }); + return child; + }; + fn.calls = []; + return fn; +} + +const L_MANIFEST = [ + { id: 'L1', content: '你不是一个孤立的工具' }, + { id: 'L2', content: '客观性 carry-over' }, + { id: 'L3', content: '传球三选一' }, + { id: 'L4', content: '五条铁律' }, + { id: 'L5', content: 'MCP 工具 index' }, + { id: 'L6', content: '能力唤醒' }, + { id: 'L7', content: '协作哲学' }, +]; + +test('getL0ManifestViaSubprocess passes --manifest-out and returns parsed L1-L7', async () => { + clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ compiled: 'PROMPT', manifest: L_MANIFEST }); + const manifest = await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + + assert.deepEqual( + manifest.map((s) => s.segmentId), + ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7'], + ); + assert.equal(manifest[3].content, '五条铁律'); + const call = spawnFn.calls[0]; + assert.ok(call.args.includes('--manifest-out'), 'compiler invoked with --manifest-out'); +}); + +test('manifest rides l0Cache lockstep — string compile is a cache hit afterward', async () => { + clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ compiled: 'PROMPT-BODY', manifest: L_MANIFEST }); + await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + // String compile for the same cat must NOT re-spawn (both caches set together). + const str = await compileL0ViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + assert.equal(str, 'PROMPT-BODY'); + assert.equal(spawnFn.calls.length, 1, 'only one subprocess for both string + manifest'); +}); + +test('second manifest read is cache-first (no re-spawn)', async () => { + clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ manifest: L_MANIFEST }); + await getL0ManifestViaSubprocess({ catId: 'codex', cwd: root, spawnFn }); + await getL0ManifestViaSubprocess({ catId: 'codex', cwd: root, spawnFn }); + assert.equal(spawnFn.calls.length, 1, 'manifest cache-first — no second spawn'); +}); + +test('clearL0Cache drops the manifest (next read re-spawns)', async () => { + clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ manifest: L_MANIFEST }); + await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + clearL0Cache('opus-47'); + await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + assert.equal(spawnFn.calls.length, 2, 'manifest cleared with string cache — re-spawned'); +}); + +test('fail-open: missing/garbage manifest → [] but string compile still succeeds', async () => { + clearL0Cache(); + const root = makeRoot(); + // manifest:null → fake does not write the manifest file at all + const spawnFn = buildManifestSpawn({ compiled: 'STILL-COMPILES', manifest: null }); + const str = await compileL0ViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + assert.equal(str, 'STILL-COMPILES', 'critical string compile unaffected by missing manifest'); + const manifest = await getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + assert.deepEqual(manifest, [], 'no manifest → empty (visible signal), not a throw'); +}); diff --git a/packages/api/test/f257-lseries-trace.test.js b/packages/api/test/f257-lseries-trace.test.js new file mode 100644 index 0000000000..a5cb044172 --- /dev/null +++ b/packages/api/test/f257-lseries-trace.test.js @@ -0,0 +1,294 @@ +/** + * F257 #2 — native-L0 L-series (L1-L7) observability via the ACTUAL L0 compiler manifest. + * + * Reworked per sol 2b R1: the trace is sourced from the compiled artifact + * (`getL0ManifestViaSubprocess`), not an out-of-band pipeline reconstruction. Covers: + * - adapter: manifest → session PipelineResult (fired L1-L7; empty → null); + * - bridge: ObservedSegments + delivery channel `native-l0` (P1-1); + * - §16e reachability: persisted L4 found by the segment-lifeline predicate; + * - producer seam (persistNativeL0SessionTrace): success persists L1-L7 with native-l0 + * channel; empty manifest → visible warning + NO false L data (P2-1 failure path). + */ + +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, test } from 'node:test'; + +// ── FakeRedis (ZSET + SADD/SMEMBERS) — mirrors segment-lifeline.test.js ── +class FakeRedis { + constructor() { + this.kv = new Map(); + this.sorted = new Map(); + this.sets = new Map(); + } + async set(key, value) { + this.kv.set(key, value); + return 'OK'; + } + async get(key) { + return this.kv.get(key) ?? null; + } + async del(key) { + this.kv.delete(key); + return 1; + } + async zadd(key, score, member) { + const s = this.sorted.get(key) ?? new Map(); + s.set(member, score); + this.sorted.set(key, s); + return 1; + } + async zrangebyscore(key, min, max) { + const s = this.sorted.get(key); + if (!s) return []; + return [...s.entries()] + .filter(([, sc]) => sc >= min && sc <= max) + .sort((a, b) => a[1] - b[1]) + .map(([m]) => m); + } + async zrevrange(key, start, stop) { + const s = this.sorted.get(key); + if (!s) return []; + return [...s.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(start, stop + 1) + .map(([m]) => m); + } + async zrem(key, member) { + return this.sorted.get(key)?.delete(member) ? 1 : 0; + } + async sadd(key, ...members) { + const s = this.sets.get(key) ?? new Set(); + for (const m of members) s.add(m); + this.sets.set(key, s); + return members.length; + } + async smembers(key) { + return [...(this.sets.get(key) ?? [])]; + } + async scan(_c, ...args) { + const i = args.indexOf('MATCH'); + const pat = i >= 0 ? args[i + 1] : '*'; + const rx = new RegExp(`^${pat.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&').replace(/\*/g, '.*')}$`); + return ['0', [...new Set([...this.kv.keys(), ...this.sorted.keys()])].filter((k) => rx.test(k))]; + } +} + +function makeRoot() { + const root = mkdtempSync(join(tmpdir(), 'l0-lseries-')); + mkdirSync(join(root, 'scripts'), { recursive: true }); + writeFileSync(join(root, 'scripts', 'compile-system-prompt-l0.mjs'), '// fake'); + return root; +} + +/** Fake spawn that writes the compiler manifest to --manifest-out (like the real CLI). */ +function buildManifestSpawn({ compiled = 'PROMPT', manifest = [] }) { + const fn = function fakeSpawn(_cmd, args) { + fn.calls.push(args); + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setImmediate(() => { + const oi = args.indexOf('--out'); + if (oi >= 0 && args[oi + 1]) writeFileSync(args[oi + 1], compiled, 'utf8'); + const mi = args.indexOf('--manifest-out'); + if (mi >= 0 && args[mi + 1]) writeFileSync(args[mi + 1], JSON.stringify(manifest), 'utf8'); + if (oi < 0) child.stdout.emit('data', Buffer.from(compiled)); + child.emit('close', 0); + }); + return child; + }; + fn.calls = []; + return fn; +} + +const RAW = [ + { id: 'L1', content: '你不是一个孤立的工具' }, + { id: 'L2', content: '客观性 carry-over' }, + { id: 'L3', content: '传球三选一' }, + { id: 'L4', content: '五条铁律:Runtime data safety…' }, + { id: 'L5', content: 'MCP 工具 index' }, + { id: 'L6', content: '能力唤醒' }, + { id: 'L7', content: '协作哲学' }, +]; +const L_IDS = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7']; +const manifestContent = RAW.map((e) => ({ segmentId: e.id, content: e.content })); + +describe('F257 #2: native-L0 L-series via compiler manifest', () => { + let adapter; + let bridge; + let StoreMod; + let l0c; + let native; + + before(async () => { + adapter = await import('../dist/domains/prompt-hooks/l0-manifest-trace.js'); + bridge = await import('../dist/domains/prompt-hooks/trace-bridge.js'); + StoreMod = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + l0c = await import('../dist/domains/cats/services/agents/providers/l0-compiler.js'); + native = await import('../dist/domains/prompt-hooks/native-l0-trace.js'); + }); + + after(() => l0c?.clearL0Cache()); + + test('l0ManifestToSessionResult → L1-L7 fired events + content patches', () => { + const r = adapter.l0ManifestToSessionResult(manifestContent); + assert.ok(r, 'non-null for a populated manifest'); + assert.deepEqual(r.events.map((e) => e.hookId).sort(), L_IDS); + assert.ok( + r.events.every((e) => e.status === 'fired'), + 'all fired', + ); + assert.ok( + r.events.every((e) => e.contentHash && typeof e.version === 'number'), + 'hash+version set', + ); + assert.ok( + r.patches.every((p) => p.content.length > 0), + 'patches carry the compiled content', + ); + }); + + test('empty manifest → null (visible signal, not a silent empty session)', () => { + assert.equal(adapter.l0ManifestToSessionResult([]), null); + assert.match(adapter.validateL0Manifest([]), /expected exactly 7/); + }); + + // 2b R2 P1-1: the manifest is ONE atomic L1-L7 artifact. A partial / foreign / duplicate / + // reordered / blank-content manifest is a producer regression → reject the WHOLE thing, + // never persist a partial "healthy" trace. red→green: every violation → reason + null. + describe('atomic manifest validation (P1-1)', () => { + const drop = (id) => manifestContent.filter((s) => s.segmentId !== id); + const cases = [ + ['partial (missing L4)', drop('L4'), /got 6/], + ['extra/foreign row (L1-L7 + X)', [...manifestContent, { segmentId: 'X9', content: 'foreign' }], /got 8/], + [ + 'foreign id replacing L4', + manifestContent.map((s) => (s.segmentId === 'L4' ? { segmentId: 'Z4', content: 'x' } : s)), + /must be L4/, + ], + [ + 'duplicate (L1 twice, missing L7)', + [manifestContent[0], ...manifestContent.slice(0, 6)], + /must be L2, got "L1"/, + ], + [ + 'blank content (L4 empty)', + manifestContent.map((s) => (s.segmentId === 'L4' ? { segmentId: 'L4', content: ' ' } : s)), + /L4 has blank content/, + ], + [ + 'reordered (L2 before L1)', + [manifestContent[1], manifestContent[0], ...manifestContent.slice(2)], + /must be L1, got "L2"/, + ], + ]; + for (const [name, mf, reasonRe] of cases) { + test(`rejects ${name} → null + descriptive reason`, () => { + assert.match(adapter.validateL0Manifest(mf), reasonRe, `${name} reason`); + assert.equal(adapter.l0ManifestToSessionResult(mf), null, `${name} → null (no partial persist)`); + }); + } + + test('exactly canonical L1-L7 non-blank → valid (null reason)', () => { + assert.equal(adapter.validateL0Manifest(manifestContent), null); + assert.ok(adapter.l0ManifestToSessionResult(manifestContent)); + }); + }); + + test('bridge maps to observed L1-L7 with native-l0 delivery channel (P1-1)', () => { + const sessionResult = adapter.l0ManifestToSessionResult(manifestContent); + const b = bridge.buildFromPipeline(sessionResult, null, { + turnId: 't1', + threadId: 'thread-A', + catId: 'opus', + hasNativeL0: true, + sessionFromNativeCompiler: true, + }); + const lSegs = b.summary.segments.filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lSegs.length, 7); + assert.ok(lSegs.every((s) => s.status === 'observed' && s.pipelineStatus === 'fired')); + const session = b.summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(session.channel, 'native-l0', 'L1-L7 delivered via native L0, not pack-only'); + }); + + test('§16e reachability: persisted L4 found by segment-lifeline predicate', async () => { + const sessionResult = adapter.l0ManifestToSessionResult(manifestContent); + const b = bridge.buildFromPipeline(sessionResult, null, { + turnId: 't1', + threadId: 'thread-A', + catId: 'opus', + hasNativeL0: true, + sessionFromNativeCompiler: true, + }); + const store = new StoreMod.InjectionTraceStore(new FakeRedis()); + await store.persist(b.summary, b.detail); + const threadIds = await store.listTracedThreadIds(); + assert.ok(threadIds.includes('thread-A')); + const summaries = await store.queryWindow('thread-A', 0, Date.now() + 1000); + const found = summaries.flatMap((s) => s.segments).filter((s) => s.segmentId === 'L4' && s.status === 'observed'); + assert.equal(found.length, 1, 'L4 reachable by the exact lifeline predicate'); + assert.ok(found[0].charCount > 0); + }); + + test('seam: persistNativeL0SessionTrace persists L1-L7 (native-l0) from the compiler cache', async () => { + l0c.clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ manifest: RAW }); + // Warm the manifest cache via the fake compiler; the helper's cache-first read hits it. + await l0c.getL0ManifestViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); + + const persisted = []; + const warns = []; + await native.persistNativeL0SessionTrace({ + traceStore: { persist: async (summary, detail) => persisted.push({ summary, detail }) }, + catId: 'opus-47', + threadId: 'thread-A', + turnId: 't1', + turnResult: null, + log: { warn: (_o, m) => warns.push(m) }, + }); + + assert.equal(persisted.length, 1, 'trace persisted'); + const lSegs = persisted[0].summary.segments.filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lSegs.length, 7, 'all L1-L7 persisted'); + const session = persisted[0].summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(session.channel, 'native-l0'); + assert.equal(warns.length, 0, 'no producer warning when manifest present'); + }); + + // 2b R2 P1-1/P2-1: a regressed producer (empty OR partial manifest) must hit the visible + // producer-failure path — warning fired, ZERO fabricated L segments persisted. + for (const [label, catId, manifest] of [ + ['empty manifest', 'codex', []], + ['partial manifest (only L1 — L2-L7 dropped)', 'sol', [{ id: 'L1', content: 'only-one' }]], + ]) { + test(`seam failure path: ${label} → visible warning + NO false L data`, async () => { + l0c.clearL0Cache(); + const root = makeRoot(); + const spawnFn = buildManifestSpawn({ compiled: 'STILL-COMPILES', manifest }); + await l0c.getL0ManifestViaSubprocess({ catId, cwd: root, spawnFn }); + + const persisted = []; + const warns = []; + await native.persistNativeL0SessionTrace({ + traceStore: { persist: async (summary) => persisted.push(summary) }, + catId, + threadId: 'thread-B', + turnId: 't2', + turnResult: null, + log: { warn: (_o, m) => warns.push(m) }, + }); + + assert.ok( + warns.some((m) => /manifest rejected/.test(m)), + 'regressed manifest emits a visible producer warning (distinguishable from healthy zero)', + ); + const lPersisted = persisted.flatMap((s) => s.segments ?? []).filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lPersisted.length, 0, 'no fabricated L segments — partial success is never persisted'); + }); + } +}); diff --git a/packages/api/test/f257-route-seam.test.js b/packages/api/test/f257-route-seam.test.js new file mode 100644 index 0000000000..82d083257c --- /dev/null +++ b/packages/api/test/f257-route-seam.test.js @@ -0,0 +1,263 @@ +/** + * F257 #2 (2b R2 P2-2) — route seam: native-L0 identity is persisted via the compiler + * manifest through BOTH route-serial and route-parallel, and non-native routing stays on + * its existing session-trace path. + * + * The unit seam test starts at persistNativeL0SessionTrace and can't catch a route dropping + * or mis-branching the call. This drives the real routes with a bootstrapped fake trace + * store + a prewarmed manifest cache (sol's recipe — no broad full-suite driver): a + * native-L0 service must yield persisted L1-L7 with channel `native-l0`; a non-native + * service must NOT (no compiler L segments, not native-l0). + */ + +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, test } from 'node:test'; + +class FakeRedis { + constructor() { + this.kv = new Map(); + this.sorted = new Map(); + this.sets = new Map(); + } + async set(k, v) { + this.kv.set(k, v); + return 'OK'; + } + async get(k) { + return this.kv.get(k) ?? null; + } + async del(k) { + this.kv.delete(k); + return 1; + } + async zadd(k, score, m) { + const s = this.sorted.get(k) ?? new Map(); + s.set(m, score); + this.sorted.set(k, s); + return 1; + } + async zrangebyscore(k, min, max) { + const s = this.sorted.get(k); + if (!s) return []; + return [...s.entries()] + .filter(([, sc]) => sc >= min && sc <= max) + .sort((a, b) => a[1] - b[1]) + .map(([m]) => m); + } + async zrevrange(k, a, b) { + const s = this.sorted.get(k); + if (!s) return []; + return [...s.entries()] + .sort((x, y) => y[1] - x[1]) + .slice(a, b + 1) + .map(([m]) => m); + } + async zrem(k, m) { + return this.sorted.get(k)?.delete(m) ? 1 : 0; + } + async sadd(k, ...ms) { + const s = this.sets.get(k) ?? new Set(); + for (const m of ms) s.add(m); + this.sets.set(k, s); + return ms.length; + } + async smembers(k) { + return [...(this.sets.get(k) ?? [])]; + } + async scan(_c, ...args) { + const i = args.indexOf('MATCH'); + const pat = i >= 0 ? args[i + 1] : '*'; + const rx = new RegExp(`^${pat.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&').replace(/\*/g, '.*')}$`); + return ['0', [...new Set([...this.kv.keys(), ...this.sorted.keys()])].filter((x) => rx.test(x))]; + } +} + +const RAW = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7'].map((id) => ({ id, content: `${id} governance content` })); + +function makeRoot() { + const root = mkdtempSync(join(tmpdir(), 'l0-seam-')); + mkdirSync(join(root, 'scripts'), { recursive: true }); + writeFileSync(join(root, 'scripts', 'compile-system-prompt-l0.mjs'), '// fake'); + return root; +} + +function buildManifestSpawn(manifest) { + return function fakeSpawn(_cmd, args) { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setImmediate(() => { + const mi = args.indexOf('--manifest-out'); + if (mi >= 0 && args[mi + 1]) writeFileSync(args[mi + 1], JSON.stringify(manifest), 'utf8'); + child.stdout.emit('data', Buffer.from('PROMPT')); + child.emit('close', 0); + }); + return child; + }; +} + +function mockService(catId, { native }) { + return { + async *invoke() { + yield { type: 'text', catId, content: 'reply', timestamp: Date.now() }; + yield { type: 'done', catId, timestamp: Date.now() }; + }, + ...(native ? { injectsL0Natively: () => true } : {}), + }; +} + +function createMockDeps(services) { + let inv = 0; + let msg = 0; + const byId = new Map(); + return { + services, + injectionTraceStore: true, // truthy → route runs the trailing drainCapturedTraces() + invocationDeps: { + registry: { + create: () => ({ invocationId: `inv-${++inv}`, callbackToken: `tok-${inv}` }), + verify: () => ({ ok: false, reason: 'unknown_invocation' }), + }, + sessionManager: { get: async () => null, getOrCreate: async () => ({}), resolveWorkingDirectory: () => '/tmp/t' }, + threadStore: { + get: async () => null, + getParticipantsWithActivity: async () => [], + updateParticipantActivity: async () => {}, + consumeMentionRoutingFeedback: async () => null, + isRebornSession: async () => false, + }, + apiUrl: 'http://127.0.0.1:3004', + }, + messageStore: { + append: async (m) => { + const s = { id: `m-${++msg}`, ...m, threadId: m.threadId ?? 'default' }; + byId.set(s.id, s); + return s; + }, + getById: async (id) => byId.get(id) ?? null, + getRecent: () => [], + getMentionsFor: () => [], + getRecentMentionsFor: () => [], + getBefore: () => [], + getByThread: () => [], + getByThreadAfter: () => [], + getByThreadBefore: () => [], + }, + draftStore: { delete: () => Promise.resolve(), touch: () => Promise.resolve(), upsert: () => Promise.resolve() }, + socketManager: { broadcastToRoom: () => {} }, + }; +} + +async function pollTrace(store, threadId, predicate, timeoutMs = 1500) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const summaries = await store.queryWindow(threadId, 0, Date.now() + 1000); + const hit = summaries.find(predicate); + if (hit) return hit; + await new Promise((r) => setTimeout(r, 20)); + } + return null; +} + +describe('F257 #2 route seam (2b R2 P2-2)', () => { + let routeParallel; + let routeSerial; + let l0c; + let StoreMod; + let catReg; + let store; + + before(async () => { + const shared = await import('@cat-cafe/shared'); + catReg = shared.catRegistry; + catReg.reset(); + for (const id of ['nativecat', 'plaincat']) { + catReg.register(id, { + displayName: '布偶猫', + nickname: id, + name: 'Ragdoll', + roleDescription: 'x', + personality: 'y', + defaultModel: 'claude-opus-4-6', + mentionPatterns: [`@${id}`], + restrictions: [], + clientId: 'anthropic', + breedId: 'ragdoll', + }); + } + routeParallel = (await import('../dist/domains/cats/services/agents/routing/route-parallel.js')).routeParallel; + routeSerial = (await import('../dist/domains/cats/services/agents/routing/route-serial.js')).routeSerial; + l0c = await import('../dist/domains/cats/services/agents/providers/l0-compiler.js'); + StoreMod = await import('../dist/domains/prompt-hooks/InjectionTraceStore.js'); + const traceBootstrap = await import('../dist/domains/prompt-hooks/trace-bootstrap.js'); + + const redis = new FakeRedis(); + traceBootstrap.bootstrapTraceStore(redis); + store = new StoreMod.InjectionTraceStore(redis); + + // Prewarm the manifest cache so the route's cache-first read hits it (no real subprocess). + l0c.clearL0Cache(); + await l0c.getL0ManifestViaSubprocess({ catId: 'nativecat', cwd: makeRoot(), spawnFn: buildManifestSpawn(RAW) }); + }); + + after(() => { + catReg?.reset(); + l0c?.clearL0Cache(); + }); + + async function drain(route, catId, threadId) { + for await (const _m of route( + createMockDeps({ [catId]: mockService(catId, { native: catId === 'nativecat' }) }), + [catId], + 'hi', + 'user1', + threadId, + {}, + )) { + // drain the route generator + } + } + + for (const [mode, getRoute] of [ + ['parallel', () => routeParallel], + ['serial', () => routeSerial], + ]) { + test(`${mode}: native-L0 cat persists L1-L7 via the compiler manifest (native-l0 channel)`, async () => { + const threadId = `seam-${mode}-native`; + await drain(getRoute(), 'nativecat', threadId); + const summary = await pollTrace(store, threadId, (s) => s.segments.some((x) => x.segmentId === 'L4')); + assert.ok(summary, `${mode}: native-L0 trace was persisted`); + const lSegs = summary.segments.filter((s) => /^L\d/.test(s.segmentId)); + assert.equal(lSegs.length, 7, 'all L1-L7 present'); + assert.ok(lSegs.every((s) => s.status === 'observed' && s.pipelineStatus === 'fired')); + const session = summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(session.channel, 'native-l0', 'session delivered via native L0'); + }); + + test(`${mode}: non-native cat stays on the existing pipeline path (message-prepend, S/D segments, no compiler L)`, async () => { + const threadId = `seam-${mode}-plain`; + await drain(getRoute(), 'plaincat', threadId); + // Non-vacuous: REQUIRE the existing path to have actually persisted a trace. If the + // non-native persistence were deleted/broken, summaries=[] would make the "no native-l0 + // / no L" checks pass falsely — so first prove a trace exists, then assert its shape. + const summary = await pollTrace(store, threadId, (s) => s.segments.length > 0); + assert.ok(summary, `${mode}: non-native path persisted a trace (existing pipeline ran)`); + const session = summary.delivery.find((d) => d.stage === 'session-init'); + assert.equal(session.channel, 'message-prepend', 'non-native session uses message-prepend, not native-l0'); + assert.ok( + !summary.segments.some((x) => /^L\d/.test(x.segmentId)), + 'non-native session trace carries no compiler L segments', + ); + const pipelineSeg = summary.segments.find((x) => /^[SD]\d/.test(x.segmentId)); + assert.ok(pipelineSeg, 'existing pipeline S/D segments present (path unchanged)'); + assert.ok( + ['observed', 'absent'].includes(pipelineSeg.status), + 'existing pipeline segment carries a real observed/absent status', + ); + }); + } +}); diff --git a/packages/api/test/l0-compiler.test.js b/packages/api/test/l0-compiler.test.js index 1dc5546b1f..7374d2b2a2 100644 --- a/packages/api/test/l0-compiler.test.js +++ b/packages/api/test/l0-compiler.test.js @@ -98,13 +98,16 @@ test('compileL0ViaSubprocess (no outPath) returns stdout as compiled L0', async const out = await compileL0ViaSubprocess({ catId: 'opus-47', cwd: root, spawnFn }); assert.match(out, /布偶猫/); const call = spawnFn.calls[0]; - assert.deepEqual(call.args, [ + // F257 #2: --manifest-out is always passed (temp path is a UUID → match on presence). + assert.deepEqual(call.args.slice(0, 5), [ resolve(root, SCRIPT_REL), '--cat', 'opus-47', '--profile-dir', resolve(root, 'private/profile'), ]); + const mIdx = call.args.indexOf('--manifest-out'); + assert.ok(mIdx >= 0 && typeof call.args[mIdx + 1] === 'string', 'passes --manifest-out '); assert.ok(!call.args.includes('--out'), 'no --out when outPath omitted'); }); @@ -116,15 +119,17 @@ test('compileL0ViaSubprocess (outPath) passes --out and returns file content', a const out = await compileL0ViaSubprocess({ catId: 'codex', cwd: root, outPath, spawnFn }); assert.equal(out, 'COMPILED-L0-FILE-CONTENT'); const call = spawnFn.calls[0]; - assert.deepEqual(call.args, [ + // F257 #2: --manifest-out is always present; --out carries the caller's path. + assert.deepEqual(call.args.slice(0, 5), [ resolve(root, SCRIPT_REL), '--cat', 'codex', '--profile-dir', resolve(root, 'private/profile'), - '--out', - outPath, ]); + const oIdx = call.args.indexOf('--out'); + assert.equal(call.args[oIdx + 1], outPath, 'passes --out '); + assert.ok(call.args.includes('--manifest-out'), 'also passes --manifest-out'); }); test('compileL0ViaSubprocess fail-closed: unresolvable script path throws', async () => { diff --git a/scripts/compile-system-prompt-l0.mjs b/scripts/compile-system-prompt-l0.mjs index 9a7511bc0c..a36caa4704 100644 --- a/scripts/compile-system-prompt-l0.mjs +++ b/scripts/compile-system-prompt-l0.mjs @@ -449,6 +449,23 @@ export function resolveUserCapsule(profileDir, catId) { * @returns {Promise} compiled L0 ready for system-prompt injection */ export async function compileL0(options) { + const { compiled } = await compileL0WithManifest(options); + return compiled; +} + +/** + * F257 #2 — compile per-cat L0 AND return the per-segment L1-L7 manifest. + * + * The L-series (governance-core identity) is delivered natively via this compiled + * artifact. `loadL0SectionTemplate()` at the 476-479 loop is the ONLY place per-segment + * L content still exists before it is flattened into the prompt (the compiler strips + * segment labels, so the flattened string is NOT re-parseable). Capturing the manifest + * here — from the actual compile — is what makes the injection trace reflect what the + * provider truly delivers, rather than an out-of-band reconstruction that can diverge. + * + * @returns {Promise<{ compiled: string, lSegments: { id: string, content: string }[] }>} + */ +export async function compileL0WithManifest(options) { await bootstrapCatRegistry(); const { catId, runtimeModel, profileDir } = options; const entry = catRegistry.tryGet(catId); @@ -472,20 +489,25 @@ export async function compileL0(options) { const resolvedProfileDir = profileDir ?? process.env.CAT_CAFE_PROFILE_DIR ?? resolve(REPO_ROOT, 'private/profile'); const capsuleSection = resolveUserCapsule(resolvedProfileDir, catId); - // Load L1-L7 section templates (static content extracted to individual files) + // Load L1-L7 section templates (static content extracted to individual files). + // Capture each rendered section as the F257 #2 manifest before flattening. let result = template; + const lSegments = []; for (const [placeholder, filename] of Object.entries(L0_SECTION_TEMPLATES)) { - result = result.replace(`{{${placeholder}}}`, loadL0SectionTemplate(filename)); + const content = loadL0SectionTemplate(filename); + lSegments.push({ id: placeholder.replace(/_CONTENT$/, ''), content }); + result = result.replace(`{{${placeholder}}}`, content); } // Dynamic per-cat substitutions - return result + const compiled = result .replace('{{IDENTITY_BLOCK}}', buildIdentityBlock(config, runtimeModel)) .replace('{{USER_CAPSULE}}', capsuleSection) .replace('{{TEAMMATE_ROSTER}}', buildTeammateRoster(catId)) .replace('{{GOVERNANCE_L0}}', governanceL0.content) .replace('{{WORKFLOW_TRIGGERS}}', buildWorkflowTriggers(config.breedId, catId, config.displayName)) .replace('{{CVO_REF}}', renderCvoRef()); + return { compiled, lSegments }; } /** @@ -508,6 +530,9 @@ export async function writeL0File(options, outPath) { // CLI: // node scripts/compile-system-prompt-l0.mjs --cat opus-47 → stdout // node scripts/compile-system-prompt-l0.mjs --cat opus-47 --out p.md → write file +// node scripts/compile-system-prompt-l0.mjs --cat opus-47 --manifest-out m.json +// → F257 #2: write the per-segment L1-L7 manifest JSON (orthogonal to --out; +// stdout still carries the prompt in stdout mode for Codex/OpenCode) // node scripts/compile-system-prompt-l0.mjs --cat opus-47 --profile-dir /abs/path // → override profile directory (gpt52 review P1: fixes symlink/packaged layouts) if (isCliEntrypoint(import.meta.url, process.argv[1])) { @@ -515,7 +540,7 @@ if (isCliEntrypoint(import.meta.url, process.argv[1])) { const catIdx = args.indexOf('--cat'); if (catIdx < 0 || !args[catIdx + 1]) { console.error( - 'Usage: node scripts/compile-system-prompt-l0.mjs --cat [--out ] [--profile-dir ]', + 'Usage: node scripts/compile-system-prompt-l0.mjs --cat [--out ] [--manifest-out ] [--profile-dir ]', ); process.exit(2); } @@ -523,11 +548,19 @@ if (isCliEntrypoint(import.meta.url, process.argv[1])) { const profileDirIdx = args.indexOf('--profile-dir'); const profileDir = profileDirIdx >= 0 ? args[profileDirIdx + 1] : undefined; const outIdx = args.indexOf('--out'); - if (outIdx >= 0 && args[outIdx + 1]) { - const outPath = args[outIdx + 1]; - await writeL0File({ catId, profileDir }, outPath); + const outPath = outIdx >= 0 && args[outIdx + 1] ? args[outIdx + 1] : undefined; + const manifestIdx = args.indexOf('--manifest-out'); + const manifestOut = manifestIdx >= 0 && args[manifestIdx + 1] ? args[manifestIdx + 1] : undefined; + + const { compiled, lSegments } = await compileL0WithManifest({ catId, profileDir }); + if (outPath) { + writeFileSync(outPath, compiled, 'utf8'); console.error(`Wrote compiled L0 for ${catId} → ${outPath}`); } else { - process.stdout.write(await compileL0({ catId, profileDir })); + process.stdout.write(compiled); + } + if (manifestOut) { + writeFileSync(manifestOut, JSON.stringify(lSegments), 'utf8'); + console.error(`Wrote L0 manifest for ${catId} (${lSegments.length} segments) → ${manifestOut}`); } }