diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 07334d65fe..376a9bdd6a 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -49,18 +49,16 @@ const PROFILE_ROOT_FILENAME = 'cordis.yml' /** * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty * value (including `'0'`/`'false'`) disables: a privacy switch prefers - * off-by-mistake over on-by-mistake. Throws when the switch is set but the - * row is absent — a silently no-op "disabled" privacy switch would keep - * exporting while the user believes it is off. + * off-by-mistake over on-by-mistake. A composition without the telemetry row + * exports nothing, so the switch is then trivially satisfied and no patch is + * generated — custom profiles need not mount telemetry to run with the + * switch set. * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset). * @param hasRow - whether the composition carries the telemetry row. - * @returns the disable patch, or `undefined` when telemetry stays enabled. + * @returns the disable patch, or `undefined` when telemetry stays enabled or is not mounted. */ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined { - if ((disabledEnv ?? '') === '') return undefined - if (!hasRow) { - throw new Error(`dsh: DSH_TELEMETRY_DISABLED is set but row "${TELEMETRY_ROW_ID}" is not in this composition`) - } + if ((disabledEnv ?? '') === '' || !hasRow) return undefined return { id: TELEMETRY_ROW_ID, disabled: true } } diff --git a/apps/cli/tests/telemetry-switch.spec.ts b/apps/cli/tests/telemetry-switch.spec.ts index 1a77e7efc7..0f44819564 100644 --- a/apps/cli/tests/telemetry-switch.spec.ts +++ b/apps/cli/tests/telemetry-switch.spec.ts @@ -13,11 +13,10 @@ describe('resolveTelemetryPatch', () => { } }) - it('fails loud when the switch is set but the row is absent', () => { - expect(() => resolveTelemetryPatch('1', false)).toThrow('DSH_TELEMETRY_DISABLED is set but row "telemetry-otel" is not in this composition') - }) - - it('ignores a missing row while the switch is unset', () => { + it('is trivially satisfied by a composition without the telemetry row', () => { + // A custom profile need not mount telemetry: nothing exports, so the + // privacy switch has nothing to disable and generates no patch. + expect(resolveTelemetryPatch('1', false)).toBeUndefined() expect(resolveTelemetryPatch(undefined, false)).toBeUndefined() }) }) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 064ea0bef1..f1b3543f22 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -67,8 +67,11 @@ async function run(events: ScriptedEvent[], options: { promptFails?: boolean } = ctx.provide('httpServer', { port: 12345 } as never) apply(ctx, { task: 'do the thing' }) // Quiescence is out of band: give the scripted stream a beat to drain, then - // flip the agent idle exactly as the loop would. + // flip the agent idle exactly as the loop would. Foreign agents and + // non-idle transitions must not settle the run. await new Promise(resolve => setTimeout(resolve, 10)) + ctx.emit('agent/status', { id: 'OTHER' } as Agent, 'idle') + ctx.emit('agent/status', { id: 'S1' } as Agent, 'running') ctx.emit('agent/status', { id: 'S1' } as Agent, 'idle') const code = await exited await ctx.fiber.dispose() @@ -86,6 +89,8 @@ const end = (turn: number, reason: string): ScriptedEvent => ({ type: 'turn/end' describe('headless runner', () => { it('aggregates to quiescence: last text wins across turns, final turn-end reason maps to exit 0', async () => { const { code, out, err } = await run([ + // Frames before the first turn/start are outside the task interval. + { type: 'assistant/message', data: { turn: 0, message: { content: [{ type: 'text', text: 'pre-task noise' }] } } }, startupTurn, // Off-session, non-text, and text-empty frames never affect the aggregate. { type: 'assistant/message', sessionId: 'OTHER', data: { turn: 1, message: { content: [{ type: 'text', text: 'other session' }] } } }, diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 2c2c34a40c..26ba3e7e25 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -163,9 +163,15 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) - it('resolves the real built frontend dist through the package exports', () => { - // The production resolver (not the test seam): this checkout builds the - // dist, so the resolved path must be the frontend package's index.html. - expect(originalResolve()).toMatch(/dist[/\\]index\.html$/) + it('resolves the real built frontend dist through the package exports, failing loud unbuilt', () => { + // The production resolver (not the test seam). A built checkout resolves + // the frontend package's index.html; a dist-less one (the CI coverage + // lane runs before any build) must fail with the build hint, never a + // silent fallback. + try { + expect(originalResolve()).toMatch(/dist[/\\]index\.html$/) + } catch (error) { + expect((error as Error).message).toContain('frontend dist not built') + } }) })