mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
* diagnostics(issue-1830): trace interruption causality * test(issue-1830): lock interruption ownership matrix * fix(codex): preserve stream deadline contract * fix(diagnostics): harden interruption trace lifecycle Refs #1830 * fix(diagnostics): harden interruption trace settlement Refs #1830 * fix(diagnostics): preserve interruption causality * fix(diagnostics): address interruption trace review * fix(diagnostics): preserve tracing observer contracts * fix(diagnostics): preserve interruption trace contracts * test(permissions): cover interactive hook interrupts
707 lines
23 KiB
TypeScript
707 lines
23 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test'
|
|
import { randomUUID } from 'crypto'
|
|
import { mkdirSync, rmSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
import {
|
|
createSdkMcpServer,
|
|
tool,
|
|
unstable_v2_createSession,
|
|
unstable_v2_resumeSession,
|
|
unstable_v2_prompt,
|
|
} from '../../src/entrypoints/sdk/index.js'
|
|
import {
|
|
getCwdState,
|
|
getOriginalCwd,
|
|
getSessionId,
|
|
getSessionProjectDir,
|
|
setCwdState,
|
|
setOriginalCwd,
|
|
switchSession,
|
|
} from '../../src/bootstrap/state.js'
|
|
import {
|
|
acquireSharedMutationLock,
|
|
releaseSharedMutationLock,
|
|
} from '../../src/test/sharedMutationLock.js'
|
|
import { clearAgentDefinitionsCache } from '../../src/tools/AgentTool/loadAgentsDir.js'
|
|
import type { SessionId } from '../../src/entrypoints/agentSdkTypes.js'
|
|
import {
|
|
drainQuery,
|
|
withTempDir,
|
|
createSessionJsonl,
|
|
createMinimalConversation,
|
|
createMultiTurnConversation,
|
|
isExpectedDrainAbort,
|
|
UUID_REGEX,
|
|
} from './helpers/query-test-doubles.js'
|
|
import { MockQueryEngine } from './helpers/mock-engine.js'
|
|
|
|
// sendMessage drains trigger init(), which checks auth. Stub it for CI.
|
|
const AUTH_KEY = 'ANTHROPIC_API_KEY'
|
|
const DISABLE_BUILTIN_AGENTS_KEY = 'CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS'
|
|
let savedApiKey: string | undefined
|
|
let savedDisableBuiltinAgents: string | undefined
|
|
let hadSavedMacro = false
|
|
let savedMacro: unknown
|
|
let originalSessionId: SessionId
|
|
let originalSessionProjectDir: string | null
|
|
let originalCwd: string
|
|
let originalOriginalCwd: string
|
|
|
|
// Collect temp dirs for cleanup
|
|
const tempDirs: string[] = []
|
|
|
|
function attachMockEngine(session: unknown, mockEngine: MockQueryEngine): void {
|
|
;(session as { setEngine(engine: MockQueryEngine): void }).setEngine(mockEngine)
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await acquireSharedMutationLock('sdk-v2-lifecycle')
|
|
savedApiKey = process.env[AUTH_KEY]
|
|
savedDisableBuiltinAgents = process.env[DISABLE_BUILTIN_AGENTS_KEY]
|
|
hadSavedMacro = Object.hasOwn(globalThis, 'MACRO')
|
|
savedMacro = (globalThis as Record<string, unknown>).MACRO
|
|
if (!savedApiKey) process.env[AUTH_KEY] = 'sk-test-v2-lifecycle-stub'
|
|
process.env[DISABLE_BUILTIN_AGENTS_KEY] = '1'
|
|
;(globalThis as Record<string, unknown>).MACRO = {
|
|
VERSION: '0.0.0-test',
|
|
DISPLAY_VERSION: '0.0.0-test',
|
|
BUILD_TIME: 'test',
|
|
ISSUES_EXPLAINER: 'test',
|
|
PACKAGE_URL: 'test',
|
|
NATIVE_PACKAGE_URL: undefined,
|
|
}
|
|
clearAgentDefinitionsCache()
|
|
})
|
|
|
|
afterAll(() => {
|
|
try {
|
|
if (savedApiKey === undefined) delete process.env[AUTH_KEY]
|
|
else process.env[AUTH_KEY] = savedApiKey
|
|
if (savedDisableBuiltinAgents === undefined) {
|
|
delete process.env[DISABLE_BUILTIN_AGENTS_KEY]
|
|
} else {
|
|
process.env[DISABLE_BUILTIN_AGENTS_KEY] = savedDisableBuiltinAgents
|
|
}
|
|
if (hadSavedMacro) {
|
|
;(globalThis as Record<string, unknown>).MACRO = savedMacro
|
|
} else {
|
|
delete (globalThis as Record<string, unknown>).MACRO
|
|
}
|
|
clearAgentDefinitionsCache()
|
|
} finally {
|
|
releaseSharedMutationLock()
|
|
}
|
|
})
|
|
|
|
afterEach(() => {
|
|
switchSession(originalSessionId, originalSessionProjectDir)
|
|
setCwdState(originalCwd)
|
|
setOriginalCwd(originalOriginalCwd)
|
|
for (const dir of tempDirs) {
|
|
try { rmSync(dir, { recursive: true, force: true }) } catch {}
|
|
}
|
|
tempDirs.length = 0
|
|
})
|
|
|
|
beforeEach(() => {
|
|
originalSessionId = getSessionId()
|
|
originalSessionProjectDir = getSessionProjectDir()
|
|
originalCwd = getCwdState()
|
|
originalOriginalCwd = getOriginalCwd()
|
|
})
|
|
|
|
describe('V2: session creation', () => {
|
|
test('createSession() returns SDKSession with valid sessionId', () => {
|
|
const session = unstable_v2_createSession({
|
|
cwd: process.cwd(),
|
|
})
|
|
expect(session.sessionId).toBeDefined()
|
|
expect(UUID_REGEX.test(session.sessionId)).toBe(true)
|
|
})
|
|
|
|
test('createSession().getMessages() returns empty array initially', () => {
|
|
const session = unstable_v2_createSession({
|
|
cwd: process.cwd(),
|
|
})
|
|
const messages = session.getMessages()
|
|
expect(Array.isArray(messages)).toBe(true)
|
|
expect(messages.length).toBe(0)
|
|
})
|
|
|
|
test('createSession() with no cwd throws', () => {
|
|
expect(() =>
|
|
unstable_v2_createSession({} as any)
|
|
).toThrow()
|
|
})
|
|
|
|
test('createSession() with model option — session created without error', () => {
|
|
const session = unstable_v2_createSession({
|
|
cwd: process.cwd(),
|
|
model: 'claude-sonnet-4-6',
|
|
})
|
|
expect(session.sessionId).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('V2: session interrupt', () => {
|
|
test('session.interrupt() does not throw', () => {
|
|
const session = unstable_v2_createSession({
|
|
cwd: process.cwd(),
|
|
})
|
|
expect(() => session.interrupt()).not.toThrow()
|
|
})
|
|
|
|
test('session.close() aborts its wrapper controller after an engine override', () => {
|
|
const abortController = new AbortController()
|
|
const session = unstable_v2_createSession({
|
|
cwd: process.cwd(),
|
|
abortController,
|
|
})
|
|
attachMockEngine(session, new MockQueryEngine())
|
|
|
|
session.close()
|
|
|
|
expect(abortController.signal.aborted).toBe(true)
|
|
})
|
|
|
|
test('session with external abortController — abort signal propagates', async () => {
|
|
const ac = new AbortController()
|
|
const session = unstable_v2_createSession({
|
|
cwd: process.cwd(),
|
|
abortController: ac,
|
|
})
|
|
ac.abort()
|
|
const messages: unknown[] = []
|
|
let caught: unknown = null
|
|
try {
|
|
for await (const msg of session.sendMessage('test')) {
|
|
messages.push(msg)
|
|
}
|
|
} catch (err) {
|
|
caught = err
|
|
}
|
|
if (caught) {
|
|
expect(isExpectedDrainAbort(caught)).toBe(true)
|
|
} else {
|
|
expect(messages.length).toBe(0)
|
|
}
|
|
}, 10_000)
|
|
})
|
|
|
|
describe('V2: session resume', () => {
|
|
test('resumeSession() loads prior messages from JSONL', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const sid = randomUUID()
|
|
const entries = createMinimalConversation(sid)
|
|
createSessionJsonl(dir, sid, entries)
|
|
|
|
const session = await unstable_v2_resumeSession(sid, { cwd: dir })
|
|
expect(session.sessionId).toBe(sid)
|
|
|
|
const messages = session.getMessages()
|
|
expect(messages.length).toBeGreaterThanOrEqual(2)
|
|
})
|
|
})
|
|
|
|
test('resumeSession() with invalid sessionId throws', async () => {
|
|
await expect(
|
|
unstable_v2_resumeSession('not-a-uuid', { cwd: process.cwd() })
|
|
).rejects.toThrow('Invalid session ID')
|
|
})
|
|
|
|
test('resumeSession() with non-existent session — creates session with empty messages', async () => {
|
|
const fakeSid = randomUUID()
|
|
const session = await unstable_v2_resumeSession(fakeSid, { cwd: process.cwd() })
|
|
expect(session.sessionId).toBe(fakeSid)
|
|
const messages = session.getMessages()
|
|
expect(messages.length).toBe(0)
|
|
})
|
|
|
|
test('resumeSession() preserves multi-turn conversation order', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const sid = randomUUID()
|
|
const entries = createMultiTurnConversation(sid, 3)
|
|
createSessionJsonl(dir, sid, entries)
|
|
|
|
const session = await unstable_v2_resumeSession(sid, { cwd: dir })
|
|
const messages = session.getMessages()
|
|
|
|
expect(messages.length).toBeGreaterThanOrEqual(6)
|
|
})
|
|
})
|
|
|
|
test('resumeSession() sets sessionProjectDir via switchSession', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const sid = randomUUID()
|
|
createSessionJsonl(dir, sid, createMinimalConversation(sid))
|
|
|
|
await unstable_v2_resumeSession(sid, { cwd: dir })
|
|
|
|
// Fix verification: resumeSession must call switchSession with the
|
|
// resolved projectPath so that transcript writes go to the correct dir.
|
|
const projectDir = getSessionProjectDir()
|
|
expect(projectDir).not.toBeNull()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('V2: permission handling', () => {
|
|
test('respondToPermission() with unknown toolUseId — no-op', () => {
|
|
const session = unstable_v2_createSession({
|
|
cwd: process.cwd(),
|
|
})
|
|
expect(() =>
|
|
session.respondToPermission('unknown-id', {
|
|
behavior: 'allow',
|
|
})
|
|
).not.toThrow()
|
|
})
|
|
|
|
test('createSession() with canUseTool callback — session created successfully', () => {
|
|
const session = unstable_v2_createSession({
|
|
cwd: process.cwd(),
|
|
canUseTool: async (name: string, _input: unknown) => ({
|
|
behavior: 'deny' as const,
|
|
message: `Tool ${name} denied by test`,
|
|
}),
|
|
})
|
|
expect(session.sessionId).toBeDefined()
|
|
})
|
|
|
|
test('createSession() with onPermissionRequest callback — session created successfully', () => {
|
|
const session = unstable_v2_createSession({
|
|
cwd: process.cwd(),
|
|
onPermissionRequest: (_msg) => {
|
|
// No-op — just verify it doesn't throw during construction
|
|
},
|
|
})
|
|
expect(session.sessionId).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('V2: SDK agents', () => {
|
|
test('createSession() injects SDK agents with maxSteps on first message', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const mockEngine = new MockQueryEngine()
|
|
const session = unstable_v2_createSession({
|
|
cwd: dir,
|
|
agents: {
|
|
helper: {
|
|
prompt: 'Help with persistent SDK agent injection coverage',
|
|
maxSteps: 2,
|
|
},
|
|
},
|
|
})
|
|
attachMockEngine(session, mockEngine)
|
|
|
|
const messages: unknown[] = []
|
|
for await (const msg of session.sendMessage('agent injection success')) {
|
|
messages.push(msg)
|
|
}
|
|
|
|
expect(
|
|
messages.some((message: any) => message?.type === 'assistant'),
|
|
).toBe(true)
|
|
expect(
|
|
mockEngine.config.agents.some(
|
|
(agent: any) =>
|
|
agent?.agentType === 'helper' &&
|
|
agent?.whenToUse === 'helper' &&
|
|
agent?.maxSteps === 2,
|
|
),
|
|
).toBe(true)
|
|
|
|
const firstTurnAgents = [...mockEngine.config.agents]
|
|
const secondTurnMessages: unknown[] = []
|
|
for await (const msg of session.sendMessage('agent injection second turn')) {
|
|
secondTurnMessages.push(msg)
|
|
}
|
|
expect(
|
|
secondTurnMessages.some((message: any) => message?.type === 'assistant'),
|
|
).toBe(true)
|
|
expect(mockEngine.config.agents.map((agent: any) => agent?.agentType)).toEqual(
|
|
firstTurnAgents.map((agent: any) => agent?.agentType),
|
|
)
|
|
expect(
|
|
mockEngine.config.agents.filter(
|
|
(agent: any) => agent?.agentType === 'helper',
|
|
),
|
|
).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
test('createSession() filters denied SDK MCP tools on every turn', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const mockEngine = new MockQueryEngine()
|
|
const deniedBash = tool(
|
|
'Bash',
|
|
'Denied persistent SDK MCP Bash duplicate',
|
|
{ type: 'object', properties: {} },
|
|
async () => ({ content: [{ type: 'text', text: 'denied' }] }),
|
|
)
|
|
const allowedSdkTool = tool(
|
|
'sdkAllowed',
|
|
'Allowed persistent SDK MCP tool',
|
|
{ type: 'object', properties: {} },
|
|
async () => ({ content: [{ type: 'text', text: 'allowed' }] }),
|
|
)
|
|
const session = unstable_v2_createSession({
|
|
cwd: dir,
|
|
disallowedTools: ['Bash'],
|
|
mcpServers: {
|
|
'sdk-tools': createSdkMcpServer({
|
|
type: 'sdk',
|
|
name: 'sdk-tools',
|
|
tools: [deniedBash, allowedSdkTool],
|
|
}),
|
|
},
|
|
})
|
|
const initialTools =
|
|
(session as unknown as { _engine: { config: { tools: unknown[] } } })
|
|
._engine.config.tools
|
|
const initialToolNames = initialTools.map((entry: any) => entry?.name)
|
|
expect(initialToolNames.length).toBeGreaterThan(0)
|
|
expect(initialToolNames).not.toContain('Bash')
|
|
mockEngine.config.tools = [...initialTools]
|
|
attachMockEngine(session, mockEngine)
|
|
|
|
const firstTurnMessages: unknown[] = []
|
|
for await (const msg of session.sendMessage('mcp deny filtering')) {
|
|
firstTurnMessages.push(msg)
|
|
}
|
|
const firstTurnToolNames = mockEngine.config.tools.map(
|
|
(entry: any) => entry?.name,
|
|
)
|
|
for (const initialToolName of initialToolNames) {
|
|
expect(firstTurnToolNames).toContain(initialToolName)
|
|
}
|
|
expect(firstTurnToolNames).toContain('sdkAllowed')
|
|
expect(firstTurnToolNames).not.toContain('Bash')
|
|
expect(
|
|
firstTurnMessages.some((message: any) => message?.type === 'assistant'),
|
|
).toBe(true)
|
|
|
|
const secondTurnMessages: unknown[] = []
|
|
for await (const msg of session.sendMessage('mcp deny filtering second turn')) {
|
|
secondTurnMessages.push(msg)
|
|
}
|
|
const secondTurnToolNames = mockEngine.config.tools.map(
|
|
(entry: any) => entry?.name,
|
|
)
|
|
expect(secondTurnToolNames).toEqual(firstTurnToolNames)
|
|
expect(secondTurnToolNames).toContain('sdkAllowed')
|
|
expect(secondTurnToolNames).not.toContain('Bash')
|
|
expect(
|
|
secondTurnMessages.some((message: any) => message?.type === 'assistant'),
|
|
).toBe(true)
|
|
})
|
|
})
|
|
|
|
test('createSession() merges filesystem and SDK agents before injection', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const agentsDir = join(dir, '.openclaude', 'agents')
|
|
mkdirSync(agentsDir, { recursive: true })
|
|
writeFileSync(
|
|
join(agentsDir, 'filesystem.md'),
|
|
[
|
|
'---',
|
|
'name: filesystem',
|
|
'description: Use for filesystem agent merge coverage',
|
|
'---',
|
|
'Filesystem agent prompt',
|
|
].join('\n'),
|
|
)
|
|
|
|
const mockEngine = new MockQueryEngine()
|
|
const session = unstable_v2_createSession({
|
|
cwd: dir,
|
|
agents: {
|
|
helper: {
|
|
description: 'Use for persistent SDK agent merge coverage',
|
|
prompt: 'Help with persistent SDK agent merge coverage',
|
|
maxSteps: 2,
|
|
},
|
|
},
|
|
})
|
|
attachMockEngine(session, mockEngine)
|
|
|
|
const messages: unknown[] = []
|
|
for await (const msg of session.sendMessage('agent merge success')) {
|
|
messages.push(msg)
|
|
}
|
|
|
|
expect(messages.some((message: any) => message?.type === 'assistant')).toBe(
|
|
true,
|
|
)
|
|
const agentTypes = mockEngine.config.agents.map(
|
|
(agent: any) => agent?.agentType,
|
|
)
|
|
expect(agentTypes).toContain('filesystem')
|
|
expect(agentTypes).toContain('helper')
|
|
|
|
const secondTurnMessages: unknown[] = []
|
|
for await (const msg of session.sendMessage('agent merge second turn')) {
|
|
secondTurnMessages.push(msg)
|
|
}
|
|
const secondTurnAgentTypes = mockEngine.config.agents.map(
|
|
(agent: any) => agent?.agentType,
|
|
)
|
|
expect(
|
|
secondTurnMessages.some((message: any) => message?.type === 'assistant'),
|
|
).toBe(true)
|
|
expect(secondTurnAgentTypes).toEqual(agentTypes)
|
|
expect(
|
|
secondTurnAgentTypes.filter(agentType => agentType === 'filesystem'),
|
|
).toHaveLength(1)
|
|
expect(secondTurnAgentTypes.filter(agentType => agentType === 'helper')).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
test('createSession() lets SDK agents override filesystem agents with the same name', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const agentsDir = join(dir, '.openclaude', 'agents')
|
|
mkdirSync(agentsDir, { recursive: true })
|
|
writeFileSync(
|
|
join(agentsDir, 'helper.md'),
|
|
[
|
|
'---',
|
|
'name: helper',
|
|
'description: Use for filesystem collision coverage',
|
|
'---',
|
|
'Filesystem helper prompt',
|
|
].join('\n'),
|
|
)
|
|
|
|
const mockEngine = new MockQueryEngine()
|
|
const session = unstable_v2_createSession({
|
|
cwd: dir,
|
|
agents: {
|
|
helper: {
|
|
description: 'Use for SDK collision coverage',
|
|
prompt: 'SDK helper prompt',
|
|
maxSteps: 2,
|
|
},
|
|
},
|
|
})
|
|
attachMockEngine(session, mockEngine)
|
|
|
|
const messages: unknown[] = []
|
|
for await (const msg of session.sendMessage('agent collision success')) {
|
|
messages.push(msg)
|
|
}
|
|
|
|
const helperAgents = mockEngine.config.agents.filter(
|
|
(agent: any) => agent?.agentType === 'helper',
|
|
)
|
|
expect(messages.some((message: any) => message?.type === 'assistant')).toBe(
|
|
true,
|
|
)
|
|
expect(helperAgents).toHaveLength(1)
|
|
expect((helperAgents[0] as any).getSystemPrompt()).toBe(
|
|
'SDK helper prompt',
|
|
)
|
|
expect((helperAgents[0] as any).maxSteps).toBe(2)
|
|
|
|
const secondTurnMessages: unknown[] = []
|
|
for await (const msg of session.sendMessage('agent collision second turn')) {
|
|
secondTurnMessages.push(msg)
|
|
}
|
|
const secondTurnHelpers = mockEngine.config.agents.filter(
|
|
(agent: any) => agent?.agentType === 'helper',
|
|
)
|
|
expect(
|
|
secondTurnMessages.some((message: any) => message?.type === 'assistant'),
|
|
).toBe(true)
|
|
expect(secondTurnHelpers).toHaveLength(1)
|
|
expect((secondTurnHelpers[0] as any).getSystemPrompt()).toBe(
|
|
'SDK helper prompt',
|
|
)
|
|
expect((secondTurnHelpers[0] as any).maxSteps).toBe(2)
|
|
})
|
|
})
|
|
|
|
test('createSession() emits invalid SDK agent failures before engine output', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const mockEngine = new MockQueryEngine()
|
|
const session = unstable_v2_createSession({
|
|
cwd: dir,
|
|
agents: {
|
|
broken: {
|
|
description: 'Use for broken persistent SDK agent coverage',
|
|
prompt: 2 as unknown as string,
|
|
},
|
|
badLimit: {
|
|
description: 'Use for invalid persistent SDK agent step limit coverage',
|
|
prompt: 'bad limit prompt',
|
|
maxSteps: 0,
|
|
},
|
|
},
|
|
})
|
|
attachMockEngine(session, mockEngine)
|
|
|
|
const messages: any[] = []
|
|
for await (const msg of session.sendMessage('agent failure visibility')) {
|
|
messages.push(msg)
|
|
}
|
|
|
|
expect(messages[0]).toMatchObject({
|
|
type: 'agent_load_failure',
|
|
stage: 'injection',
|
|
})
|
|
expect(messages[0].error_message).toContain("Invalid SDK agent 'broken'")
|
|
const loadFailures = messages.filter(
|
|
message => message?.type === 'agent_load_failure',
|
|
)
|
|
expect(loadFailures).toHaveLength(2)
|
|
expect(loadFailures[1].error_message).toContain(
|
|
"Invalid SDK agent 'badLimit'",
|
|
)
|
|
expect(loadFailures[1].error_message).toContain('maxSteps')
|
|
const assistantIndex = messages.findIndex(
|
|
message => message?.type === 'assistant',
|
|
)
|
|
expect(assistantIndex).toBeGreaterThan(1)
|
|
expect(
|
|
loadFailures.every(failure => messages.indexOf(failure) < assistantIndex),
|
|
).toBe(true)
|
|
expect(messages.some(message => message?.type === 'assistant')).toBe(true)
|
|
expect(
|
|
mockEngine.config.agents.some(
|
|
(agent: any) => agent?.agentType === 'broken',
|
|
),
|
|
).toBe(false)
|
|
expect(
|
|
mockEngine.config.agents.some(
|
|
(agent: any) => agent?.agentType === 'badLimit',
|
|
),
|
|
).toBe(false)
|
|
|
|
const secondTurnMessages: any[] = []
|
|
for await (const msg of session.sendMessage('agent failure second turn')) {
|
|
secondTurnMessages.push(msg)
|
|
}
|
|
|
|
expect(
|
|
secondTurnMessages.some(
|
|
message => message?.type === 'agent_load_failure',
|
|
),
|
|
).toBe(false)
|
|
expect(secondTurnMessages.some(message => message?.type === 'assistant')).toBe(
|
|
true,
|
|
)
|
|
})
|
|
})
|
|
|
|
test('createSession() emits filesystem agent parse failures before engine output', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const agentsDir = join(dir, '.openclaude', 'agents')
|
|
mkdirSync(agentsDir, { recursive: true })
|
|
writeFileSync(
|
|
join(agentsDir, 'broken.md'),
|
|
[
|
|
'---',
|
|
'name: broken',
|
|
'---',
|
|
'Broken filesystem agent prompt',
|
|
].join('\n'),
|
|
)
|
|
|
|
const mockEngine = new MockQueryEngine()
|
|
const session = unstable_v2_createSession({ cwd: dir })
|
|
attachMockEngine(session, mockEngine)
|
|
|
|
const messages: any[] = []
|
|
for await (const msg of session.sendMessage('agent parse failure visibility')) {
|
|
messages.push(msg)
|
|
}
|
|
|
|
expect(messages[0]).toMatchObject({
|
|
type: 'agent_load_failure',
|
|
stage: 'definitions',
|
|
})
|
|
expect(messages[0].error_message).toContain('broken.md')
|
|
expect(messages[0].error_message).toContain(
|
|
'Missing required "description" field',
|
|
)
|
|
expect(messages.some(message => message?.type === 'assistant')).toBe(true)
|
|
|
|
const secondTurnMessages: any[] = []
|
|
for await (const msg of session.sendMessage('agent parse second turn')) {
|
|
secondTurnMessages.push(msg)
|
|
}
|
|
|
|
expect(
|
|
secondTurnMessages.some(
|
|
message => message?.type === 'agent_load_failure',
|
|
),
|
|
).toBe(false)
|
|
expect(secondTurnMessages.some(message => message?.type === 'assistant')).toBe(
|
|
true,
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('V2: unstable_v2_prompt', () => {
|
|
test('throws when query completes without a result message (aborted)', async () => {
|
|
const ac = new AbortController()
|
|
// Abort immediately so the query never produces a result
|
|
ac.abort()
|
|
|
|
await expect(
|
|
unstable_v2_prompt('test', {
|
|
cwd: process.cwd(),
|
|
abortController: ac,
|
|
}),
|
|
).rejects.toThrow()
|
|
})
|
|
|
|
test('throws when cwd is missing', () => {
|
|
expect(() =>
|
|
unstable_v2_prompt('test', {} as any),
|
|
).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('E2E: transcript placement — resume sets project dir and resolve still finds file', () => {
|
|
test('resumeSession sets projectDir so resolveSessionFilePath finds the file', async () => {
|
|
await withTempDir(async (dir) => {
|
|
tempDirs.push(dir)
|
|
const sid = randomUUID()
|
|
createSessionJsonl(dir, sid, createMinimalConversation(sid))
|
|
|
|
// Before resume: file exists on disk
|
|
const { resolveSessionFilePath } = await import('../../src/utils/sessionStoragePortable.js')
|
|
const before = await resolveSessionFilePath(sid, dir)
|
|
expect(before).toBeDefined()
|
|
expect(before!.filePath).toContain(sid)
|
|
|
|
// Resume the session — this should call switchSession internally
|
|
const session = await unstable_v2_resumeSession(sid, { cwd: dir })
|
|
|
|
// Verify session is usable
|
|
expect(session.sessionId).toBe(sid)
|
|
const messages = session.getMessages()
|
|
expect(messages.length).toBeGreaterThanOrEqual(2)
|
|
|
|
// Verify project dir was set by switchSession
|
|
const projectDir = getSessionProjectDir()
|
|
expect(projectDir).not.toBeNull()
|
|
|
|
// Verify resolveSessionFilePath still finds the file at the same path
|
|
const after = await resolveSessionFilePath(sid, dir)
|
|
expect(after).toBeDefined()
|
|
expect(after!.filePath).toBe(before!.filePath)
|
|
})
|
|
})
|
|
})
|