merge: absorb main into next — 23-commit divergence (08-05..13 base=main window)
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
17 content commits + 3 merge bubbles were genuinely missing from next (~8,000 lines: goal controller #1152, framework enforcement #1174/#1195, pr-edit wrapper #1173/#1200, pipefail series #1100/#1105/#1106/#1107, git-tools fixes #1073/#1085/#1086/#1089, #991, #1007, enrollment tolerance #1094). 3 commits were already in next by content (#1060 identical, #1066/#1062 evolved twins — conflicts resolved to next's side). Per-commit classification and evidence: mosaic-brain fleet/lanes/stack-remediation/main-next-divergence.md. Conflict resolutions (6 files) itemized in the PR body.
This commit is contained in:
@@ -131,13 +131,14 @@ async function exists(path: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
||||
it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => {
|
||||
it('maps a roster-v2 agent to exactly the nine generated projection keys', (): void => {
|
||||
const roster = parseRosterV2(rosterYaml, 'yaml');
|
||||
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
|
||||
expect(agent).toBeDefined();
|
||||
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
|
||||
expect(values).toEqual({
|
||||
MOSAIC_AGENT_NAME: 'coder0',
|
||||
MOSAIC_GIT_IDENTITY: 'coder0',
|
||||
MOSAIC_AGENT_CLASS: 'code',
|
||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
|
||||
|
||||
@@ -422,6 +422,7 @@ describe('fleet roster parsing', () => {
|
||||
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_GIT_IDENTITY=coder0',
|
||||
// Reflects the roster's canonicalized compatibility class (A3a).
|
||||
'MOSAIC_AGENT_CLASS=code',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
@@ -3799,6 +3800,7 @@ describe('fleet add command', () => {
|
||||
'utf8',
|
||||
);
|
||||
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||
expect(envContent).toContain('MOSAIC_GIT_IDENTITY=coder0');
|
||||
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
|
||||
});
|
||||
|
||||
|
||||
@@ -486,6 +486,7 @@ function generateAgentEnvValues(
|
||||
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
||||
return {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_GIT_IDENTITY: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
buildPiSkillArgs,
|
||||
discoverPiExtensionArgs,
|
||||
enumerateSkillDirs,
|
||||
piForceSkillNames,
|
||||
registerRuntimeLaunchers,
|
||||
@@ -178,6 +179,52 @@ describe('buildPiSkillArgs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverPiExtensionArgs', () => {
|
||||
it('loads the core and goal extensions in deterministic order from Mosaic home', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-pi-extensions-'));
|
||||
const runtimeDir = join(root, 'runtime', 'pi');
|
||||
mkdirSync(runtimeDir, { recursive: true });
|
||||
writeFileSync(join(runtimeDir, 'goal-extension.ts'), '// goal\n');
|
||||
writeFileSync(join(runtimeDir, 'mosaic-extension.ts'), '// core\n');
|
||||
|
||||
try {
|
||||
expect(discoverPiExtensionArgs(root)).toEqual([
|
||||
'--extension',
|
||||
join(runtimeDir, 'mosaic-extension.ts'),
|
||||
'--extension',
|
||||
join(runtimeDir, 'goal-extension.ts'),
|
||||
]);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('remains backward-compatible when the optional goal extension is absent', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-pi-extensions-'));
|
||||
const runtimeDir = join(root, 'runtime', 'pi');
|
||||
mkdirSync(runtimeDir, { recursive: true });
|
||||
writeFileSync(join(runtimeDir, 'mosaic-extension.ts'), '// core\n');
|
||||
|
||||
try {
|
||||
expect(discoverPiExtensionArgs(root)).toEqual([
|
||||
'--extension',
|
||||
join(runtimeDir, 'mosaic-extension.ts'),
|
||||
]);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('emits no extension arguments when Mosaic runtime assets are absent', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-pi-extensions-'));
|
||||
try {
|
||||
expect(discoverPiExtensionArgs(root)).toEqual([]);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('enumerateSkillDirs (real FS)', () => {
|
||||
let root: string;
|
||||
|
||||
|
||||
@@ -921,9 +921,15 @@ export function buildPiSkillArgs(
|
||||
return ['--no-skills', ...forcedSkillArgs];
|
||||
}
|
||||
|
||||
function discoverPiExtension(): string[] {
|
||||
const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts');
|
||||
return existsSync(ext) ? ['--extension', ext] : [];
|
||||
const PI_EXTENSION_FILES = ['mosaic-extension.ts', 'goal-extension.ts'] as const;
|
||||
|
||||
export function discoverPiExtensionArgs(mosaicHome: string = MOSAIC_HOME): string[] {
|
||||
const args: string[] = [];
|
||||
for (const fileName of PI_EXTENSION_FILES) {
|
||||
const extensionPath = join(mosaicHome, 'runtime', 'pi', fileName);
|
||||
if (existsSync(extensionPath)) args.push('--extension', extensionPath);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
// ─── Launch functions ────────────────────────────────────────────────────────
|
||||
@@ -1002,7 +1008,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
const prompt = buildRuntimePrompt('pi');
|
||||
const cliArgs = ['--append-system-prompt', prompt];
|
||||
cliArgs.push(...buildPiSkillArgs(args));
|
||||
cliArgs.push(...discoverPiExtension());
|
||||
cliArgs.push(...discoverPiExtensionArgs());
|
||||
if (hasMissionNoArgs) {
|
||||
cliArgs.push(missionPrompt);
|
||||
} else {
|
||||
|
||||
@@ -209,6 +209,20 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('deploys the Mosaic-owned Pi goal extension only inside the Mosaic runtime tree', async () => {
|
||||
const sourceRuntime = join(fixture.sourceDir, 'runtime', 'pi');
|
||||
mkdirSync(sourceRuntime, { recursive: true });
|
||||
writeFileSync(join(sourceRuntime, 'goal-extension.ts'), '// persistent goal extension\n');
|
||||
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
await adapter.syncFramework('fresh');
|
||||
|
||||
expect(
|
||||
readFileSync(join(fixture.mosaicHome, 'runtime', 'pi', 'goal-extension.ts'), 'utf-8'),
|
||||
).toBe('// persistent goal extension\n');
|
||||
expect(existsSync(join(fixture.mosaicHome, '.pi'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
||||
rmSync(fixture.defaultsDir, { recursive: true });
|
||||
|
||||
|
||||
@@ -358,6 +358,7 @@ function generatedValues(
|
||||
): Readonly<Record<string, string>> {
|
||||
return {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_GIT_IDENTITY: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.model,
|
||||
|
||||
@@ -380,7 +380,7 @@ const COMMAND_RECORDS: Readonly<Record<string, RegExp>> = {
|
||||
|
||||
const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
||||
'DATA.DOTENV.FLEET_LAUNCH':
|
||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||
'DATA.TEXT_TABLE.FLEET_TASKS':
|
||||
'| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |',
|
||||
'DATA.TEXT_DIAGRAM.BACKLOG_FLOW':
|
||||
@@ -406,7 +406,7 @@ const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
||||
'DATA.JSON.MUTATION_RESULT':
|
||||
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
|
||||
'DATA.DOTENV.GENERATED_ENV':
|
||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||
'DATA.YAML.ROSTER_FIELDS':
|
||||
'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true',
|
||||
};
|
||||
@@ -922,8 +922,8 @@ describe('fleet operator documentation', (): void => {
|
||||
);
|
||||
expect(
|
||||
surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'),
|
||||
).toHaveLength(858);
|
||||
expect(surfaces).toHaveLength(882);
|
||||
).toHaveLength(863);
|
||||
expect(surfaces).toHaveLength(887);
|
||||
|
||||
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
|
||||
const auxiliary: CodeSurface = {
|
||||
|
||||
@@ -598,6 +598,7 @@ export function projectRosterV2AgentGeneratedEnv(
|
||||
): Readonly<Record<string, string>> {
|
||||
return {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_GIT_IDENTITY: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.model,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
|
||||
const generatedValues = {
|
||||
MOSAIC_AGENT_NAME: 'coder0',
|
||||
MOSAIC_GIT_IDENTITY: 'coder0',
|
||||
MOSAIC_AGENT_CLASS: 'code',
|
||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
|
||||
@@ -45,6 +46,7 @@ describe('generated fleet agent environment boundary', (): void => {
|
||||
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_GIT_IDENTITY=coder0',
|
||||
'MOSAIC_AGENT_CLASS=code',
|
||||
'MOSAIC_AGENT_RUNTIME=pi',
|
||||
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
|
||||
@@ -78,6 +80,22 @@ describe('generated fleet agent environment boundary', (): void => {
|
||||
expect(String(error)).toMatch(/key=.*sha256=/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unsafe-git-identity', 'other/identity'],
|
||||
['git-identity-mismatch', 'reviewer0'],
|
||||
])('rejects %s before any launch consumer can use it', (code: string, identity: string): void => {
|
||||
expect((): void => {
|
||||
renderGeneratedAgentEnvironment({
|
||||
...generatedValues,
|
||||
MOSAIC_GIT_IDENTITY: identity,
|
||||
});
|
||||
}).toThrow(
|
||||
expect.objectContaining({
|
||||
diagnostic: expect.objectContaining({ code, key: 'MOSAIC_GIT_IDENTITY' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
|
||||
expect((): void => {
|
||||
renderGeneratedAgentEnvironment({
|
||||
|
||||
@@ -74,6 +74,7 @@ export class AgentEnvBoundaryError extends Error {
|
||||
|
||||
export const GENERATED_AGENT_ENV_KEYS = [
|
||||
'MOSAIC_AGENT_NAME',
|
||||
'MOSAIC_GIT_IDENTITY',
|
||||
'MOSAIC_AGENT_CLASS',
|
||||
'MOSAIC_AGENT_RUNTIME',
|
||||
'MOSAIC_AGENT_MODEL',
|
||||
@@ -403,6 +404,7 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||
}
|
||||
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
|
||||
const gitIdentity = requiredGeneratedValue(values, 'MOSAIC_GIT_IDENTITY');
|
||||
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
|
||||
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
|
||||
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
|
||||
@@ -413,6 +415,12 @@ function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
||||
|
||||
if (!AGENT_NAME.test(name))
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
|
||||
if (!AGENT_NAME.test(gitIdentity)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-git-identity', 'MOSAIC_GIT_IDENTITY', gitIdentity);
|
||||
}
|
||||
if (gitIdentity !== name) {
|
||||
throw new AgentEnvBoundaryError('git-identity-mismatch', 'MOSAIC_GIT_IDENTITY', gitIdentity);
|
||||
}
|
||||
if (!POLICY_NAME.test(className)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
|
||||
}
|
||||
|
||||
@@ -1405,6 +1405,7 @@ function generatedValues(
|
||||
): Readonly<Record<string, string>> {
|
||||
return {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_GIT_IDENTITY: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.model,
|
||||
|
||||
@@ -146,6 +146,21 @@ describe('resolveOwnership (deny-wins + fail-safe)', () => {
|
||||
expect(resolveOwnership(m, 'tools/git/pr-create.sh')).toBe('framework');
|
||||
});
|
||||
|
||||
it('limits an exact operator carve-out to that path while requiring /** for subtrees', () => {
|
||||
const exact: FrameworkManifest = {
|
||||
framework: ['tools/**'],
|
||||
operator: ['tools/git'],
|
||||
};
|
||||
expect(resolveOwnership(exact, 'tools/git')).toBe('operator');
|
||||
expect(resolveOwnership(exact, 'tools/git/guard.sh')).toBe('framework');
|
||||
|
||||
const subtree: FrameworkManifest = {
|
||||
framework: ['tools/**'],
|
||||
operator: ['tools/git/**'],
|
||||
};
|
||||
expect(resolveOwnership(subtree, 'tools/git/guard.sh')).toBe('operator');
|
||||
});
|
||||
|
||||
it('framework-declared paths resolve to framework', () => {
|
||||
expect(resolveOwnership(m, 'guides/E2E-DELIVERY.md')).toBe('framework');
|
||||
expect(resolveOwnership(m, 'CONSTITUTION.md')).toBe('framework');
|
||||
|
||||
@@ -154,12 +154,25 @@ export function matchesAny(globs: readonly string[], relPath: string): boolean {
|
||||
return globs.some((g) => matchGlob(g, relPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator entries without wildcards are exact file carve-outs. Treating them
|
||||
* as directory prefixes would let one bare entry hide an entire framework
|
||||
* subtree from reconciliation and drift detection. Operator subtree ownership
|
||||
* remains explicit through `dir/**`.
|
||||
*/
|
||||
function matchesOperatorGlob(glob: string, relPath: string): boolean {
|
||||
const pattern = normalizeRel(glob);
|
||||
if (pattern === '') return false;
|
||||
if (!pattern.includes('*')) return normalizeRel(relPath) === pattern;
|
||||
return matchGlob(pattern, relPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ownership of a mosaic-home-relative path (deny-wins / fail-safe):
|
||||
* operator globs win, then framework globs, else operator by default.
|
||||
*/
|
||||
export function resolveOwnership(manifest: FrameworkManifest, relPath: string): Ownership {
|
||||
if (matchesAny(manifest.operator, relPath)) return 'operator';
|
||||
if (manifest.operator.some((glob) => matchesOperatorGlob(glob, relPath))) return 'operator';
|
||||
if (matchesAny(manifest.framework, relPath)) return 'framework';
|
||||
return 'operator';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface FakeEntry {
|
||||
type: string;
|
||||
customType?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface SentMessage {
|
||||
message: {
|
||||
customType: string;
|
||||
content: string;
|
||||
display: boolean;
|
||||
};
|
||||
options?: {
|
||||
triggerTurn?: boolean;
|
||||
deliverAs?: 'steer' | 'followUp' | 'nextTurn';
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeContext {
|
||||
cwd: string;
|
||||
ui: {
|
||||
notifications: Array<{ message: string; level?: string }>;
|
||||
statuses: Map<string, string | undefined>;
|
||||
notify(message: string, level?: string): void;
|
||||
setStatus(key: string, value: string | undefined): void;
|
||||
};
|
||||
sessionManager: {
|
||||
getBranch(): FakeEntry[];
|
||||
};
|
||||
isIdle(): boolean;
|
||||
hasPendingMessages(): boolean;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
type EventHandler = (
|
||||
event: Record<string, unknown>,
|
||||
context: FakeContext,
|
||||
) => unknown | Promise<unknown>;
|
||||
|
||||
type CommandHandler = (args: string, context: FakeContext) => unknown | Promise<unknown>;
|
||||
|
||||
interface FakeToolResult {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
details?: unknown;
|
||||
terminate?: boolean;
|
||||
}
|
||||
|
||||
interface FakeTool {
|
||||
name: string;
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: undefined,
|
||||
context: FakeContext,
|
||||
): Promise<FakeToolResult>;
|
||||
}
|
||||
|
||||
interface GoalExtensionFactory {
|
||||
(api: FakePiApi): void;
|
||||
}
|
||||
|
||||
interface GoalExtensionModule {
|
||||
default: GoalExtensionFactory;
|
||||
}
|
||||
|
||||
interface FakePiApi {
|
||||
on(event: string, handler: EventHandler): void;
|
||||
registerCommand(name: string, options: { description: string; handler: CommandHandler }): void;
|
||||
registerTool(tool: FakeTool): void;
|
||||
appendEntry(customType: string, data?: unknown): void;
|
||||
sendMessage(message: SentMessage['message'], options?: SentMessage['options']): void;
|
||||
}
|
||||
|
||||
function isGoalExtensionModule(value: unknown): value is GoalExtensionModule {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
return typeof Reflect.get(value, 'default') === 'function';
|
||||
}
|
||||
|
||||
const goalExtensionUrl = new URL('../../framework/runtime/pi/goal-extension.ts', import.meta.url)
|
||||
.href;
|
||||
const importedGoalExtension: unknown = await import(goalExtensionUrl);
|
||||
if (!isGoalExtensionModule(importedGoalExtension)) {
|
||||
throw new Error('Pi goal extension must export a default registration function');
|
||||
}
|
||||
const registerGoalExtension = importedGoalExtension.default;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
class FakePi {
|
||||
readonly handlers = new Map<string, EventHandler[]>();
|
||||
readonly commands = new Map<string, CommandHandler>();
|
||||
readonly tools = new Map<string, FakeTool>();
|
||||
readonly entries: FakeEntry[] = [];
|
||||
readonly sentMessages: SentMessage[] = [];
|
||||
readonly notifications: Array<{ message: string; level?: string }> = [];
|
||||
readonly statuses = new Map<string, string | undefined>();
|
||||
branch: FakeEntry[] = [];
|
||||
idle = true;
|
||||
pending = false;
|
||||
abortCount = 0;
|
||||
|
||||
readonly context: FakeContext = {
|
||||
cwd: '/tmp/project',
|
||||
ui: {
|
||||
notifications: this.notifications,
|
||||
statuses: this.statuses,
|
||||
notify: (message: string, level?: string): void => {
|
||||
this.notifications.push({ message, level });
|
||||
},
|
||||
setStatus: (key: string, value: string | undefined): void => {
|
||||
this.statuses.set(key, value);
|
||||
},
|
||||
},
|
||||
sessionManager: {
|
||||
getBranch: (): FakeEntry[] => [...this.branch],
|
||||
},
|
||||
isIdle: (): boolean => this.idle,
|
||||
hasPendingMessages: (): boolean => this.pending,
|
||||
abort: (): void => {
|
||||
this.abortCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
readonly api: FakePiApi = {
|
||||
on: (event: string, handler: EventHandler): void => {
|
||||
this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]);
|
||||
},
|
||||
registerCommand: (
|
||||
name: string,
|
||||
options: { description: string; handler: CommandHandler },
|
||||
): void => {
|
||||
this.commands.set(name, options.handler);
|
||||
},
|
||||
registerTool: (tool: FakeTool): void => {
|
||||
this.tools.set(tool.name, tool);
|
||||
},
|
||||
appendEntry: (customType: string, data?: unknown): void => {
|
||||
const entry: FakeEntry = { type: 'custom', customType, data };
|
||||
this.entries.push(entry);
|
||||
this.branch.push(entry);
|
||||
},
|
||||
sendMessage: (message: SentMessage['message'], options?: SentMessage['options']): void => {
|
||||
this.sentMessages.push({ message, options });
|
||||
},
|
||||
};
|
||||
|
||||
constructor(initialBranch: FakeEntry[] = []) {
|
||||
this.branch = [...initialBranch];
|
||||
registerGoalExtension(this.api);
|
||||
}
|
||||
|
||||
async emit(event: string, value: Record<string, unknown> = {}): Promise<unknown[]> {
|
||||
const results: unknown[] = [];
|
||||
for (const handler of this.handlers.get(event) ?? []) {
|
||||
results.push(await handler(value, this.context));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async goal(args: string): Promise<void> {
|
||||
const handler = this.commands.get('goal');
|
||||
if (handler === undefined) throw new Error('/goal was not registered');
|
||||
await handler(args, this.context);
|
||||
}
|
||||
|
||||
async report(params: Record<string, unknown>): Promise<FakeToolResult> {
|
||||
const tool = this.tools.get('mosaic_goal_report');
|
||||
if (tool === undefined) throw new Error('mosaic_goal_report was not registered');
|
||||
return await tool.execute('goal-report-1', params, undefined, undefined, this.context);
|
||||
}
|
||||
}
|
||||
|
||||
function latestGoalStateData(pi: FakePi): Record<string, unknown> {
|
||||
for (let index = pi.entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = pi.entries[index];
|
||||
if (entry?.customType === 'mosaic-goal-state' && isRecord(entry.data)) return entry.data;
|
||||
}
|
||||
throw new Error('No persisted Mosaic goal state found');
|
||||
}
|
||||
|
||||
function stateField(pi: FakePi, field: string): unknown {
|
||||
return latestGoalStateData(pi)[field];
|
||||
}
|
||||
|
||||
function activeGoalStatementFromContext(result: unknown): string {
|
||||
if (!isRecord(result)) throw new Error('Context handler did not return an object');
|
||||
const messages = result['messages'];
|
||||
if (!Array.isArray(messages)) throw new Error('Context result did not include messages');
|
||||
const goalMessage = messages.find(
|
||||
(message: unknown): boolean =>
|
||||
isRecord(message) && message['customType'] === 'mosaic-goal-context',
|
||||
);
|
||||
if (!isRecord(goalMessage) || typeof goalMessage['content'] !== 'string') {
|
||||
throw new Error('Goal context message was not injected');
|
||||
}
|
||||
return goalMessage['content'];
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('Mosaic Pi goal extension commands', () => {
|
||||
it('shows help and handles controls safely when no goal exists', async () => {
|
||||
const pi = new FakePi();
|
||||
|
||||
await pi.goal('');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('/goal set');
|
||||
await pi.goal('status');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('No Mosaic goal is set');
|
||||
|
||||
for (const command of ['pause', 'resume', 'cancel']) {
|
||||
await pi.goal(command);
|
||||
expect(pi.notifications.at(-1)?.level).toBe('warning');
|
||||
}
|
||||
expect(pi.entries).toHaveLength(0);
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sets, reports, pauses, resumes, and cancels a bounded goal', async () => {
|
||||
const pi = new FakePi();
|
||||
|
||||
await pi.goal('set Deliver the local goal extension with tests');
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
expect(stateField(pi, 'statement')).toBe('Deliver the local goal extension with tests');
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
expect(pi.sentMessages[0]?.options?.triggerTurn).toBe(true);
|
||||
|
||||
await pi.goal('status');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('Deliver the local goal extension');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('active');
|
||||
|
||||
await pi.goal('pause');
|
||||
expect(stateField(pi, 'phase')).toBe('paused');
|
||||
pi.sentMessages.length = 0;
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
|
||||
await pi.goal('resume');
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
|
||||
await pi.goal('cancel');
|
||||
expect(stateField(pi, 'phase')).toBe('cancelled');
|
||||
pi.sentMessages.length = 0;
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts /goal <statement> shorthand but refuses to replace an active goal', async () => {
|
||||
const pi = new FakePi();
|
||||
|
||||
await pi.goal('First goal');
|
||||
const firstGoalId = stateField(pi, 'goalId');
|
||||
await pi.goal('set Second goal');
|
||||
|
||||
expect(stateField(pi, 'goalId')).toBe(firstGoalId);
|
||||
expect(stateField(pi, 'statement')).toBe('First goal');
|
||||
expect(pi.notifications.at(-1)?.level).toBe('warning');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('/goal cancel');
|
||||
});
|
||||
|
||||
it('rejects invalid phase transitions, aborts busy work, and supports clear as cancel', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Preserve transition safety');
|
||||
|
||||
await pi.goal('resume');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('cannot be resumed');
|
||||
pi.idle = false;
|
||||
await pi.goal('pause maintenance window');
|
||||
expect(stateField(pi, 'stopReason')).toBe('maintenance window');
|
||||
expect(pi.abortCount).toBe(1);
|
||||
await pi.goal('pause');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('cannot be paused');
|
||||
await pi.goal('clear');
|
||||
expect(stateField(pi, 'phase')).toBe('cancelled');
|
||||
expect(pi.abortCount).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects empty and oversized goal statements without starting a run', async () => {
|
||||
const pi = new FakePi();
|
||||
|
||||
await pi.goal('set');
|
||||
await pi.goal(`set ${'x'.repeat(8_001)}`);
|
||||
|
||||
expect(pi.entries).toHaveLength(0);
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
expect(pi.notifications.at(-1)?.level).toBe('warning');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mosaic Pi goal lifecycle', () => {
|
||||
it('injects one fresh active contract before every model context', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Keep the agent oriented');
|
||||
|
||||
const existingGoalContext = {
|
||||
role: 'custom',
|
||||
customType: 'mosaic-goal-context',
|
||||
content: 'stale',
|
||||
};
|
||||
const existingContinuation = {
|
||||
role: 'custom',
|
||||
customType: 'mosaic-goal-continuation',
|
||||
content: 'stale continuation',
|
||||
};
|
||||
const first = await pi.emit('context', {
|
||||
messages: [existingGoalContext, existingContinuation],
|
||||
});
|
||||
const second = await pi.emit('context', { messages: [] });
|
||||
|
||||
expect(activeGoalStatementFromContext(first[0])).toContain('Keep the agent oriented');
|
||||
expect(activeGoalStatementFromContext(first[0])).toContain('mosaic_goal_report');
|
||||
expect(activeGoalStatementFromContext(first[0])).not.toContain('stale');
|
||||
if (!isRecord(first[0]) || !Array.isArray(first[0]['messages'])) {
|
||||
throw new Error('Expected filtered context messages');
|
||||
}
|
||||
expect(first[0]['messages']).toHaveLength(1);
|
||||
expect(activeGoalStatementFromContext(second[0])).toContain('Keep the agent oriented');
|
||||
|
||||
await pi.goal('cancel');
|
||||
expect(
|
||||
await pi.emit('context', {
|
||||
messages: [existingGoalContext, existingContinuation],
|
||||
}),
|
||||
).toEqual([{ messages: [] }]);
|
||||
});
|
||||
|
||||
it('lets an existing busy run adopt the goal and waits behind a pending message', async () => {
|
||||
const busy = new FakePi();
|
||||
busy.idle = false;
|
||||
await busy.goal('set Join the current run safely');
|
||||
expect(busy.sentMessages).toHaveLength(0);
|
||||
|
||||
busy.pending = true;
|
||||
await busy.emit('agent_settled');
|
||||
expect(busy.sentMessages).toHaveLength(0);
|
||||
busy.pending = false;
|
||||
busy.idle = true;
|
||||
await busy.emit('agent_settled');
|
||||
expect(busy.sentMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('records every turn and continues once when an active run settles', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Finish all acceptance criteria');
|
||||
pi.sentMessages.length = 0;
|
||||
|
||||
await pi.emit('turn_end', { turnIndex: 0, message: {}, toolResults: [] });
|
||||
expect(stateField(pi, 'turnCount')).toBe(1);
|
||||
expect(stateField(pi, 'lastCheckSource')).toBe('turn');
|
||||
|
||||
await pi.emit('agent_settled');
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
expect(pi.sentMessages[0]?.message.content).toContain('Goal remains active');
|
||||
|
||||
await pi.emit('agent_start');
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('requires two consecutive evidence-bearing achievement reports', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Prove the feature works');
|
||||
pi.sentMessages.length = 0;
|
||||
|
||||
const first = await pi.report({
|
||||
status: 'achieved',
|
||||
summary: 'Focused tests pass',
|
||||
evidence: ['pnpm test: 12 passed'],
|
||||
});
|
||||
expect(first.terminate).toBe(true);
|
||||
expect(stateField(pi, 'phase')).toBe('verifying');
|
||||
expect(stateField(pi, 'verificationPasses')).toBe(1);
|
||||
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
expect(pi.sentMessages[0]?.message.content).toContain('verification pass');
|
||||
|
||||
await pi.emit('agent_start');
|
||||
const second = await pi.report({
|
||||
status: 'achieved',
|
||||
summary: 'Independent recheck confirms completion',
|
||||
evidence: ['rerun: 12 passed', 'framework path verified'],
|
||||
});
|
||||
expect(second.terminate).toBe(true);
|
||||
expect(stateField(pi, 'phase')).toBe('achieved');
|
||||
expect(stateField(pi, 'verificationPasses')).toBe(2);
|
||||
|
||||
pi.sentMessages.length = 0;
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects malformed progress reports and reports submitted without an active goal', async () => {
|
||||
const noGoal = new FakePi();
|
||||
await expect(
|
||||
noGoal.report({ status: 'continue', summary: 'work', evidence: [] }),
|
||||
).rejects.toThrow(/No active Mosaic goal/);
|
||||
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Validate report boundaries');
|
||||
const invalidReports: Record<string, unknown>[] = [
|
||||
{},
|
||||
{ status: 'invalid', summary: 'work', evidence: [] },
|
||||
{ status: 'continue', evidence: [] },
|
||||
{ status: 'continue', summary: ' ', evidence: [] },
|
||||
{ status: 'continue', summary: 'x'.repeat(2_001), evidence: [] },
|
||||
{ status: 'continue', summary: 'work', evidence: 'not-an-array' },
|
||||
{ status: 'continue', summary: 'work', evidence: Array.from({ length: 21 }, () => 'x') },
|
||||
{ status: 'continue', summary: 'work', evidence: [4] },
|
||||
{ status: 'continue', summary: 'work', evidence: [''] },
|
||||
{ status: 'continue', summary: 'work', evidence: ['x'.repeat(1_001)] },
|
||||
{ status: 'continue', summary: 'work', evidence: [], nextStep: 4 },
|
||||
{ status: 'continue', summary: 'work', evidence: [], nextStep: ' ' },
|
||||
{ status: 'continue', summary: 'work', evidence: [], nextStep: 'x'.repeat(2_001) },
|
||||
];
|
||||
for (const report of invalidReports) {
|
||||
await expect(pi.report(report)).rejects.toThrow();
|
||||
}
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
});
|
||||
|
||||
it('rejects an achievement claim without evidence', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Require evidence');
|
||||
|
||||
await expect(
|
||||
pi.report({ status: 'achieved', summary: 'Trust me', evidence: [] }),
|
||||
).rejects.toThrow(/evidence/i);
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
});
|
||||
|
||||
it('redacts credential-shaped goal and report text before persistence or display', async () => {
|
||||
const githubToken = `ghp_${'a'.repeat(32)}`;
|
||||
const anthropicKey = `sk-ant-api03-${'b'.repeat(40)}`;
|
||||
const bearerToken = 'header.payload.signature-canary';
|
||||
const databaseUrl = 'postgresql://mosaic:[email protected]/mosaic';
|
||||
const password = 'password-canary';
|
||||
const pi = new FakePi();
|
||||
|
||||
await pi.goal(`set Rotate ${githubToken} without retaining it`);
|
||||
const context = await pi.emit('context', { messages: [] });
|
||||
expect(activeGoalStatementFromContext(context[0])).not.toContain(githubToken);
|
||||
|
||||
const result = await pi.report({
|
||||
status: 'achieved',
|
||||
summary: `Validated ${anthropicKey}`,
|
||||
evidence: [`Authorization: Bearer ${bearerToken}`, `DATABASE_URL=${databaseUrl}`],
|
||||
nextStep: `password=${password}`,
|
||||
});
|
||||
await pi.goal('status');
|
||||
|
||||
const persisted = JSON.stringify(latestGoalStateData(pi));
|
||||
const displayed = pi.notifications.at(-1)?.message ?? '';
|
||||
const toolOutput = JSON.stringify(result);
|
||||
for (const secret of [githubToken, anthropicKey, bearerToken, databaseUrl, password]) {
|
||||
expect(persisted).not.toContain(secret);
|
||||
expect(displayed).not.toContain(secret);
|
||||
expect(toolOutput).not.toContain(secret);
|
||||
}
|
||||
expect(persisted).toContain('[REDACTED-SECRET]');
|
||||
});
|
||||
|
||||
it('preserves ordinary typed fields that resemble sensitive assignment names', async () => {
|
||||
const typedFields = 'token: string, password: boolean, secret: false';
|
||||
const pi = new FakePi();
|
||||
|
||||
await pi.goal(`set Preserve TypeScript fields: ${typedFields}`);
|
||||
await pi.report({
|
||||
status: 'continue',
|
||||
summary: `Schema still contains ${typedFields}`,
|
||||
evidence: [`interface Config { ${typedFields} }`],
|
||||
nextStep: `Keep ${typedFields} unchanged`,
|
||||
});
|
||||
|
||||
expect(stateField(pi, 'statement')).toContain(typedFields);
|
||||
expect(JSON.stringify(stateField(pi, 'lastReport'))).toContain(typedFields);
|
||||
expect(JSON.stringify(latestGoalStateData(pi))).not.toContain('[REDACTED-SECRET]');
|
||||
});
|
||||
|
||||
it('stops autonomous continuation when the max-turn limit is reached', async () => {
|
||||
vi.stubEnv('MOSAIC_GOAL_MAX_TURNS', '2');
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Bound this run');
|
||||
pi.sentMessages.length = 0;
|
||||
|
||||
await pi.emit('turn_end', { turnIndex: 0, message: {}, toolResults: [] });
|
||||
await pi.emit('turn_end', { turnIndex: 1, message: {}, toolResults: [] });
|
||||
|
||||
expect(stateField(pi, 'phase')).toBe('exhausted');
|
||||
expect(pi.abortCount).toBe(1);
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resets the no-progress sequence when a continuation report changes', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Track changing progress');
|
||||
|
||||
await pi.report({
|
||||
status: 'continue',
|
||||
summary: 'First checkpoint',
|
||||
evidence: ['file A changed'],
|
||||
nextStep: 'Run focused tests',
|
||||
});
|
||||
await pi.report({
|
||||
status: 'continue',
|
||||
summary: 'Second checkpoint',
|
||||
evidence: ['focused tests passed'],
|
||||
nextStep: 'Review the diff',
|
||||
});
|
||||
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
expect(stateField(pi, 'noProgressReports')).toBe(1);
|
||||
await pi.goal('status');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('Next step: Review the diff');
|
||||
expect(pi.notifications.at(-1)?.message).toContain('focused tests passed');
|
||||
});
|
||||
|
||||
it('stops after a bounded number of identical no-progress reports', async () => {
|
||||
vi.stubEnv('MOSAIC_GOAL_MAX_NO_PROGRESS', '2');
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Detect stalled work');
|
||||
|
||||
const report = {
|
||||
status: 'continue',
|
||||
summary: 'No change yet',
|
||||
evidence: ['same observation'],
|
||||
nextStep: 'Try again',
|
||||
};
|
||||
await pi.report(report);
|
||||
await pi.report(report);
|
||||
|
||||
expect(stateField(pi, 'phase')).toBe('exhausted');
|
||||
expect(stateField(pi, 'noProgressReports')).toBe(2);
|
||||
pi.sentMessages.length = 0;
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a goal report mixed with another tool result in the same turn', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Require a sole final report');
|
||||
await pi.report({
|
||||
status: 'achieved',
|
||||
summary: 'Premature mixed claim',
|
||||
evidence: ['one observation'],
|
||||
});
|
||||
|
||||
await pi.emit('turn_end', {
|
||||
turnIndex: 0,
|
||||
message: {},
|
||||
toolResults: [{ toolName: 'mosaic_goal_report' }, { toolName: 'read' }],
|
||||
});
|
||||
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
expect(stateField(pi, 'verificationPasses')).toBe(0);
|
||||
expect(stateField(pi, 'lastCheckOutcome')).toBe('mixed-goal-report-rejected');
|
||||
expect(pi.notifications.at(-1)?.level).toBe('warning');
|
||||
});
|
||||
|
||||
it('marks blocked reports terminal until the operator resumes', async () => {
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Stop on a real blocker');
|
||||
|
||||
await pi.report({
|
||||
status: 'blocked',
|
||||
summary: 'Missing required access',
|
||||
evidence: ['provider returned 403'],
|
||||
});
|
||||
expect(stateField(pi, 'phase')).toBe('blocked');
|
||||
|
||||
pi.sentMessages.length = 0;
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
|
||||
await pi.goal('resume');
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
expect(stateField(pi, 'turnCount')).toBe(0);
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mosaic Pi goal compaction and restoration', () => {
|
||||
it('resets provisional verification and defers manual-compaction continuation until idle', async () => {
|
||||
vi.useFakeTimers();
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Survive compaction');
|
||||
await pi.report({
|
||||
status: 'achieved',
|
||||
summary: 'Initial claim',
|
||||
evidence: ['focused test passed'],
|
||||
});
|
||||
expect(stateField(pi, 'phase')).toBe('verifying');
|
||||
pi.sentMessages.length = 0;
|
||||
|
||||
await pi.emit('session_compact', { reason: 'manual', willRetry: false });
|
||||
expect(stateField(pi, 'phase')).toBe('active');
|
||||
expect(stateField(pi, 'verificationPasses')).toBe(0);
|
||||
expect(stateField(pi, 'compactionCount')).toBe(1);
|
||||
expect(stateField(pi, 'lastCheckSource')).toBe('compact');
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
expect(pi.sentMessages[0]?.message.content).toContain('compaction');
|
||||
});
|
||||
|
||||
it('does not re-enter an active automatic compaction and relies on the settled backstop', async () => {
|
||||
vi.useFakeTimers();
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Avoid compaction races');
|
||||
pi.sentMessages.length = 0;
|
||||
pi.idle = false;
|
||||
|
||||
await pi.emit('session_compact', { reason: 'threshold', willRetry: false });
|
||||
await vi.runAllTimersAsync();
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
|
||||
pi.idle = true;
|
||||
await pi.emit('agent_settled');
|
||||
expect(pi.sentMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('restores branch-specific state on session start and tree navigation', async () => {
|
||||
vi.useFakeTimers();
|
||||
const source = new FakePi();
|
||||
await source.goal('set Restore this exact branch goal');
|
||||
const activeState = latestGoalStateData(source);
|
||||
|
||||
const restored = new FakePi([
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: activeState },
|
||||
]);
|
||||
await restored.emit('session_start', { reason: 'resume' });
|
||||
expect(restored.statuses.get('mosaic-goal')).toContain('active');
|
||||
const context = await restored.emit('context', { messages: [] });
|
||||
expect(activeGoalStatementFromContext(context[0])).toContain('Restore this exact branch goal');
|
||||
|
||||
await restored.goal('pause');
|
||||
const pausedState = latestGoalStateData(restored);
|
||||
restored.branch = [{ type: 'custom', customType: 'mosaic-goal-state', data: pausedState }];
|
||||
await restored.emit('session_tree', {});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(stateField(restored, 'phase')).toBe('paused');
|
||||
expect(restored.sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('restores only fully valid persisted states and ignores malformed entries', async () => {
|
||||
vi.useFakeTimers();
|
||||
const source = new FakePi();
|
||||
await source.goal('set Validate persisted branch state');
|
||||
await source.report({
|
||||
status: 'continue',
|
||||
summary: 'Valid report',
|
||||
evidence: ['valid evidence'],
|
||||
nextStep: 'Continue validation',
|
||||
});
|
||||
const valid = latestGoalStateData(source);
|
||||
|
||||
const validRestore = new FakePi([
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: valid },
|
||||
]);
|
||||
await validRestore.emit('session_start', { reason: 'resume' });
|
||||
expect(stateField(validRestore, 'statement')).toBe('Validate persisted branch state');
|
||||
await validRestore.emit('session_shutdown', { reason: 'reload' });
|
||||
|
||||
const validReport = latestGoalStateData(source)['lastReport'];
|
||||
if (!isRecord(validReport)) throw new Error('Expected a valid persisted report fixture');
|
||||
const integerFields = [
|
||||
'turnCount',
|
||||
'reportCount',
|
||||
'verificationPasses',
|
||||
'requiredVerificationPasses',
|
||||
'noProgressReports',
|
||||
'maxTurns',
|
||||
'maxNoProgressReports',
|
||||
'compactionCount',
|
||||
];
|
||||
const corruptions: Array<(state: Record<string, unknown>) => unknown> = [
|
||||
(): unknown => null,
|
||||
(state): unknown => ({ ...state, version: 99 }),
|
||||
(state): unknown => ({ ...state, goalId: '' }),
|
||||
(state): unknown => ({ ...state, statement: '' }),
|
||||
(state): unknown => ({ ...state, statement: 'x'.repeat(8_001) }),
|
||||
(state): unknown => ({ ...state, phase: 'unknown' }),
|
||||
(state): unknown => ({ ...state, lastCheckSource: 'unknown' }),
|
||||
(state): unknown => ({ ...state, startedAt: 4 }),
|
||||
(state): unknown => ({ ...state, lastCheckAt: 4 }),
|
||||
(state): unknown => ({ ...state, lastReport: null }),
|
||||
(state): unknown => ({ ...state, lastProgressFingerprint: 4 }),
|
||||
(state): unknown => ({ ...state, stopReason: 4 }),
|
||||
...integerFields.map((field: string) => (state: Record<string, unknown>): unknown => ({
|
||||
...state,
|
||||
[field]: -1,
|
||||
})),
|
||||
];
|
||||
|
||||
corruptions.push(
|
||||
(state: Record<string, unknown>): unknown => ({ ...state, maxTurns: 501 }),
|
||||
(state: Record<string, unknown>): unknown => ({ ...state, maxNoProgressReports: 101 }),
|
||||
(state: Record<string, unknown>): unknown => ({ ...state, requiredVerificationPasses: 3 }),
|
||||
);
|
||||
const reportCorruptions: Array<Record<string, unknown>> = [
|
||||
{ ...validReport, status: 'bad' },
|
||||
{ ...validReport, summary: '' },
|
||||
{ ...validReport, evidence: 'bad' },
|
||||
{ ...validReport, evidence: [4] },
|
||||
{ ...validReport, fingerprint: '' },
|
||||
{ ...validReport, reportedAt: '' },
|
||||
{ ...validReport, nextStep: 4 },
|
||||
];
|
||||
for (const corruptReport of reportCorruptions) {
|
||||
corruptions.push((state: Record<string, unknown>): unknown => ({
|
||||
...state,
|
||||
lastReport: corruptReport,
|
||||
}));
|
||||
}
|
||||
|
||||
for (const corrupt of corruptions) {
|
||||
const candidate = corrupt(structuredClone(valid));
|
||||
const restored = new FakePi([
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: candidate },
|
||||
]);
|
||||
await restored.emit('session_start', { reason: 'resume' });
|
||||
expect(restored.statuses.get('mosaic-goal')).toBeUndefined();
|
||||
expect(await restored.emit('context', { messages: [] })).toEqual([undefined]);
|
||||
}
|
||||
|
||||
const failClosed = new FakePi([
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: valid },
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: { ...valid, version: 99 } },
|
||||
]);
|
||||
await failClosed.emit('session_start', { reason: 'resume' });
|
||||
expect(failClosed.statuses.get('mosaic-goal')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails closed instead of reusing credential-bearing legacy branch state', async () => {
|
||||
const source = new FakePi();
|
||||
await source.goal('set Build a valid restore fixture');
|
||||
const cleanState = latestGoalStateData(source);
|
||||
const legacyState = structuredClone(cleanState);
|
||||
legacyState['statement'] = `Legacy secret ghp_${'z'.repeat(32)}`;
|
||||
|
||||
const restored = new FakePi([
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: legacyState },
|
||||
{ type: 'custom', customType: 'mosaic-goal-state', data: cleanState },
|
||||
]);
|
||||
await restored.emit('session_start', { reason: 'resume' });
|
||||
|
||||
expect(restored.statuses.get('mosaic-goal')).toBeUndefined();
|
||||
expect(await restored.emit('context', { messages: [] })).toEqual([undefined]);
|
||||
expect(restored.notifications.at(-1)?.level).toBe('warning');
|
||||
expect(restored.notifications.at(-1)?.message).toContain('was not restored');
|
||||
expect(restored.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('schedules an active tree-restored goal and preserves terminal state through compaction', async () => {
|
||||
vi.useFakeTimers();
|
||||
const source = new FakePi();
|
||||
await source.goal('set Restore active tree work');
|
||||
const active = latestGoalStateData(source);
|
||||
|
||||
const restored = new FakePi();
|
||||
restored.branch = [{ type: 'custom', customType: 'mosaic-goal-state', data: active }];
|
||||
await restored.emit('session_tree', {});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(restored.sentMessages).toHaveLength(1);
|
||||
expect(restored.sentMessages[0]?.message.content).toContain('tree navigation');
|
||||
|
||||
await restored.goal('cancel');
|
||||
await restored.emit('session_compact', { reason: 'manual', willRetry: false });
|
||||
expect(stateField(restored, 'phase')).toBe('cancelled');
|
||||
expect(stateField(restored, 'compactionCount')).toBe(1);
|
||||
});
|
||||
|
||||
it('cancels deferred continuation when the session shuts down', async () => {
|
||||
vi.useFakeTimers();
|
||||
const pi = new FakePi();
|
||||
await pi.goal('set Do not leak a stale timer');
|
||||
pi.sentMessages.length = 0;
|
||||
|
||||
await pi.emit('session_compact', { reason: 'manual', willRetry: false });
|
||||
await pi.emit('session_shutdown', { reason: 'reload' });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(pi.sentMessages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user