393 lines
14 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|