Files
makelore/tests/unit/project-agent-runtime.test.ts

393 lines
14 KiB
TypeScript

import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import {
createInitialProjectConfig,
writeProjectConfig,
} from '../../electron/opencode/project-config';
import {
acceptProjectAgentRuntime,
markProjectAgentRuntimePending,
mutateProjectAgentRuntime,
observeProjectAgentRuntime,
preflightProjectAgentRuntime,
} from '../../electron/opencode/project-agent-runtime';
import {
isRuntimeConfigRefreshPending,
withRuntimeAcceptanceTimeout,
withRuntimeConfigCoordinator,
} from '../../electron/opencode/runtime-config-readiness';
import type { ProjectAgentConfig } from '../../shared/project-config';
function createAgent(): ProjectAgentConfig {
const now = '2026-08-17T00:00:00.000Z';
return {
id: 'game-design',
avatarId: 'avatar-01',
roleName: '游戏设计伙伴',
name: '小明',
builtIn: false,
enabled: true,
model: 'openai/gpt-4o-mini',
skillIds: [],
responsibility: {
mission: '帮助用户完成游戏设计。',
owns: [],
boundaries: [],
collaborators: [],
principles: [],
},
prompt: '',
archivedAt: null,
pinned: false,
createdAt: now,
updatedAt: now,
};
}
describe('project Agent runtime readiness', () => {
it('rethrows the runtime operation AbortError unchanged when the timeout aborts it', async () => {
vi.useFakeTimers();
const abortError = new DOMException('runtime request aborted', 'AbortError');
try {
const request = withRuntimeAcceptanceTimeout(async (signal) => await new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(abortError), { once: true });
}), 10_000);
const rejection = expect(request).rejects.toBe(abortError);
await vi.advanceTimersByTimeAsync(10_000);
await rejection;
} finally {
vi.useRealTimers();
}
});
it('keeps an uncertain latch sticky until a successful explicit apply owns a fresh generation', async () => {
let generation = 1;
const manager = {
getRuntimeGeneration: () => generation,
getRuntimeGenerationProvenance: () => 'fresh' as const,
};
await withRuntimeConfigCoordinator(manager, async (lease) => {
lease.markRefreshPending();
lease.retainRefreshPending();
});
generation = 2;
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true);
await withRuntimeConfigCoordinator(manager, async (lease) => {
expect(lease.isRefreshPending()).toBe(true);
lease.markRefreshPending();
lease.retainRefreshPending();
});
generation = 3;
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true);
await withRuntimeConfigCoordinator(manager, async (lease) => {
expect(lease.isRefreshPending()).toBe(true);
lease.markRefreshPending();
});
generation = 4;
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(false);
});
it('keeps an aborted waiter tail queued until the active project mutation releases', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-aborted-waiter-tail-'));
let releaseActive!: () => void;
try {
const initial = await createInitialProjectConfig(projectPath);
const config = await writeProjectConfig(projectPath, {
...initial,
initialized: true,
agents: [createAgent()],
});
const manager = {
getRuntimeGeneration: () => 1,
getRuntimeGenerationProvenance: () => 'fresh' as const,
};
let activeEntered!: () => void;
const activeStarted = new Promise<void>((resolve) => {
activeEntered = resolve;
});
const activeGate = new Promise<void>((resolve) => {
releaseActive = resolve;
});
const active = mutateProjectAgentRuntime(manager, projectPath, async () => {
activeEntered();
await activeGate;
return { value: undefined, config };
});
await activeStarted;
const controller = new AbortController();
const abortedWaiter = acceptProjectAgentRuntime(
manager,
projectPath,
{ listAgents: vi.fn(async () => [{ id: 'game-design' }]) },
'game-design',
async () => undefined,
controller.signal,
);
const abortError = new DOMException('queued acceptance aborted', 'AbortError');
controller.abort(abortError);
await expect(abortedWaiter).rejects.toBe(abortError);
let nextMutationEntered = false;
const nextMutation = mutateProjectAgentRuntime(manager, projectPath, async () => {
nextMutationEntered = true;
return { value: 'continued', config };
});
for (let index = 0; index < 10; index += 1) await Promise.resolve();
expect(nextMutationEntered).toBe(false);
releaseActive();
await expect(active).resolves.toBeUndefined();
await expect(nextMutation).resolves.toBe('continued');
expect(nextMutationEntered).toBe(true);
} finally {
releaseActive?.();
await rm(projectPath, { recursive: true, force: true });
}
});
it('applies the startup baseline only after the same generation becomes fresh', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-starting-unchanged-'));
try {
const initial = await createInitialProjectConfig(projectPath);
const config = await writeProjectConfig(projectPath, {
...initial,
initialized: true,
agents: [createAgent()],
});
let provenance: 'starting' | 'fresh' = 'starting';
const manager = {
getRuntimeGeneration: () => 1,
getRuntimeGenerationProvenance: () => provenance,
};
const listAgents = vi.fn(async () => [{ id: 'game-design' }]);
await expect(observeProjectAgentRuntime(manager, projectPath, config)).resolves.toMatchObject({
runtimeGeneration: 1,
desiredFingerprint: expect.any(String),
appliedFingerprint: null,
});
provenance = 'fresh';
await expect(preflightProjectAgentRuntime(
manager,
projectPath,
{ listAgents },
'game-design',
)).resolves.toEqual({ ready: true, runtimeGeneration: 1 });
expect(listAgents).toHaveBeenCalledOnce();
} finally {
await rm(projectPath, { recursive: true, force: true });
}
});
it('keeps a same-id startup edit pending after the generation becomes fresh until fresh rollover', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-starting-edited-'));
try {
const initial = await createInitialProjectConfig(projectPath);
const original = await writeProjectConfig(projectPath, {
...initial,
initialized: true,
agents: [createAgent()],
});
let runtimeGeneration = 1;
let provenance: 'starting' | 'fresh' = 'starting';
const manager = {
getRuntimeGeneration: () => runtimeGeneration,
getRuntimeGenerationProvenance: () => provenance,
};
const listAgents = vi.fn(async () => [{ id: 'game-design' }]);
await observeProjectAgentRuntime(manager, projectPath, original);
const revised = await writeProjectConfig(projectPath, {
...original,
agents: [{ ...original.agents[0], prompt: 'Revised during startup.' }],
});
await observeProjectAgentRuntime(manager, projectPath, revised);
provenance = 'fresh';
await expect(preflightProjectAgentRuntime(
manager,
projectPath,
{ listAgents },
'game-design',
)).resolves.toEqual({ ready: false, runtimeGeneration: 1 });
expect(listAgents).not.toHaveBeenCalled();
runtimeGeneration = 2;
await expect(preflightProjectAgentRuntime(
manager,
projectPath,
{ listAgents },
'game-design',
)).resolves.toEqual({ ready: true, runtimeGeneration: 2 });
expect(listAgents).toHaveBeenCalledOnce();
} finally {
await rm(projectPath, { recursive: true, force: true });
}
});
it('keeps a deleted and recreated same-id Agent pending until generation rollover', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-recreated-same-id-'));
try {
const initial = await createInitialProjectConfig(projectPath);
const agent = createAgent();
const original = await writeProjectConfig(projectPath, {
...initial,
initialized: true,
agents: [agent],
});
let runtimeGeneration = 1;
const manager = {
getRuntimeGeneration: () => runtimeGeneration,
getRuntimeGenerationProvenance: () => 'fresh' as const,
};
const listAgents = vi.fn(async () => [{ id: agent.id }]);
await expect(preflightProjectAgentRuntime(
manager,
projectPath,
{ listAgents },
agent.id,
)).resolves.toEqual({ ready: true, runtimeGeneration: 1 });
const removed = await writeProjectConfig(projectPath, {
...original,
agents: [],
});
await observeProjectAgentRuntime(manager, projectPath, removed);
const recreated = await writeProjectConfig(projectPath, {
...removed,
agents: [agent],
});
await observeProjectAgentRuntime(manager, projectPath, recreated);
listAgents.mockClear();
await expect(preflightProjectAgentRuntime(
manager,
projectPath,
{ listAgents },
agent.id,
)).resolves.toEqual({ ready: false, runtimeGeneration: 1 });
expect(listAgents).not.toHaveBeenCalled();
runtimeGeneration = 2;
await expect(preflightProjectAgentRuntime(
manager,
projectPath,
{ listAgents },
agent.id,
)).resolves.toEqual({ ready: true, runtimeGeneration: 2 });
} finally {
await rm(projectPath, { recursive: true, force: true });
}
});
it('never treats an attached runtime generation as an authoritative Agent baseline', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-attached-runtime-'));
try {
const initial = await createInitialProjectConfig(projectPath);
await writeProjectConfig(projectPath, {
...initial,
initialized: true,
agents: [createAgent()],
});
let runtimeGeneration = 1;
let provenance: 'attached' | 'fresh' = 'attached';
const manager = {
getRuntimeGeneration: () => runtimeGeneration,
getRuntimeGenerationProvenance: () => provenance,
};
const listAgents = vi.fn(async () => [{ id: 'game-design' }]);
await expect(preflightProjectAgentRuntime(
manager,
projectPath,
{ listAgents },
'game-design',
)).resolves.toEqual({ ready: false, runtimeGeneration: 1 });
expect(listAgents).not.toHaveBeenCalled();
runtimeGeneration = 2;
provenance = 'fresh';
await expect(preflightProjectAgentRuntime(
manager,
projectPath,
{ listAgents },
'game-design',
)).resolves.toEqual({ ready: true, runtimeGeneration: 2 });
expect(listAgents).toHaveBeenCalledOnce();
} finally {
await rm(projectPath, { recursive: true, force: true });
}
});
it('keeps an unknown active-generation baseline pending until generation rollover', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-unknown-baseline-'));
try {
const initial = await createInitialProjectConfig(projectPath);
const config = await writeProjectConfig(projectPath, {
...initial,
initialized: true,
agents: [createAgent()],
});
let runtimeGeneration = 1;
let provenance: 'unknown' | 'fresh' = 'unknown';
const manager = {
getRuntimeGeneration: () => runtimeGeneration,
getRuntimeGenerationProvenance: () => provenance,
};
const listAgents = vi.fn(async () => [{ id: 'game-design' }]);
const client = { listAgents };
await markProjectAgentRuntimePending(manager, projectPath, config);
await expect(preflightProjectAgentRuntime(
manager,
projectPath,
client,
'game-design',
)).resolves.toEqual({ ready: false, runtimeGeneration: 1 });
expect(listAgents).not.toHaveBeenCalled();
runtimeGeneration = 2;
provenance = 'fresh';
await expect(preflightProjectAgentRuntime(
manager,
projectPath,
client,
'game-design',
)).resolves.toEqual({ ready: true, runtimeGeneration: 2 });
expect(listAgents).toHaveBeenCalledOnce();
} finally {
await rm(projectPath, { recursive: true, force: true });
}
});
it('fails closed when a runtime generation exists but provenance is unavailable', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-missing-provenance-'));
try {
const initial = await createInitialProjectConfig(projectPath);
await writeProjectConfig(projectPath, {
...initial,
initialized: true,
agents: [createAgent()],
});
const listAgents = vi.fn(async () => [{ id: 'game-design' }]);
await expect(preflightProjectAgentRuntime(
{ getRuntimeGeneration: () => 7 },
projectPath,
{ listAgents },
'game-design',
)).resolves.toEqual({ ready: false, runtimeGeneration: 7 });
expect(listAgents).not.toHaveBeenCalled();
} finally {
await rm(projectPath, { recursive: true, force: true });
}
});
});