This commit is contained in:
nrslib 2026-01-31 17:08:28 +09:00
parent a2ee86c7a2
commit 2b35021d45
7 changed files with 149 additions and 63 deletions

View File

@ -24,6 +24,11 @@ import {
addToInputHistory, addToInputHistory,
getInputHistoryPath, getInputHistoryPath,
MAX_INPUT_HISTORY, MAX_INPUT_HISTORY,
// Agent session functions
type AgentSessionData,
loadAgentSessions,
updateAgentSession,
getAgentSessionsPath,
// Worktree session functions // Worktree session functions
getWorktreeSessionsDir, getWorktreeSessionsDir,
encodeWorktreePath, encodeWorktreePath,
@ -797,3 +802,100 @@ describe('updateWorktreeSession', () => {
expect(sessions2.coder).toBe('wt2-session'); 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');
});
});
});

View File

@ -182,17 +182,20 @@ describe('NDJSON log', () => {
}; };
appendNdjsonLine(filepath, stepStart); appendNdjsonLine(filepath, stepStart);
const streamRecord: NdjsonRecord = { const stepComplete: NdjsonStepComplete = {
type: 'stream', type: 'step_complete',
step: 'plan', step: 'plan',
event: { type: 'text', data: { text: 'hello' } }, agent: 'planner',
status: 'done',
content: 'Plan completed',
instruction: 'Create a plan',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
appendNdjsonLine(filepath, streamRecord); appendNdjsonLine(filepath, stepComplete);
const content = readFileSync(filepath, 'utf-8'); const content = readFileSync(filepath, 'utf-8');
const lines = content.trim().split('\n'); 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; const parsed0 = JSON.parse(lines[0]!) as NdjsonRecord;
expect(parsed0.type).toBe('workflow_start'); expect(parsed0.type).toBe('workflow_start');
@ -206,9 +209,10 @@ describe('NDJSON log', () => {
} }
const parsed2 = JSON.parse(lines[2]!) as NdjsonRecord; const parsed2 = JSON.parse(lines[2]!) as NdjsonRecord;
expect(parsed2.type).toBe('stream'); expect(parsed2.type).toBe('step_complete');
if (parsed2.type === 'stream') { if (parsed2.type === 'step_complete') {
expect(parsed2.event.type).toBe('text'); expect(parsed2.step).toBe('plan');
expect(parsed2.content).toBe('Plan completed');
} }
}); });
}); });
@ -311,7 +315,7 @@ describe('NDJSON log', () => {
expect(result).toBeNull(); 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); const filepath = initNdjsonLog('sess-005', 'task', 'wf', projectDir);
// Add various records // Add various records
@ -323,13 +327,6 @@ describe('NDJSON log', () => {
timestamp: '2025-01-01T00:00:01.000Z', 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, { appendNdjsonLine(filepath, {
type: 'step_complete', type: 'step_complete',
step: 'plan', step: 'plan',
@ -412,9 +409,10 @@ describe('NDJSON log', () => {
// Append more records // Append more records
appendNdjsonLine(filepath, { appendNdjsonLine(filepath, {
type: 'stream', type: 'step_start',
step: 'plan', step: 'plan',
event: { type: 'text', data: { text: 'chunk1' } }, agent: 'planner',
iteration: 1,
timestamp: '2025-01-01T00:00:01.000Z', timestamp: '2025-01-01T00:00:01.000Z',
}); });
@ -429,16 +427,17 @@ describe('NDJSON log', () => {
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
appendNdjsonLine(filepath, { appendNdjsonLine(filepath, {
type: 'stream', type: 'step_start',
step: 'plan', step: `step-${i}`,
event: { type: 'text', data: { text: `chunk-${i}` } }, agent: 'planner',
iteration: i + 1,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} }
const content = readFileSync(filepath, 'utf-8'); const content = readFileSync(filepath, 'utf-8');
const lines = content.trim().split('\n'); 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 // Every line should be valid JSON
for (const line of lines) { for (const line of lines) {

View File

@ -25,7 +25,6 @@ import {
createCanUseToolCallback, createCanUseToolCallback,
createAskUserQuestionHooks, createAskUserQuestionHooks,
} from './options-builder.js'; } from './options-builder.js';
import { resolveAnthropicApiKey } from '../config/globalConfig.js';
import type { import type {
StreamCallback, StreamCallback,
PermissionHandler, PermissionHandler,
@ -94,11 +93,10 @@ function buildSdkOptions(options: ExecuteOptions): Options {
if (canUseTool) sdkOptions.canUseTool = canUseTool; if (canUseTool) sdkOptions.canUseTool = canUseTool;
if (hooks) sdkOptions.hooks = hooks; if (hooks) sdkOptions.hooks = hooks;
const anthropicApiKey = options.anthropicApiKey ?? resolveAnthropicApiKey(); if (options.anthropicApiKey) {
if (anthropicApiKey) {
sdkOptions.env = { sdkOptions.env = {
...process.env as Record<string, string>, ...process.env as Record<string, string>,
ANTHROPIC_API_KEY: anthropicApiKey, ANTHROPIC_API_KEY: options.anthropicApiKey,
}; };
} }

View File

@ -3,6 +3,7 @@
*/ */
import { loadAgentSessions, updateAgentSession } from '../config/paths.js'; import { loadAgentSessions, updateAgentSession } from '../config/paths.js';
import { loadGlobalConfig } from '../config/globalConfig.js';
import type { AgentResponse } from '../models/types.js'; import type { AgentResponse } from '../models/types.js';
/** /**
@ -15,13 +16,14 @@ export async function withAgentSession(
fn: (sessionId?: string) => Promise<AgentResponse>, fn: (sessionId?: string) => Promise<AgentResponse>,
provider?: string provider?: string
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const sessions = loadAgentSessions(cwd, provider); const resolvedProvider = provider ?? loadGlobalConfig().provider ?? 'claude';
const sessions = loadAgentSessions(cwd, resolvedProvider);
const sessionId = sessions[agentName]; const sessionId = sessions[agentName];
const result = await fn(sessionId); const result = await fn(sessionId);
if (result.sessionId) { if (result.sessionId) {
updateAgentSession(cwd, agentName, result.sessionId, provider); updateAgentSession(cwd, agentName, result.sessionId, resolvedProvider);
} }
return result; return result;

View File

@ -31,7 +31,6 @@ import {
appendNdjsonLine, appendNdjsonLine,
type NdjsonStepStart, type NdjsonStepStart,
type NdjsonStepComplete, type NdjsonStepComplete,
type NdjsonStream,
type NdjsonWorkflowComplete, type NdjsonWorkflowComplete,
type NdjsonWorkflowAbort, type NdjsonWorkflowAbort,
} from '../utils/session.js'; } from '../utils/session.js';
@ -103,28 +102,13 @@ export async function executeWorkflow(
const ndjsonLogPath = initNdjsonLog(workflowSessionId, task, workflowConfig.name, projectCwd); const ndjsonLogPath = initNdjsonLog(workflowSessionId, task, workflowConfig.name, projectCwd);
updateLatestPointer(sessionLog, workflowSessionId, projectCwd, { copyToPrevious: true }); updateLatestPointer(sessionLog, workflowSessionId, projectCwd, { copyToPrevious: true });
// Track current step name for stream log records
const stepRef: { current: string } = { current: '' };
// Track current display for streaming // Track current display for streaming
const displayRef: { current: StreamDisplay | null } = { current: null }; 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 = ( const streamHandler = (
event: Parameters<ReturnType<StreamDisplay['createHandler']>>[0] event: Parameters<ReturnType<StreamDisplay['createHandler']>>[0]
): void => { ): 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 (!displayRef.current) return;
if (event.type === 'result') return; if (event.type === 'result') return;
displayRef.current.createHandler()(event); displayRef.current.createHandler()(event);
@ -134,14 +118,14 @@ export async function executeWorkflow(
const isWorktree = cwd !== projectCwd; const isWorktree = cwd !== projectCwd;
const currentProvider = loadGlobalConfig().provider ?? 'claude'; const currentProvider = loadGlobalConfig().provider ?? 'claude';
const savedSessions = isWorktree const savedSessions = isWorktree
? loadWorktreeSessions(projectCwd, cwd) ? loadWorktreeSessions(projectCwd, cwd, currentProvider)
: loadAgentSessions(projectCwd, currentProvider); : loadAgentSessions(projectCwd, currentProvider);
// Session update handler - persist session IDs when they change // Session update handler - persist session IDs when they change
// Clone sessions are stored separately per clone path // Clone sessions are stored separately per clone path
const sessionUpdateHandler = isWorktree const sessionUpdateHandler = isWorktree
? (agentName: string, agentSessionId: string): void => { ? (agentName: string, agentSessionId: string): void => {
updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId); updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId, currentProvider);
} }
: (agentName: string, agentSessionId: string): void => { : (agentName: string, agentSessionId: string): void => {
updateAgentSession(projectCwd, agentName, agentSessionId, currentProvider); updateAgentSession(projectCwd, agentName, agentSessionId, currentProvider);
@ -211,7 +195,6 @@ export async function executeWorkflow(
} }
displayRef.current = new StreamDisplay(step.agentDisplayName); displayRef.current = new StreamDisplay(step.agentDisplayName);
stepRef.current = step.name;
// Write step_start record to NDJSON log // Write step_start record to NDJSON log
const record: NdjsonStepStart = { const record: NdjsonStepStart = {

View File

@ -207,16 +207,20 @@ export function getWorktreeSessionPath(projectDir: string, worktreePath: string)
return join(dir, `${encoded}.json`); 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( export function loadWorktreeSessions(
projectDir: string, projectDir: string,
worktreePath: string worktreePath: string,
currentProvider?: string
): Record<string, string> { ): Record<string, string> {
const sessionPath = getWorktreeSessionPath(projectDir, worktreePath); const sessionPath = getWorktreeSessionPath(projectDir, worktreePath);
if (existsSync(sessionPath)) { if (existsSync(sessionPath)) {
try { try {
const content = readFileSync(sessionPath, 'utf-8'); const content = readFileSync(sessionPath, 'utf-8');
const data = JSON.parse(content) as AgentSessionData; const data = JSON.parse(content) as AgentSessionData;
if (currentProvider && data.provider !== currentProvider) {
return {};
}
return data.agentSessions || {}; return data.agentSessions || {};
} catch { } catch {
return {}; return {};
@ -230,19 +234,26 @@ export function updateWorktreeSession(
projectDir: string, projectDir: string,
worktreePath: string, worktreePath: string,
agentName: string, agentName: string,
sessionId: string sessionId: string,
provider?: string
): void { ): void {
const dir = getWorktreeSessionsDir(projectDir); const dir = getWorktreeSessionsDir(projectDir);
ensureDir(dir); ensureDir(dir);
const sessionPath = getWorktreeSessionPath(projectDir, worktreePath); const sessionPath = getWorktreeSessionPath(projectDir, worktreePath);
let sessions: Record<string, string> = {}; let sessions: Record<string, string> = {};
let existingProvider: string | undefined;
if (existsSync(sessionPath)) { if (existsSync(sessionPath)) {
try { try {
const content = readFileSync(sessionPath, 'utf-8'); const content = readFileSync(sessionPath, 'utf-8');
const data = JSON.parse(content) as AgentSessionData; const data = JSON.parse(content) as AgentSessionData;
existingProvider = data.provider;
if (provider && existingProvider && existingProvider !== provider) {
sessions = {};
} else {
sessions = data.agentSessions || {}; sessions = data.agentSessions || {};
}
} catch { } catch {
sessions = {}; sessions = {};
} }
@ -253,6 +264,7 @@ export function updateWorktreeSession(
const data: AgentSessionData = { const data: AgentSessionData = {
agentSessions: sessions, agentSessions: sessions,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
provider: provider ?? existingProvider,
}; };
writeFileAtomic(sessionPath, JSON.stringify(data, null, 2)); writeFileAtomic(sessionPath, JSON.stringify(data, null, 2));
} }

View File

@ -4,7 +4,6 @@
import { existsSync, readFileSync, copyFileSync, appendFileSync } from 'node:fs'; import { existsSync, readFileSync, copyFileSync, appendFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { StreamEvent } from '../claude/types.js';
import { getProjectLogsDir, getGlobalLogsDir, ensureDir, writeFileAtomic } from '../config/paths.js'; import { getProjectLogsDir, getGlobalLogsDir, ensureDir, writeFileAtomic } from '../config/paths.js';
/** Session log entry */ /** Session log entry */
@ -52,14 +51,6 @@ export interface NdjsonStepStart {
instruction?: string; instruction?: string;
} }
/** NDJSON record: streaming chunk received */
export interface NdjsonStream {
type: 'stream';
step: string;
event: StreamEvent;
timestamp: string;
}
/** NDJSON record: step completed */ /** NDJSON record: step completed */
export interface NdjsonStepComplete { export interface NdjsonStepComplete {
type: 'step_complete'; type: 'step_complete';
@ -93,7 +84,6 @@ export interface NdjsonWorkflowAbort {
export type NdjsonRecord = export type NdjsonRecord =
| NdjsonWorkflowStart | NdjsonWorkflowStart
| NdjsonStepStart | NdjsonStepStart
| NdjsonStream
| NdjsonStepComplete | NdjsonStepComplete
| NdjsonWorkflowComplete | NdjsonWorkflowComplete
| NdjsonWorkflowAbort; | NdjsonWorkflowAbort;
@ -194,7 +184,7 @@ export function loadNdjsonLog(filepath: string): SessionLog | null {
} }
break; break;
// stream and step_start records are not stored in SessionLog // step_start records are not stored in SessionLog
default: default:
break; break;
} }