fix: isolate concurrent OpenCode chat runs
This commit is contained in:
336
tests/unit/project-agent-runtime.test.ts
Normal file
336
tests/unit/project-agent-runtime.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
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('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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user