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,
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');
});
});
});

View File

@ -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) {

View File

@ -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<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 { loadGlobalConfig } from '../config/globalConfig.js';
import type { AgentResponse } from '../models/types.js';
/**
@ -15,13 +16,14 @@ export async function withAgentSession(
fn: (sessionId?: string) => Promise<AgentResponse>,
provider?: string
): Promise<AgentResponse> {
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;

View File

@ -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<ReturnType<StreamDisplay['createHandler']>>[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 = {

View File

@ -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<string, string> {
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<string, string> = {};
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));
}

View File

@ -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;
}