resolved #38
This commit is contained in:
parent
a2ee86c7a2
commit
2b35021d45
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user