diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 4fffa89..218462a 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -24,6 +24,11 @@ import { addToInputHistory, getInputHistoryPath, MAX_INPUT_HISTORY, + // Agent session functions + type AgentSessionData, + loadAgentSessions, + updateAgentSession, + getAgentSessionsPath, // Worktree session functions getWorktreeSessionsDir, encodeWorktreePath, @@ -797,3 +802,100 @@ describe('updateWorktreeSession', () => { expect(sessions2.coder).toBe('wt2-session'); }); }); + +describe('provider-based session management', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('loadAgentSessions with provider', () => { + it('should return sessions when provider matches', () => { + updateAgentSession(testDir, 'coder', 'session-1', 'claude'); + + const sessions = loadAgentSessions(testDir, 'claude'); + expect(sessions.coder).toBe('session-1'); + }); + + it('should return empty when provider has changed', () => { + updateAgentSession(testDir, 'coder', 'session-1', 'claude'); + + const sessions = loadAgentSessions(testDir, 'codex'); + expect(sessions).toEqual({}); + }); + + it('should return sessions when no provider is specified (legacy)', () => { + updateAgentSession(testDir, 'coder', 'session-1'); + + const sessions = loadAgentSessions(testDir); + expect(sessions.coder).toBe('session-1'); + }); + }); + + describe('updateAgentSession with provider', () => { + it('should discard old sessions when provider changes', () => { + updateAgentSession(testDir, 'coder', 'claude-session', 'claude'); + updateAgentSession(testDir, 'coder', 'codex-session', 'codex'); + + const sessions = loadAgentSessions(testDir, 'codex'); + expect(sessions.coder).toBe('codex-session'); + // Old claude sessions should not remain + expect(Object.keys(sessions)).toHaveLength(1); + }); + + it('should store provider in session data', () => { + updateAgentSession(testDir, 'coder', 'session-1', 'claude'); + + const path = getAgentSessionsPath(testDir); + const data = JSON.parse(readFileSync(path, 'utf-8')) as AgentSessionData; + expect(data.provider).toBe('claude'); + }); + }); + + describe('loadWorktreeSessions with provider', () => { + it('should return sessions when provider matches', () => { + const worktreePath = '/project/worktree'; + updateWorktreeSession(testDir, worktreePath, 'coder', 'session-1', 'claude'); + + const sessions = loadWorktreeSessions(testDir, worktreePath, 'claude'); + expect(sessions.coder).toBe('session-1'); + }); + + it('should return empty when provider has changed', () => { + const worktreePath = '/project/worktree'; + updateWorktreeSession(testDir, worktreePath, 'coder', 'session-1', 'claude'); + + const sessions = loadWorktreeSessions(testDir, worktreePath, 'codex'); + expect(sessions).toEqual({}); + }); + }); + + describe('updateWorktreeSession with provider', () => { + it('should discard old sessions when provider changes', () => { + const worktreePath = '/project/worktree'; + updateWorktreeSession(testDir, worktreePath, 'coder', 'claude-session', 'claude'); + updateWorktreeSession(testDir, worktreePath, 'coder', 'codex-session', 'codex'); + + const sessions = loadWorktreeSessions(testDir, worktreePath, 'codex'); + expect(sessions.coder).toBe('codex-session'); + expect(Object.keys(sessions)).toHaveLength(1); + }); + + it('should store provider in session data', () => { + const worktreePath = '/project/worktree'; + updateWorktreeSession(testDir, worktreePath, 'coder', 'session-1', 'claude'); + + const sessionPath = getWorktreeSessionPath(testDir, worktreePath); + const data = JSON.parse(readFileSync(sessionPath, 'utf-8')) as AgentSessionData; + expect(data.provider).toBe('claude'); + }); + }); +}); diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts index 69e508f..d4b07c1 100644 --- a/src/__tests__/session.test.ts +++ b/src/__tests__/session.test.ts @@ -182,17 +182,20 @@ describe('NDJSON log', () => { }; appendNdjsonLine(filepath, stepStart); - const streamRecord: NdjsonRecord = { - type: 'stream', + const stepComplete: NdjsonStepComplete = { + type: 'step_complete', step: 'plan', - event: { type: 'text', data: { text: 'hello' } }, + agent: 'planner', + status: 'done', + content: 'Plan completed', + instruction: 'Create a plan', timestamp: new Date().toISOString(), }; - appendNdjsonLine(filepath, streamRecord); + appendNdjsonLine(filepath, stepComplete); const content = readFileSync(filepath, 'utf-8'); const lines = content.trim().split('\n'); - expect(lines).toHaveLength(3); // workflow_start + step_start + stream + expect(lines).toHaveLength(3); // workflow_start + step_start + step_complete const parsed0 = JSON.parse(lines[0]!) as NdjsonRecord; expect(parsed0.type).toBe('workflow_start'); @@ -206,9 +209,10 @@ describe('NDJSON log', () => { } const parsed2 = JSON.parse(lines[2]!) as NdjsonRecord; - expect(parsed2.type).toBe('stream'); - if (parsed2.type === 'stream') { - expect(parsed2.event.type).toBe('text'); + expect(parsed2.type).toBe('step_complete'); + if (parsed2.type === 'step_complete') { + expect(parsed2.step).toBe('plan'); + expect(parsed2.content).toBe('Plan completed'); } }); }); @@ -311,7 +315,7 @@ describe('NDJSON log', () => { expect(result).toBeNull(); }); - it('should skip stream and step_start records when reconstructing SessionLog', () => { + it('should skip step_start records when reconstructing SessionLog', () => { const filepath = initNdjsonLog('sess-005', 'task', 'wf', projectDir); // Add various records @@ -323,13 +327,6 @@ describe('NDJSON log', () => { timestamp: '2025-01-01T00:00:01.000Z', }); - appendNdjsonLine(filepath, { - type: 'stream', - step: 'plan', - event: { type: 'text', data: { text: 'working...' } }, - timestamp: '2025-01-01T00:00:01.500Z', - }); - appendNdjsonLine(filepath, { type: 'step_complete', step: 'plan', @@ -412,9 +409,10 @@ describe('NDJSON log', () => { // Append more records appendNdjsonLine(filepath, { - type: 'stream', + type: 'step_start', step: 'plan', - event: { type: 'text', data: { text: 'chunk1' } }, + agent: 'planner', + iteration: 1, timestamp: '2025-01-01T00:00:01.000Z', }); @@ -429,16 +427,17 @@ describe('NDJSON log', () => { for (let i = 0; i < 5; i++) { appendNdjsonLine(filepath, { - type: 'stream', - step: 'plan', - event: { type: 'text', data: { text: `chunk-${i}` } }, + type: 'step_start', + step: `step-${i}`, + agent: 'planner', + iteration: i + 1, timestamp: new Date().toISOString(), }); } const content = readFileSync(filepath, 'utf-8'); const lines = content.trim().split('\n'); - expect(lines).toHaveLength(6); // 1 init + 5 stream + expect(lines).toHaveLength(6); // 1 init + 5 step_start // Every line should be valid JSON for (const line of lines) { diff --git a/src/claude/executor.ts b/src/claude/executor.ts index 276b1e2..3dc4a35 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -25,7 +25,6 @@ import { createCanUseToolCallback, createAskUserQuestionHooks, } from './options-builder.js'; -import { resolveAnthropicApiKey } from '../config/globalConfig.js'; import type { StreamCallback, PermissionHandler, @@ -94,11 +93,10 @@ function buildSdkOptions(options: ExecuteOptions): Options { if (canUseTool) sdkOptions.canUseTool = canUseTool; if (hooks) sdkOptions.hooks = hooks; - const anthropicApiKey = options.anthropicApiKey ?? resolveAnthropicApiKey(); - if (anthropicApiKey) { + if (options.anthropicApiKey) { sdkOptions.env = { ...process.env as Record, - ANTHROPIC_API_KEY: anthropicApiKey, + ANTHROPIC_API_KEY: options.anthropicApiKey, }; } diff --git a/src/commands/session.ts b/src/commands/session.ts index 56d971c..38d3714 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -3,6 +3,7 @@ */ import { loadAgentSessions, updateAgentSession } from '../config/paths.js'; +import { loadGlobalConfig } from '../config/globalConfig.js'; import type { AgentResponse } from '../models/types.js'; /** @@ -15,13 +16,14 @@ export async function withAgentSession( fn: (sessionId?: string) => Promise, provider?: string ): Promise { - const sessions = loadAgentSessions(cwd, provider); + const resolvedProvider = provider ?? loadGlobalConfig().provider ?? 'claude'; + const sessions = loadAgentSessions(cwd, resolvedProvider); const sessionId = sessions[agentName]; const result = await fn(sessionId); if (result.sessionId) { - updateAgentSession(cwd, agentName, result.sessionId, provider); + updateAgentSession(cwd, agentName, result.sessionId, resolvedProvider); } return result; diff --git a/src/commands/workflowExecution.ts b/src/commands/workflowExecution.ts index 48e37b2..30e22bd 100644 --- a/src/commands/workflowExecution.ts +++ b/src/commands/workflowExecution.ts @@ -31,7 +31,6 @@ import { appendNdjsonLine, type NdjsonStepStart, type NdjsonStepComplete, - type NdjsonStream, type NdjsonWorkflowComplete, type NdjsonWorkflowAbort, } from '../utils/session.js'; @@ -103,28 +102,13 @@ export async function executeWorkflow( const ndjsonLogPath = initNdjsonLog(workflowSessionId, task, workflowConfig.name, projectCwd); updateLatestPointer(sessionLog, workflowSessionId, projectCwd, { copyToPrevious: true }); - // Track current step name for stream log records - const stepRef: { current: string } = { current: '' }; - // Track current display for streaming const displayRef: { current: StreamDisplay | null } = { current: null }; - // Create stream handler that delegates to UI display + writes NDJSON log + // Create stream handler that delegates to UI display const streamHandler = ( event: Parameters>[0] ): void => { - // Write stream event to NDJSON log (real-time) - if (stepRef.current) { - const record: NdjsonStream = { - type: 'stream', - step: stepRef.current, - event, - timestamp: new Date().toISOString(), - }; - appendNdjsonLine(ndjsonLogPath, record); - } - - // Delegate to UI display if (!displayRef.current) return; if (event.type === 'result') return; displayRef.current.createHandler()(event); @@ -134,14 +118,14 @@ export async function executeWorkflow( const isWorktree = cwd !== projectCwd; const currentProvider = loadGlobalConfig().provider ?? 'claude'; const savedSessions = isWorktree - ? loadWorktreeSessions(projectCwd, cwd) + ? loadWorktreeSessions(projectCwd, cwd, currentProvider) : loadAgentSessions(projectCwd, currentProvider); // Session update handler - persist session IDs when they change // Clone sessions are stored separately per clone path const sessionUpdateHandler = isWorktree ? (agentName: string, agentSessionId: string): void => { - updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId); + updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId, currentProvider); } : (agentName: string, agentSessionId: string): void => { updateAgentSession(projectCwd, agentName, agentSessionId, currentProvider); @@ -211,7 +195,6 @@ export async function executeWorkflow( } displayRef.current = new StreamDisplay(step.agentDisplayName); - stepRef.current = step.name; // Write step_start record to NDJSON log const record: NdjsonStepStart = { diff --git a/src/config/sessionStore.ts b/src/config/sessionStore.ts index 9c7857e..0d50993 100644 --- a/src/config/sessionStore.ts +++ b/src/config/sessionStore.ts @@ -207,16 +207,20 @@ export function getWorktreeSessionPath(projectDir: string, worktreePath: string) return join(dir, `${encoded}.json`); } -/** Load saved agent sessions for a worktree */ +/** Load saved agent sessions for a worktree. Returns empty if provider has changed. */ export function loadWorktreeSessions( projectDir: string, - worktreePath: string + worktreePath: string, + currentProvider?: string ): Record { const sessionPath = getWorktreeSessionPath(projectDir, worktreePath); if (existsSync(sessionPath)) { try { const content = readFileSync(sessionPath, 'utf-8'); const data = JSON.parse(content) as AgentSessionData; + if (currentProvider && data.provider !== currentProvider) { + return {}; + } return data.agentSessions || {}; } catch { return {}; @@ -230,19 +234,26 @@ export function updateWorktreeSession( projectDir: string, worktreePath: string, agentName: string, - sessionId: string + sessionId: string, + provider?: string ): void { const dir = getWorktreeSessionsDir(projectDir); ensureDir(dir); const sessionPath = getWorktreeSessionPath(projectDir, worktreePath); let sessions: Record = {}; + let existingProvider: string | undefined; if (existsSync(sessionPath)) { try { const content = readFileSync(sessionPath, 'utf-8'); const data = JSON.parse(content) as AgentSessionData; - sessions = data.agentSessions || {}; + existingProvider = data.provider; + if (provider && existingProvider && existingProvider !== provider) { + sessions = {}; + } else { + sessions = data.agentSessions || {}; + } } catch { sessions = {}; } @@ -253,6 +264,7 @@ export function updateWorktreeSession( const data: AgentSessionData = { agentSessions: sessions, updatedAt: new Date().toISOString(), + provider: provider ?? existingProvider, }; writeFileAtomic(sessionPath, JSON.stringify(data, null, 2)); } diff --git a/src/utils/session.ts b/src/utils/session.ts index e287d68..e7c1826 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -4,7 +4,6 @@ import { existsSync, readFileSync, copyFileSync, appendFileSync } from 'node:fs'; import { join } from 'node:path'; -import type { StreamEvent } from '../claude/types.js'; import { getProjectLogsDir, getGlobalLogsDir, ensureDir, writeFileAtomic } from '../config/paths.js'; /** Session log entry */ @@ -52,14 +51,6 @@ export interface NdjsonStepStart { instruction?: string; } -/** NDJSON record: streaming chunk received */ -export interface NdjsonStream { - type: 'stream'; - step: string; - event: StreamEvent; - timestamp: string; -} - /** NDJSON record: step completed */ export interface NdjsonStepComplete { type: 'step_complete'; @@ -93,7 +84,6 @@ export interface NdjsonWorkflowAbort { export type NdjsonRecord = | NdjsonWorkflowStart | NdjsonStepStart - | NdjsonStream | NdjsonStepComplete | NdjsonWorkflowComplete | NdjsonWorkflowAbort; @@ -194,7 +184,7 @@ export function loadNdjsonLog(filepath: string): SessionLog | null { } break; - // stream and step_start records are not stored in SessionLog + // step_start records are not stored in SessionLog default: break; }