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((_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((resolve) => { activeEntered = resolve; }); const activeGate = new Promise((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 }); } }); });