From 880a420940965bd54fbee74155e5dd11af3595f8 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 17 Jun 2026 20:10:16 +0800 Subject: [PATCH 1/7] Refactor the context compression logic and improve the conversation model management --- packages/codingcode/src/agent/agent.ts | 31 +- .../src/client/direct/agent-runtime.ts | 3 +- packages/codingcode/src/context/service.ts | 35 ++- packages/codingcode/src/context/types.ts | 1 + packages/codingcode/src/memory/index.ts | 2 +- .../codingcode/src/server/routes/sessions.ts | 28 +- packages/codingcode/src/session/file-ops.ts | 2 +- packages/codingcode/src/session/store.ts | 24 +- packages/codingcode/src/session/types.ts | 1 - .../test/agent/agent-cache-stability.test.ts | 3 +- .../test/agent/agent-concurrent.test.ts | 3 +- .../test/agent/agent-todo-event.test.ts | 1 + packages/codingcode/test/agent/agent.test.ts | 1 + .../test/agent/hooks-deps-type.test.ts | 1 + .../test/agent/loop-options.test.ts | 1 + .../test/agent/memory-snapshot.test.ts | 6 +- .../codingcode/test/agent/stop-hook.test.ts | 1 + .../test/ci/tooling-scripts.test.ts | 4 +- .../test/context/compressor/behavior.test.ts | 16 +- .../compressor/compact-if-needed.test.ts | 21 +- .../codingcode/test/memory/config.test.ts | 1 + packages/codingcode/test/orchestrate.test.ts | 1 + .../test/server/compact-route.test.ts | 284 ++++++++++++++++++ .../test/server/settings-routes.test.ts | 12 +- packages/codingcode/test/session/fork.test.ts | 6 +- .../codingcode/test/session/io-error.test.ts | 3 + .../test/session/prompt-estimate.test.ts | 27 +- .../test/session/store-diff-rebuild.test.ts | 5 +- .../test/session/view-assembly.test.ts | 7 - .../codingcode/test/subagent/dispatch.test.ts | 1 + packages/desktop/src/agent/MessageStream.tsx | 7 +- packages/desktop/src/settings/MemoryPanel.tsx | 1 - packages/desktop/test/global-store.test.ts | 49 +-- packages/infra/src/config.ts | 2 + 34 files changed, 492 insertions(+), 99 deletions(-) create mode 100644 packages/codingcode/test/server/compact-route.test.ts diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 03ecf75d..b58d029b 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -253,16 +253,19 @@ export function agentLoop( const config = getContextConfig(); const maxOverflowRetries = REACTIVE_COMPACT_MAX_RETRIES; - const model = state.sessionMeta?.model ?? 'unknown'; + const model = state.model ?? 'unknown'; const effectiveMaxSteps = opts.maxStepsOverride ?? maxSteps; let stopContinuations = 0; const effectiveMaxStopContinuations = opts.maxStopContinuations ?? maxStopContinuations; + let messages: Message[] = []; + for (let attempt = 0; attempt <= maxOverflowRetries; attempt++) { - const { messages } = yield* Effect.sync(() => + const payload = yield* Effect.sync(() => context.assemblePayload(state.sessionId, state.projectPath, config, llm.modelInfo.maxTokens) ); + messages = payload.messages; let lastResult: Result | null = null; let overflow = false; @@ -309,7 +312,7 @@ export function agentLoop( ), catch: (e) => new AgentError('LLM_FAILED', String(e)), }); - if (compressResult.didCompress) { + if (compressResult.didCompress && compressResult.messages) { yield* q.offer({ _tag: 'ReactiveCompact', attempt: 1, @@ -317,18 +320,9 @@ export function agentLoop( promptEstimate: compressResult.promptEstimate, }); - const rebuilt = yield* Effect.sync(() => - context.assemblePayload( - state.sessionId, - state.projectPath, - config, - llm.modelInfo.maxTokens - ) - ); - messages.length = 0; - messages.push(...rebuilt.messages); + messages = compressResult.messages; state.usage = undefined; - state.promptEstimate = rebuilt.promptEstimate; + state.promptEstimate = compressResult.promptEstimate; } const llmMessages = [...messages]; @@ -364,15 +358,18 @@ export function agentLoop( context.compactWithLLM( state.sessionId, state.projectPath, + messages, config, - null, - undefined, - undefined, + llm, undefined, llm.modelInfo.maxTokens ), catch: (e) => new AgentError('LLM_FAILED', String(e)), }); + if (compressResult.didCompress && compressResult.messages) { + messages = compressResult.messages; + state.promptEstimate = compressResult.promptEstimate; + } yield* q.offer({ _tag: 'ReactiveCompact', attempt: attempt + 1, diff --git a/packages/codingcode/src/client/direct/agent-runtime.ts b/packages/codingcode/src/client/direct/agent-runtime.ts index 11b57ff3..767d65cb 100644 --- a/packages/codingcode/src/client/direct/agent-runtime.ts +++ b/packages/codingcode/src/client/direct/agent-runtime.ts @@ -109,8 +109,9 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu await rt.runPromise( Effect.gen(function* () { const context = yield* ContextService; + const { messages } = context.assemblePayload(sessionId, cwd, getContextConfig()); return yield* Effect.promise(() => - context.compactWithLLM(sessionId, cwd, getContextConfig(), null) + context.compactWithLLM(sessionId, cwd, messages, getContextConfig(), null) ); }) ); diff --git a/packages/codingcode/src/context/service.ts b/packages/codingcode/src/context/service.ts index c7dafe8e..3913f883 100644 --- a/packages/codingcode/src/context/service.ts +++ b/packages/codingcode/src/context/service.ts @@ -161,9 +161,7 @@ export class ContextService extends Effect.Service()('Context', messages: Message[], modelMaxTokens: number, config: ContextConfig, - llm: LLMClient | null, - compactedEvents?: SessionEvent[], - currentTurnId?: number + llm: LLMClient | null ): Promise => { const promptEstimate = estimateTokens(messages); const failures = getFailures(sessionId); @@ -179,10 +177,9 @@ export class ContextService extends Effect.Service()('Context', const result = await compactWithLLM( sessionId, encodedProjectPath, + messages, config, llm, - compactedEvents, - currentTurnId, promptEstimate, modelMaxTokens ); @@ -199,38 +196,46 @@ export class ContextService extends Effect.Service()('Context', const compactWithLLM = async ( sessionId: string, encodedProjectPath: string, + messages: Message[], config: ContextConfig, llm: LLMClient | null, - compactedEvents?: SessionEvent[], - currentTurnId?: number, usage?: number, modelMaxTokens?: number ): Promise => { - const payload = assemblePayload(sessionId, encodedProjectPath, config, modelMaxTokens); - if (!compactedEvents || currentTurnId === undefined) { - compactedEvents = payload.compactedEvents; - currentTurnId = payload.currentTurnId; - } - let released = 0; const threshold = modelMaxTokens ? modelMaxTokens * COMPACTION_THRESHOLD : Infinity; if (usage === undefined || usage - released > threshold) { + const { compactedEvents, currentTurnId, compactedTurnIds } = assemblePayload( + sessionId, + encodedProjectPath, + config, + modelMaxTokens + ); released += await tryCompaction( sessionId, config, llm, compactedEvents, currentTurnId, - payload.compactedTurnIds + compactedTurnIds ); } + if (released <= 0) { + return { + didCompress: false, + released: 0, + promptEstimate: usage ?? estimateTokens(messages), + }; + } + const postPayload = assemblePayload(sessionId, encodedProjectPath, config, modelMaxTokens); return { - didCompress: released > 0, + didCompress: true, released, promptEstimate: estimateTokens(postPayload.messages), + messages: postPayload.messages, }; }; diff --git a/packages/codingcode/src/context/types.ts b/packages/codingcode/src/context/types.ts index 10691bc2..66f0118a 100644 --- a/packages/codingcode/src/context/types.ts +++ b/packages/codingcode/src/context/types.ts @@ -13,4 +13,5 @@ export interface CompressResult { didCompress: boolean; released: number; promptEstimate: number; + messages?: Message[]; } diff --git a/packages/codingcode/src/memory/index.ts b/packages/codingcode/src/memory/index.ts index bbcfedad..bd086a14 100644 --- a/packages/codingcode/src/memory/index.ts +++ b/packages/codingcode/src/memory/index.ts @@ -47,7 +47,7 @@ export class MemoryService extends Effect.Service()('Memory', { if (!projectAuto) return ''; const stripped = stripMarkersForPrompt(projectAuto); - const truncated = truncateForPrompt(stripped, PROMPT_MAX_BYTES); + const truncated = truncateForPrompt(stripped, cfg.promptMaxBytes); return truncated ? `## Long-term Memory\n\n${truncated}` : ''; } diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 7996f0db..b78409ab 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -14,6 +14,8 @@ import { ContextService } from '../../context/service.js'; import { getContextConfig } from '../../context/config.js'; import { CheckpointService } from '../../checkpoint/checkpoint-service.js'; import { WorkspaceService } from '../../core/workspace.js'; +import { LLMFactoryService } from '../../llm/factory.js'; +import type { LLMClient } from '../../llm/client.js'; import { errorResponse } from '../util.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -136,9 +138,31 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const result = await runWithLayer( Effect.gen(function* () { const context = yield* ContextService; - const state = yield* (yield* SessionService).create(normalizedCwd, 'unknown', sessionId); + const factory = yield* LLMFactoryService; + const session = yield* SessionService; + const state = yield* session.create(normalizedCwd, 'unknown', sessionId); + + let llm: LLMClient | null = null; + const entry = yield* factory.getActiveEntry().pipe(Effect.either); + if (entry._tag === 'Right') { + const client = yield* factory.createClient(entry.right).pipe(Effect.either); + if (client._tag === 'Right') llm = client.right; + } + + const { messages } = context.assemblePayload( + state.sessionId, + state.projectPath, + getContextConfig() + ); + return yield* Effect.promise(() => - context.compactWithLLM(state.sessionId, state.projectPath, getContextConfig(), null) + context.compactWithLLM( + state.sessionId, + state.projectPath, + messages, + getContextConfig(), + llm + ) ); }) ); diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index 156ca675..7d8f450f 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -103,7 +103,7 @@ function buildIndexFromMeta(meta: SessionMetaEvent, history: SessionEvent[]): Se sessionId: meta.sessionId, projectPath: meta.projectPath, cwd: meta.cwd, - model: meta.model, + model: 'unknown', createdAt: meta.createdAt, updatedAt: meta.createdAt, messageCount: countNonMetaEvents(history), diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index 34a47d14..fce4e05e 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -47,6 +47,7 @@ export interface SessionStoreState { indexPath: string; messageCount: number; sessionMeta: SessionMetaEvent | null; + model: string; title: string; currentTurnId: number; usage: TokenUsage | undefined; @@ -85,7 +86,7 @@ export class SessionService extends Effect.Service()('Session', sessionId: state.sessionId, projectPath: state.projectPath, cwd: state.cwd, - model: state.sessionMeta.model, + model: state.model, createdAt: state.sessionMeta.createdAt, updatedAt: new Date().toISOString(), messageCount: state.messageCount, @@ -111,6 +112,8 @@ export class SessionService extends Effect.Service()('Session', const state = initState(cwd, sessionId, opts?.parentSessionId); ensureDirs(state.transcriptPath); + state.model = model; + if (existsSync(state.transcriptPath)) { const history = readHistory(state.transcriptPath); const meta = history.find((e) => e.type === 'session_meta') as @@ -130,7 +133,6 @@ export class SessionService extends Effect.Service()('Session', sessionId: state.sessionId, projectPath: state.projectPath, cwd: state.cwd, - model, createdAt: new Date().toISOString(), ...(opts?.parentSessionId && { parentSessionId: opts.parentSessionId }), ...(opts?.parentAgentId && { parentAgentId: opts.parentAgentId }), @@ -451,6 +453,7 @@ function initState(cwd: string, sessionId?: string, parentSessionId?: string): S let usage: TokenUsage | undefined = undefined; let promptEstimate = 0; let memorySnapshot = ''; + let model = ''; try { if (existsSync(indexPath)) { const idx = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; @@ -458,6 +461,7 @@ function initState(cwd: string, sessionId?: string, parentSessionId?: string): S usage = idx.usage ?? undefined; promptEstimate = idx.promptEstimate ?? 0; memorySnapshot = idx.memorySnapshot ?? ''; + model = idx.model ?? ''; } } catch { /* ignore corrupt index */ @@ -477,6 +481,7 @@ function initState(cwd: string, sessionId?: string, parentSessionId?: string): S indexPath, messageCount: 0, sessionMeta: null, + model, title: id.slice(0, 8), currentTurnId, usage, @@ -485,11 +490,13 @@ function initState(cwd: string, sessionId?: string, parentSessionId?: string): S }; } -function forkSessionImpl(sourceSessionId: string, sourceJsonlPath: string, atTurnId: number): string { +function forkSessionImpl( + sourceSessionId: string, + sourceJsonlPath: string, + atTurnId: number +): string { const events = readHistory(sourceJsonlPath); - const atIdx = events.findIndex( - (e) => e.type === 'user' && (e as any).turnId === atTurnId - ); + const atIdx = events.findIndex((e) => e.type === 'user' && (e as any).turnId === atTurnId); const chain = atIdx >= 0 ? events.slice(0, atIdx + 1) : events; const newSessionId = randomUUID(); @@ -526,9 +533,10 @@ function forkSessionImpl(sourceSessionId: string, sourceJsonlPath: string, atTur let usage: TokenUsage | undefined = undefined; let promptEstimate = 0; let permissionMode = 'default'; + let srcIdx: SessionIndex | undefined; if (existsSync(sourceIdxPath)) { try { - const srcIdx = JSON.parse(readFileSync(sourceIdxPath, 'utf8')) as SessionIndex; + srcIdx = JSON.parse(readFileSync(sourceIdxPath, 'utf8')) as SessionIndex; title = srcIdx.title; usage = srcIdx.usage ?? undefined; promptEstimate = srcIdx.promptEstimate ?? 0; @@ -552,7 +560,7 @@ function forkSessionImpl(sourceSessionId: string, sourceJsonlPath: string, atTur sessionId: newSessionId, projectPath: meta?.projectPath ?? '', cwd: meta?.cwd ?? '', - model: meta?.model ?? '', + model: srcIdx?.model ?? '', createdAt: meta?.createdAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(), messageCount: countNonMetaEvents(chain), diff --git a/packages/codingcode/src/session/types.ts b/packages/codingcode/src/session/types.ts index 98656666..a7241bab 100644 --- a/packages/codingcode/src/session/types.ts +++ b/packages/codingcode/src/session/types.ts @@ -3,7 +3,6 @@ export interface SessionMetaEvent { sessionId: string; projectPath: string; cwd: string; - model: string; createdAt: string; parentSessionId?: string; parentAgentId?: string; diff --git a/packages/codingcode/test/agent/agent-cache-stability.test.ts b/packages/codingcode/test/agent/agent-cache-stability.test.ts index 2867ef13..a7e89bf8 100644 --- a/packages/codingcode/test/agent/agent-cache-stability.test.ts +++ b/packages/codingcode/test/agent/agent-cache-stability.test.ts @@ -91,7 +91,8 @@ const mockState = { messageCount: 0, currentTurnId: 1, sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, - title: 'cache-test', + model: 'test-model', + title: 'cache-stability', usage: undefined, promptEstimate: 0, memorySnapshot: '', diff --git a/packages/codingcode/test/agent/agent-concurrent.test.ts b/packages/codingcode/test/agent/agent-concurrent.test.ts index 430cf95c..142bb0f6 100644 --- a/packages/codingcode/test/agent/agent-concurrent.test.ts +++ b/packages/codingcode/test/agent/agent-concurrent.test.ts @@ -91,7 +91,8 @@ const mockState = { messageCount: 0, currentTurnId: 1, sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, - title: 'test', + model: 'test-model', + title: 'concurrent', usage: undefined, promptEstimate: 0, memorySnapshot: '', diff --git a/packages/codingcode/test/agent/agent-todo-event.test.ts b/packages/codingcode/test/agent/agent-todo-event.test.ts index 362d7e11..aa5f84dd 100644 --- a/packages/codingcode/test/agent/agent-todo-event.test.ts +++ b/packages/codingcode/test/agent/agent-todo-event.test.ts @@ -98,6 +98,7 @@ const mockState = { messageCount: 0, currentTurnId: 1, sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, + model: 'test-model', title: 'test', usage: undefined, promptEstimate: 0, diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index 895ef37d..e9a96398 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -75,6 +75,7 @@ const mockState = { messageCount: 0, currentTurnId: 1, sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, + model: 'test-model', title: 'test', usage: undefined, promptEstimate: 0, diff --git a/packages/codingcode/test/agent/hooks-deps-type.test.ts b/packages/codingcode/test/agent/hooks-deps-type.test.ts index e8986377..d3a1c29e 100644 --- a/packages/codingcode/test/agent/hooks-deps-type.test.ts +++ b/packages/codingcode/test/agent/hooks-deps-type.test.ts @@ -104,6 +104,7 @@ describe('agentLoop hooks type', () => { messageCount: 0, currentTurnId: 1, sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, + model: 'test-model', title: 'type-test', usage: undefined, promptEstimate: 0, diff --git a/packages/codingcode/test/agent/loop-options.test.ts b/packages/codingcode/test/agent/loop-options.test.ts index 5d103e23..d99faf30 100644 --- a/packages/codingcode/test/agent/loop-options.test.ts +++ b/packages/codingcode/test/agent/loop-options.test.ts @@ -84,6 +84,7 @@ describe('agentLoop loop options', () => { cwd: process.cwd(), currentTurnId: 0, sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, + model: 'test-model', title: 'test', usage: undefined, projectPath: '', diff --git a/packages/codingcode/test/agent/memory-snapshot.test.ts b/packages/codingcode/test/agent/memory-snapshot.test.ts index 0f00c497..f8898fc9 100644 --- a/packages/codingcode/test/agent/memory-snapshot.test.ts +++ b/packages/codingcode/test/agent/memory-snapshot.test.ts @@ -97,6 +97,7 @@ function makeState(memorySnapshot: string = '') { messageCount: 0, currentTurnId: 1, sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, + model: 'test-model', title: 'memory-test', usage: undefined, promptEstimate: 0, @@ -154,7 +155,7 @@ describe('Memory snapshot stability', () => { expect(second).toBe(first); }); - it('injects when memory changed since snapshot', async () => { + it('does not inject when memory changed since snapshot', async () => { const { llm, captured } = makeCapturingLlm(); await runOnce( llm, @@ -166,8 +167,7 @@ describe('Memory snapshot stability', () => { .reverse() .find((m: any) => m.role === 'user'); expect(lastUserMsg).toBeDefined(); - expect(lastUserMsg.content).toContain(''); - expect(lastUserMsg.content).toContain('Updated on disk'); + expect(lastUserMsg.content).not.toContain(''); }); it('does not inject when memory matches snapshot', async () => { diff --git a/packages/codingcode/test/agent/stop-hook.test.ts b/packages/codingcode/test/agent/stop-hook.test.ts index 1f755582..99dd9afc 100644 --- a/packages/codingcode/test/agent/stop-hook.test.ts +++ b/packages/codingcode/test/agent/stop-hook.test.ts @@ -84,6 +84,7 @@ describe('agentLoop stop hook', () => { cwd: process.cwd(), currentTurnId: 0, sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, + model: 'test-model', title: 'test', usage: undefined, projectPath: '', diff --git a/packages/codingcode/test/ci/tooling-scripts.test.ts b/packages/codingcode/test/ci/tooling-scripts.test.ts index 1002b5df..3833b54c 100644 --- a/packages/codingcode/test/ci/tooling-scripts.test.ts +++ b/packages/codingcode/test/ci/tooling-scripts.test.ts @@ -72,9 +72,9 @@ describe('CI tooling configuration', () => { it('pnpm run lint exits successfully', () => { expect(() => execSync('pnpm run lint', { cwd: root, stdio: 'pipe' })).not.toThrow(); - }, 20000); + }, 60000); it('pnpm run format:check exits successfully', () => { expect(() => execSync('pnpm run format:check', { cwd: root, stdio: 'pipe' })).not.toThrow(); - }, 20000); + }, 60000); }); diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index 3b4ea8ac..3bc6ac41 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -37,7 +37,6 @@ function makeFixture(opts: FixtureOptions) { sessionId, projectPath: slug, cwd: '/tmp/test', - model: 'test', createdAt: new Date().toISOString(), }, ]; @@ -164,7 +163,8 @@ describe('compressor behavior', () => { '## Compacted History\n\n### Goal\nfix bug\n\n### Instructions\nbe careful\n\n### Discoveries\nrace condition\n\n### Accomplished\npatched\n\n### Relevant Files\nsrc/x.ts'; const llm = makeMockLLM(summary); const ctx = await getCtxService(); - await ctx.compactWithLLM(fx.sessionId, fx.slug, cfg, llm); + const { messages } = ctx.assemblePayload(fx.sessionId, fx.slug, cfg); + await ctx.compactWithLLM(fx.sessionId, fx.slug, messages, cfg, llm); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries.length).toBe(1); expect(summaries[0]!.summaryText).toContain('### Goal'); @@ -179,8 +179,10 @@ describe('compressor behavior', () => { try { const cfg = tinyConfig(); const ctx = await getCtxService(); - const result = await ctx.compactWithLLM(fx.sessionId, fx.slug, cfg, null); + const { messages } = ctx.assemblePayload(fx.sessionId, fx.slug, cfg); + const result = await ctx.compactWithLLM(fx.sessionId, fx.slug, messages, cfg, null); expect(result.didCompress).toBe(false); + expect(result.messages).toBeUndefined(); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries).toHaveLength(0); } finally { @@ -198,7 +200,8 @@ describe('compressor behavior', () => { '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' ); const ctx = await getCtxService(); - await ctx.compactWithLLM(fx.sessionId, fx.slug, cfg, llm); + const { messages } = ctx.assemblePayload(fx.sessionId, fx.slug, cfg); + await ctx.compactWithLLM(fx.sessionId, fx.slug, messages, cfg, llm); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries).toHaveLength(1); @@ -219,11 +222,14 @@ describe('compressor behavior', () => { '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' ); const ctx = await getCtxService(); - const result = await ctx.compactWithLLM(fx.sessionId, fx.slug, cfg, llm); + const { messages } = ctx.assemblePayload(fx.sessionId, fx.slug, cfg); + const result = await ctx.compactWithLLM(fx.sessionId, fx.slug, messages, cfg, llm); expect(result.didCompress).toBe(true); expect(result.promptEstimate).toBeGreaterThan(0); expect(result.promptEstimate).toBeLessThan(before); expect(result.released).toBeGreaterThan(0); + expect(result.messages).toBeDefined(); + expect(result.messages!.length).toBeGreaterThan(0); } finally { cleanup(fx.slug); } diff --git a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts index 20d10ef9..699d38fc 100644 --- a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts +++ b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts @@ -120,8 +120,27 @@ describe('compactIfNeeded', () => { it('returns didCompress=true when promptEstimate exceeds threshold', async () => { (estimateTokens as any).mockReturnValue(10000); + (estimateMessageTokens as any).mockReturnValue(50); const ctx = await getCtxService(); - const result = await ctx.compactIfNeeded('s1', 'proj', [], 10000, config(0.5), null); + const result = await ctx.compactIfNeeded( + 's1', + 'proj', + [ + { type: 'user', content: 'a'.repeat(200), uuid: 'u1', turnId: 1 }, + { type: 'assistant', content: 'b'.repeat(200), uuid: 'a1', turnId: 1 }, + { + type: 'tool_result', + output: 'c'.repeat(5000), + uuid: 't1', + turnId: 1, + toolName: 'read_file', + toolCallId: 'tc1', + }, + ] as any, + 10000, + config(0.5), + null + ); expect(result.didCompress).toBe(true); expect(result.released).toBeGreaterThan(0); expect(result.promptEstimate).toBeGreaterThanOrEqual(0); diff --git a/packages/codingcode/test/memory/config.test.ts b/packages/codingcode/test/memory/config.test.ts index 77d9a9fd..cfa7fc7d 100644 --- a/packages/codingcode/test/memory/config.test.ts +++ b/packages/codingcode/test/memory/config.test.ts @@ -29,6 +29,7 @@ function makeCfg(overrides?: Partial): MemoryConfig { model: '', extraTypes: [], disabledTypes: [], + promptMaxBytes: 8192, ...overrides, }; } diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index 31a07b52..6138e225 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -56,6 +56,7 @@ const mockState = { messageCount: 0, currentTurnId: 0, sessionMeta: null, + model: 'test', title: 'test-sess', usage: undefined, promptEstimate: 0, diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts new file mode 100644 index 00000000..f1486676 --- /dev/null +++ b/packages/codingcode/test/server/compact-route.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { createServer } from '../../src/server/index.js'; +import { WorkspaceService } from '../../src/core/workspace.js'; +import { SessionService } from '../../src/session/store.js'; +import { LLMFactoryService } from '../../src/llm/factory.js'; +import { ApprovalService } from '../../src/approval/index.js'; +import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { SkillService } from '../../src/skills/service.js'; +import { McpService } from '../../src/mcp/index.js'; +import { MemoryService } from '../../src/memory/index.js'; +import { SchedulerService } from '../../src/scheduler/service.js'; +import { ContextService } from '../../src/context/service.js'; +import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; + +const mockCompactWithLLM = vi.fn(); + +const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { + getWorkspaceCwd: () => '/tmp/test', + resolveWorkspaceCwd: (override?: string) => override ?? '/tmp/test', +} as any); + +const MockSessionLayer = Layer.succeed(SessionService, { + create: () => + Effect.succeed({ + sessionId: 'test-sid', + cwd: '/tmp/test', + projectPath: 'test-path', + model: 'deepseek-chat', + }), + recordUser: () => + Effect.succeed({ type: 'user', uuid: 'u1', content: '', turnId: 0, timestamp: '' }), + recordAssistant: () => + Effect.succeed({ + type: 'assistant', + uuid: 'a1', + content: '', + toolCalls: [], + model: 'test', + turnId: 0, + timestamp: '', + }), + recordToolResult: () => + Effect.succeed({ + type: 'tool_result', + uuid: 't1', + parentUuid: 'a1', + toolName: 'test', + toolCallId: 'tc1', + output: '', + turnId: 0, + timestamp: '', + tokenCount: 0, + }), + incrementTurn: () => 0, +} as any); + +const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { + findModel: () => + Effect.succeed({ + id: 'deepseek-chat', + model: 'deepseek-chat', + provider: 'deepseek', + driver: 'openai', + api_key_env: 'DEEPSEEK_API_KEY', + base_url: 'https://api.deepseek.com', + }), + createClient: () => + Effect.succeed({ + modelInfo: { + provider: 'deepseek', + model: 'deepseek-chat', + maxTokens: 64000, + supportsToolCalling: true, + supportsStreaming: true, + }, + }), + getLLMClient: () => Effect.succeed(null), + listModels: () => Effect.succeed([]), + getActiveEntry: () => + Effect.succeed({ + id: 'deepseek-chat', + model: 'deepseek-chat', + provider: 'deepseek', + driver: 'openai', + api_key_env: 'DEEPSEEK_API_KEY', + base_url: 'https://api.deepseek.com', + }), + switchModel: () => Effect.fail(new Error('no models')), +} as any); + +const MockApprovalLayer = ApprovalService.Default.pipe( + Layer.provide(Layer.mergeAll(HookService.Default, ApprovalWaitService.Default)) +); + +const MockSkillLayer = Layer.succeed(SkillService, { + _tag: 'Skill' as const, + getAll: () => Effect.succeed([]), + findByName: () => Effect.succeed(undefined), + select: () => Effect.succeed(undefined), + selectImplicit: () => Effect.succeed(undefined), + extractSkill: (_p: string, q: string) => Effect.sync(() => [undefined, q] as [undefined, string]), + enableSkill: () => Effect.void, + disableSkill: () => Effect.void, + listWithStatus: () => Effect.succeed([]), + evictProject: () => Effect.void, +} as any); + +const MockMcpLayer = Layer.succeed(McpService, { + syncConnections: () => Effect.void, + connectServers: () => Effect.void, + disconnectServers: () => Effect.void, + getServerToolNames: () => [], + disconnectAll: () => Effect.void, + status: () => Effect.succeed([]), + listProjectMcpTools: () => [], +} as any); + +const MockMemoryLayer = Layer.succeed(MemoryService, { + getMemoryEnabled: () => true, + setMemoryEnabled: () => {}, + loadMemoryForPrompt: () => '', + flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), +} as any); + +const MockSchedulerLayer = Layer.succeed(SchedulerService, { + list: () => [], + add: () => ({}), + update: () => null, + remove: () => false, + runOnce: () => Promise.resolve('session-id'), +} as any); + +const MockContextLayer = Layer.succeed(ContextService, { + assemblePayload: () => ({ + messages: [], + compactedEvents: [], + promptEstimate: 0, + currentTurnId: 0, + compactedTurnIds: new Set(), + }), + compactWithLLM: mockCompactWithLLM, +} as any); + +const MockCheckpointLayer = Layer.succeed(CheckpointService, { + _tag: 'Checkpoint' as const, + snapshotBaseline: () => Effect.void, + snapshotFinal: () => Effect.void, + getCompletedTurns: () => Effect.succeed([]), + getCheckpoints: () => Effect.succeed([]), + getCheckpointDiff: () => Effect.succeed({ turnId: 0, files: [] }), + revertCheckpointFiles: () => + Effect.succeed({ + reverted: false, + throughTurnId: 0, + affectedTurns: [], + selectedFiles: [], + restoreEntry: null, + }), + previewRollbackDiff: () => Effect.succeed({ throughTurnId: 0, affectedTurns: [], diff: '' }), + rollbackCodeToTurn: () => + Effect.succeed({ + reverted: false, + throughTurnId: 0, + affectedTurns: [], + selectedFiles: [], + restoreEntry: null, + }), + undoLastCodeRollback: () => + Effect.succeed({ + restored: false, + conflict: false, + conflictFiles: [], + restoredFiles: [], + remainingRolledBack: [], + }), + getLatestRestoreEntry: () => Effect.succeed(null), +} as any); + +const TestLayer = Layer.mergeAll( + MockWorkspaceLayer, + MockSessionLayer, + MockLLMFactoryLayer, + MockApprovalLayer, + HookService.Default, + ApprovalWaitService.Default, + MockSkillLayer, + MockMcpLayer, + MockMemoryLayer, + MockSchedulerLayer, + MockContextLayer, + MockCheckpointLayer +); + +const rt = ManagedRuntime.make(TestLayer); + +describe('POST /api/sessions/:id/compact (manual compact)', () => { + beforeEach(() => { + mockCompactWithLLM.mockReset(); + mockCompactWithLLM.mockResolvedValue({ + didCompress: true, + released: 5000, + promptEstimate: 3000, + }); + }); + + it('should call compactWithLLM with a non-null llm when session has a valid model', async () => { + const app = await createServer(rt); + const res = await app.request('/api/sessions/test-sid/compact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cwd: '' }), + }); + + expect(res.status).toBe(200); + expect(mockCompactWithLLM).toHaveBeenCalledTimes(1); + + const args = mockCompactWithLLM.mock.calls[0]; + // args[4] is the llm parameter — should not be null + expect(args?.[4]).not.toBeNull(); + expect(args?.[4].modelInfo.model).toBe('deepseek-chat'); + }); + + it('should return CompressResult from the API', async () => { + const app = await createServer(rt); + const res = await app.request('/api/sessions/test-sid/compact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cwd: '' }), + }); + + const body = await res.json(); + expect(body).toEqual({ didCompress: true, released: 5000, promptEstimate: 3000 }); + }); + + it('should call compactWithLLM with null llm when getActiveEntry fails', async () => { + const FailingFactoryLayer = Layer.succeed(LLMFactoryService, { + findModel: () => Effect.succeed(null), + createClient: () => + Effect.succeed({ + modelInfo: { + provider: 'deepseek', + model: 'deepseek-chat', + maxTokens: 64000, + supportsToolCalling: true, + supportsStreaming: true, + }, + }), + getLLMClient: () => Effect.succeed(null), + listModels: () => Effect.succeed([]), + getActiveEntry: () => Effect.fail(new Error('no active model')), + switchModel: () => Effect.fail(new Error('no models')), + } as any); + + const FailLayer = Layer.mergeAll( + MockWorkspaceLayer, + MockSessionLayer, + FailingFactoryLayer, + MockApprovalLayer, + HookService.Default, + ApprovalWaitService.Default, + MockSkillLayer, + MockMcpLayer, + MockMemoryLayer, + MockSchedulerLayer, + MockContextLayer, + MockCheckpointLayer + ); + const failRt = ManagedRuntime.make(FailLayer); + const app = await createServer(failRt); + const res = await app.request('/api/sessions/test-sid/compact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cwd: '' }), + }); + + expect(res.status).toBe(200); + expect(mockCompactWithLLM).toHaveBeenCalledTimes(1); + + const args = mockCompactWithLLM.mock.calls[0]; + expect(args?.[4]).toBeNull(); + }); +}); diff --git a/packages/codingcode/test/server/settings-routes.test.ts b/packages/codingcode/test/server/settings-routes.test.ts index 38470e1a..b0272c9e 100644 --- a/packages/codingcode/test/server/settings-routes.test.ts +++ b/packages/codingcode/test/server/settings-routes.test.ts @@ -30,12 +30,12 @@ vi.mock('@codingcode/infra/config', () => ({ })); vi.mock('../../src/memory/config.js', () => ({ - getMemoryConfig: vi.fn().mockReturnValue({ - enabled: true, - disabledTypes: [], - extraTypes: [], - model: '', - }), + getMemoryConfig: vi.fn().mockReturnValue({ + enabled: true, + disabledTypes: [], + extraTypes: [], + model: '', + }), getAllTypesWithStatus: vi .fn() .mockReturnValue([ diff --git a/packages/codingcode/test/session/fork.test.ts b/packages/codingcode/test/session/fork.test.ts index 60feb6fe..494aa697 100644 --- a/packages/codingcode/test/session/fork.test.ts +++ b/packages/codingcode/test/session/fork.test.ts @@ -22,7 +22,6 @@ function makeFixture(sessionId: string, slug: string) { sessionId, projectPath: slug, cwd: '/tmp/test', - model: 'test', createdAt: new Date().toISOString(), }, { type: 'user', turnId: 1, uuid: 'u1', content: 'first', timestamp: new Date().toISOString() }, @@ -116,6 +115,7 @@ describe('forkSession', () => { messageCount: 7, currentTurnId: 3, sessionMeta: null, + model: 'test', title: 'fixture', usage: undefined, promptEstimate: 0, @@ -160,6 +160,7 @@ describe('forkSession', () => { messageCount: 7, currentTurnId: 3, sessionMeta: null, + model: 'test', title: 'fixture', usage: undefined, promptEstimate: 0, @@ -205,6 +206,7 @@ describe('forkSession', () => { messageCount: 7, currentTurnId: 3, sessionMeta: null, + model: 'test', title: 'fixture', usage: undefined, promptEstimate: 0, @@ -268,6 +270,7 @@ describe('forkSession', () => { messageCount: 7, currentTurnId: 3, sessionMeta: null, + model: 'test', title: 'fixture', usage: undefined, promptEstimate: 0, @@ -288,6 +291,7 @@ describe('forkSession', () => { expect(idx.sessionId).toBe(newSessionId); expect(idx.title).toBe('fixture'); expect(idx.permissionMode).toBe('default'); + expect(idx.model).toBe('test'); } finally { rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); } diff --git a/packages/codingcode/test/session/io-error.test.ts b/packages/codingcode/test/session/io-error.test.ts index 073c6d91..3a96e02b 100644 --- a/packages/codingcode/test/session/io-error.test.ts +++ b/packages/codingcode/test/session/io-error.test.ts @@ -22,6 +22,7 @@ describe('SessionService — SESSION_IO_ERROR', () => { messageCount: 0, currentTurnId: 1, sessionMeta: { model: 'test', createdAt: new Date().toISOString() }, + model: 'test', title: 'io-err-sid'.slice(0, 8), usage: undefined, promptEstimate: 0, @@ -53,6 +54,7 @@ describe('SessionService — SESSION_IO_ERROR', () => { messageCount: 0, currentTurnId: 1, sessionMeta: { model: 'test', createdAt: new Date().toISOString() }, + model: 'test', title: 'io-err-asst'.slice(0, 8), usage: undefined, promptEstimate: 0, @@ -83,6 +85,7 @@ describe('SessionService — SESSION_IO_ERROR', () => { messageCount: 0, currentTurnId: 1, sessionMeta: { model: 'test', createdAt: new Date().toISOString() }, + model: 'test', title: 'io-err-eff'.slice(0, 8), usage: undefined, promptEstimate: 0, diff --git a/packages/codingcode/test/session/prompt-estimate.test.ts b/packages/codingcode/test/session/prompt-estimate.test.ts index bb39916e..6a417dbc 100644 --- a/packages/codingcode/test/session/prompt-estimate.test.ts +++ b/packages/codingcode/test/session/prompt-estimate.test.ts @@ -141,7 +141,6 @@ describe('promptEstimate', () => { sessionId, projectPath: slug, cwd: '/tmp/test', - model: 'test', createdAt: new Date().toISOString(), }, { @@ -211,6 +210,7 @@ describe('promptEstimate', () => { messageCount: 4, currentTurnId: 2, sessionMeta: null, + model: 'test', title: 'fixture', usage, promptEstimate: usage.prompt, @@ -245,6 +245,7 @@ describe('promptEstimate', () => { messageCount: 4, currentTurnId: 2, sessionMeta: null, + model: 'test', title: 'fixture', usage: undefined, promptEstimate: 0, @@ -272,6 +273,30 @@ describe('token estimation', () => { }); }); +describe('SessionService create sets model', () => { + it('create sets state.model and persists it to index', async () => { + const slug = randomUUID(); + const dir = join(PROJECT_BASE, slug); + mkdirSync(dir, { recursive: true }); + try { + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, 'my-test-model'); + }) + ); + expect(state.model).toBe('my-test-model'); + + const idx = JSON.parse(readFileSync(state.indexPath, 'utf8')); + expect(idx.model).toBe('my-test-model'); + } finally { + await new Promise((r) => setTimeout(r, 50)); + rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe('SessionService record methods update promptEstimate', () => { it('recordUser increments promptEstimate', async () => { const slug = randomUUID(); diff --git a/packages/codingcode/test/session/store-diff-rebuild.test.ts b/packages/codingcode/test/session/store-diff-rebuild.test.ts index 55b04710..36275df2 100644 --- a/packages/codingcode/test/session/store-diff-rebuild.test.ts +++ b/packages/codingcode/test/session/store-diff-rebuild.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { sessionEventsToTurns } from '../../src/session/messages.js'; import type { SessionEvent } from '../../src/session/types.js'; @@ -10,7 +10,6 @@ describe('sessionEventsToTurns', () => { sessionId: 's1', projectPath: 'p', cwd: '/tmp', - model: 'test', createdAt: new Date().toISOString(), }, { @@ -69,7 +68,6 @@ describe('sessionEventsToTurns', () => { sessionId: 's1', projectPath: 'p', cwd: '/tmp', - model: 'test', createdAt: new Date().toISOString(), }, { @@ -126,7 +124,6 @@ describe('sessionEventsToTurns', () => { sessionId: 's1', projectPath: 'p', cwd: '/tmp', - model: 'test', createdAt: new Date().toISOString(), }, { diff --git a/packages/codingcode/test/session/view-assembly.test.ts b/packages/codingcode/test/session/view-assembly.test.ts index 02bbf5d8..a6345a04 100644 --- a/packages/codingcode/test/session/view-assembly.test.ts +++ b/packages/codingcode/test/session/view-assembly.test.ts @@ -11,7 +11,6 @@ function makeEvents(overrides: SessionEvent[] = []): SessionEvent[] { sessionId: 's1', projectPath: 'p', cwd: '/tmp', - model: 'test', createdAt: new Date().toISOString(), }, { type: 'user', turnId: 1, uuid: 'u1', content: 'hello', timestamp: new Date().toISOString() }, @@ -200,7 +199,6 @@ describe('buildMessagesFromEvents', () => { sessionId: 's1', projectPath: 'p', cwd: '/tmp', - model: 'test', createdAt: new Date().toISOString(), }, { @@ -234,7 +232,6 @@ describe('buildMessagesFromEvents', () => { sessionId: 's1', projectPath: 'p', cwd: '/tmp', - model: 'test', createdAt: new Date().toISOString(), }, { @@ -299,7 +296,6 @@ describe('buildMessagesFromEvents', () => { sessionId: 's1', projectPath: 'p', cwd: '/tmp', - model: 'test', createdAt: new Date().toISOString(), }, { @@ -369,7 +365,6 @@ describe('buildMessagesFromEvents', () => { sessionId: 's1', projectPath: 'p', cwd: '/tmp', - model: 'test', createdAt: new Date().toISOString(), }, { @@ -424,7 +419,6 @@ describe('buildMessagesFromEvents', () => { sessionId: 's1', projectPath: 'p', cwd: '/tmp', - model: 'test', createdAt: new Date().toISOString(), }, { @@ -480,7 +474,6 @@ describe('buildMessagesFromEvents', () => { sessionId: 's1', projectPath: 'p', cwd: '/tmp', - model: 'test', createdAt: new Date().toISOString(), }, { type: 'user', turnId: 1, uuid: 'u1', content: 'q1', timestamp: new Date().toISOString() }, diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index e1f14372..e791a97b 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -59,6 +59,7 @@ const mockSession = { messageCount: 0, currentTurnId: 0, sessionMeta: null, + model: 'test', title: 'child', usage: undefined, promptEstimate: 0, diff --git a/packages/desktop/src/agent/MessageStream.tsx b/packages/desktop/src/agent/MessageStream.tsx index 4b475ca2..05fda801 100644 --- a/packages/desktop/src/agent/MessageStream.tsx +++ b/packages/desktop/src/agent/MessageStream.tsx @@ -407,11 +407,12 @@ export default function MessageStream({ threadId }: MessageStreamProps) { if (loadedCheckpointRef.current === loadKey) return; loadedCheckpointRef.current = loadKey; - const existingMapping = useRollbackStore.getState().turnCheckpointMapping[threadId] ?? EMPTY_MAPPING; + const existingMapping = + useRollbackStore.getState().turnCheckpointMapping[threadId] ?? EMPTY_MAPPING; const existingDiffs = useRollbackStore.getState().checkpointDiffByTurnId; - const alreadyLoaded = completedTurnIds.some((id) => - getCheckpointKey(threadId, id, existingDiffs, existingMapping) !== null + const alreadyLoaded = completedTurnIds.some( + (id) => getCheckpointKey(threadId, id, existingDiffs, existingMapping) !== null ); if (alreadyLoaded) return; diff --git a/packages/desktop/src/settings/MemoryPanel.tsx b/packages/desktop/src/settings/MemoryPanel.tsx index 3c6c4b0c..446f3630 100644 --- a/packages/desktop/src/settings/MemoryPanel.tsx +++ b/packages/desktop/src/settings/MemoryPanel.tsx @@ -191,7 +191,6 @@ export default function MemoryPanel() { - )} diff --git a/packages/desktop/test/global-store.test.ts b/packages/desktop/test/global-store.test.ts index a6bd9f38..529a0e85 100644 --- a/packages/desktop/test/global-store.test.ts +++ b/packages/desktop/test/global-store.test.ts @@ -450,11 +450,9 @@ describe('global store - per-thread isStreaming derivation', () => { useAgentStore.getState().startTurn(threadB, { id: 'turn-b', items: [], status: 'running' }); const isStreamingA = () => - useAgentStore.getState().threads[threadA]?.turns.some((t) => t.status === 'running') ?? - false; + useAgentStore.getState().threads[threadA]?.turns.some((t) => t.status === 'running') ?? false; const isStreamingB = () => - useAgentStore.getState().threads[threadB]?.turns.some((t) => t.status === 'running') ?? - false; + useAgentStore.getState().threads[threadB]?.turns.some((t) => t.status === 'running') ?? false; expect(isStreamingA()).toBe(true); expect(isStreamingB()).toBe(true); @@ -469,9 +467,8 @@ describe('global store - per-thread isStreaming derivation', () => { it('thread with no running turns is not streaming', () => { const threadId = 'thread-x'; const isStreaming = () => - useAgentStore - .getState() - .threads[threadId]?.turns.some((t) => t.status === 'running') ?? false; + useAgentStore.getState().threads[threadId]?.turns.some((t) => t.status === 'running') ?? + false; // Thread not yet created expect(isStreaming()).toBe(false); @@ -614,9 +611,9 @@ describe('global store - compressing state', () => { describe('global store - loadThreads orphan data cleanup', () => { it('cleans up todoByThreadId for deleted threads', () => { - useAgentStore.getState().applyTodoUpdate('deleted-thread', [ - { id: '1', text: 'todo', status: 'in_progress' }, - ]); + useAgentStore + .getState() + .applyTodoUpdate('deleted-thread', [{ id: '1', text: 'todo', status: 'in_progress' }]); expect(useAgentStore.getState().todoByThreadId['deleted-thread']).toBeDefined(); useAgentStore.getState().loadThreads([]); @@ -624,11 +621,19 @@ describe('global store - loadThreads orphan data cleanup', () => { }); it('preserves todoByThreadId for threads still in the list', () => { - useAgentStore.getState().applyTodoUpdate('kept-thread', [ - { id: '1', text: 'todo', status: 'in_progress' }, - ]); + useAgentStore + .getState() + .applyTodoUpdate('kept-thread', [{ id: '1', text: 'todo', status: 'in_progress' }]); useAgentStore.getState().loadThreads([ - { id: 'kept-thread', projectId: '', title: 'test', cwd: '/x', turns: [], createdAt: 1, updatedAt: 2 }, + { + id: 'kept-thread', + projectId: '', + title: 'test', + cwd: '/x', + turns: [], + createdAt: 1, + updatedAt: 2, + }, ]); expect(useAgentStore.getState().todoByThreadId['kept-thread']).toBeDefined(); }); @@ -644,7 +649,8 @@ describe('global store - loadThreads orphan data cleanup', () => { it('cleans up checkpointDiffByTurnId for deleted threads', () => { useRollbackStore.getState().setCheckpointDiff('deleted-thread', '1', { - turnId: 1, files: [], + turnId: 1, + files: [], } as any); useAgentStore.getState().loadThreads([]); expect(useRollbackStore.getState().checkpointDiffByTurnId['deleted-thread:1']).toBeUndefined(); @@ -668,13 +674,22 @@ describe('global store - loadThreads orphan data cleanup', () => { code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: '' }, } as any); useRollbackStore.getState().setCheckpointDiff('kept-thread', '1', { - turnId: 1, files: [], + turnId: 1, + files: [], } as any); useRollbackStore.getState().markFileReverted('kept-thread', '1', '/a.ts'); useRollbackStore.getState().setTurnCheckpointMapping('kept-thread', 1, 'ui-1'); useAgentStore.getState().loadThreads([ - { id: 'kept-thread', projectId: '', title: 'test', cwd: '/x', turns: [], createdAt: 1, updatedAt: 2 }, + { + id: 'kept-thread', + projectId: '', + title: 'test', + cwd: '/x', + turns: [], + createdAt: 1, + updatedAt: 2, + }, ]); expect(useRollbackStore.getState().rollbackStateByThreadId['kept-thread']).toBeDefined(); diff --git a/packages/infra/src/config.ts b/packages/infra/src/config.ts index 38bc7a89..32f7a58f 100644 --- a/packages/infra/src/config.ts +++ b/packages/infra/src/config.ts @@ -24,6 +24,7 @@ export interface MemoryConfig { model: string; extraTypes: MemoryTypeConfig[]; disabledTypes: string[]; + promptMaxBytes: number; } export interface ActiveModelConfig { @@ -57,6 +58,7 @@ export const DEFAULT_MEMORY: MemoryConfig = { model: '', extraTypes: [], disabledTypes: [], + promptMaxBytes: 8192, }; export const DEFAULT_CONFIG: AppConfig = { From f6c158b37b7b0ae4b524a27127b027b04c908b7d Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 17 Jun 2026 23:41:01 +0800 Subject: [PATCH 2/7] Simplify the conversation event structure and clean up redundant fields --- docs/context.md | 10 +- packages/codingcode/src/agent/agent.ts | 13 +- .../codingcode/src/client/direct/sessions.ts | 3 +- packages/codingcode/src/context/service.ts | 46 +- packages/codingcode/src/session/messages.ts | 119 ++-- packages/codingcode/src/session/store.ts | 127 ++--- packages/codingcode/src/session/types.ts | 53 +- packages/codingcode/src/tools/executor.ts | 2 +- .../test/agent/agent-cache-stability.test.ts | 4 +- .../test/agent/agent-concurrent.test.ts | 4 +- .../test/agent/agent-todo-event.test.ts | 4 +- packages/codingcode/test/agent/agent.test.ts | 18 +- .../test/agent/hooks-deps-type.test.ts | 4 +- .../test/agent/loop-options.test.ts | 4 +- .../test/agent/memory-snapshot.test.ts | 4 +- .../codingcode/test/agent/stop-hook.test.ts | 4 +- .../test/checkpoint/checkpoint-undo.test.ts | 2 - .../codingcode/test/client/direct.test.ts | 2 +- .../test/context/append-turn-end.test.ts | 21 +- .../test/context/budget-integration.test.ts | 17 +- .../test/context/compressor/behavior.test.ts | 17 +- .../compressor/compact-if-needed.test.ts | 9 +- .../codingcode/test/context/organizer.test.ts | 13 +- packages/codingcode/test/orchestrate.test.ts | 10 +- .../test/server/compact-route.test.ts | 10 +- packages/codingcode/test/server/index.test.ts | 12 +- .../test/session/delete-message.test.ts | 188 ------- packages/codingcode/test/session/fork.test.ts | 79 +-- .../codingcode/test/session/io-error.test.ts | 8 +- .../test/session/prompt-estimate.test.ts | 73 +-- .../record-tool-result-persist.test.ts | 22 +- .../codingcode/test/session/rollback.test.ts | 109 +--- .../test/session/store-diff-rebuild.test.ts | 29 +- .../test/session/ui-history-rollback.test.ts | 507 +----------------- .../test/session/update-index-dedup.test.ts | 6 +- .../test/session/usage-persist.test.ts | 6 +- .../test/session/view-assembly.test.ts | 194 +------ .../codingcode/test/subagent/dispatch.test.ts | 28 +- packages/tui/src/utils.ts | 41 +- packages/tui/test/utils.test.ts | 68 ++- 40 files changed, 391 insertions(+), 1499 deletions(-) delete mode 100644 packages/codingcode/test/session/delete-message.test.ts diff --git a/docs/context.md b/docs/context.md index c5635090..67c3357f 100644 --- a/docs/context.md +++ b/docs/context.md @@ -30,7 +30,7 @@ Coding Code 采用两层压缩策略,在不同阈值下自动触发: | 触发阈值 | `promptEstimate > modelMaxTokens * 0.9` | prompt 估算超过模型最大 token 90% 时触发 | | 保留最近 turn | 1 | 保留最近 1 个 turn 不压缩 | | 压缩方式 | 调用 LLM 生成摘要 | 输出 `...` 块 | -| 增量压缩 | 是 | 找到已有 SummaryEvent,只压缩 `lastSummarizedTurnId` 之后的事件 | +| 增量压缩 | 是 | 找到已有 SummaryEvent,只压缩 `endTurnId` 之后的事件 | | 失败追踪 | 连续 3 次失败后停止 | 24 小时 TTL 后重置 | --- @@ -90,17 +90,15 @@ interface CompactEvent { uuid: string; startTurnId: number; endTurnId: number; - timestamp: string; } // LLM 压缩摘要事件 interface SummaryEvent { type: 'summary'; uuid: string; - replaces: string[]; // 被替换的事件 UUID 列表 - summaryText: string; // 摘要文本 - lastSummarizedTurnId: number; // 最后压缩到的 turn ID - timestamp: string; + startTurnId: number; // 摘要覆盖的起始 turn ID + endTurnId: number; // 摘要覆盖的结束 turn ID + summaryText: string; // 摘要文本 } ``` diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index b58d029b..440fc876 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -253,7 +253,6 @@ export function agentLoop( const config = getContextConfig(); const maxOverflowRetries = REACTIVE_COMPACT_MAX_RETRIES; - const model = state.model ?? 'unknown'; const effectiveMaxSteps = opts.maxStepsOverride ?? maxSteps; let stopContinuations = 0; @@ -408,7 +407,7 @@ export function agentLoop( if (!toolCalls || toolCalls.length === 0) { if (session) { - yield* session.recordAssistant(state, resp.content, toolCalls || [], model, resp.usage); + yield* session.recordAssistant(state, resp.content, toolCalls || [], resp.usage); } const stopDecision = yield* hooks.emitDecision('agent.turn.stop', { sessionId, @@ -464,13 +463,7 @@ export function agentLoop( } } - const record = yield* session.recordAssistant( - state, - resp.content, - toolCalls!, - model, - resp.usage - ); + const record = yield* session.recordAssistant(state, resp.content, toolCalls!, resp.usage); const allResults = yield* executor.executeBatch(toolCalls, state.sessionId, { turnId: state.currentTurnId, projectPath, @@ -482,7 +475,7 @@ export function agentLoop( let todoPrinted = false; for (const r of allResults) { const resultOut = r.type === 'denied' ? '' : r.output; - yield* session.recordToolResult(state, record.uuid, r.name, r.id, resultOut); + yield* session.recordToolResult(state, r.name, r.id, resultOut); if (r.type === 'denied') { yield* q.offer({ _tag: 'ToolDenied', id: r.id, name: r.name, reason: r.reason }); } else { diff --git a/packages/codingcode/src/client/direct/sessions.ts b/packages/codingcode/src/client/direct/sessions.ts index fae8de5f..3619a8e3 100644 --- a/packages/codingcode/src/client/direct/sessions.ts +++ b/packages/codingcode/src/client/direct/sessions.ts @@ -1,4 +1,4 @@ -import { Effect } from 'effect'; +import { Effect } from 'effect'; import { SessionService } from '../../session/store.js'; import { WorkspaceService } from '../../core/workspace.js'; import { deleteSession } from '../../session/file-ops.js'; @@ -21,6 +21,7 @@ export interface SessionClient { resumeSession(input: { sessionId: string; cwd: string }): Promise; listSessions(input: { cwd: string }): Promise; getSessionHistory(input: { sessionId: string }): Promise; + deleteSession(input: { sessionId: string }): Promise; getSessionPermissionMode(input: { sessionId: string }): Promise; setSessionPermissionMode(input: { sessionId: string; mode: PermissionMode }): Promise; diff --git a/packages/codingcode/src/context/service.ts b/packages/codingcode/src/context/service.ts index 3913f883..fe7e969f 100644 --- a/packages/codingcode/src/context/service.ts +++ b/packages/codingcode/src/context/service.ts @@ -64,8 +64,12 @@ export class ContextService extends Effect.Service()('Context', const idx = session.findSessionIndexProxy(sessionId); const currentTurnId = idx?.currentTurnId ?? 0; - const { hidden, compactedTurnIds: initialCompactedTurnIds } = applyVisibilityEvents(events); - let visible = filterVisible(events, hidden); + const { + hiddenTurnIds, + hiddenOpUuids, + compactedTurnIds: initialCompactedTurnIds, + } = applyVisibilityEvents(events); + let visible = filterVisible(events, hiddenTurnIds, hiddenOpUuids); let compactedTurnIds = initialCompactedTurnIds; const preEstimate = estimateTokensFromEvents(visible); @@ -82,7 +86,7 @@ export class ContextService extends Effect.Service()('Context', if (didCompact) { events = session.readHistoryFile(jsonlPath); const updated = applyVisibilityEvents(events); - visible = filterVisible(events, updated.hidden); + visible = filterVisible(events, updated.hiddenTurnIds, updated.hiddenOpUuids); compactedTurnIds = updated.compactedTurnIds; } @@ -96,11 +100,17 @@ export class ContextService extends Effect.Service()('Context', }; }; - function filterVisible(events: SessionEvent[], hidden: Set): SessionEvent[] { + function filterVisible( + events: SessionEvent[], + hiddenTurnIds: Set, + hiddenOpUuids: Set + ): SessionEvent[] { return events.filter((ev) => { - if (ev.type === 'hide' || ev.type === 'unhide') return false; - if (ev.type === 'compact') return false; - if ('uuid' in ev && hidden.has((ev as any).uuid)) return false; + if (ev.type === 'session_meta') return false; + if (ev.type === 'rollback') return false; + if (ev.type === 'summary' && hiddenOpUuids.has(ev.uuid)) return false; + if (ev.type === 'compact' && hiddenOpUuids.has(ev.uuid)) return false; + if ('turnId' in ev && hiddenTurnIds.has(ev.turnId)) return false; return true; }) as SessionEvent[]; } @@ -145,7 +155,6 @@ export class ContextService extends Effect.Service()('Context', uuid: randomUUID(), startTurnId, endTurnId, - timestamp: new Date().toISOString(), }; appendLine(jsonlPath, compactEvent); return true; @@ -275,23 +284,18 @@ export class ContextService extends Effect.Service()('Context', const summary = await callLLMForCompaction(msgs, compactionLlm, config); if (!summary) return 0; - const replacedUuids: string[] = []; - for (const ev of targetEvents) { - if ('uuid' in (ev as any)) replacedUuids.push((ev as any).uuid); - } - - const lastTurnId = Math.max( - ...targetEvents.filter((e) => 'turnId' in e).map((e) => (e as any).turnId), - 0 - ); + const turnIds = targetEvents + .filter((e) => 'turnId' in e) + .map((e) => (e as any).turnId as number); + const startTurnId = Math.min(...turnIds); + const endTurnId = Math.max(...turnIds); const event: SummaryEvent = { type: 'summary', uuid: randomUUID(), - replaces: replacedUuids, + startTurnId, + endTurnId, summaryText: summary, - lastSummarizedTurnId: lastTurnId, - timestamp: new Date().toISOString(), }; appendLine(resolveSessionJsonlPath(sessionId), event); @@ -306,7 +310,7 @@ export class ContextService extends Effect.Service()('Context', if (!existingSummary) return inRange; - const lastTurn = existingSummary.lastSummarizedTurnId ?? 0; + const lastTurn = existingSummary.endTurnId ?? 0; return inRange.filter((e) => 'turnId' in e && (e as any).turnId > lastTurn); } diff --git a/packages/codingcode/src/session/messages.ts b/packages/codingcode/src/session/messages.ts index 87fe877d..433a28d9 100644 --- a/packages/codingcode/src/session/messages.ts +++ b/packages/codingcode/src/session/messages.ts @@ -1,6 +1,12 @@ import { join } from 'path'; import type { Message } from '../core/types.js'; -import type { SessionEvent, AssistantEvent, TokenUsage } from './types.js'; +import type { + SessionEvent, + AssistantEvent, + SummaryEvent, + CompactEvent, + TokenUsage, +} from './types.js'; import { readHistory, resolveSessionDir } from './file-ops.js'; import { getContextConfig } from '../context/config.js'; @@ -18,71 +24,78 @@ const COMPACTABLE_TOOLS = new Set([ const MICRO_COMPACT_MIN_CHARS = 120; export interface VisibilityResult { - hidden: Set; + hiddenTurnIds: Set; + hiddenOpUuids: Set; compactedTurnIds: Set; } export function applyVisibilityEvents(events: SessionEvent[]): VisibilityResult { - const hidden = new Set(); + const hiddenTurnIds = new Set(); + const hiddenOpUuids = new Set(); const compactedTurnIds = new Set(); - const hideEffects = new Map>(); + // First pass: find operation events revoked by rollback. for (const ev of events) { - switch (ev.type) { - case 'hide': { - let effect: Set; - if (ev.kind === 'message') { - effect = new Set([ev.targetUuid]); - } else { - effect = new Set(); - for (const prior of events) { - if (prior === ev) break; - if ('turnId' in prior && prior.turnId >= ev.throughTurnId && 'uuid' in prior) { - effect.add((prior as any).uuid); - } - } + if (ev.type !== 'rollback') continue; + for (const prior of events) { + if (prior === ev) break; + if (prior.type === 'summary' || prior.type === 'compact') { + if (prior.endTurnId >= ev.throughTurnId) { + hiddenOpUuids.add(prior.uuid); } - hideEffects.set(ev.uuid, effect); - for (const u of effect) hidden.add(u); - break; } - case 'unhide': { - const effect = hideEffects.get(ev.targetHideUuid); - if (effect) { - for (const u of effect) hidden.delete(u); + } + } + + // Second pass: compute visible turn ranges. + for (const ev of events) { + switch (ev.type) { + case 'rollback': { + for (const prior of events) { + if (prior === ev) break; + if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { + hiddenTurnIds.add(prior.turnId); + } } break; } case 'summary': { - for (const u of ev.replaces) hidden.add(u); + if (hiddenOpUuids.has(ev.uuid)) break; + for (let t = ev.startTurnId; t <= ev.endTurnId; t++) { + hiddenTurnIds.add(t); + } break; } case 'compact': { - if (!hidden.has(ev.uuid)) { - for (let t = ev.startTurnId; t <= ev.endTurnId; t++) { - compactedTurnIds.add(t); - } + if (hiddenOpUuids.has(ev.uuid)) break; + for (let t = ev.startTurnId; t <= ev.endTurnId; t++) { + compactedTurnIds.add(t); } break; } } } - return { hidden, compactedTurnIds }; + return { hiddenTurnIds, hiddenOpUuids, compactedTurnIds }; } export function buildMessagesFromEvents( events: SessionEvent[], externalCompactedTurnIds?: Set ): Message[] { - const { hidden, compactedTurnIds: derivedIds } = applyVisibilityEvents(events); + const { + hiddenTurnIds, + hiddenOpUuids, + compactedTurnIds: derivedIds, + } = applyVisibilityEvents(events); const compactedTurnIds = externalCompactedTurnIds ?? derivedIds; const visible: SessionEvent[] = []; for (const ev of events) { - if (ev.type === 'hide' || ev.type === 'unhide') continue; + if (ev.type === 'rollback') continue; if (ev.type === 'compact') continue; - if ('uuid' in ev && hidden.has((ev as any).uuid)) continue; + if (ev.type === 'summary' && hiddenOpUuids.has(ev.uuid)) continue; + if ('turnId' in ev && hiddenTurnIds.has(ev.turnId)) continue; visible.push(ev); } @@ -187,20 +200,25 @@ export function findLastVisibleAssistantUsage(path: string): TokenUsage | undefi return undefined; } +function createTurnScopedIdGenerator() { + const counters = new Map(); + return (prefix: string, turnId: number): string => { + const key = `${prefix}:${turnId}`; + const next = (counters.get(key) ?? 0) + 1; + counters.set(key, next); + return `${prefix}-${turnId}-${next}`; + }; +} + export function sessionEventsToTurns( events: SessionEvent[] ): Array<{ id: string; items: object[]; status: string }> { const turnsMap = new Map(); + const nextId = createTurnScopedIdGenerator(); + for (const event of events) { if (event.type === 'session_meta') continue; - if ( - event.type === 'summary' || - event.type === 'hide' || - event.type === 'unhide' || - event.type === 'title' || - event.type === 'compact' - ) - continue; + if (event.type === 'summary' || event.type === 'compact' || event.type === 'rollback') continue; let turn = turnsMap.get(event.turnId); if (!turn) { turn = { id: String(event.turnId), items: [], status: 'completed' }; @@ -208,12 +226,17 @@ export function sessionEventsToTurns( } switch (event.type) { case 'user': - turn.items.push({ id: event.uuid, type: 'message', role: 'user', content: event.content }); + turn.items.push({ + id: nextId('user', event.turnId), + type: 'message', + role: 'user', + content: event.content, + }); break; case 'assistant': if (event.content) { turn.items.push({ - id: event.uuid, + id: nextId('assistant', event.turnId), type: 'message', role: 'assistant', content: event.content, @@ -232,7 +255,7 @@ export function sessionEventsToTurns( break; case 'tool_result': { const item: Record = { - id: event.uuid, + id: `result-${event.toolCallId}`, type: 'tool_result', callId: event.toolCallId, name: event.toolName, @@ -253,10 +276,12 @@ export function readUIHistory( if (!dir) return []; const jsonlPath = join(dir, `${sessionId}.jsonl`); const events = readHistory(jsonlPath); - const { hidden } = applyVisibilityEvents(events); + const { hiddenTurnIds, hiddenOpUuids } = applyVisibilityEvents(events); const visibleEvents = events.filter((ev) => { - if (ev.type === 'hide' || ev.type === 'unhide') return false; - if ('uuid' in ev && hidden.has((ev as any).uuid)) return false; + if (ev.type === 'rollback') return false; + if (ev.type === 'summary' && hiddenOpUuids.has((ev as SummaryEvent).uuid)) return false; + if (ev.type === 'compact' && hiddenOpUuids.has((ev as CompactEvent).uuid)) return false; + if ('turnId' in ev && hiddenTurnIds.has(ev.turnId)) return false; return true; }); return sessionEventsToTurns(visibleEvents); diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index fce4e05e..4652c823 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -12,13 +12,12 @@ import type { AssistantEvent, ToolResultEvent, SummaryEvent, - HideEvent, - UnhideEvent, - TitleEvent, + RollbackEvent, SessionIndex, TokenUsage, + SessionEvent, } from './types.js'; -import { estimateTokens, estimateTokensForContent, estimateMessageTokens } from '../core/util.js'; +import { estimateTokens, estimateMessageTokens } from '../core/util.js'; import { projectSessionsDir, ensureDirs, @@ -159,9 +158,7 @@ export class SessionService extends Effect.Service()('Session', const event: UserEvent = { type: 'user', turnId: state.currentTurnId, - uuid: randomUUID(), content, - timestamp: new Date().toISOString(), }; if (state.title === state.sessionId.slice(0, 8)) { state.title = truncateTitle(content); @@ -182,7 +179,6 @@ export class SessionService extends Effect.Service()('Session', state: SessionStoreState, content: string, toolCalls: AssistantEvent['toolCalls'], - model: string, usage?: TokenUsage ): Effect.Effect => Effect.try({ @@ -190,11 +186,8 @@ export class SessionService extends Effect.Service()('Session', const event: AssistantEvent = { type: 'assistant', turnId: state.currentTurnId, - uuid: randomUUID(), content, toolCalls, - model, - timestamp: new Date().toISOString(), usage, }; appendLine(state.transcriptPath, event); @@ -216,24 +209,18 @@ export class SessionService extends Effect.Service()('Session', const recordToolResult = ( state: SessionStoreState, - parentUuid: string, toolName: string, toolCallId: string, output: string ): Effect.Effect => Effect.try({ try: () => { - const tokenCount = estimateTokensForContent(output); const event: ToolResultEvent = { type: 'tool_result', turnId: state.currentTurnId, - uuid: randomUUID(), - parentUuid, toolName, toolCallId, output, - timestamp: new Date().toISOString(), - tokenCount, }; appendLine(state.transcriptPath, event); state.messageCount++; @@ -254,19 +241,18 @@ export class SessionService extends Effect.Service()('Session', const appendSummary = ( state: SessionStoreState, - replaces: string[], summaryText: string, - lastSummarizedTurnId: number = 0 + startTurnId: number, + endTurnId: number ): Effect.Effect => Effect.try({ try: () => { const event: SummaryEvent = { type: 'summary', uuid: randomUUID(), - replaces, + startTurnId, + endTurnId, summaryText, - lastSummarizedTurnId, - timestamp: new Date().toISOString(), }; appendLine(state.transcriptPath, event); state.messageCount++; @@ -281,41 +267,16 @@ export class SessionService extends Effect.Service()('Session', : new AgentError('SESSION_IO_ERROR', `Session write failed: ${String(e)}`, e), }); - const hideMessage = ( - state: SessionStoreState, - targetUuid: string, - reason: string - ): Effect.Effect => - Effect.sync(() => { - const event: HideEvent = { - type: 'hide', - uuid: randomUUID(), - kind: 'message', - targetUuid, - reason, - timestamp: new Date().toISOString(), - }; - appendLine(state.transcriptPath, event); - state.messageCount++; - updateIndex(state); - state.usage = undefined; - state.promptEstimate = estimateTokens(buildMessages(state.transcriptPath)); - return event; - }); - const rollbackToTurn = ( state: SessionStoreState, throughTurnId: number, reason: string - ): Effect.Effect => + ): Effect.Effect => Effect.sync(() => { - const event: HideEvent = { - type: 'hide', - uuid: randomUUID(), - kind: 'rollback', + const event: RollbackEvent = { + type: 'rollback', throughTurnId, reason, - timestamp: new Date().toISOString(), }; appendLine(state.transcriptPath, event); state.messageCount++; @@ -326,30 +287,6 @@ export class SessionService extends Effect.Service()('Session', return event; }); - const undoLastHide = (state: SessionStoreState): Effect.Effect => - Effect.sync(() => { - const history = readHistory(state.transcriptPath); - let lastHideUuid: string | null = null; - const unhidTargets = new Set(); - for (const ev of history) { - if (ev.type === 'hide' && ev.kind === 'message') lastHideUuid = ev.uuid; - if (ev.type === 'unhide') unhidTargets.add(ev.targetHideUuid); - } - if (!lastHideUuid || unhidTargets.has(lastHideUuid)) return null; - const event: UnhideEvent = { - type: 'unhide', - uuid: randomUUID(), - targetHideUuid: lastHideUuid, - timestamp: new Date().toISOString(), - }; - appendLine(state.transcriptPath, event); - state.messageCount++; - updateIndex(state); - state.usage = undefined; - state.promptEstimate = estimateTokens(buildMessages(state.transcriptPath)); - return event; - }); - const forkSession = ( state: SessionStoreState, atTurnId: number @@ -361,24 +298,13 @@ export class SessionService extends Effect.Service()('Session', const renameSession = ( state: SessionStoreState, text: string - ): Effect.Effect => + ): Effect.Effect => Effect.sync(() => { - const event: TitleEvent = { - type: 'title', - uuid: randomUUID(), - text, - timestamp: new Date().toISOString(), - }; state.title = text; - appendLine(state.transcriptPath, event); - state.messageCount++; updateIndex(state); - return event; }); - const readHistoryFromState = ( - state: SessionStoreState - ): Effect.Effect => + const readHistoryFromState = (state: SessionStoreState): Effect.Effect => Effect.sync(() => readHistory(state.transcriptPath)); const readMessages = (state: SessionStoreState): Effect.Effect => @@ -417,9 +343,7 @@ export class SessionService extends Effect.Service()('Session', recordAssistant, recordToolResult, appendSummary, - hideMessage, rollbackToTurn, - undoLastHide, forkSession, renameSession, readHistory: readHistoryFromState, @@ -432,7 +356,7 @@ export class SessionService extends Effect.Service()('Session', getPermissionMode: getPermissionModeFromState, incrementTurn, resolveSessionJsonlPath: (sessionId: string): string => _resolveSessionJsonlPath(sessionId), - readHistoryFile: (path: string): import('./types.js').SessionEvent[] => readHistory(path), + readHistoryFile: (path: string): SessionEvent[] => readHistory(path), findSessionIndexProxy: (sessionId: string): SessionIndex | null => findSessionIndex(sessionId), appendLineProxy: (path: string, event: object): void => appendLine(path, event), @@ -505,19 +429,28 @@ function forkSessionImpl( const newJsonlPath = join(sessionsDir, `${newSessionId}.jsonl`); const newIndexPath = join(sessionsDir, `${newSessionId}.index.json`); - const uuidMap = new Map(); + const toolCallIdMap = new Map(); let turnId = 0; for (const ev of chain) { - const oldUuid = 'uuid' in ev ? ((ev as any).uuid as string) : undefined; - const newUuid = randomUUID(); - if (oldUuid) uuidMap.set(oldUuid, newUuid); - const cloned: any = { ...ev }; - if (oldUuid) cloned.uuid = newUuid; - if ('parentUuid' in cloned && cloned.parentUuid) { - cloned.parentUuid = uuidMap.get(cloned.parentUuid) ?? cloned.parentUuid; + + if (cloned.type === 'summary' || cloned.type === 'compact') { + cloned.uuid = randomUUID(); + } + + if (cloned.type === 'assistant' && Array.isArray(cloned.toolCalls)) { + for (const tc of cloned.toolCalls) { + const newId = randomUUID(); + toolCallIdMap.set(tc.id, newId); + tc.id = newId; + } + } + + if (cloned.type === 'tool_result' && cloned.toolCallId) { + cloned.toolCallId = toolCallIdMap.get(cloned.toolCallId) ?? cloned.toolCallId; } + if (cloned.type === 'session_meta') { cloned.sessionId = newSessionId; } diff --git a/packages/codingcode/src/session/types.ts b/packages/codingcode/src/session/types.ts index a7241bab..36dcf458 100644 --- a/packages/codingcode/src/session/types.ts +++ b/packages/codingcode/src/session/types.ts @@ -12,75 +12,37 @@ export interface SessionMetaEvent { export interface UserEvent { type: 'user'; turnId: number; - uuid: string; content: string; - timestamp: string; } export interface AssistantEvent { type: 'assistant'; turnId: number; - uuid: string; content: string; toolCalls: Array<{ id: string; name: string; arguments: Record }>; - model: string; - timestamp: string; usage?: TokenUsage; } export interface ToolResultEvent { type: 'tool_result'; turnId: number; - uuid: string; - parentUuid: string; - toolName: string; toolCallId: string; + toolName: string; output: string; - timestamp: string; - tokenCount: number; } export interface SummaryEvent { type: 'summary'; uuid: string; - replaces: string[]; + startTurnId: number; + endTurnId: number; summaryText: string; - lastSummarizedTurnId: number; - timestamp: string; -} - -export interface HideMessageEvent { - type: 'hide'; - uuid: string; - kind: 'message'; - targetUuid: string; - reason: string; - timestamp: string; } -export interface HideRollbackEvent { - type: 'hide'; - uuid: string; - kind: 'rollback'; +export interface RollbackEvent { + type: 'rollback'; throughTurnId: number; reason: string; - timestamp: string; -} - -export type HideEvent = HideMessageEvent | HideRollbackEvent; - -export interface UnhideEvent { - type: 'unhide'; - uuid: string; - targetHideUuid: string; - timestamp: string; -} - -export interface TitleEvent { - type: 'title'; - uuid: string; - text: string; - timestamp: string; } export interface CompactEvent { @@ -88,7 +50,6 @@ export interface CompactEvent { uuid: string; startTurnId: number; endTurnId: number; - timestamp: string; } export type SessionEvent = @@ -97,9 +58,7 @@ export type SessionEvent = | AssistantEvent | ToolResultEvent | SummaryEvent - | HideEvent - | UnhideEvent - | TitleEvent + | RollbackEvent | CompactEvent; export interface TokenUsage { diff --git a/packages/codingcode/src/tools/executor.ts b/packages/codingcode/src/tools/executor.ts index 54f675a5..892d2fc0 100644 --- a/packages/codingcode/src/tools/executor.ts +++ b/packages/codingcode/src/tools/executor.ts @@ -58,7 +58,7 @@ export class ToolExecutorService extends Effect.Service()(' } // Use modified input from pipeline if present - let finalArgs: Record = + const finalArgs: Record = decision.type === 'modified' ? decision.input : (args as Record); // 2. Notification hook — use callId for consistent pairing diff --git a/packages/codingcode/test/agent/agent-cache-stability.test.ts b/packages/codingcode/test/agent/agent-cache-stability.test.ts index a7e89bf8..30bff013 100644 --- a/packages/codingcode/test/agent/agent-cache-stability.test.ts +++ b/packages/codingcode/test/agent/agent-cache-stability.test.ts @@ -33,8 +33,8 @@ const AllMockLayer = Layer.mergeAll( snapshotFinal: () => Effect.void, } as any), Layer.succeed(SessionService, { - recordAssistant: () => Effect.succeed({ uuid: 'a1' }), - recordUser: () => Effect.succeed({ uuid: 'u1' }), + recordAssistant: () => Effect.succeed({}), + recordUser: () => Effect.succeed({}), recordToolResult: () => Effect.succeed({}), } as any), Layer.succeed(ProjectRuntimeService, { diff --git a/packages/codingcode/test/agent/agent-concurrent.test.ts b/packages/codingcode/test/agent/agent-concurrent.test.ts index 142bb0f6..7e5ad54e 100644 --- a/packages/codingcode/test/agent/agent-concurrent.test.ts +++ b/packages/codingcode/test/agent/agent-concurrent.test.ts @@ -33,8 +33,8 @@ const AllMockLayer = Layer.mergeAll( snapshotFinal: () => Effect.void, } as any), Layer.succeed(SessionService, { - recordAssistant: () => Effect.succeed({ uuid: 'a1' }), - recordUser: () => Effect.succeed({ uuid: 'u1' }), + recordAssistant: () => Effect.succeed({}), + recordUser: () => Effect.succeed({}), recordToolResult: () => Effect.succeed({}), } as any), Layer.succeed(ProjectRuntimeService, { diff --git a/packages/codingcode/test/agent/agent-todo-event.test.ts b/packages/codingcode/test/agent/agent-todo-event.test.ts index aa5f84dd..2310b2ed 100644 --- a/packages/codingcode/test/agent/agent-todo-event.test.ts +++ b/packages/codingcode/test/agent/agent-todo-event.test.ts @@ -36,8 +36,8 @@ const AllMockLayer = Layer.mergeAll( snapshotFinal: () => Effect.void, } as any), Layer.succeed(SessionService, { - recordAssistant: () => Effect.succeed({ uuid: 'a1' }), - recordUser: () => Effect.succeed({ uuid: 'u1' }), + recordAssistant: () => Effect.succeed({}), + recordUser: () => Effect.succeed({}), recordToolResult: () => Effect.succeed({}), } as any), Layer.succeed(ProjectRuntimeService, { diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index e9a96398..ca43a46e 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -54,16 +54,10 @@ const mockAgentService = { }; const mockSession = { - recordAssistant: (_state: any, _content: string, _toolCalls: any, _model: string) => - Effect.succeed({ uuid: 'a1' }), - recordToolResult: ( - _state: any, - _parentUuid: string, - _toolName: string, - _toolCallId: string, - _output: string - ) => Effect.succeed({}), - recordUser: (_state: any, _content: string) => Effect.succeed({ uuid: 'm1' }), + recordAssistant: (_state: any, _content: string, _toolCalls: any) => Effect.succeed({}), + recordToolResult: (_state: any, _toolName: string, _toolCallId: string, _output: string) => + Effect.succeed({}), + recordUser: (_state: any, _content: string) => Effect.succeed({}), }; const mockState = { @@ -135,8 +129,8 @@ const AllMockLayer = Layer.mergeAll( getLatestRestoreEntry: () => Effect.succeed(null), } as any), Layer.succeed(SessionService, { - recordAssistant: () => Effect.succeed({ uuid: 'a1' }), - recordUser: () => Effect.succeed({ uuid: 'u1' }), + recordAssistant: () => Effect.succeed({}), + recordUser: () => Effect.succeed({}), recordToolResult: () => Effect.succeed({}), } as any), Layer.succeed(HookService, { diff --git a/packages/codingcode/test/agent/hooks-deps-type.test.ts b/packages/codingcode/test/agent/hooks-deps-type.test.ts index d3a1c29e..acf45e65 100644 --- a/packages/codingcode/test/agent/hooks-deps-type.test.ts +++ b/packages/codingcode/test/agent/hooks-deps-type.test.ts @@ -34,8 +34,8 @@ const AllMockLayer = Layer.mergeAll( snapshotFinal: () => Effect.void, } as any), Layer.succeed(SessionService, { - recordAssistant: () => Effect.succeed({ uuid: 'a1' }), - recordUser: () => Effect.succeed({ uuid: 'u1' }), + recordAssistant: () => Effect.succeed({}), + recordUser: () => Effect.succeed({}), recordToolResult: () => Effect.succeed({}), } as any), Layer.succeed(ProjectRuntimeService, { diff --git a/packages/codingcode/test/agent/loop-options.test.ts b/packages/codingcode/test/agent/loop-options.test.ts index d99faf30..178dd8b7 100644 --- a/packages/codingcode/test/agent/loop-options.test.ts +++ b/packages/codingcode/test/agent/loop-options.test.ts @@ -34,8 +34,8 @@ const AllMockLayer = Layer.mergeAll( snapshotFinal: () => Effect.void, } as any), Layer.succeed(SessionService, { - recordAssistant: () => Effect.succeed({ uuid: 'a1' }), - recordUser: () => Effect.succeed({ uuid: 'u1' }), + recordAssistant: () => Effect.succeed({}), + recordUser: () => Effect.succeed({}), recordToolResult: () => Effect.succeed({}), } as any), Layer.succeed(ProjectRuntimeService, { diff --git a/packages/codingcode/test/agent/memory-snapshot.test.ts b/packages/codingcode/test/agent/memory-snapshot.test.ts index f8898fc9..33da365d 100644 --- a/packages/codingcode/test/agent/memory-snapshot.test.ts +++ b/packages/codingcode/test/agent/memory-snapshot.test.ts @@ -44,8 +44,8 @@ const BaseMockLayer = Layer.mergeAll( snapshotFinal: () => Effect.void, } as any), Layer.succeed(SessionService, { - recordAssistant: () => Effect.succeed({ uuid: 'a1' }), - recordUser: () => Effect.succeed({ uuid: 'u1' }), + recordAssistant: () => Effect.succeed({}), + recordUser: () => Effect.succeed({}), recordToolResult: () => Effect.succeed({}), } as any), Layer.succeed(ProjectRuntimeService, { diff --git a/packages/codingcode/test/agent/stop-hook.test.ts b/packages/codingcode/test/agent/stop-hook.test.ts index 99dd9afc..c6fe6d0b 100644 --- a/packages/codingcode/test/agent/stop-hook.test.ts +++ b/packages/codingcode/test/agent/stop-hook.test.ts @@ -34,8 +34,8 @@ const AllMockLayer = Layer.mergeAll( snapshotFinal: () => Effect.void, } as any), Layer.succeed(SessionService, { - recordAssistant: () => Effect.succeed({ uuid: 'a1' }), - recordUser: () => Effect.succeed({ uuid: 'u1' }), + recordAssistant: () => Effect.succeed({}), + recordUser: () => Effect.succeed({}), recordToolResult: () => Effect.succeed({}), } as any), Layer.succeed(ProjectRuntimeService, { diff --git a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts index e528d1c6..72f33720 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts @@ -168,7 +168,6 @@ describe('undoLastCodeRollback end-to-end via ShadowGit', () => { affectedTurns: [], selectedFiles: [join(projectPath, 'src/main.ts')], safetyCommit: safetyHash, - timestamp: new Date().toISOString(), }; writeFileSync(restorePath, JSON.stringify(entry, null, 2), 'utf8'); @@ -336,7 +335,6 @@ describe('undoLastCodeRollback case-insensitive path matching', () => { affectedTurns: [], selectedFiles: [join(projectPath, 'src/main.ts').toLowerCase()], safetyCommit: safetyHash, - timestamp: new Date().toISOString(), }; writeFileSync(restorePath, JSON.stringify(entry, null, 2), 'utf8'); diff --git a/packages/codingcode/test/client/direct.test.ts b/packages/codingcode/test/client/direct.test.ts index a16a6be0..a8fdad33 100644 --- a/packages/codingcode/test/client/direct.test.ts +++ b/packages/codingcode/test/client/direct.test.ts @@ -54,8 +54,8 @@ const noopLlm: LLMClient = { usage: { prompt: 0, completion: 0, total: 0 }, }), modelInfo: { - model: 'test', provider: 'test', + model: 'test-model', maxTokens: 128000, supportsToolCalling: true, supportsStreaming: true, diff --git a/packages/codingcode/test/context/append-turn-end.test.ts b/packages/codingcode/test/context/append-turn-end.test.ts index ff0d6bca..240ced48 100644 --- a/packages/codingcode/test/context/append-turn-end.test.ts +++ b/packages/codingcode/test/context/append-turn-end.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; @@ -47,23 +47,4 @@ describe('appendTurnEnd', () => { expect(tokens).toBeGreaterThan(0); expect(Number.isInteger(tokens)).toBe(true); }); - - it('tokenCount is included in ToolResultEvent write', () => { - const output = 'short output'; - const tokens = estimateTokensForContent(output); - const event = { - type: 'tool_result', - turnId: 1, - uuid: 't1', - parentUuid: 'a1', - toolName: 'bash', - toolCallId: 'tc1', - output, - timestamp: new Date().toISOString(), - tokenCount: tokens, - }; - const serialized = JSON.stringify(event); - const parsed = JSON.parse(serialized); - expect(parsed.tokenCount).toBe(tokens); - }); }); diff --git a/packages/codingcode/test/context/budget-integration.test.ts b/packages/codingcode/test/context/budget-integration.test.ts index 6a68a19e..b8602641 100644 --- a/packages/codingcode/test/context/budget-integration.test.ts +++ b/packages/codingcode/test/context/budget-integration.test.ts @@ -57,43 +57,32 @@ describe('assemblePayload integration', () => { sessionId, projectPath: projectSlug, cwd: '/tmp/test', - model: 'test', + createdAt: new Date().toISOString(), }, - { type: 'user', turnId: 1, uuid: 'u1', content: 'q1', timestamp: new Date().toISOString() }, + { type: 'user', turnId: 1, content: 'q1' }, { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'r1', toolCalls: [ { id: 'tc1', name: 'bash', arguments: {} }, { id: 'tc2', name: 'bash', arguments: {} }, ], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'tool_result', turnId: 1, - uuid: 't1', - parentUuid: 'a1', toolName: 'bash', toolCallId: 'tc1', output: 'x'.repeat(200), - timestamp: new Date().toISOString(), - tokenCount: 0, }, { type: 'tool_result', turnId: 1, - uuid: 't2', - parentUuid: 'a1', toolName: 'bash', toolCallId: 'tc2', output: 'y'.repeat(200), - timestamp: new Date().toISOString(), - tokenCount: 0, }, ]; writeFileSync(jsonlPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); @@ -102,7 +91,7 @@ describe('assemblePayload integration', () => { sessionId, projectPath: projectSlug, cwd: '/tmp/test', - model: 'test', + model: 'test-model', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), messageCount: lines.length, diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index 3bc6ac41..787898a6 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -46,29 +46,20 @@ function makeFixture(opts: FixtureOptions) { lines.push({ type: 'user', turnId: turn, - uuid: `u${turn}`, content: `q${turn}`, - timestamp: new Date().toISOString(), }); lines.push({ type: 'assistant', turnId: turn, - uuid: `a${turn}`, content: `r${turn}`, toolCalls: [{ id: `tc${turn}`, name: opts.toolName ?? 'bash', arguments: '{}' }], - model: 'test', - timestamp: new Date().toISOString(), }); lines.push({ type: 'tool_result', turnId: turn, - uuid: `t${turn}`, - parentUuid: `a${turn}`, toolName: opts.toolName ?? 'bash', toolCallId: `tc${turn}`, output: toolContent, - timestamp: new Date().toISOString(), - tokenCount: Math.ceil(toolContent.length / 3.5), }); } @@ -78,7 +69,7 @@ function makeFixture(opts: FixtureOptions) { sessionId, projectPath: slug, cwd: '/tmp/test', - model: 'test', + model: 'test-model', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), messageCount: opts.numTurns * 3, @@ -168,7 +159,8 @@ describe('compressor behavior', () => { const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries.length).toBe(1); expect(summaries[0]!.summaryText).toContain('### Goal'); - expect(summaries[0]!.replaces.length).toBeGreaterThan(0); + expect(summaries[0]!.startTurnId).toBeLessThanOrEqual(summaries[0]!.endTurnId); + expect(summaries[0]!.endTurnId).toBeGreaterThan(0); } finally { cleanup(fx.slug); } @@ -205,7 +197,8 @@ describe('compressor behavior', () => { const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries).toHaveLength(1); - expect(summaries[0]!.replaces.length).toBeGreaterThan(0); + expect(summaries[0]!.startTurnId).toBeLessThanOrEqual(summaries[0]!.endTurnId); + expect(summaries[0]!.endTurnId).toBeGreaterThan(0); } finally { cleanup(fx.slug); } diff --git a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts index 699d38fc..d15014fb 100644 --- a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts +++ b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts @@ -37,8 +37,8 @@ vi.mock('../../../src/session/file-ops.js', async (importOriginal) => { return `${dir}/${sessionId}.jsonl`; }), readHistory: vi.fn(() => [ - { type: 'user', content: 'a'.repeat(200), uuid: 'u1', turnId: 1 }, - { type: 'assistant', content: 'b'.repeat(200), uuid: 'a1', turnId: 1 }, + { type: 'user', content: 'a'.repeat(200), turnId: 1 }, + { type: 'assistant', content: 'b'.repeat(200), turnId: 1 }, ]), }; }); @@ -126,12 +126,11 @@ describe('compactIfNeeded', () => { 's1', 'proj', [ - { type: 'user', content: 'a'.repeat(200), uuid: 'u1', turnId: 1 }, - { type: 'assistant', content: 'b'.repeat(200), uuid: 'a1', turnId: 1 }, + { type: 'user', content: 'a'.repeat(200), turnId: 1 }, + { type: 'assistant', content: 'b'.repeat(200), turnId: 1 }, { type: 'tool_result', output: 'c'.repeat(5000), - uuid: 't1', turnId: 1, toolName: 'read_file', toolCallId: 'tc1', diff --git a/packages/codingcode/test/context/organizer.test.ts b/packages/codingcode/test/context/organizer.test.ts index 809bd376..deb981d4 100644 --- a/packages/codingcode/test/context/organizer.test.ts +++ b/packages/codingcode/test/context/organizer.test.ts @@ -10,18 +10,15 @@ const baseConfig = { }; function makeUserEvent(content: string, turnId: number): SessionEvent { - return { type: 'user', uuid: `u${turnId}`, content, turnId, timestamp: new Date().toISOString() }; + return { type: 'user', content, turnId }; } function makeAssistant(content: string, turnId: number): SessionEvent { return { type: 'assistant', - uuid: `a${turnId}`, content, turnId, toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), }; } @@ -29,18 +26,14 @@ function makeToolResult( toolName: string, output: string, turnId: number, - uuid: string + toolCallId: string ): ToolResultEvent { return { type: 'tool_result', - uuid, - parentUuid: 'a1', toolName, - toolCallId: `tc${uuid}`, + toolCallId, output, turnId, - timestamp: new Date().toISOString(), - tokenCount: 0, }; } diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index 6138e225..761037e7 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -210,32 +210,24 @@ const MockSessionLayer = Layer.succeed(SessionService, { recordUser: () => Effect.succeed({ type: 'user' as const, - uuid: 'u1', content: '', turnId: 0, - timestamp: new Date().toISOString(), }), recordAssistant: () => Effect.succeed({ type: 'assistant' as const, - uuid: 'a1', content: '', toolCalls: [], - model: 'test', + turnId: 0, - timestamp: new Date().toISOString(), }), recordToolResult: () => Effect.succeed({ type: 'tool_result' as const, - uuid: 't1', - parentUuid: 'a1', toolName: 'test', toolCallId: 'tc1', output: '', turnId: 0, - timestamp: new Date().toISOString(), - tokenCount: 0, }), incrementTurn: () => 0, } as any); diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts index f1486676..b6262738 100644 --- a/packages/codingcode/test/server/compact-route.test.ts +++ b/packages/codingcode/test/server/compact-route.test.ts @@ -29,29 +29,21 @@ const MockSessionLayer = Layer.succeed(SessionService, { projectPath: 'test-path', model: 'deepseek-chat', }), - recordUser: () => - Effect.succeed({ type: 'user', uuid: 'u1', content: '', turnId: 0, timestamp: '' }), + recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 }), recordAssistant: () => Effect.succeed({ type: 'assistant', - uuid: 'a1', content: '', toolCalls: [], - model: 'test', turnId: 0, - timestamp: '', }), recordToolResult: () => Effect.succeed({ type: 'tool_result', - uuid: 't1', - parentUuid: 'a1', toolName: 'test', toolCallId: 'tc1', output: '', turnId: 0, - timestamp: '', - tokenCount: 0, }), incrementTurn: () => 0, } as any); diff --git a/packages/codingcode/test/server/index.test.ts b/packages/codingcode/test/server/index.test.ts index 89170a59..5e7504bd 100644 --- a/packages/codingcode/test/server/index.test.ts +++ b/packages/codingcode/test/server/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; import { createServer } from '../../src/server/index.js'; import { WorkspaceService } from '../../src/core/workspace.js'; @@ -21,29 +21,21 @@ const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { const MockSessionLayer = Layer.succeed(SessionService, { create: () => Effect.succeed({ sessionId: 'test', cwd: '/tmp/test' }), - recordUser: () => - Effect.succeed({ type: 'user', uuid: 'u1', content: '', turnId: 0, timestamp: '' }), + recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 }), recordAssistant: () => Effect.succeed({ type: 'assistant', - uuid: 'a1', content: '', toolCalls: [], - model: 'test', turnId: 0, - timestamp: '', }), recordToolResult: () => Effect.succeed({ type: 'tool_result', - uuid: 't1', - parentUuid: 'a1', toolName: 'test', toolCallId: 'tc1', output: '', turnId: 0, - timestamp: '', - tokenCount: 0, }), incrementTurn: () => 0, } as any); diff --git a/packages/codingcode/test/session/delete-message.test.ts b/packages/codingcode/test/session/delete-message.test.ts deleted file mode 100644 index 5d0e0205..00000000 --- a/packages/codingcode/test/session/delete-message.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; -import { randomUUID } from 'crypto'; -import { buildMessages } from '../../src/session/messages.js'; -import type { SessionIndex } from '../../src/session/types.js'; - -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); - -function makeFixture(sessionId: string, slug: string) { - const dir = join(PROJECT_BASE, slug, 'sessions'); - mkdirSync(dir, { recursive: true }); - const transcriptPath = join(dir, `${sessionId}.jsonl`); - const indexPath = join(dir, `${sessionId}.index.json`); - - const lines: any[] = [ - { - type: 'session_meta', - sessionId, - projectPath: slug, - cwd: '/tmp/test', - model: 'test', - createdAt: new Date().toISOString(), - }, - { type: 'user', turnId: 1, uuid: 'u1', content: 'hello', timestamp: new Date().toISOString() }, - { - type: 'assistant', - turnId: 1, - uuid: 'a1', - content: 'hi', - toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), - }, - { - type: 'user', - turnId: 2, - uuid: 'u2', - content: 'oops wrong message', - timestamp: new Date().toISOString(), - }, - { - type: 'assistant', - turnId: 2, - uuid: 'a2', - content: 'ok', - toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), - }, - { - type: 'user', - turnId: 3, - uuid: 'u3', - content: 'correct message', - timestamp: new Date().toISOString(), - }, - { - type: 'assistant', - turnId: 3, - uuid: 'a3', - content: 'got it', - toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), - }, - ]; - - writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - - const idx: SessionIndex = { - sessionId, - projectPath: slug, - cwd: '/tmp/test', - model: 'test', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 6, - title: 'fixture', - currentTurnId: 3, - usage: undefined, - promptEstimate: 0, - permissionMode: 'default', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { dir, transcriptPath, indexPath }; -} - -import { appendFileSync } from 'fs'; - -describe('hideMessage and unhide', () => { - it('hide message removes it from the view', () => { - const sessionId = randomUUID(); - const slug = randomUUID(); - const fx = makeFixture(sessionId, slug); - try { - // Hide u2 ("oops wrong message") - appendFileSync( - fx.transcriptPath, - JSON.stringify({ - type: 'hide', - uuid: randomUUID(), - kind: 'message', - targetUuid: 'u2', - reason: 'user deleted', - timestamp: new Date().toISOString(), - }) + '\n', - 'utf8' - ); - - const messages = buildMessages(fx.transcriptPath); - const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); - expect(userContents).toEqual(['hello', 'correct message']); - expect(userContents).not.toContain('oops wrong message'); - } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); - } - }); - - it('unhide restores the hidden message', () => { - const sessionId = randomUUID(); - const slug = randomUUID(); - const fx = makeFixture(sessionId, slug); - try { - const hideUuid = randomUUID(); - appendFileSync( - fx.transcriptPath, - JSON.stringify({ - type: 'hide', - uuid: hideUuid, - kind: 'message', - targetUuid: 'u2', - reason: 'user deleted', - timestamp: new Date().toISOString(), - }) + '\n', - 'utf8' - ); - - appendFileSync( - fx.transcriptPath, - JSON.stringify({ - type: 'unhide', - uuid: randomUUID(), - targetHideUuid: hideUuid, - timestamp: new Date().toISOString(), - }) + '\n', - 'utf8' - ); - - const messages = buildMessages(fx.transcriptPath); - const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); - expect(userContents).toEqual(['hello', 'oops wrong message', 'correct message']); - } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); - } - }); - - it('hiding an assistant message also removes it', () => { - const sessionId = randomUUID(); - const slug = randomUUID(); - const fx = makeFixture(sessionId, slug); - try { - appendFileSync( - fx.transcriptPath, - JSON.stringify({ - type: 'hide', - uuid: randomUUID(), - kind: 'message', - targetUuid: 'a2', - reason: 'user deleted', - timestamp: new Date().toISOString(), - }) + '\n', - 'utf8' - ); - - const messages = buildMessages(fx.transcriptPath); - const assistantContents = messages - .filter((m) => m.role === 'assistant') - .map((m) => m.content); - expect(assistantContents).toEqual(['hi', 'got it']); - expect(assistantContents).not.toContain('ok'); - } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); - } - }); -}); diff --git a/packages/codingcode/test/session/fork.test.ts b/packages/codingcode/test/session/fork.test.ts index 494aa697..a7971b2e 100644 --- a/packages/codingcode/test/session/fork.test.ts +++ b/packages/codingcode/test/session/fork.test.ts @@ -24,46 +24,33 @@ function makeFixture(sessionId: string, slug: string) { cwd: '/tmp/test', createdAt: new Date().toISOString(), }, - { type: 'user', turnId: 1, uuid: 'u1', content: 'first', timestamp: new Date().toISOString() }, + { type: 'user', turnId: 1, content: 'first' }, { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'reply1', toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), }, - { type: 'user', turnId: 2, uuid: 'u2', content: 'second', timestamp: new Date().toISOString() }, + { type: 'user', turnId: 2, content: 'second' }, { type: 'assistant', turnId: 2, - uuid: 'a2', content: 'reply2', toolCalls: [{ id: 'tc1', name: 'bash', arguments: '{}' }], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'tool_result', turnId: 2, - uuid: 't1', - parentUuid: 'a2', toolName: 'bash', toolCallId: 'tc1', output: 'cmd output', - timestamp: new Date().toISOString(), - tokenCount: 5, }, - { type: 'user', turnId: 3, uuid: 'u3', content: 'third', timestamp: new Date().toISOString() }, + { type: 'user', turnId: 3, content: 'third' }, { type: 'assistant', turnId: 3, - uuid: 'a3', content: 'reply3', toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), }, ]; @@ -96,6 +83,21 @@ function readEvents(jsonlPath: string): SessionEvent[] { .map((l) => JSON.parse(l) as SessionEvent); } +function collectToolCallIds(events: SessionEvent[]): Set { + const ids = new Set(); + for (const e of events) { + if (e.type === 'assistant') { + for (const tc of e.toolCalls) { + ids.add(tc.id); + } + } + if (e.type === 'tool_result') { + ids.add(e.toolCallId); + } + } + return ids; +} + function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); } @@ -146,7 +148,7 @@ describe('forkSession', () => { } }); - it('forked session has new UUIDs', async () => { + it('forked session has regenerated toolCallIds', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const fx = makeFixture(sessionId, slug); @@ -170,7 +172,7 @@ describe('forkSession', () => { const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 2); + return yield* svc.forkSession(state, 3); }) ); @@ -178,21 +180,29 @@ describe('forkSession', () => { const newEvents = readEvents(newJsonlPath); const originalEvents = readEvents(fx.transcriptPath); - const originalUuids = new Set( - originalEvents.filter((e) => 'uuid' in e).map((e) => (e as any).uuid) - ); - const newUuids = newEvents.filter((e) => 'uuid' in e).map((e) => (e as any).uuid); + const originalToolCallIds = collectToolCallIds(originalEvents); + const newToolCallIds = collectToolCallIds(newEvents); - // No UUID overlap - for (const u of newUuids) { - expect(originalUuids.has(u)).toBe(false); + // No toolCallId overlap + for (const id of newToolCallIds) { + expect(originalToolCallIds.has(id)).toBe(false); } + // Tool result still maps to the regenerated assistant toolCall id + const forkedAssistant = newEvents.find((e) => e.type === 'assistant' && e.turnId === 2) as + | { toolCalls: Array<{ id: string }> } + | undefined; + const forkedToolResult = newEvents.find((e) => e.type === 'tool_result') as + | { toolCallId: string } + | undefined; + expect(forkedAssistant).toBeDefined(); + expect(forkedToolResult).toBeDefined(); + expect(forkedToolResult!.toolCallId).toBe(forkedAssistant!.toolCalls[0]!.id); } finally { rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); } }); - it('deleting events in forked session does not affect source', async () => { + it('rollback in forked session does not affect source', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const fx = makeFixture(sessionId, slug); @@ -222,19 +232,14 @@ describe('forkSession', () => { const newJsonlPath = join(fx.dir, `${newSessionId}.jsonl`); - // Append a hide event in the forked session - const newEvents = readEvents(newJsonlPath); - const targetUuid = (newEvents[1] as any).uuid; // first user event in fork + // Append a rollback event in the forked session writeFileSync( newJsonlPath, readFileSync(newJsonlPath, 'utf8') + JSON.stringify({ - type: 'hide', - uuid: randomUUID(), - kind: 'message', - targetUuid, - reason: 'deleted in fork', - timestamp: new Date().toISOString(), + type: 'rollback', + throughTurnId: 2, + reason: 'rolled back in fork', }) + '\n', 'utf8' @@ -247,10 +252,10 @@ describe('forkSession', () => { .map((m) => m.content); expect(sourceUserContents).toEqual(['first', 'second', 'third']); - // Fork should reflect the hide + // Fork should reflect the rollback const forkMessages = buildMessages(newJsonlPath); const forkUserContents = forkMessages.filter((m) => m.role === 'user').map((m) => m.content); - expect(forkUserContents).toEqual(['second']); + expect(forkUserContents).toEqual(['first']); } finally { rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); } diff --git a/packages/codingcode/test/session/io-error.test.ts b/packages/codingcode/test/session/io-error.test.ts index 3a96e02b..aeb6652c 100644 --- a/packages/codingcode/test/session/io-error.test.ts +++ b/packages/codingcode/test/session/io-error.test.ts @@ -22,7 +22,7 @@ describe('SessionService — SESSION_IO_ERROR', () => { messageCount: 0, currentTurnId: 1, sessionMeta: { model: 'test', createdAt: new Date().toISOString() }, - model: 'test', + title: 'io-err-sid'.slice(0, 8), usage: undefined, promptEstimate: 0, @@ -54,7 +54,7 @@ describe('SessionService — SESSION_IO_ERROR', () => { messageCount: 0, currentTurnId: 1, sessionMeta: { model: 'test', createdAt: new Date().toISOString() }, - model: 'test', + title: 'io-err-asst'.slice(0, 8), usage: undefined, promptEstimate: 0, @@ -64,7 +64,7 @@ describe('SessionService — SESSION_IO_ERROR', () => { const exit = await Effect.runPromiseExit( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.recordAssistant(state, 'hi', [], 'model'); + return yield* svc.recordAssistant(state, 'hi', []); }).pipe(Effect.provide(SessionService.Default)) ); @@ -85,7 +85,7 @@ describe('SessionService — SESSION_IO_ERROR', () => { messageCount: 0, currentTurnId: 1, sessionMeta: { model: 'test', createdAt: new Date().toISOString() }, - model: 'test', + title: 'io-err-eff'.slice(0, 8), usage: undefined, promptEstimate: 0, diff --git a/packages/codingcode/test/session/prompt-estimate.test.ts b/packages/codingcode/test/session/prompt-estimate.test.ts index 6a417dbc..bfd10ec0 100644 --- a/packages/codingcode/test/session/prompt-estimate.test.ts +++ b/packages/codingcode/test/session/prompt-estimate.test.ts @@ -9,7 +9,7 @@ import { findSessionIndex } from '../../src/session/file-ops.js'; import { findLastVisibleAssistantUsage, buildMessages } from '../../src/session/messages.js'; import { estimateTokensForContent, estimateTokens } from '../../src/core/util.js'; import { encodeProjectPath } from '../../src/core/path.js'; -import type { SessionIndex, SessionEvent } from '../../src/session/types.js'; +import type { SessionIndex } from '../../src/session/types.js'; const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); @@ -29,41 +29,30 @@ function makeFixture( sessionId, projectPath: slug, cwd: '/tmp/test', - model: 'test', createdAt: new Date().toISOString(), }, { type: 'user', turnId: 1, - uuid: 'u1', content: 'hello world', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'hi there', toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), usage, }, { type: 'user', turnId: 2, - uuid: 'u2', content: 'do stuff', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 2, - uuid: 'a2', content: 'ok done', toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), usage: usage ? { prompt: usage.prompt + 100, @@ -80,7 +69,7 @@ function makeFixture( sessionId, projectPath: slug, cwd: '/tmp/test', - model: 'test', + model: 'test-model', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), messageCount: 4, @@ -126,7 +115,7 @@ describe('promptEstimate', () => { } }); - it('findLastVisibleAssistantUsage skips hidden assistant events', () => { + it('findLastVisibleAssistantUsage skips rolled-back assistant events', () => { const sessionId = randomUUID(); const slug = randomUUID(); const dir = join(PROJECT_BASE, slug, 'sessions'); @@ -146,29 +135,20 @@ describe('promptEstimate', () => { { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'first', toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), usage: usage1, }, { - type: 'hide', - uuid: 'h1', - kind: 'message', - targetUuid: 'a1', + type: 'rollback', + throughTurnId: 1, reason: 'test', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 2, - uuid: 'a2', content: 'second', toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), usage: usage2, }, ]; @@ -185,7 +165,7 @@ describe('promptEstimate', () => { it('findSessionIndex reads promptEstimate from index.json', () => { const sessionId = randomUUID(); const slug = randomUUID(); - const fx = makeFixture(sessionId, slug, { prompt: 500, completion: 200, total: 700 }); + const _fx = makeFixture(sessionId, slug, { prompt: 500, completion: 200, total: 700 }); try { const idx = findSessionIndex(sessionId); expect(idx).not.toBeNull(); @@ -210,7 +190,7 @@ describe('promptEstimate', () => { messageCount: 4, currentTurnId: 2, sessionMeta: null, - model: 'test', + model: 'test-model', title: 'fixture', usage, promptEstimate: usage.prompt, @@ -245,7 +225,7 @@ describe('promptEstimate', () => { messageCount: 4, currentTurnId: 2, sessionMeta: null, - model: 'test', + model: 'test-model', title: 'fixture', usage: undefined, promptEstimate: 0, @@ -349,7 +329,7 @@ describe('SessionService record methods update promptEstimate', () => { await run( Effect.gen(function* () { const svc = yield* SessionService; - yield* svc.recordAssistant(state, 'reply', [], 'test-model'); + yield* svc.recordAssistant(state, 'reply', []); }) ); expect(state.promptEstimate).toBeGreaterThan(before); @@ -377,7 +357,7 @@ describe('SessionService record methods update promptEstimate', () => { await run( Effect.gen(function* () { const svc = yield* SessionService; - yield* svc.recordAssistant(state, 'reply', [], 'test-model', usage); + yield* svc.recordAssistant(state, 'reply', [], usage); }) ); expect(state.promptEstimate).toBe(999); @@ -389,7 +369,7 @@ describe('SessionService record methods update promptEstimate', () => { } }); - it('recordToolResult increments promptEstimate and stores tokenCount', async () => { + it('recordToolResult increments promptEstimate', async () => { const slug = randomUUID(); const dir = join(PROJECT_BASE, slug); mkdirSync(dir, { recursive: true }); @@ -401,15 +381,12 @@ describe('SessionService record methods update promptEstimate', () => { }) ); - const assistantEvent = await run( + await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.recordAssistant( - state, - 'use tool', - [{ id: 'tc1', name: 'bash', arguments: {} }], - 'test-model' - ); + yield* svc.recordAssistant(state, 'use tool', [ + { id: 'tc1', name: 'bash', arguments: {} }, + ]); }) ); const before = state.promptEstimate; @@ -417,17 +394,11 @@ describe('SessionService record methods update promptEstimate', () => { const toolEvent = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.recordToolResult( - state, - assistantEvent.uuid, - 'bash', - 'tc1', - 'tool output here' - ); + return yield* svc.recordToolResult(state, 'bash', 'tc1', 'tool output here'); }) ); expect(state.promptEstimate).toBeGreaterThan(before); - expect(toolEvent.tokenCount).toBeGreaterThan(0); + expect(toolEvent.output).toBe('tool output here'); } finally { await new Promise((r) => setTimeout(r, 50)); rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); @@ -435,7 +406,7 @@ describe('SessionService record methods update promptEstimate', () => { } }); - it('hideMessage resets usage and recalculates promptEstimate', async () => { + it('rollbackToTurn resets usage and recalculates promptEstimate', async () => { const slug = randomUUID(); const dir = join(PROJECT_BASE, slug); mkdirSync(dir, { recursive: true }); @@ -456,7 +427,7 @@ describe('SessionService record methods update promptEstimate', () => { await run( Effect.gen(function* () { const svc = yield* SessionService; - yield* svc.recordAssistant(state, 'reply', [], 'test-model', { + yield* svc.recordAssistant(state, 'reply', [], { prompt: 100, completion: 50, total: 150, @@ -468,7 +439,7 @@ describe('SessionService record methods update promptEstimate', () => { await run( Effect.gen(function* () { const svc = yield* SessionService; - yield* svc.hideMessage(state, userEv.uuid, 'test'); + yield* svc.rollbackToTurn(state, userEv.turnId, 'test'); }) ); expect(state.usage).toBeUndefined(); @@ -497,7 +468,7 @@ describe('SessionService record methods update promptEstimate', () => { Effect.gen(function* () { const svc = yield* SessionService; yield* svc.recordUser(state, 'hello world'); - yield* svc.recordAssistant(state, 'reply one', [], 'test-model', { + yield* svc.recordAssistant(state, 'reply one', [], { prompt: 1000, completion: 100, total: 1100, @@ -511,7 +482,7 @@ describe('SessionService record methods update promptEstimate', () => { Effect.gen(function* () { const svc = yield* SessionService; yield* svc.recordUser(state, 'do more stuff'); - yield* svc.recordAssistant(state, 'reply two', [], 'test-model', { + yield* svc.recordAssistant(state, 'reply two', [], { prompt: 5000, completion: 200, total: 5200, diff --git a/packages/codingcode/test/session/record-tool-result-persist.test.ts b/packages/codingcode/test/session/record-tool-result-persist.test.ts index f6e4cdc5..a8a3cf0f 100644 --- a/packages/codingcode/test/session/record-tool-result-persist.test.ts +++ b/packages/codingcode/test/session/record-tool-result-persist.test.ts @@ -25,19 +25,16 @@ describe('recordToolResult', () => { const assistantEvent = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.recordAssistant( - state, - 'use tool', - [{ id: 'tc1', name: 'bash', arguments: { cmd: 'echo' } }], - 'test-model' - ); + return yield* svc.recordAssistant(state, 'use tool', [ + { id: 'tc1', name: 'bash', arguments: { cmd: 'echo' } }, + ]); }) ); const event = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.recordToolResult(state, assistantEvent.uuid, 'bash', 'tc1', longOutput); + return yield* svc.recordToolResult(state, 'bash', 'tc1', longOutput); }) ); @@ -57,19 +54,16 @@ describe('recordToolResult', () => { const assistantEvent = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.recordAssistant( - state, - 'use tool', - [{ id: 'tc1', name: 'bash', arguments: { cmd: 'echo' } }], - 'test-model' - ); + return yield* svc.recordAssistant(state, 'use tool', [ + { id: 'tc1', name: 'bash', arguments: { cmd: 'echo' } }, + ]); }) ); const event = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.recordToolResult(state, assistantEvent.uuid, 'bash', 'tc1', shortOutput); + return yield* svc.recordToolResult(state, 'bash', 'tc1', shortOutput); }) ); diff --git a/packages/codingcode/test/session/rollback.test.ts b/packages/codingcode/test/session/rollback.test.ts index 642d963b..9e41c94c 100644 --- a/packages/codingcode/test/session/rollback.test.ts +++ b/packages/codingcode/test/session/rollback.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect } from 'vitest'; -import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; +import { describe, it, expect } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync, appendFileSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { buildMessages } from '../../src/session/messages.js'; -import type { SessionIndex, SessionEvent } from '../../src/session/types.js'; +import type { SessionIndex } from '../../src/session/types.js'; const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); @@ -20,56 +20,26 @@ function makeFixture(sessionId: string, slug: string) { sessionId, projectPath: slug, cwd: '/tmp/test', - model: 'test', createdAt: new Date().toISOString(), }, - { type: 'user', turnId: 1, uuid: 'u1', content: 'hello', timestamp: new Date().toISOString() }, - { - type: 'assistant', - turnId: 1, - uuid: 'a1', - content: 'hi', - toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), - }, - { - type: 'user', - turnId: 2, - uuid: 'u2', - content: 'do stuff', - timestamp: new Date().toISOString(), - }, + { type: 'user', turnId: 1, content: 'hello' }, + { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, + { type: 'user', turnId: 2, content: 'do stuff' }, { type: 'assistant', turnId: 2, - uuid: 'a2', content: 'ok', toolCalls: [{ id: 'tc1', name: 'bash', arguments: '{}' }], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'tool_result', turnId: 2, - uuid: 't1', - parentUuid: 'a2', toolName: 'bash', toolCallId: 'tc1', output: 'result', - timestamp: new Date().toISOString(), - tokenCount: 5, - }, - { type: 'user', turnId: 3, uuid: 'u3', content: 'done', timestamp: new Date().toISOString() }, - { - type: 'assistant', - turnId: 3, - uuid: 'a3', - content: 'great', - toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), }, + { type: 'user', turnId: 3, content: 'done' }, + { type: 'assistant', turnId: 3, content: 'great', toolCalls: [] }, ]; writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); @@ -78,7 +48,7 @@ function makeFixture(sessionId: string, slug: string) { sessionId, projectPath: slug, cwd: '/tmp/test', - model: 'test', + model: 'test-model', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), messageCount: 7, @@ -97,22 +67,16 @@ function appendEvent(jsonlPath: string, event: object): void { appendFileSync(jsonlPath, JSON.stringify(event) + '\n', 'utf8'); } -import { appendFileSync } from 'fs'; - -describe('rollback and undo', () => { +describe('rollback', () => { it('rollback hides events after the target turn', () => { const sessionId = randomUUID(); const slug = randomUUID(); const fx = makeFixture(sessionId, slug); try { - // Simulate rollback to turn 1 appendEvent(fx.transcriptPath, { - type: 'hide', - uuid: randomUUID(), - kind: 'rollback', + type: 'rollback', throughTurnId: 1, reason: 'user rollback', - timestamp: new Date().toISOString(), }); const messages = buildMessages(fx.transcriptPath); @@ -123,63 +87,20 @@ describe('rollback and undo', () => { } }); - it('undoLastHide restores the view after rollback', () => { + it('partial rollback keeps earlier turns visible', () => { const sessionId = randomUUID(); const slug = randomUUID(); const fx = makeFixture(sessionId, slug); try { - const hideUuid = randomUUID(); - // Rollback appendEvent(fx.transcriptPath, { - type: 'hide', - uuid: hideUuid, - kind: 'rollback', - throughTurnId: 1, + type: 'rollback', + throughTurnId: 2, reason: 'user rollback', - timestamp: new Date().toISOString(), - }); - // Undo - appendEvent(fx.transcriptPath, { - type: 'unhide', - uuid: randomUUID(), - targetHideUuid: hideUuid, - timestamp: new Date().toISOString(), }); const messages = buildMessages(fx.transcriptPath); const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); - // All messages should be restored - expect(userContents).toEqual(['hello', 'do stuff', 'done']); - } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); - } - }); - - it('view is byte-level consistent after rollback + undo', () => { - const sessionId = randomUUID(); - const slug = randomUUID(); - const fx = makeFixture(sessionId, slug); - try { - const before = buildMessages(fx.transcriptPath); - - const hideUuid = randomUUID(); - appendEvent(fx.transcriptPath, { - type: 'hide', - uuid: hideUuid, - kind: 'rollback', - throughTurnId: 2, - reason: 'rollback', - timestamp: new Date().toISOString(), - }); - appendEvent(fx.transcriptPath, { - type: 'unhide', - uuid: randomUUID(), - targetHideUuid: hideUuid, - timestamp: new Date().toISOString(), - }); - - const after = buildMessages(fx.transcriptPath); - expect(after).toEqual(before); + expect(userContents).toEqual(['hello']); } finally { rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); } diff --git a/packages/codingcode/test/session/store-diff-rebuild.test.ts b/packages/codingcode/test/session/store-diff-rebuild.test.ts index 36275df2..78c509d5 100644 --- a/packages/codingcode/test/session/store-diff-rebuild.test.ts +++ b/packages/codingcode/test/session/store-diff-rebuild.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { sessionEventsToTurns } from '../../src/session/messages.js'; import type { SessionEvent } from '../../src/session/types.js'; +import { sessionEventsToTurns } from '../../src/session/messages.js'; describe('sessionEventsToTurns', () => { it('parses edit_file tool_result without diff (diff is computed on frontend)', () => { @@ -15,14 +15,11 @@ describe('sessionEventsToTurns', () => { { type: 'user', turnId: 1, - uuid: 'u1', content: 'edit file', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'editing', toolCalls: [ { @@ -31,19 +28,13 @@ describe('sessionEventsToTurns', () => { arguments: { path: 'src/utils.ts', old_string: 'a\nb\nc', new_string: 'a\nB\nc' }, }, ], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'tool_result', turnId: 1, - uuid: 'tr1', - parentUuid: 'a1', toolName: 'edit_file', toolCallId: 'tc1', output: 'File updated', - timestamp: new Date().toISOString(), - tokenCount: 5, }, ]; @@ -73,14 +64,11 @@ describe('sessionEventsToTurns', () => { { type: 'user', turnId: 1, - uuid: 'u1', content: 'write file', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'writing', toolCalls: [ { @@ -89,19 +77,13 @@ describe('sessionEventsToTurns', () => { arguments: { path: 'README.md', content: '# Title\n\nHello' }, }, ], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'tool_result', turnId: 1, - uuid: 'tr1', - parentUuid: 'a1', toolName: 'write_file', toolCallId: 'tc1', output: 'File written', - timestamp: new Date().toISOString(), - tokenCount: 5, }, ]; @@ -129,14 +111,11 @@ describe('sessionEventsToTurns', () => { { type: 'user', turnId: 1, - uuid: 'u1', content: 'run command', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'running', toolCalls: [ { @@ -145,19 +124,13 @@ describe('sessionEventsToTurns', () => { arguments: { command: 'echo hi' }, }, ], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'tool_result', turnId: 1, - uuid: 'tr1', - parentUuid: 'a1', toolName: 'bash', toolCallId: 'tc1', output: 'hi', - timestamp: new Date().toISOString(), - tokenCount: 5, }, ]; diff --git a/packages/codingcode/test/session/ui-history-rollback.test.ts b/packages/codingcode/test/session/ui-history-rollback.test.ts index 5d787d88..df23c620 100644 --- a/packages/codingcode/test/session/ui-history-rollback.test.ts +++ b/packages/codingcode/test/session/ui-history-rollback.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { mkdirSync, writeFileSync, appendFileSync, rmSync } from 'fs'; +import { describe, it, expect } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { randomUUID } from 'crypto'; @@ -20,56 +20,26 @@ function makeFixture(sessionId: string, slug: string, extraEvents?: object[]) { sessionId, projectPath: slug, cwd: '/tmp/test', - model: 'test', createdAt: new Date().toISOString(), }, - { type: 'user', turnId: 1, uuid: 'u1', content: 'hello', timestamp: new Date().toISOString() }, - { - type: 'assistant', - turnId: 1, - uuid: 'a1', - content: 'hi', - toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), - }, - { - type: 'user', - turnId: 2, - uuid: 'u2', - content: 'do stuff', - timestamp: new Date().toISOString(), - }, + { type: 'user', turnId: 1, content: 'hello' }, + { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, + { type: 'user', turnId: 2, content: 'do stuff' }, { type: 'assistant', turnId: 2, - uuid: 'a2', content: 'ok', toolCalls: [{ id: 'tc1', name: 'bash', arguments: '{}' }], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'tool_result', turnId: 2, - uuid: 't1', - parentUuid: 'a2', toolName: 'bash', toolCallId: 'tc1', output: 'result', - timestamp: new Date().toISOString(), - tokenCount: 5, - }, - { type: 'user', turnId: 3, uuid: 'u3', content: 'done', timestamp: new Date().toISOString() }, - { - type: 'assistant', - turnId: 3, - uuid: 'a3', - content: 'great', - toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), }, + { type: 'user', turnId: 3, content: 'done' }, + { type: 'assistant', turnId: 3, content: 'great', toolCalls: [] }, ...(extraEvents ?? []), ]; @@ -79,7 +49,7 @@ function makeFixture(sessionId: string, slug: string, extraEvents?: object[]) { sessionId, projectPath: slug, cwd: '/tmp/test', - model: 'test', + model: 'test-model', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), messageCount: lines.length, @@ -105,161 +75,21 @@ describe('applyVisibilityEvents', () => { sessionId, projectPath: slug, cwd: '/tmp', - model: 't', createdAt: new Date().toISOString(), }, - { - type: 'user' as const, - turnId: 1, - uuid: 'u1', - content: 'hello', - timestamp: new Date().toISOString(), - }, - { - type: 'assistant' as const, - turnId: 1, - uuid: 'a1', - content: 'hi', - toolCalls: [], - model: 't', - timestamp: new Date().toISOString(), - }, - { - type: 'user' as const, - turnId: 2, - uuid: 'u2', - content: 'bye', - timestamp: new Date().toISOString(), - }, - { - type: 'assistant' as const, - turnId: 2, - uuid: 'a2', - content: 'bye', - toolCalls: [], - model: 't', - timestamp: new Date().toISOString(), - }, - { - type: 'hide' as const, - uuid: 'h1', - kind: 'rollback' as const, - throughTurnId: 1, - reason: 'test', - timestamp: new Date().toISOString(), - }, + { type: 'user' as const, turnId: 1, content: 'hello' }, + { type: 'assistant' as const, turnId: 1, content: 'hi', toolCalls: [] }, + { type: 'user' as const, turnId: 2, content: 'bye' }, + { type: 'assistant' as const, turnId: 2, content: 'bye', toolCalls: [] }, + { type: 'rollback' as const, throughTurnId: 1, reason: 'test' }, ]; - const { hidden } = applyVisibilityEvents(events); - expect(hidden.has('u2')).toBe(true); - expect(hidden.has('a2')).toBe(true); - expect(hidden.has('u1')).toBe(true); - expect(hidden.has('a1')).toBe(true); + const { hiddenTurnIds } = applyVisibilityEvents(events); + expect(hiddenTurnIds.has(2)).toBe(true); + expect(hiddenTurnIds.has(1)).toBe(true); } finally { rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); } }); - - it('unhide restores rollback-hidden events', () => { - const events = [ - { - type: 'session_meta' as const, - sessionId: 's', - projectPath: 'p', - cwd: '/tmp', - model: 't', - createdAt: new Date().toISOString(), - }, - { - type: 'user' as const, - turnId: 1, - uuid: 'u1', - content: 'hello', - timestamp: new Date().toISOString(), - }, - { - type: 'assistant' as const, - turnId: 1, - uuid: 'a1', - content: 'hi', - toolCalls: [], - model: 't', - timestamp: new Date().toISOString(), - }, - { - type: 'user' as const, - turnId: 2, - uuid: 'u2', - content: 'bye', - timestamp: new Date().toISOString(), - }, - { - type: 'assistant' as const, - turnId: 2, - uuid: 'a2', - content: 'bye', - toolCalls: [], - model: 't', - timestamp: new Date().toISOString(), - }, - { - type: 'hide' as const, - uuid: 'h1', - kind: 'rollback' as const, - throughTurnId: 1, - reason: 'test', - timestamp: new Date().toISOString(), - }, - { - type: 'unhide' as const, - uuid: 'uh1', - targetHideUuid: 'h1', - timestamp: new Date().toISOString(), - }, - ]; - const { hidden } = applyVisibilityEvents(events); - expect(hidden.has('u2')).toBe(false); - expect(hidden.has('a2')).toBe(false); - }); - - it('message hide only hides the target', () => { - const events = [ - { - type: 'session_meta' as const, - sessionId: 's', - projectPath: 'p', - cwd: '/tmp', - model: 't', - createdAt: new Date().toISOString(), - }, - { - type: 'user' as const, - turnId: 1, - uuid: 'u1', - content: 'hello', - timestamp: new Date().toISOString(), - }, - { - type: 'assistant' as const, - turnId: 1, - uuid: 'a1', - content: 'hi', - toolCalls: [], - model: 't', - timestamp: new Date().toISOString(), - }, - { - type: 'hide' as const, - uuid: 'h1', - kind: 'message' as const, - targetUuid: 'u1', - reason: 'test', - timestamp: new Date().toISOString(), - }, - ]; - const { hidden } = applyVisibilityEvents(events); - expect(hidden.has('u1')).toBe(true); - expect(hidden.has('a1')).toBe(false); - }); }); describe('buildMessages with visibility filtering', () => { @@ -267,14 +97,7 @@ describe('buildMessages with visibility filtering', () => { const sessionId = randomUUID(); const slug = randomUUID(); const fx = makeFixture(sessionId, slug, [ - { - type: 'hide', - uuid: randomUUID(), - kind: 'rollback', - throughTurnId: 1, - reason: 'test', - timestamp: new Date().toISOString(), - }, + { type: 'rollback', throughTurnId: 1, reason: 'test' }, ]); try { const messages = buildMessages(fx.transcriptPath); @@ -284,253 +107,6 @@ describe('buildMessages with visibility filtering', () => { rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); } }); - - it('messages after rollback and unhide match original', () => { - const sessionId = randomUUID(); - const slug = randomUUID(); - try { - const beforeEvents = [ - { - type: 'session_meta', - sessionId, - projectPath: slug, - cwd: '/tmp', - model: 't', - createdAt: new Date().toISOString(), - }, - { - type: 'user', - turnId: 1, - uuid: 'u1', - content: 'hello', - timestamp: new Date().toISOString(), - }, - { - type: 'assistant', - turnId: 1, - uuid: 'a1', - content: 'hi', - toolCalls: [], - model: 't', - timestamp: new Date().toISOString(), - }, - ]; - const dir = join(PROJECT_BASE, slug, 'sessions'); - mkdirSync(dir, { recursive: true }); - const tp = join(dir, `${sessionId}.jsonl`); - writeFileSync(tp, beforeEvents.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - - const before = buildMessages(tp); - const hideUuid = randomUUID(); - appendFileSync( - tp, - JSON.stringify({ - type: 'hide', - uuid: hideUuid, - kind: 'rollback' as const, - throughTurnId: 0, - reason: 'test', - timestamp: new Date().toISOString(), - }) + '\n', - 'utf8' - ); - appendFileSync( - tp, - JSON.stringify({ - type: 'unhide', - uuid: randomUUID(), - targetHideUuid: hideUuid, - timestamp: new Date().toISOString(), - }) + '\n', - 'utf8' - ); - - const after = buildMessages(tp); - expect(after).toEqual(before); - } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); - } - }); -}); - -describe('undoLastHide only undoes message hides', () => { - it('message hide can be undone', () => { - const sessionId = randomUUID(); - const slug = randomUUID(); - try { - const events = [ - { - type: 'session_meta', - sessionId, - projectPath: slug, - cwd: '/tmp', - model: 't', - createdAt: new Date().toISOString(), - }, - { - type: 'user', - turnId: 1, - uuid: 'u1', - content: 'hello', - timestamp: new Date().toISOString(), - }, - { - type: 'assistant', - turnId: 1, - uuid: 'a1', - content: 'hi', - toolCalls: [], - model: 't', - timestamp: new Date().toISOString(), - }, - { - type: 'hide', - uuid: 'h-msg', - kind: 'message' as const, - targetUuid: 'u1', - reason: 'test', - timestamp: new Date().toISOString(), - }, - ]; - const dir = join(PROJECT_BASE, slug, 'sessions'); - mkdirSync(dir, { recursive: true }); - const tp = join(dir, `${sessionId}.jsonl`); - writeFileSync(tp, events.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - - // Before undo: u1 should be hidden - const beforeMessages = buildMessages(tp); - const userMessageCount = beforeMessages.filter((m) => m.role === 'user').length; - expect(userMessageCount).toBe(0); // u1 hidden - - // Simulate undoLastHide (which now only undoes kind='message') - appendFileSync( - tp, - JSON.stringify({ - type: 'unhide', - uuid: randomUUID(), - targetHideUuid: 'h-msg', - timestamp: new Date().toISOString(), - }) + '\n', - 'utf8' - ); - - const afterMessages = buildMessages(tp); - const restoredCount = afterMessages.filter((m) => m.role === 'user').length; - expect(restoredCount).toBe(1); // u1 restored - } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); - } - }); - - it('rollback hide is NOT undone by undoLastHide simulation', () => { - // undoLastHide now only looks at kind='message' hides. - // We add a message hide (hiding 'hello') AND a rollback hide (hiding turn 2). - // Simulating undoLastHide: since it only undoes message hides, undoLastHide - // will unhide 'hello' but 'bye' stays hidden by rollback. - const sessionId = randomUUID(); - const slug = randomUUID(); - try { - const events = [ - { - type: 'session_meta', - sessionId, - projectPath: slug, - cwd: '/tmp', - model: 't', - createdAt: new Date().toISOString(), - }, - { - type: 'user', - turnId: 1, - uuid: 'u1', - content: 'hello', - timestamp: new Date().toISOString(), - }, - { - type: 'assistant', - turnId: 1, - uuid: 'a1', - content: 'hi', - toolCalls: [], - model: 't', - timestamp: new Date().toISOString(), - }, - { - type: 'user', - turnId: 2, - uuid: 'u2', - content: 'bye', - timestamp: new Date().toISOString(), - }, - { - type: 'assistant', - turnId: 2, - uuid: 'a2', - content: 'bye', - toolCalls: [], - model: 't', - timestamp: new Date().toISOString(), - }, - ]; - const dir = join(PROJECT_BASE, slug, 'sessions'); - mkdirSync(dir, { recursive: true }); - const tp = join(dir, `${sessionId}.jsonl`); - writeFileSync(tp, events.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - - // Add message hide (hides u1, 'hello') - appendFileSync( - tp, - JSON.stringify({ - type: 'hide', - uuid: 'h-msg', - kind: 'message', - targetUuid: 'u1', - reason: 'test', - timestamp: new Date().toISOString(), - }) + '\n', - 'utf8' - ); - - // Add rollback hide to turn 1 (hides turnId > 1 i.e. turn 2, 'bye') - appendFileSync( - tp, - JSON.stringify({ - type: 'hide', - uuid: 'h-rollback', - kind: 'rollback', - throughTurnId: 1, - reason: 'test', - timestamp: new Date().toISOString(), - }) + '\n', - 'utf8' - ); - - // Verify both are hidden before undo - const beforeMessages = buildMessages(tp); - const beforeContents = beforeMessages.filter((m) => m.role === 'user').map((m) => m.content); - expect(beforeContents).toEqual([]); // both hidden - - // Simulate undoLastHide: unhides the last kind='message' hide (h-msg) - appendFileSync( - tp, - JSON.stringify({ - type: 'unhide', - uuid: randomUUID(), - targetHideUuid: 'h-msg', - timestamp: new Date().toISOString(), - }) + '\n', - 'utf8' - ); - - const messages = buildMessages(tp); - const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); - // 'hello' restored (message hide undone), 'bye' still hidden (rollback hide remains) - expect(userContents).toContain('hello'); - expect(userContents).not.toContain('bye'); - } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); - } - }); }); describe('readUIHistory with visibility filtering', () => { @@ -544,49 +120,13 @@ describe('readUIHistory with visibility filtering', () => { sessionId, projectPath: slug, cwd: '/tmp', - model: 't', createdAt: new Date().toISOString(), }, - { - type: 'user', - turnId: 1, - uuid: 'u1', - content: 'hello', - timestamp: new Date().toISOString(), - }, - { - type: 'assistant', - turnId: 1, - uuid: 'a1', - content: 'hi', - toolCalls: [], - model: 't', - timestamp: new Date().toISOString(), - }, - { - type: 'user', - turnId: 2, - uuid: 'u2', - content: 'bye', - timestamp: new Date().toISOString(), - }, - { - type: 'assistant', - turnId: 2, - uuid: 'a2', - content: 'bye', - toolCalls: [], - model: 't', - timestamp: new Date().toISOString(), - }, - { - type: 'hide', - uuid: 'h1', - kind: 'rollback' as const, - throughTurnId: 1, - reason: 'test', - timestamp: new Date().toISOString(), - }, + { type: 'user', turnId: 1, content: 'hello' }, + { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, + { type: 'user', turnId: 2, content: 'bye' }, + { type: 'assistant', turnId: 2, content: 'bye', toolCalls: [] }, + { type: 'rollback', throughTurnId: 1, reason: 'test' }, ]; const dir = join(PROJECT_BASE, slug, 'sessions'); mkdirSync(dir, { recursive: true }); @@ -611,7 +151,6 @@ describe('readUIHistory with visibility filtering', () => { ); const turns = readUIHistory(sessionId); - // No turns should be visible (turn 1 rolled back) expect(turns.length).toBe(0); } finally { rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); @@ -621,7 +160,7 @@ describe('readUIHistory with visibility filtering', () => { it('returns all turns when no rollback', () => { const sessionId = randomUUID(); const slug = randomUUID(); - const fx = makeFixture(sessionId, slug); + const _fx = makeFixture(sessionId, slug); try { const turns = readUIHistory(sessionId); expect(turns.length).toBe(3); diff --git a/packages/codingcode/test/session/update-index-dedup.test.ts b/packages/codingcode/test/session/update-index-dedup.test.ts index 82a26fc8..eee8de76 100644 --- a/packages/codingcode/test/session/update-index-dedup.test.ts +++ b/packages/codingcode/test/session/update-index-dedup.test.ts @@ -65,7 +65,7 @@ describe('updateIndex deduplication after removing appendEvent', () => { await run( Effect.gen(function* () { const svc = yield* SessionService; - yield* svc.recordAssistant(state, 'reply', [], 'test-model'); + yield* svc.recordAssistant(state, 'reply', []); }) ); @@ -77,7 +77,7 @@ describe('updateIndex deduplication after removing appendEvent', () => { } }); - it('hideMessage calls readCurrentIndex exactly once', async () => { + it('rollbackToTurn calls readCurrentIndex exactly once', async () => { const slug = randomUUID(); const dir = join(PROJECT_BASE, slug); mkdirSync(dir, { recursive: true }); @@ -96,7 +96,7 @@ describe('updateIndex deduplication after removing appendEvent', () => { await run( Effect.gen(function* () { const svc = yield* SessionService; - yield* svc.hideMessage(state, 'dummy-uuid', 'test'); + yield* svc.rollbackToTurn(state, 1, 'test'); }) ); diff --git a/packages/codingcode/test/session/usage-persist.test.ts b/packages/codingcode/test/session/usage-persist.test.ts index 2f838a58..65ea3579 100644 --- a/packages/codingcode/test/session/usage-persist.test.ts +++ b/packages/codingcode/test/session/usage-persist.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; @@ -23,7 +23,7 @@ function makeFixture( sessionId, projectPath: slug, cwd: '/tmp/test', - model: 'test', + createdAt: new Date().toISOString(), }; writeFileSync(transcriptPath, JSON.stringify(meta) + '\n', 'utf8'); @@ -32,7 +32,7 @@ function makeFixture( sessionId, projectPath: slug, cwd: '/tmp/test', - model: 'test', + model: 'test-model', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), messageCount: 0, diff --git a/packages/codingcode/test/session/view-assembly.test.ts b/packages/codingcode/test/session/view-assembly.test.ts index a6345a04..c6c413ed 100644 --- a/packages/codingcode/test/session/view-assembly.test.ts +++ b/packages/codingcode/test/session/view-assembly.test.ts @@ -1,10 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { randomUUID } from 'crypto'; import { buildMessagesFromEvents } from '../../src/session/messages.js'; import type { SessionEvent } from '../../src/session/types.js'; -function makeEvents(overrides: SessionEvent[] = []): SessionEvent[] { - // Use type assertion to handle Partial→SessionEvent incompatibility +function makeEvents(extra: SessionEvent[] = []): SessionEvent[] { const base: SessionEvent[] = [ { type: 'session_meta', @@ -13,70 +11,46 @@ function makeEvents(overrides: SessionEvent[] = []): SessionEvent[] { cwd: '/tmp', createdAt: new Date().toISOString(), }, - { type: 'user', turnId: 1, uuid: 'u1', content: 'hello', timestamp: new Date().toISOString() }, + { type: 'user', turnId: 1, content: 'hello' }, { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'hi there', toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'user', turnId: 2, - uuid: 'u2', content: 'run a command', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 2, - uuid: 'a2', content: 'running...', toolCalls: [{ id: 'tc1', name: 'bash', arguments: {} }], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'tool_result', turnId: 2, - uuid: 't1', - parentUuid: 'a2', toolName: 'bash', toolCallId: 'tc1', output: 'output line 1\nline 2', - timestamp: new Date().toISOString(), - tokenCount: 10, }, - { type: 'user', turnId: 3, uuid: 'u3', content: 'thanks', timestamp: new Date().toISOString() }, + { type: 'user', turnId: 3, content: 'thanks' }, { type: 'assistant', turnId: 3, - uuid: 'a3', content: 'welcome', toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), }, ]; - // Merge overrides by type+uuid match - for (const ov of overrides) { - const idx = base.findIndex( - (e) => 'uuid' in e && 'uuid' in ov && (e as any).uuid === (ov as any).uuid - ); - if (idx !== -1) base[idx] = ov; - else base.push(ov); - } - return base; + return [...base, ...extra]; } describe('buildMessagesFromEvents', () => { it('converts user/assistant/tool_result events to messages', () => { const events = makeEvents(); const messages = buildMessagesFromEvents(events); - // session_meta is filtered out; 7 visible events 鈫?7 messages expect(messages).toHaveLength(7); expect(messages[0]).toEqual({ role: 'user', content: 'hello' }); expect(messages[1]).toEqual({ role: 'assistant', content: 'hi there' }); @@ -93,14 +67,12 @@ describe('buildMessagesFromEvents', () => { { type: 'summary', uuid: 's1', - replaces: ['t1'], + startTurnId: 1, + endTurnId: 2, summaryText: '[compacted]', - lastSummarizedTurnId: 1, - timestamp: new Date().toISOString(), - } as any, + }, ]); const messages = buildMessagesFromEvents(events); - // t1 is hidden, summary appears as system message const toolMessages = messages.filter((m) => m.role === 'tool'); expect(toolMessages).toHaveLength(0); const summaryMessages = messages.filter((m) => m.role === 'system'); @@ -108,90 +80,21 @@ describe('buildMessagesFromEvents', () => { expect(summaryMessages[0]?.content).toBe('[compacted]'); }); - it('hide(kind=message) removes the target message from the view', () => { - const events = makeEvents([ - { - type: 'hide', - uuid: 'h1', - kind: 'message', - targetUuid: 'u2', - reason: 'user deleted', - timestamp: new Date().toISOString(), - } as any, - ]); - const messages = buildMessagesFromEvents(events); - // u2 is hidden, so the view should not contain "run a command" - const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); - expect(userContents).not.toContain('run a command'); - expect(userContents).toContain('hello'); - expect(userContents).toContain('thanks'); - }); - - it('hide(kind=rollback) removes all events from the given turn onwards', () => { + it('rollback removes all events from the given turn onwards', () => { const events = makeEvents([ { - type: 'hide', - uuid: 'h1', - kind: 'rollback', + type: 'rollback', throughTurnId: 1, reason: 'rollback', - timestamp: new Date().toISOString(), - } as any, + }, ]); const messages = buildMessagesFromEvents(events); - // Turn 1 events should also be hidden (>= semantics) const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); expect(userContents).toEqual([]); const assistantContents = messages.filter((m) => m.role === 'assistant').map((m) => m.content); expect(assistantContents).toEqual([]); }); - it('unhide restores previously hidden messages', () => { - const events = makeEvents([ - { - type: 'hide', - uuid: 'h1', - kind: 'message', - targetUuid: 'u2', - reason: 'user deleted', - timestamp: new Date().toISOString(), - } as any, - { - type: 'unhide', - uuid: 'uh1', - targetHideUuid: 'h1', - timestamp: new Date().toISOString(), - } as any, - ]); - const messages = buildMessagesFromEvents(events); - // u2 should be restored - const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); - expect(userContents).toContain('run a command'); - }); - - it('unhide after rollback restores rolled-back messages', () => { - const events = makeEvents([ - { - type: 'hide', - uuid: 'h1', - kind: 'rollback', - throughTurnId: 1, - reason: 'rollback', - timestamp: new Date().toISOString(), - } as any, - { - type: 'unhide', - uuid: 'uh1', - targetHideUuid: 'h1', - timestamp: new Date().toISOString(), - } as any, - ]); - const messages = buildMessagesFromEvents(events); - // All messages should be visible again - const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); - expect(userContents).toEqual(['hello', 'run a command', 'thanks']); - }); - it('strips trailing assistant messages with unresolved tool_calls', () => { const events: SessionEvent[] = [ { @@ -204,23 +107,16 @@ describe('buildMessagesFromEvents', () => { { type: 'user', turnId: 1, - uuid: 'u1', content: 'do something', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'ok', toolCalls: [{ id: 'tc1', name: 'bash', arguments: {} }], - model: 'test', - timestamp: new Date().toISOString(), }, - // Missing tool_result for tc1 ]; const messages = buildMessagesFromEvents(events); - // The trailing assistant with unresolved tool_call should be stripped expect(messages).toHaveLength(1); expect(messages[0]).toEqual({ role: 'user', content: 'do something' }); }); @@ -237,53 +133,37 @@ describe('buildMessagesFromEvents', () => { { type: 'user', turnId: 1, - uuid: 'u1', content: 'step 1', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'ok', toolCalls: [ { id: 'tc1', name: 'bash', arguments: {} }, { id: 'tc2', name: 'read', arguments: {} }, ], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'tool_result', turnId: 1, - uuid: 't1', - parentUuid: 'a1', toolName: 'bash', toolCallId: 'tc1', output: 'bash output', - timestamp: new Date().toISOString(), - tokenCount: 10, }, { type: 'user', turnId: 2, - uuid: 'u2', content: 'step 2', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 2, - uuid: 'a2', content: 'done', toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), }, - // tc2's tool_result is missing (e.g. hidden by summary) ]; const messages = buildMessagesFromEvents(events); - // a1 has unresolved tc2 鈫?entire a1 and its matched tc1 result should be removed expect(messages.filter((m) => m.role === 'assistant')).toHaveLength(1); expect((messages.find((m) => m.role === 'assistant') as any).content).toBe('done'); expect(messages.filter((m) => m.role === 'tool')).toHaveLength(0); @@ -301,58 +181,42 @@ describe('buildMessagesFromEvents', () => { { type: 'user', turnId: 1, - uuid: 'u1', content: 'do something', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'ok', toolCalls: [{ id: 'tc1', name: 'bash', arguments: {} }], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'tool_result', turnId: 1, - uuid: 't1', - parentUuid: 'a1', toolName: 'bash', toolCallId: 'tc1', output: 'old output', - timestamp: new Date().toISOString(), - tokenCount: 10, }, { type: 'summary', uuid: 's1', - replaces: ['t1'], + startTurnId: 1, + endTurnId: 1, summaryText: '[compacted]', - lastSummarizedTurnId: 1, - timestamp: new Date().toISOString(), }, - { type: 'user', turnId: 2, uuid: 'u2', content: 'next', timestamp: new Date().toISOString() }, + { type: 'user', turnId: 2, content: 'next' }, { type: 'assistant', turnId: 2, - uuid: 'a2', content: 'done', toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), }, ]; const messages = buildMessagesFromEvents(events); - // a1 should be removed because tc1 is hidden by summary const assistantContents = messages .filter((m) => m.role === 'assistant') .map((m) => (m as any).content); expect(assistantContents).toEqual(['done']); - // No tool messages should remain expect(messages.filter((m) => m.role === 'tool')).toHaveLength(0); - // Summary should remain as system expect(messages.filter((m) => m.role === 'system').map((m) => m.content)).toContain( '[compacted]' ); @@ -370,39 +234,28 @@ describe('buildMessagesFromEvents', () => { { type: 'user', turnId: 1, - uuid: 'u1', content: 'first', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'ok', toolCalls: [ { id: 'tc1', name: 'bash', arguments: {} }, { id: 'tc2', name: 'bash', arguments: {} }, ], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'tool_result', turnId: 1, - uuid: 't1', - parentUuid: 'a1', toolName: 'bash', toolCallId: 'tc1', output: 'out1', - timestamp: new Date().toISOString(), - tokenCount: 10, }, { type: 'user', turnId: 2, - uuid: 'u2', content: 'second', - timestamp: new Date().toISOString(), }, ]; const messages = buildMessagesFromEvents(events); @@ -424,43 +277,30 @@ describe('buildMessagesFromEvents', () => { { type: 'user', turnId: 1, - uuid: 'u1', content: 'do something', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'ok', toolCalls: [ { id: 'tc1', name: 'bash', arguments: {} }, { id: 'tc2', name: 'bash', arguments: {} }, ], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'tool_result', turnId: 1, - uuid: 't1', - parentUuid: 'a1', toolName: 'bash', toolCallId: 'tc1', output: 'out1', - timestamp: new Date().toISOString(), - tokenCount: 10, }, { type: 'tool_result', turnId: 1, - uuid: 't2', - parentUuid: 'a1', toolName: 'bash', toolCallId: 'tc2', output: 'out2', - timestamp: new Date().toISOString(), - tokenCount: 10, }, ]; const messages = buildMessagesFromEvents(events); @@ -476,24 +316,18 @@ describe('buildMessagesFromEvents', () => { cwd: '/tmp', createdAt: new Date().toISOString(), }, - { type: 'user', turnId: 1, uuid: 'u1', content: 'q1', timestamp: new Date().toISOString() }, + { type: 'user', turnId: 1, content: 'q1' }, { type: 'assistant', turnId: 1, - uuid: 'a1', content: 'reply1', toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), }, { type: 'assistant', turnId: 2, - uuid: 'a2', content: 'reply2', toolCalls: [], - model: 'test', - timestamp: new Date().toISOString(), }, ]; const messages = buildMessagesFromEvents(events); diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index e791a97b..3ff92017 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -59,57 +59,37 @@ const mockSession = { messageCount: 0, currentTurnId: 0, sessionMeta: null, - model: 'test', + title: 'child', usage: undefined, promptEstimate: 0, memorySnapshot: '', }), incrementTurn: () => 0, - recordUser: () => - Effect.succeed({ type: 'user', uuid: 'u1', content: '', turnId: 0, timestamp: '' }), + recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 }), recordAssistant: () => Effect.succeed({ type: 'assistant', - uuid: 'a1', content: '', toolCalls: [], - model: 'test', turnId: 0, - timestamp: '', }), recordToolResult: () => Effect.succeed({ type: 'tool_result', - uuid: 't1', - parentUuid: 'a1', toolName: 'test', toolCallId: 'tc1', output: '', turnId: 0, - timestamp: '', - tokenCount: 0, - }), - hideMessage: () => - Effect.succeed({ - type: 'hide', - uuid: 'h1', - kind: 'message', - targetUuid: '', - reason: '', - timestamp: '', }), rollbackToTurn: () => Effect.succeed({ - type: 'hide', - uuid: 'h1', - kind: 'rollback', + type: 'rollback', throughTurnId: 0, reason: '', - timestamp: '', }), forkSession: () => Effect.succeed('forked-session-id'), - renameSession: () => Effect.succeed({ type: 'title', uuid: 't1', text: '', timestamp: '' }), + renameSession: () => Effect.succeed(undefined), readHistory: () => Effect.succeed([]), readMessages: () => Effect.succeed([]), listSessions: () => Effect.succeed([]), diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 09d6b3fb..b8774433 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -3,13 +3,12 @@ import type { UIMessage } from './types.js'; type SessionEvent = { type: string; - uuid: string; + turnId?: number; content?: string; output?: string; - timestamp: string; model?: string; toolName?: string; - toolCalls?: any[]; + toolCallId?: string; }; export function generateId(): string { @@ -21,36 +20,54 @@ export function formatTime(ts: number): string { return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`; } +function createTurnScopedIdGenerator() { + const counters = new Map(); + return (prefix: string, turnId: number): string => { + const key = `${prefix}:${turnId}`; + const next = (counters.get(key) ?? 0) + 1; + counters.set(key, next); + return `${prefix}-${turnId}-${next}`; + }; +} + export function historyToUIMessages(history: SessionEvent[]): UIMessage[] { const messages: UIMessage[] = []; + const nextId = createTurnScopedIdGenerator(); + for (const event of history) { switch (event.type) { - case 'user': + case 'user': { + if (event.turnId === undefined) break; messages.push({ - id: event.uuid, - timestamp: new Date(event.timestamp).getTime(), + id: nextId('user', event.turnId), + timestamp: Date.now(), role: 'user', content: event.content ?? '', }); break; - case 'assistant': + } + case 'assistant': { + if (event.turnId === undefined) break; messages.push({ - id: event.uuid, - timestamp: new Date(event.timestamp).getTime(), + id: nextId('assistant', event.turnId), + timestamp: Date.now(), role: 'assistant', content: event.content ?? '', model: event.model, }); break; - case 'tool_result': + } + case 'tool_result': { + if (event.toolCallId === undefined) break; messages.push({ - id: event.uuid, - timestamp: new Date(event.timestamp).getTime(), + id: `result-${event.toolCallId}`, + timestamp: Date.now(), role: 'tool', content: event.output ?? '', toolName: event.toolName, }); break; + } } } return messages; diff --git a/packages/tui/test/utils.test.ts b/packages/tui/test/utils.test.ts index 81154b44..43d976b4 100644 --- a/packages/tui/test/utils.test.ts +++ b/packages/tui/test/utils.test.ts @@ -7,56 +7,53 @@ describe('historyToUIMessages', () => { }); it('should convert user events to UIMessage', () => { - const history = [ - { type: 'user', uuid: 'u1', content: 'hello', timestamp: '2025-01-01T00:00:00.000Z' }, - ]; + const history = [{ type: 'user', turnId: 1, content: 'hello' }]; const result = historyToUIMessages(history); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ - id: 'u1', + id: 'user-1-1', role: 'user', content: 'hello', }); + expect(typeof result[0].timestamp).toBe('number'); }); it('should convert assistant events to UIMessage', () => { - const history = [ - { type: 'assistant', uuid: 'a1', content: 'hi there', timestamp: '2025-01-01T00:00:00.000Z' }, - ]; + const history = [{ type: 'assistant', turnId: 1, content: 'hi there' }]; const result = historyToUIMessages(history); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ - id: 'a1', + id: 'assistant-1-1', role: 'assistant', content: 'hi there', }); + expect(typeof result[0].timestamp).toBe('number'); }); it('should convert tool_result events to UIMessage with toolName', () => { const history = [ { type: 'tool_result', - uuid: 't1', + toolCallId: 'tc1', output: 'result', - timestamp: '2025-01-01T00:00:00.000Z', toolName: 'read', }, ]; const result = historyToUIMessages(history); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ - id: 't1', + id: 'result-tc1', role: 'tool', content: 'result', toolName: 'read', }); + expect(typeof result[0].timestamp).toBe('number'); }); it('should skip session_meta, role_switch, and compact_boundary events', () => { const history = [ { type: 'session_meta', - uuid: 'm1', sessionId: 's1', projectSlug: 'test', cwd: '/', @@ -65,16 +62,14 @@ describe('historyToUIMessages', () => { createdAt: '', version: '1', }, - { type: 'user', uuid: 'u1', content: 'hello', timestamp: '2025-01-01T00:00:00.000Z' }, - { type: 'role_switch', uuid: 'r1', fromRole: 'a', toRole: 'b', timestamp: '' }, - { type: 'assistant', uuid: 'a1', content: 'hi', timestamp: '2025-01-01T00:00:00.000Z' }, + { type: 'user', turnId: 1, content: 'hello' }, + { type: 'role_switch', fromRole: 'a', toRole: 'b' }, + { type: 'assistant', turnId: 2, content: 'hi' }, { type: 'compact_boundary', - uuid: 'c1', summary: '...', replacedRange: [0, 1], messageCount: 1, - timestamp: '', }, ]; const result = historyToUIMessages(history); @@ -85,30 +80,25 @@ describe('historyToUIMessages', () => { it('should handle conversation with interleaved tool calls', () => { const history = [ - { type: 'user', uuid: 'u1', content: 'read file', timestamp: '2025-01-01T00:00:01.000Z' }, + { type: 'user', turnId: 1, content: 'read file' }, { type: 'assistant', - uuid: 'a1', + turnId: 2, content: 'let me read that', - timestamp: '2025-01-01T00:00:02.000Z', model: 'test-model', toolCalls: [{ id: 'tc1', name: 'read', arguments: '{}' }], }, { type: 'tool_result', - uuid: 't1', content: undefined, output: 'file contents here', - timestamp: '2025-01-01T00:00:03.000Z', toolName: 'read', - parentUuid: 'a1', toolCallId: 'tc1', }, { type: 'assistant', - uuid: 'a2', + turnId: 3, content: 'the file contains...', - timestamp: '2025-01-01T00:00:04.000Z', model: 'test-model', toolCalls: [], }, @@ -116,21 +106,41 @@ describe('historyToUIMessages', () => { const result = historyToUIMessages(history); expect(result).toHaveLength(4); expect(result[0].role).toBe('user'); + expect(result[0].id).toBe('user-1-1'); expect(result[1].role).toBe('assistant'); + expect(result[1].id).toBe('assistant-2-1'); expect(result[1].model).toBe('test-model'); expect(result[2].role).toBe('tool'); + expect(result[2].id).toBe('result-tc1'); expect(result[2].toolName).toBe('read'); expect(result[2].content).toBe('file contents here'); expect(result[3].role).toBe('assistant'); + expect(result[3].id).toBe('assistant-3-1'); }); it('should preserve message order from history', () => { const history = [ - { type: 'user', uuid: 'u1', content: 'msg1', timestamp: '2025-01-01T00:00:01.000Z' }, - { type: 'user', uuid: 'u2', content: 'msg2', timestamp: '2025-01-01T00:00:02.000Z' }, - { type: 'user', uuid: 'u3', content: 'msg3', timestamp: '2025-01-01T00:00:03.000Z' }, + { type: 'user', turnId: 1, content: 'msg1' }, + { type: 'user', turnId: 2, content: 'msg2' }, + { type: 'user', turnId: 3, content: 'msg3' }, + ]; + const result = historyToUIMessages(history); + expect(result.map((m) => m.id)).toEqual(['user-1-1', 'user-2-1', 'user-3-1']); + }); + + it('should scope per-turn ids independently for same turn', () => { + const history = [ + { type: 'user', turnId: 1, content: 'msg1' }, + { type: 'user', turnId: 1, content: 'msg2' }, + { type: 'assistant', turnId: 1, content: 'msg3' }, + { type: 'assistant', turnId: 1, content: 'msg4' }, ]; const result = historyToUIMessages(history); - expect(result.map((m) => m.id)).toEqual(['u1', 'u2', 'u3']); + expect(result.map((m) => m.id)).toEqual([ + 'user-1-1', + 'user-1-2', + 'assistant-1-1', + 'assistant-1-2', + ]); }); }); From 160e01326f6e10c267dde647cd4002b9d5588375 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 18 Jun 2026 17:35:25 +0800 Subject: [PATCH 3/7] Refactor the chat message handling logic and move the UI filtering and message assembling capabilities over --- packages/codingcode/src/agent/agent.ts | 7 +- packages/codingcode/src/agent/types.ts | 2 +- .../codingcode/src/client/direct/sessions.ts | 10 +- packages/codingcode/src/context/service.ts | 222 +++++++++++-- .../codingcode/src/server/routes/sessions.ts | 152 ++++++++- packages/codingcode/src/session/file-ops.ts | 1 - packages/codingcode/src/session/messages.ts | 288 ----------------- packages/codingcode/src/session/store.ts | 206 ++++-------- packages/codingcode/src/session/types.ts | 17 +- .../src/tools/domains/subagent/dispatch.ts | 17 +- .../test/agent/agent-cache-stability.test.ts | 1 - .../test/agent/agent-concurrent.test.ts | 1 - .../test/agent/agent-todo-event.test.ts | 1 - packages/codingcode/test/agent/agent.test.ts | 1 - .../test/agent/hooks-deps-type.test.ts | 1 - .../test/agent/loop-options.test.ts | 1 - .../test/agent/memory-snapshot.test.ts | 1 - .../codingcode/test/agent/stop-hook.test.ts | 1 - .../test/context/budget-integration.test.ts | 1 - .../test/context/compressor/behavior.test.ts | 8 +- packages/codingcode/test/orchestrate.test.ts | 1 - .../test/server/compact-route.test.ts | 8 + .../codingcode/test/session/filter-ui.test.ts | 266 ++++++++++++++++ packages/codingcode/test/session/fork.test.ts | 103 +++++- .../test/session/index-write-error.test.ts | 74 +++++ .../test/session/index-write-sync.test.ts | 90 ++++++ .../codingcode/test/session/io-error.test.ts | 3 - .../test/session/load-create.test.ts | 301 ++++++++++++++++++ .../test/session/prompt-estimate.test.ts | 262 +-------------- .../codingcode/test/session/rollback.test.ts | 10 +- .../test/session/store-diff-rebuild.test.ts | 87 ++++- .../test/session/types-export.test.ts | 23 ++ .../test/session/ui-history-rollback.test.ts | 58 +++- .../test/session/usage-persist.test.ts | 1 - .../test/session/view-assembly.test.ts | 29 +- .../codingcode/test/subagent/dispatch.test.ts | 5 +- 36 files changed, 1445 insertions(+), 815 deletions(-) delete mode 100644 packages/codingcode/src/session/messages.ts create mode 100644 packages/codingcode/test/session/filter-ui.test.ts create mode 100644 packages/codingcode/test/session/index-write-error.test.ts create mode 100644 packages/codingcode/test/session/index-write-sync.test.ts create mode 100644 packages/codingcode/test/session/load-create.test.ts create mode 100644 packages/codingcode/test/session/types-export.test.ts diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 440fc876..c82d078a 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -141,7 +141,10 @@ export const sendMessage = ( yield* runtime.prepareProject(normalizedCwd); yield* skills.evictProject(normalizedCwd); - const state = yield* session.create(normalizedCwd, llm.modelInfo.model, sessionId); + const state = sessionId + ? yield* session.load(normalizedCwd, sessionId) + : yield* session.create(normalizedCwd, llm.modelInfo.model); + state.model = llm.modelInfo.model; state.memorySnapshot = memory.loadMemoryForPrompt(state.cwd); const sid = state.sessionId; @@ -321,7 +324,6 @@ export function agentLoop( messages = compressResult.messages; state.usage = undefined; - state.promptEstimate = compressResult.promptEstimate; } const llmMessages = [...messages]; @@ -367,7 +369,6 @@ export function agentLoop( }); if (compressResult.didCompress && compressResult.messages) { messages = compressResult.messages; - state.promptEstimate = compressResult.promptEstimate; } yield* q.offer({ _tag: 'ReactiveCompact', diff --git a/packages/codingcode/src/agent/types.ts b/packages/codingcode/src/agent/types.ts index 0d145549..bc36b1b9 100644 --- a/packages/codingcode/src/agent/types.ts +++ b/packages/codingcode/src/agent/types.ts @@ -1,6 +1,6 @@ import type { ToolCall } from '../core/types.js'; import type { AgentError } from '../core/error.js'; -import type { SessionStoreState } from '../session/store.js'; +import type { SessionStoreState } from '../session/types.js'; import type { LLMClient } from '../llm/client.js'; import type { ToolDefinition, ToolVisibilityPolicy } from '../tools/types.js'; import type { AgentProfile } from '../subagent/types.js'; diff --git a/packages/codingcode/src/client/direct/sessions.ts b/packages/codingcode/src/client/direct/sessions.ts index 3619a8e3..87b0242a 100644 --- a/packages/codingcode/src/client/direct/sessions.ts +++ b/packages/codingcode/src/client/direct/sessions.ts @@ -95,7 +95,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { return rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - const state = yield* session.create(cwd, 'unknown', sessionId); + const state = yield* session.load(cwd, sessionId); return yield* session.readHistory(state); }) ); @@ -115,7 +115,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { return rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - const state = yield* session.create(cwd, 'unknown', sessionId); + const state = yield* session.load(cwd, sessionId); return yield* session.readHistory(state); }) ); @@ -130,7 +130,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { const mode = await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - const state = yield* session.create(cwd, 'unknown', sessionId); + const state = yield* session.load(cwd, sessionId); return yield* session.getPermissionMode(state); }) ); @@ -142,7 +142,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { return rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - const state = yield* session.create(cwd, 'unknown', sessionId); + const state = yield* session.load(cwd, sessionId); yield* session.setPermissionMode(state, mode); }) ); @@ -227,7 +227,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { const newSessionId = await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - const state = yield* session.create(cwd, 'unknown', sessionId); + const state = yield* session.load(cwd, sessionId); return yield* session.forkSession(state, atTurnId ?? 0); }) ); diff --git a/packages/codingcode/src/context/service.ts b/packages/codingcode/src/context/service.ts index fe7e969f..4c608810 100644 --- a/packages/codingcode/src/context/service.ts +++ b/packages/codingcode/src/context/service.ts @@ -3,17 +3,18 @@ import { randomUUID } from 'crypto'; import type { ContextConfig } from '@codingcode/infra/config'; import type { Message } from '../core/types.js'; import { SessionService } from '../session/store.js'; -import { applyVisibilityEvents, buildMessagesFromEvents } from '../session/messages.js'; import { estimateTokens, estimateMessageTokens } from '../core/util.js'; -import { resolveSessionJsonlPath, appendLine } from '../session/file-ops.js'; +import { resolveSessionJsonlPath, appendLine, readHistory } from '../session/file-ops.js'; import { resolveLLM } from '../llm/llm-resolver.js'; import { LLMFactoryService } from '../llm/factory.js'; import { COMPACTION_SYSTEM_PROMPT } from './compaction-prompt.js'; import type { SessionEvent, + AssistantEvent, ToolResultEvent, CompactEvent, SummaryEvent, + TokenUsage, } from '../session/types.js'; import type { LLMClient } from '../llm/client.js'; import type { BuildResult, CompressResult } from './types.js'; @@ -35,6 +36,186 @@ const COMPACTION_THRESHOLD = 0.9; const KEEP_RECENT_TURNS = 1; const REACTIVE_COMPACT_MAX_RETRIES = 3; +// --- Internal: visibility computation for LLM context --- + +function applyVisibilityEvents(events: SessionEvent[]): { + hiddenTurnIds: Set; + hiddenOpUuids: Set; + compactedTurnIds: Set; +} { + const hiddenTurnIds = new Set(); + const hiddenOpUuids = new Set(); + const compactedTurnIds = new Set(); + + for (const ev of events) { + if (ev.type !== 'rollback') continue; + for (const prior of events) { + if (prior === ev) break; + if (prior.type === 'summary' || prior.type === 'compact') { + if (prior.endTurnId >= ev.throughTurnId) { + hiddenOpUuids.add(prior.uuid); + } + } + } + } + + for (const ev of events) { + switch (ev.type) { + case 'rollback': { + for (const prior of events) { + if (prior === ev) break; + if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { + hiddenTurnIds.add(prior.turnId); + } + } + break; + } + case 'summary': { + if (hiddenOpUuids.has(ev.uuid)) break; + for (let t = ev.startTurnId; t <= ev.endTurnId; t++) { + hiddenTurnIds.add(t); + } + break; + } + case 'compact': { + if (hiddenOpUuids.has(ev.uuid)) break; + for (let t = ev.startTurnId; t <= ev.endTurnId; t++) { + compactedTurnIds.add(t); + } + break; + } + } + } + + return { hiddenTurnIds, hiddenOpUuids, compactedTurnIds }; +} + +/** Filter events for LLM context building: hide summary-covered turns, apply rollback */ +export function filterForContext(events: SessionEvent[]): { + visible: SessionEvent[]; + compactedTurnIds: Set; +} { + const { hiddenTurnIds, hiddenOpUuids, compactedTurnIds } = applyVisibilityEvents(events); + const visible = events.filter((ev) => { + if (ev.type === 'session_meta') return false; + if (ev.type === 'rollback') return false; + if (ev.type === 'compact') return false; + if (ev.type === 'summary' && hiddenOpUuids.has(ev.uuid)) return false; + if ('turnId' in ev && hiddenTurnIds.has(ev.turnId)) return false; + return true; + }) as SessionEvent[]; + return { visible, compactedTurnIds }; +} + +/** Format filtered events as LLM messages, with micro-compaction for compacted turns */ +export function buildContextMessages( + events: SessionEvent[], + compactedTurnIds?: Set +): Message[] { + const messages: Message[] = []; + const resolvedIds = new Set(); + for (const event of events) { + switch (event.type) { + case 'user': + messages.push({ role: 'user', content: event.content }); + break; + case 'assistant': { + const ev = event as AssistantEvent; + const msg: Message = { role: 'assistant', content: event.content }; + if (event.toolCalls && event.toolCalls.length > 0) { + (msg as any).tool_calls = event.toolCalls.map((tc: any) => ({ + id: tc.id, + name: tc.name, + arguments: tc.arguments, + })); + } + if (ev.usage) (msg as any).usage = ev.usage; + messages.push(msg); + break; + } + case 'tool_result': { + let output = event.output; + if ( + compactedTurnIds?.has(event.turnId) && + COMPACTABLE_TOOLS.has(event.toolName.toLowerCase()) && + event.output.length > MICRO_COMPACT_MIN_CHARS + ) { + output = `[Earlier: used ${event.toolName}]`; + } + resolvedIds.add(event.toolCallId); + messages.push({ + role: 'tool', + content: output, + tool_call_id: event.toolCallId, + tool_name: event.toolName, + } as any); + break; + } + case 'summary': + messages.push({ role: 'system', name: 'compacted_history', content: event.summaryText }); + break; + } + } + + // tool call pairing validation + filter + const validAssistantIds = new Set(); + for (const m of messages) { + if (m.role !== 'assistant') continue; + const tcs = (m as any).tool_calls as Array<{ id: string }> | undefined; + if (!tcs || tcs.length === 0) continue; + if (tcs.every((tc) => resolvedIds.has(tc.id))) { + for (const tc of tcs) validAssistantIds.add(tc.id); + } + } + + const filtered = messages.filter((m) => { + if (m.role === 'assistant') { + const tcs = (m as any).tool_calls as Array<{ id: string }> | undefined; + if (!tcs || tcs.length === 0) return true; + return tcs.every((tc) => resolvedIds.has(tc.id)); + } + if (m.role === 'tool') { + return validAssistantIds.has((m as any).tool_call_id); + } + return true; + }); + + // merge adjacent same-role messages + for (let i = filtered.length - 1; i > 0; i--) { + const curr = filtered[i]!; + const prev = filtered[i - 1]!; + if (curr.role === prev.role && curr.role !== 'system') { + if (curr.role === 'tool') continue; + if (curr.role === 'assistant' && (curr as any).tool_calls?.length > 0) continue; + prev.content += '\n\n' + curr.content; + filtered.splice(i, 1); + } + } + + return filtered; +} + +/** Find the last visible assistant usage for token estimation */ +export function findLastVisibleAssistantUsage(path: string): TokenUsage | undefined { + const events = readHistory(path); + const { visible, compactedTurnIds } = filterForContext(events); + const messages = buildContextMessages(visible, compactedTurnIds); + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]!; + if (m.role !== 'assistant') continue; + const usage = (m as any).usage as TokenUsage | undefined; + if (usage) return usage; + } + return undefined; +} + +/** Estimate prompt tokens for a session's jsonl file */ +export function estimatePromptTokens(jsonlPath: string): number { + const events = readHistory(jsonlPath); + const { visible, compactedTurnIds } = filterForContext(events); + return estimateTokens(buildContextMessages(visible, compactedTurnIds)); +} + export class ContextService extends Effect.Service()('Context', { effect: Effect.gen(function* () { const session = yield* SessionService; @@ -64,15 +245,9 @@ export class ContextService extends Effect.Service()('Context', const idx = session.findSessionIndexProxy(sessionId); const currentTurnId = idx?.currentTurnId ?? 0; - const { - hiddenTurnIds, - hiddenOpUuids, - compactedTurnIds: initialCompactedTurnIds, - } = applyVisibilityEvents(events); - let visible = filterVisible(events, hiddenTurnIds, hiddenOpUuids); - let compactedTurnIds = initialCompactedTurnIds; + let { visible, compactedTurnIds } = filterForContext(events); - const preEstimate = estimateTokensFromEvents(visible); + const preEstimate = estimateTokens(buildContextMessages(visible, compactedTurnIds)); const didCompact = applyOldTurnCompaction( visible, @@ -85,12 +260,10 @@ export class ContextService extends Effect.Service()('Context', if (didCompact) { events = session.readHistoryFile(jsonlPath); - const updated = applyVisibilityEvents(events); - visible = filterVisible(events, updated.hiddenTurnIds, updated.hiddenOpUuids); - compactedTurnIds = updated.compactedTurnIds; + ({ visible, compactedTurnIds } = filterForContext(events)); } - const messages = buildMessagesFromEvents(visible); + const messages = buildContextMessages(visible, compactedTurnIds); return { messages, compactedEvents: visible, @@ -100,21 +273,6 @@ export class ContextService extends Effect.Service()('Context', }; }; - function filterVisible( - events: SessionEvent[], - hiddenTurnIds: Set, - hiddenOpUuids: Set - ): SessionEvent[] { - return events.filter((ev) => { - if (ev.type === 'session_meta') return false; - if (ev.type === 'rollback') return false; - if (ev.type === 'summary' && hiddenOpUuids.has(ev.uuid)) return false; - if (ev.type === 'compact' && hiddenOpUuids.has(ev.uuid)) return false; - if ('turnId' in ev && hiddenTurnIds.has(ev.turnId)) return false; - return true; - }) as SessionEvent[]; - } - function applyOldTurnCompaction( events: SessionEvent[], currentTurnId: number, @@ -160,10 +318,6 @@ export class ContextService extends Effect.Service()('Context', return true; } - function estimateTokensFromEvents(events: SessionEvent[]): number { - return estimateTokens(buildMessagesFromEvents(events)); - } - const compactIfNeeded = async ( sessionId: string, encodedProjectPath: string, @@ -269,7 +423,7 @@ export class ContextService extends Effect.Service()('Context', const targetEvents = getIncrementalEvents(inRange); if (targetEvents.length === 0) return 0; - const msgs = buildMessagesFromEvents(targetEvents, compactedTurnIds); + const msgs = buildContextMessages(targetEvents, compactedTurnIds); const totalTokens = estimateTokens(msgs); let compactionLlm = await Effect.runPromise( diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index b78409ab..cdd835e8 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -1,7 +1,8 @@ import { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { join } from 'path'; -import { SessionService, type SessionStoreState } from '../../session/store.js'; +import type { SessionStoreState } from '../../session/types.js'; +import { SessionService } from '../../session/store.js'; import { resolveSessionDir, getPermissionMode, @@ -9,8 +10,8 @@ import { readHistory, deleteSession, } from '../../session/file-ops.js'; -import { readUIHistory } from '../../session/messages.js'; -import { ContextService } from '../../context/service.js'; +import type { SessionEvent, SummaryEvent, CompactEvent } from '../../session/types.js'; +import { ContextService, estimatePromptTokens } from '../../context/service.js'; import { getContextConfig } from '../../context/config.js'; import { CheckpointService } from '../../checkpoint/checkpoint-service.js'; import { WorkspaceService } from '../../core/workspace.js'; @@ -25,6 +26,131 @@ export const activeApprovalForks = new Map< { setPermissionMode: (mode: any) => Promise | void } >(); +// --- UI history functions (moved from messages.ts) --- + +function filterForUI(events: SessionEvent[]): SessionEvent[] { + const rollbackHiddenTurnIds = new Set(); + const rollbackHiddenOpUuids = new Set(); + + for (const ev of events) { + if (ev.type !== 'rollback') continue; + for (const prior of events) { + if (prior === ev) break; + if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { + rollbackHiddenTurnIds.add(prior.turnId); + } + if (prior.type === 'summary' || prior.type === 'compact') { + if ((prior as SummaryEvent | CompactEvent).endTurnId >= ev.throughTurnId) { + rollbackHiddenOpUuids.add((prior as SummaryEvent | CompactEvent).uuid); + } + } + } + } + + return events.filter((ev) => { + if (ev.type === 'rollback') return false; + if (ev.type === 'summary' && rollbackHiddenOpUuids.has((ev as SummaryEvent).uuid)) return false; + if (ev.type === 'compact' && rollbackHiddenOpUuids.has((ev as CompactEvent).uuid)) return false; + if ('turnId' in ev && rollbackHiddenTurnIds.has(ev.turnId)) return false; + return true; + }) as SessionEvent[]; +} + +function createTurnScopedIdGenerator() { + const counters = new Map(); + return (prefix: string, turnId: number): string => { + const key = `${prefix}:${turnId}`; + const next = (counters.get(key) ?? 0) + 1; + counters.set(key, next); + return `${prefix}-${turnId}-${next}`; + }; +} + +function sessionEventsToTurns( + events: SessionEvent[] +): Array<{ id: string; items: object[]; status: string }> { + const turnsMap = new Map(); + const nextId = createTurnScopedIdGenerator(); + + for (const event of events) { + if (event.type === 'session_meta') continue; + if (event.type === 'compact' || event.type === 'rollback') continue; + + if (event.type === 'summary') { + let turn = turnsMap.get(event.endTurnId); + if (!turn) { + turn = { id: String(event.endTurnId), items: [], status: 'completed' }; + turnsMap.set(event.endTurnId, turn); + } + turn.items.push({ + id: `summary-${event.uuid}`, + type: 'summary', + content: event.summaryText, + startTurnId: event.startTurnId, + endTurnId: event.endTurnId, + }); + continue; + } + + let turn = turnsMap.get(event.turnId); + if (!turn) { + turn = { id: String(event.turnId), items: [], status: 'completed' }; + turnsMap.set(event.turnId, turn); + } + switch (event.type) { + case 'user': + turn.items.push({ + id: nextId('user', event.turnId), + type: 'message', + role: 'user', + content: event.content, + }); + break; + case 'assistant': + if (event.content) { + turn.items.push({ + id: nextId('assistant', event.turnId), + type: 'message', + role: 'assistant', + content: event.content, + }); + } + for (const tc of event.toolCalls ?? []) { + const args = tc.arguments ?? {}; + turn.items.push({ + id: tc.id, + type: 'tool_call', + name: tc.name, + args, + status: 'approved', + }); + } + break; + case 'tool_result': { + const item: Record = { + id: `result-${event.toolCallId}`, + type: 'tool_result', + callId: event.toolCallId, + name: event.toolName, + output: event.output, + }; + turn.items.push(item); + break; + } + } + } + return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); +} + +function readUIHistory(sessionId: string): Array<{ id: string; items: object[]; status: string }> { + const dir = resolveSessionDir(sessionId); + if (!dir) return []; + const jsonlPath = join(dir, `${sessionId}.jsonl`); + const events = readHistory(jsonlPath); + const visibleEvents = filterForUI(events); + return sessionEventsToTurns(visibleEvents); +} + function findUserMessageForTurn(sessionId: string, turnId: number): string { const dir = resolveSessionDir(sessionId); if (!dir) return ''; @@ -115,7 +241,7 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; - const state = yield* session.create(normalizedCwd, 'unknown', sessionId); + const state = yield* session.load(normalizedCwd, sessionId); return yield* session.readHistory(state); }) as any ); @@ -140,7 +266,7 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const context = yield* ContextService; const factory = yield* LLMFactoryService; const session = yield* SessionService; - const state = yield* session.create(normalizedCwd, 'unknown', sessionId); + const state = yield* session.load(normalizedCwd, sessionId); let llm: LLMClient | null = null; const entry = yield* factory.getActiveEntry().pipe(Effect.either); @@ -403,11 +529,12 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; - const state = yield* session.create(cwd, 'unknown', sessionId); + const state = yield* session.load(cwd, sessionId); const rolledBackMessage = findUserMessageForTurn(sessionId, body.throughTurnId); yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); const turns = readUIHistory(sessionId); - return { ok: true, turns, rolledBackMessage, promptEstimate: state.promptEstimate }; + const promptEstimate = estimatePromptTokens(state.transcriptPath); + return { ok: true, turns, rolledBackMessage, promptEstimate }; }) as any ); if (!result.ok) { @@ -431,16 +558,17 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const session = yield* SessionService; const checkpoint = yield* CheckpointService; const codeResult = yield* checkpoint.rollbackCodeToTurn(cwd, sessionId, body.throughTurnId); - const state = yield* session.create(cwd, 'unknown', sessionId); + const state = yield* session.load(cwd, sessionId); const rolledBackMessage = findUserMessageForTurn(sessionId, body.throughTurnId); yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); const turns = readUIHistory(sessionId); + const promptEstimate = estimatePromptTokens(state.transcriptPath); return { ok: true, turns, codeResult, rolledBackMessage, - promptEstimate: state.promptEstimate, + promptEstimate, }; }) as any ); @@ -489,10 +617,12 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; - const state = yield* session.create(cwd, 'unknown', sessionId); + const state = yield* session.load(cwd, sessionId); const newSessionId = yield* session.forkSession(state, atTurnId); const turns = readUIHistory(newSessionId); - return { sessionId: newSessionId, turns }; + const newJsonlPath = join(resolveSessionDir(newSessionId)!, `${newSessionId}.jsonl`); + const promptEstimate = estimatePromptTokens(newJsonlPath); + return { sessionId: newSessionId, turns, promptEstimate }; }) as any ); if (!result.ok) { diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index 7d8f450f..636c9a2f 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -110,7 +110,6 @@ function buildIndexFromMeta(meta: SessionMetaEvent, history: SessionEvent[]): Se title: firstUser ? truncateTitle(firstUser) : meta.sessionId.slice(0, 8), currentTurnId: 0, usage: undefined, - promptEstimate: 0, permissionMode: 'default', }; } diff --git a/packages/codingcode/src/session/messages.ts b/packages/codingcode/src/session/messages.ts deleted file mode 100644 index 433a28d9..00000000 --- a/packages/codingcode/src/session/messages.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { join } from 'path'; -import type { Message } from '../core/types.js'; -import type { - SessionEvent, - AssistantEvent, - SummaryEvent, - CompactEvent, - TokenUsage, -} from './types.js'; -import { readHistory, resolveSessionDir } from './file-ops.js'; -import { getContextConfig } from '../context/config.js'; - -const COMPACTABLE_TOOLS = new Set([ - 'read_file', - 'execute_command', - 'search_code', - 'search_files', - 'web_search', - 'fetch_url', - 'write_file', - 'edit_file', -]); - -const MICRO_COMPACT_MIN_CHARS = 120; - -export interface VisibilityResult { - hiddenTurnIds: Set; - hiddenOpUuids: Set; - compactedTurnIds: Set; -} - -export function applyVisibilityEvents(events: SessionEvent[]): VisibilityResult { - const hiddenTurnIds = new Set(); - const hiddenOpUuids = new Set(); - const compactedTurnIds = new Set(); - - // First pass: find operation events revoked by rollback. - for (const ev of events) { - if (ev.type !== 'rollback') continue; - for (const prior of events) { - if (prior === ev) break; - if (prior.type === 'summary' || prior.type === 'compact') { - if (prior.endTurnId >= ev.throughTurnId) { - hiddenOpUuids.add(prior.uuid); - } - } - } - } - - // Second pass: compute visible turn ranges. - for (const ev of events) { - switch (ev.type) { - case 'rollback': { - for (const prior of events) { - if (prior === ev) break; - if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { - hiddenTurnIds.add(prior.turnId); - } - } - break; - } - case 'summary': { - if (hiddenOpUuids.has(ev.uuid)) break; - for (let t = ev.startTurnId; t <= ev.endTurnId; t++) { - hiddenTurnIds.add(t); - } - break; - } - case 'compact': { - if (hiddenOpUuids.has(ev.uuid)) break; - for (let t = ev.startTurnId; t <= ev.endTurnId; t++) { - compactedTurnIds.add(t); - } - break; - } - } - } - - return { hiddenTurnIds, hiddenOpUuids, compactedTurnIds }; -} - -export function buildMessagesFromEvents( - events: SessionEvent[], - externalCompactedTurnIds?: Set -): Message[] { - const { - hiddenTurnIds, - hiddenOpUuids, - compactedTurnIds: derivedIds, - } = applyVisibilityEvents(events); - const compactedTurnIds = externalCompactedTurnIds ?? derivedIds; - - const visible: SessionEvent[] = []; - for (const ev of events) { - if (ev.type === 'rollback') continue; - if (ev.type === 'compact') continue; - if (ev.type === 'summary' && hiddenOpUuids.has(ev.uuid)) continue; - if ('turnId' in ev && hiddenTurnIds.has(ev.turnId)) continue; - visible.push(ev); - } - - const messages: Message[] = []; - for (const event of visible) { - switch (event.type) { - case 'user': - messages.push({ role: 'user', content: event.content }); - break; - case 'assistant': { - const ev = event as AssistantEvent; - const msg: Message = { role: 'assistant', content: event.content }; - if (event.toolCalls && event.toolCalls.length > 0) { - (msg as any).tool_calls = event.toolCalls.map((tc: any) => ({ - id: tc.id, - name: tc.name, - arguments: tc.arguments, - })); - } - if (ev.usage) (msg as any).usage = ev.usage; - messages.push(msg); - break; - } - case 'tool_result': { - let output = event.output; - if ( - compactedTurnIds.has(event.turnId) && - COMPACTABLE_TOOLS.has(event.toolName.toLowerCase()) && - event.output.length > MICRO_COMPACT_MIN_CHARS - ) { - output = `[Earlier: used ${event.toolName}]`; - } - messages.push({ - role: 'tool', - content: output, - tool_call_id: event.toolCallId, - tool_name: event.toolName, - } as any); - break; - } - case 'summary': - messages.push({ role: 'system', name: 'compacted_history', content: event.summaryText }); - break; - } - } - - const resolvedIds = new Set(); - for (const m of messages) { - if (m.role === 'tool') resolvedIds.add((m as any).tool_call_id); - } - - const validAssistantIds = new Set(); - for (const m of messages) { - if (m.role !== 'assistant') continue; - const tcs = (m as any).tool_calls as Array<{ id: string }> | undefined; - if (!tcs || tcs.length === 0) continue; - if (tcs.every((tc) => resolvedIds.has(tc.id))) { - for (const tc of tcs) validAssistantIds.add(tc.id); - } - } - - const filtered = messages.filter((m) => { - if (m.role === 'assistant') { - const tcs = (m as any).tool_calls as Array<{ id: string }> | undefined; - if (!tcs || tcs.length === 0) return true; - return tcs.every((tc) => resolvedIds.has(tc.id)); - } - if (m.role === 'tool') { - return validAssistantIds.has((m as any).tool_call_id); - } - return true; - }); - - for (let i = filtered.length - 1; i > 0; i--) { - const curr = filtered[i]!; - const prev = filtered[i - 1]!; - if (curr.role === prev.role && curr.role !== 'system') { - if (curr.role === 'tool') continue; - if (curr.role === 'assistant' && (curr as any).tool_calls?.length > 0) continue; - prev.content += '\n\n' + curr.content; - filtered.splice(i, 1); - } - } - - return filtered; -} - -export function buildMessages(path: string): Message[] { - const events = readHistory(path); - return buildMessagesFromEvents(events); -} - -export function findLastVisibleAssistantUsage(path: string): TokenUsage | undefined { - const events = readHistory(path); - const messages = buildMessagesFromEvents(events); - for (let i = messages.length - 1; i >= 0; i--) { - const m = messages[i]!; - if (m.role !== 'assistant') continue; - const usage = (m as any).usage as TokenUsage | undefined; - if (usage) return usage; - } - return undefined; -} - -function createTurnScopedIdGenerator() { - const counters = new Map(); - return (prefix: string, turnId: number): string => { - const key = `${prefix}:${turnId}`; - const next = (counters.get(key) ?? 0) + 1; - counters.set(key, next); - return `${prefix}-${turnId}-${next}`; - }; -} - -export function sessionEventsToTurns( - events: SessionEvent[] -): Array<{ id: string; items: object[]; status: string }> { - const turnsMap = new Map(); - const nextId = createTurnScopedIdGenerator(); - - for (const event of events) { - if (event.type === 'session_meta') continue; - if (event.type === 'summary' || event.type === 'compact' || event.type === 'rollback') continue; - let turn = turnsMap.get(event.turnId); - if (!turn) { - turn = { id: String(event.turnId), items: [], status: 'completed' }; - turnsMap.set(event.turnId, turn); - } - switch (event.type) { - case 'user': - turn.items.push({ - id: nextId('user', event.turnId), - type: 'message', - role: 'user', - content: event.content, - }); - break; - case 'assistant': - if (event.content) { - turn.items.push({ - id: nextId('assistant', event.turnId), - type: 'message', - role: 'assistant', - content: event.content, - }); - } - for (const tc of event.toolCalls ?? []) { - const args = tc.arguments ?? {}; - turn.items.push({ - id: tc.id, - type: 'tool_call', - name: tc.name, - args, - status: 'approved', - }); - } - break; - case 'tool_result': { - const item: Record = { - id: `result-${event.toolCallId}`, - type: 'tool_result', - callId: event.toolCallId, - name: event.toolName, - output: event.output, - }; - turn.items.push(item); - break; - } - } - } - return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); -} - -export function readUIHistory( - sessionId: string -): Array<{ id: string; items: object[]; status: string }> { - const dir = resolveSessionDir(sessionId); - if (!dir) return []; - const jsonlPath = join(dir, `${sessionId}.jsonl`); - const events = readHistory(jsonlPath); - const { hiddenTurnIds, hiddenOpUuids } = applyVisibilityEvents(events); - const visibleEvents = events.filter((ev) => { - if (ev.type === 'rollback') return false; - if (ev.type === 'summary' && hiddenOpUuids.has((ev as SummaryEvent).uuid)) return false; - if (ev.type === 'compact' && hiddenOpUuids.has((ev as CompactEvent).uuid)) return false; - if ('turnId' in ev && hiddenTurnIds.has(ev.turnId)) return false; - return true; - }); - return sessionEventsToTurns(visibleEvents); -} diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index 4652c823..9bc355b4 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -2,10 +2,8 @@ import { Effect } from 'effect'; import { randomUUID } from 'crypto'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join, dirname } from 'path'; -import type { Message } from '../core/types.js'; import { AgentError } from '../core/error.js'; import { normalizePath, encodeProjectPath } from '../core/path.js'; -import { createLogger } from '@codingcode/infra/logger'; import type { SessionMetaEvent, UserEvent, @@ -16,8 +14,8 @@ import type { SessionIndex, TokenUsage, SessionEvent, + SessionStoreState, } from './types.js'; -import { estimateTokens, estimateMessageTokens } from '../core/util.js'; import { projectSessionsDir, ensureDirs, @@ -31,28 +29,8 @@ import { countNonMetaEvents, truncateTitle, findFirstUserContent, - resolveSessionDir, resolveSessionJsonlPath as _resolveSessionJsonlPath, } from './file-ops.js'; -import { buildMessages, findLastVisibleAssistantUsage } from './messages.js'; - -const logger = createLogger(); - -export interface SessionStoreState { - sessionId: string; - cwd: string; - projectPath: string; - transcriptPath: string; - indexPath: string; - messageCount: number; - sessionMeta: SessionMetaEvent | null; - model: string; - title: string; - currentTurnId: number; - usage: TokenUsage | undefined; - promptEstimate: number; - memorySnapshot: string; -} function assertResumeWorkspace(cwd: string, sessionId: string): void { const index = findSessionIndex(sessionId); @@ -64,20 +42,6 @@ function assertResumeWorkspace(cwd: string, sessionId: string): void { export class SessionService extends Effect.Service()('Session', { effect: Effect.gen(function* () { - const writeQueues = new Map>(); - - const enqueueWriteLocal = (sessionId: string, path: string, data: unknown): void => { - const prev = writeQueues.get(sessionId) ?? Promise.resolve(); - const task = prev - .then(() => { - writeFileSync(path, JSON.stringify(data, null, 2), 'utf8'); - }) - .catch((err) => { - logger.error(`write queue error for ${path}:`, err); - }); - writeQueues.set(sessionId, task); - }; - function updateIndex(state: SessionStoreState): void { if (!state.sessionMeta) return; const current = readCurrentIndex(state.indexPath); @@ -92,40 +56,32 @@ export class SessionService extends Effect.Service()('Session', title: state.title, currentTurnId: state.currentTurnId, usage: state.usage, - promptEstimate: state.promptEstimate, permissionMode: current?.permissionMode ?? 'default', memorySnapshot: state.memorySnapshot, }; - enqueueWriteLocal(state.sessionId, state.indexPath, index); + writeFileSync(state.indexPath, JSON.stringify(index, null, 2), 'utf8'); } const create = ( cwd: string, model: string, - sessionId?: string, - opts?: { parentSessionId?: string; parentAgentId?: string; agentName?: string } + opts?: { parentSessionId?: string; agentName?: string } ): Effect.Effect => Effect.try({ try: () => { - if (sessionId && !opts?.parentSessionId) assertResumeWorkspace(cwd, sessionId); - const state = initState(cwd, sessionId, opts?.parentSessionId); - ensureDirs(state.transcriptPath); - - state.model = model; - - if (existsSync(state.transcriptPath)) { - const history = readHistory(state.transcriptPath); - const meta = history.find((e) => e.type === 'session_meta') as - | SessionMetaEvent - | undefined; - if (meta) { - state.sessionMeta = meta; - state.messageCount = history.filter((e) => e.type !== 'session_meta').length; - } - const firstUser = findFirstUserContent(history); - if (firstUser) state.title = truncateTitle(firstUser); - return state; - } + const paths = computePaths(cwd, randomUUID(), opts?.parentSessionId); + ensureDirs(paths.transcriptPath); + + const state: SessionStoreState = { + ...paths, + messageCount: 0, + sessionMeta: null, + model, + title: paths.sessionId.slice(0, 8), + currentTurnId: 0, + usage: undefined, + memorySnapshot: '', + }; const meta: SessionMetaEvent = { type: 'session_meta', @@ -134,7 +90,6 @@ export class SessionService extends Effect.Service()('Session', cwd: state.cwd, createdAt: new Date().toISOString(), ...(opts?.parentSessionId && { parentSessionId: opts.parentSessionId }), - ...(opts?.parentAgentId && { parentAgentId: opts.parentAgentId }), ...(opts?.agentName && { agentName: opts.agentName }), }; state.sessionMeta = meta; @@ -149,6 +104,46 @@ export class SessionService extends Effect.Service()('Session', : new AgentError('SESSION_IO_ERROR', `Session write failed: ${String(e)}`, e), }); + const load = (cwd: string, sessionId: string): Effect.Effect => + Effect.try({ + try: () => { + assertResumeWorkspace(cwd, sessionId); + const paths = computePaths(cwd, sessionId); + ensureDirs(paths.transcriptPath); + + const idx = readCurrentIndex(paths.indexPath); + + const state: SessionStoreState = { + ...paths, + messageCount: 0, + sessionMeta: null, + model: idx?.model ?? '', + title: paths.sessionId.slice(0, 8), + currentTurnId: idx?.currentTurnId ?? 0, + usage: idx?.usage ?? undefined, + memorySnapshot: idx?.memorySnapshot ?? '', + }; + + if (existsSync(state.transcriptPath)) { + const history = readHistory(state.transcriptPath); + const meta = history.find((e) => e.type === 'session_meta') as + | SessionMetaEvent + | undefined; + if (meta) { + state.sessionMeta = meta; + state.messageCount = history.filter((e) => e.type !== 'session_meta').length; + } + const firstUser = findFirstUserContent(history); + if (firstUser) state.title = truncateTitle(firstUser); + } + return state; + }, + catch: (e) => + e instanceof AgentError + ? e + : new AgentError('SESSION_IO_ERROR', `Session load failed: ${String(e)}`, e), + }); + const recordUser = ( state: SessionStoreState, content: string @@ -166,7 +161,6 @@ export class SessionService extends Effect.Service()('Session', appendLine(state.transcriptPath, event); state.messageCount++; updateIndex(state); - state.promptEstimate += estimateMessageTokens({ role: 'user', content }); return event; }, catch: (e) => @@ -195,9 +189,6 @@ export class SessionService extends Effect.Service()('Session', updateIndex(state); if (usage) { state.usage = usage; - state.promptEstimate = usage.prompt; - } else { - state.promptEstimate += estimateMessageTokens({ role: 'assistant', content }); } return event; }, @@ -225,12 +216,6 @@ export class SessionService extends Effect.Service()('Session', appendLine(state.transcriptPath, event); state.messageCount++; updateIndex(state); - state.promptEstimate += estimateMessageTokens({ - role: 'tool', - content: output, - tool_call_id: toolCallId, - tool_name: toolName, - }); return event; }, catch: (e) => @@ -258,7 +243,6 @@ export class SessionService extends Effect.Service()('Session', state.messageCount++; updateIndex(state); state.usage = undefined; - state.promptEstimate = estimateTokens(buildMessages(state.transcriptPath)); return event; }, catch: (e) => @@ -281,9 +265,6 @@ export class SessionService extends Effect.Service()('Session', appendLine(state.transcriptPath, event); state.messageCount++; updateIndex(state); - const lastUsage = findLastVisibleAssistantUsage(state.transcriptPath); - state.usage = lastUsage; - state.promptEstimate = lastUsage?.prompt ?? 0; return event; }); @@ -292,7 +273,7 @@ export class SessionService extends Effect.Service()('Session', atTurnId: number ): Effect.Effect => Effect.sync(() => { - return forkSessionImpl(state.sessionId, state.transcriptPath, atTurnId); + return forkSessionImpl(state.transcriptPath, atTurnId); }); const renameSession = ( @@ -307,9 +288,6 @@ export class SessionService extends Effect.Service()('Session', const readHistoryFromState = (state: SessionStoreState): Effect.Effect => Effect.sync(() => readHistory(state.transcriptPath)); - const readMessages = (state: SessionStoreState): Effect.Effect => - Effect.sync(() => buildMessages(state.transcriptPath)); - const listSessionsFromCwd = (cwd?: string): Effect.Effect => Effect.sync(() => listSessions(cwd ? encodeProjectPath(cwd) : undefined)); @@ -339,6 +317,7 @@ export class SessionService extends Effect.Service()('Session', return { create, + load, recordUser, recordAssistant, recordToolResult, @@ -347,7 +326,6 @@ export class SessionService extends Effect.Service()('Session', forkSession, renameSession, readHistory: readHistoryFromState, - readMessages, listSessions: listSessionsFromCwd, findSessionIndex: findSessionIndexFromId, getSessionId, @@ -364,58 +342,22 @@ export class SessionService extends Effect.Service()('Session', }), }) {} -function initState(cwd: string, sessionId?: string, parentSessionId?: string): SessionStoreState { - const id = sessionId ?? randomUUID(); +function computePaths( + cwd: string, + sessionId: string, + parentSessionId?: string +): Pick { const normalizedCwd = normalizePath(cwd); const projectPath = encodeProjectPath(normalizedCwd); const sessionsDir = projectSessionsDir(projectPath); const transcriptPath = parentSessionId - ? join(sessionsDir, parentSessionId, 'subagents', `${id}.jsonl`) - : join(sessionsDir, `${id}.jsonl`); + ? join(sessionsDir, parentSessionId, 'subagents', `${sessionId}.jsonl`) + : join(sessionsDir, `${sessionId}.jsonl`); const indexPath = transcriptPath.replace('.jsonl', '.index.json'); - let currentTurnId = 0; - let usage: TokenUsage | undefined = undefined; - let promptEstimate = 0; - let memorySnapshot = ''; - let model = ''; - try { - if (existsSync(indexPath)) { - const idx = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; - currentTurnId = idx.currentTurnId ?? 0; - usage = idx.usage ?? undefined; - promptEstimate = idx.promptEstimate ?? 0; - memorySnapshot = idx.memorySnapshot ?? ''; - model = idx.model ?? ''; - } - } catch { - /* ignore corrupt index */ - } - if (!usage && promptEstimate === 0) { - const lastUsage = findLastVisibleAssistantUsage(transcriptPath); - if (lastUsage) { - usage = lastUsage; - promptEstimate = lastUsage.prompt; - } - } - return { - sessionId: id, - cwd: normalizedCwd, - projectPath, - transcriptPath, - indexPath, - messageCount: 0, - sessionMeta: null, - model, - title: id.slice(0, 8), - currentTurnId, - usage, - promptEstimate, - memorySnapshot, - }; + return { sessionId, cwd: normalizedCwd, projectPath, transcriptPath, indexPath }; } function forkSessionImpl( - sourceSessionId: string, sourceJsonlPath: string, atTurnId: number ): string { @@ -435,10 +377,6 @@ function forkSessionImpl( for (const ev of chain) { const cloned: any = { ...ev }; - if (cloned.type === 'summary' || cloned.type === 'compact') { - cloned.uuid = randomUUID(); - } - if (cloned.type === 'assistant' && Array.isArray(cloned.toolCalls)) { for (const tc of cloned.toolCalls) { const newId = randomUUID(); @@ -464,7 +402,6 @@ function forkSessionImpl( const sourceIdxPath = sourceJsonlPath.replace('.jsonl', '.index.json'); let title = newSessionId.slice(0, 8); let usage: TokenUsage | undefined = undefined; - let promptEstimate = 0; let permissionMode = 'default'; let srcIdx: SessionIndex | undefined; if (existsSync(sourceIdxPath)) { @@ -472,22 +409,12 @@ function forkSessionImpl( srcIdx = JSON.parse(readFileSync(sourceIdxPath, 'utf8')) as SessionIndex; title = srcIdx.title; usage = srcIdx.usage ?? undefined; - promptEstimate = srcIdx.promptEstimate ?? 0; permissionMode = srcIdx.permissionMode ?? 'default'; } catch { /* corrupt */ } } - const lastUsage = findLastVisibleAssistantUsage(newJsonlPath); - if (lastUsage) { - usage = lastUsage; - promptEstimate = lastUsage.prompt; - } else { - usage = undefined; - promptEstimate = estimateTokens(buildMessages(newJsonlPath)); - } - const meta = chain[0] as SessionMetaEvent | undefined; const newIdx: SessionIndex = { sessionId: newSessionId, @@ -500,7 +427,6 @@ function forkSessionImpl( title, currentTurnId: turnId, usage, - promptEstimate, permissionMode, }; writeFileSync(newIndexPath, JSON.stringify(newIdx, null, 2), 'utf8'); diff --git a/packages/codingcode/src/session/types.ts b/packages/codingcode/src/session/types.ts index 36dcf458..21cd4046 100644 --- a/packages/codingcode/src/session/types.ts +++ b/packages/codingcode/src/session/types.ts @@ -5,7 +5,6 @@ export interface SessionMetaEvent { cwd: string; createdAt: string; parentSessionId?: string; - parentAgentId?: string; agentName?: string; } @@ -78,7 +77,21 @@ export interface SessionIndex { title: string; currentTurnId: number; usage: TokenUsage | undefined; - promptEstimate?: number; permissionMode: string; memorySnapshot?: string; } + +export interface SessionStoreState { + sessionId: string; + cwd: string; + projectPath: string; + transcriptPath: string; + indexPath: string; + messageCount: number; + sessionMeta: SessionMetaEvent | null; + model: string; + title: string; + currentTurnId: number; + usage: TokenUsage | undefined; + memorySnapshot: string; +} diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index bced67d7..ca304e91 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'crypto'; import { z } from 'zod'; import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; @@ -105,17 +104,11 @@ export function createDispatchAgentTool(): Effect.Effect< } // Create subagent transcript nested under parent session - const childUuid = randomUUID(); - - const childState = yield* session.create( - projectPath, - (ctx as any)?.model ?? 'subagent', - childUuid, - { - parentSessionId: ctx?.sessionId, - agentName: agentName, - } - ); + const childState = yield* session.create(projectPath, (ctx as any)?.model ?? 'subagent', { + parentSessionId: ctx?.sessionId, + agentName: agentName, + }); + const childUuid = childState.sessionId; session.incrementTurn(childState); yield* session.recordUser(childState, prompt); diff --git a/packages/codingcode/test/agent/agent-cache-stability.test.ts b/packages/codingcode/test/agent/agent-cache-stability.test.ts index 30bff013..5b1ff428 100644 --- a/packages/codingcode/test/agent/agent-cache-stability.test.ts +++ b/packages/codingcode/test/agent/agent-cache-stability.test.ts @@ -94,7 +94,6 @@ const mockState = { model: 'test-model', title: 'cache-stability', usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; diff --git a/packages/codingcode/test/agent/agent-concurrent.test.ts b/packages/codingcode/test/agent/agent-concurrent.test.ts index 7e5ad54e..293ad70d 100644 --- a/packages/codingcode/test/agent/agent-concurrent.test.ts +++ b/packages/codingcode/test/agent/agent-concurrent.test.ts @@ -94,7 +94,6 @@ const mockState = { model: 'test-model', title: 'concurrent', usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; diff --git a/packages/codingcode/test/agent/agent-todo-event.test.ts b/packages/codingcode/test/agent/agent-todo-event.test.ts index 2310b2ed..ab639c83 100644 --- a/packages/codingcode/test/agent/agent-todo-event.test.ts +++ b/packages/codingcode/test/agent/agent-todo-event.test.ts @@ -101,7 +101,6 @@ const mockState = { model: 'test-model', title: 'test', usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index ca43a46e..d7c10b36 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -72,7 +72,6 @@ const mockState = { model: 'test-model', title: 'test', usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; diff --git a/packages/codingcode/test/agent/hooks-deps-type.test.ts b/packages/codingcode/test/agent/hooks-deps-type.test.ts index acf45e65..cf154558 100644 --- a/packages/codingcode/test/agent/hooks-deps-type.test.ts +++ b/packages/codingcode/test/agent/hooks-deps-type.test.ts @@ -107,7 +107,6 @@ describe('agentLoop hooks type', () => { model: 'test-model', title: 'type-test', usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; diff --git a/packages/codingcode/test/agent/loop-options.test.ts b/packages/codingcode/test/agent/loop-options.test.ts index 178dd8b7..c952136a 100644 --- a/packages/codingcode/test/agent/loop-options.test.ts +++ b/packages/codingcode/test/agent/loop-options.test.ts @@ -91,7 +91,6 @@ describe('agentLoop loop options', () => { transcriptPath: '', indexPath: '', messageCount: 0, - promptEstimate: 0, memorySnapshot: '', }; diff --git a/packages/codingcode/test/agent/memory-snapshot.test.ts b/packages/codingcode/test/agent/memory-snapshot.test.ts index 33da365d..7a161040 100644 --- a/packages/codingcode/test/agent/memory-snapshot.test.ts +++ b/packages/codingcode/test/agent/memory-snapshot.test.ts @@ -100,7 +100,6 @@ function makeState(memorySnapshot: string = '') { model: 'test-model', title: 'memory-test', usage: undefined, - promptEstimate: 0, memorySnapshot, }; } diff --git a/packages/codingcode/test/agent/stop-hook.test.ts b/packages/codingcode/test/agent/stop-hook.test.ts index c6fe6d0b..1294565d 100644 --- a/packages/codingcode/test/agent/stop-hook.test.ts +++ b/packages/codingcode/test/agent/stop-hook.test.ts @@ -91,7 +91,6 @@ describe('agentLoop stop hook', () => { transcriptPath: '', indexPath: '', messageCount: 0, - promptEstimate: 0, memorySnapshot: '', }; diff --git a/packages/codingcode/test/context/budget-integration.test.ts b/packages/codingcode/test/context/budget-integration.test.ts index b8602641..48c6e688 100644 --- a/packages/codingcode/test/context/budget-integration.test.ts +++ b/packages/codingcode/test/context/budget-integration.test.ts @@ -98,7 +98,6 @@ describe('assemblePayload integration', () => { title: 'fixture', currentTurnId: 1, usage: undefined, - promptEstimate: 0, permissionMode: 'default', }; writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index 787898a6..5514a173 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -11,7 +11,8 @@ import type { ContextConfig } from '@codingcode/infra/config'; import type { LLMClient } from '../../../src/llm/client.js'; import { Result } from '../../../src/core/result.js'; import type { SessionIndex, SessionEvent, SummaryEvent } from '../../../src/session/types.js'; -import { buildMessages } from '../../../src/session/messages.js'; +import { filterForContext, buildContextMessages } from '../../../src/context/service.js'; +import { readHistory } from '../../../src/session/file-ops.js'; import { estimateTokens } from '../../../src/core/util.js'; const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); @@ -209,7 +210,10 @@ describe('compressor behavior', () => { it('returns promptEstimate after compression', async () => { const fx = makeFixture({ numTurns: 5 }); try { - const before = estimateTokens(buildMessages(fx.transcriptPath)); + const { visible: bVisible, compactedTurnIds: bCompacted } = filterForContext( + readHistory(fx.transcriptPath) + ); + const before = estimateTokens(buildContextMessages(bVisible, bCompacted)); const cfg = tinyConfig(); const llm = makeMockLLM( '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index 761037e7..a14301d8 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -59,7 +59,6 @@ const mockState = { model: 'test', title: 'test-sess', usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts index b6262738..c53ebabf 100644 --- a/packages/codingcode/test/server/compact-route.test.ts +++ b/packages/codingcode/test/server/compact-route.test.ts @@ -29,6 +29,14 @@ const MockSessionLayer = Layer.succeed(SessionService, { projectPath: 'test-path', model: 'deepseek-chat', }), + load: () => + Effect.succeed({ + sessionId: 'test-sid', + cwd: '/tmp/test', + projectPath: 'test-path', + transcriptPath: '/tmp/test.jsonl', + model: 'deepseek-chat', + }), recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 }), recordAssistant: () => Effect.succeed({ diff --git a/packages/codingcode/test/session/filter-ui.test.ts b/packages/codingcode/test/session/filter-ui.test.ts new file mode 100644 index 00000000..21c2883a --- /dev/null +++ b/packages/codingcode/test/session/filter-ui.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect } from 'vitest'; +import type { SessionEvent, SummaryEvent, CompactEvent } from '../../src/session/types.js'; + +function filterForUI(events: SessionEvent[]): SessionEvent[] { + const rollbackHiddenTurnIds = new Set(); + const rollbackHiddenOpUuids = new Set(); + + for (const ev of events) { + if (ev.type !== 'rollback') continue; + for (const prior of events) { + if (prior === ev) break; + if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { + rollbackHiddenTurnIds.add(prior.turnId); + } + if (prior.type === 'summary' || prior.type === 'compact') { + if ((prior as SummaryEvent | CompactEvent).endTurnId >= ev.throughTurnId) { + rollbackHiddenOpUuids.add((prior as SummaryEvent | CompactEvent).uuid); + } + } + } + } + + return events.filter((ev) => { + if (ev.type === 'rollback') return false; + if (ev.type === 'summary' && rollbackHiddenOpUuids.has((ev as SummaryEvent).uuid)) return false; + if (ev.type === 'compact' && rollbackHiddenOpUuids.has((ev as CompactEvent).uuid)) return false; + if ('turnId' in ev && rollbackHiddenTurnIds.has(ev.turnId)) return false; + return true; + }) as SessionEvent[]; +} + +function createTurnScopedIdGenerator() { + const counters = new Map(); + return (prefix: string, turnId: number): string => { + const key = `${prefix}:${turnId}`; + const next = (counters.get(key) ?? 0) + 1; + counters.set(key, next); + return `${prefix}-${turnId}-${next}`; + }; +} + +function sessionEventsToTurns( + events: SessionEvent[] +): Array<{ id: string; items: object[]; status: string }> { + const turnsMap = new Map(); + const nextId = createTurnScopedIdGenerator(); + + for (const event of events) { + if (event.type === 'session_meta') continue; + if (event.type === 'compact' || event.type === 'rollback') continue; + + if (event.type === 'summary') { + let turn = turnsMap.get(event.endTurnId); + if (!turn) { + turn = { id: String(event.endTurnId), items: [], status: 'completed' }; + turnsMap.set(event.endTurnId, turn); + } + turn.items.push({ + id: `summary-${event.uuid}`, + type: 'summary', + content: event.summaryText, + startTurnId: event.startTurnId, + endTurnId: event.endTurnId, + }); + continue; + } + + let turn = turnsMap.get(event.turnId); + if (!turn) { + turn = { id: String(event.turnId), items: [], status: 'completed' }; + turnsMap.set(event.turnId, turn); + } + switch (event.type) { + case 'user': + turn.items.push({ + id: nextId('user', event.turnId), + type: 'message', + role: 'user', + content: event.content, + }); + break; + case 'assistant': + if (event.content) { + turn.items.push({ + id: nextId('assistant', event.turnId), + type: 'message', + role: 'assistant', + content: event.content, + }); + } + for (const tc of event.toolCalls ?? []) { + const args = tc.arguments ?? {}; + turn.items.push({ + id: tc.id, + type: 'tool_call', + name: tc.name, + args, + status: 'approved', + }); + } + break; + case 'tool_result': { + const item: Record = { + id: `result-${event.toolCallId}`, + type: 'tool_result', + callId: event.toolCallId, + name: event.toolName, + output: event.output, + }; + turn.items.push(item); + break; + } + } + } + return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); +} + +function makeBaseEvents(extra: SessionEvent[] = []): SessionEvent[] { + const base: SessionEvent[] = [ + { + type: 'session_meta', + sessionId: 's1', + projectPath: 'p', + cwd: '/tmp', + createdAt: new Date().toISOString(), + }, + { type: 'user', turnId: 1, content: 'hello' }, + { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, + { type: 'user', turnId: 2, content: 'do stuff' }, + { type: 'assistant', turnId: 2, content: 'ok', toolCalls: [] }, + { type: 'user', turnId: 3, content: 'done' }, + { type: 'assistant', turnId: 3, content: 'great', toolCalls: [] }, + ]; + return [...base, ...extra]; +} + +describe('filterForUI', () => { + it('keeps all turns when no rollback or summary', () => { + const events = makeBaseEvents(); + const visible = filterForUI(events); + const turnIds = visible.filter((e) => 'turnId' in e).map((e) => (e as any).turnId); + expect(turnIds).toEqual([1, 1, 2, 2, 3, 3]); + }); + + it("hides rollback'd turns", () => { + const events = makeBaseEvents([{ type: 'rollback', throughTurnId: 2, reason: 'test' }]); + const visible = filterForUI(events); + const turnIds = visible.filter((e) => 'turnId' in e).map((e) => (e as any).turnId); + expect(turnIds).toEqual([1, 1]); + }); + + it('does NOT hide summary-covered turns (unlike filterForContext)', () => { + const events = makeBaseEvents([ + { + type: 'summary', + uuid: 'sum1', + startTurnId: 1, + endTurnId: 2, + summaryText: '[compacted]', + }, + ]); + const visible = filterForUI(events); + const turnIds = visible.filter((e) => 'turnId' in e).map((e) => (e as any).turnId); + // Turns 1 and 2 should still be visible in UI + expect(turnIds).toContain(1); + expect(turnIds).toContain(2); + expect(turnIds).toContain(3); + }); + + it('keeps summary event visible in UI', () => { + const events = makeBaseEvents([ + { + type: 'summary', + uuid: 'sum1', + startTurnId: 1, + endTurnId: 2, + summaryText: '[compacted]', + }, + ]); + const visible = filterForUI(events); + const summaries = visible.filter((e) => e.type === 'summary'); + expect(summaries).toHaveLength(1); + expect((summaries[0] as any).summaryText).toBe('[compacted]'); + }); + + it('hides summary that was rolled back', () => { + const events = makeBaseEvents([ + { + type: 'summary', + uuid: 'sum1', + startTurnId: 1, + endTurnId: 2, + summaryText: '[compacted]', + }, + { type: 'rollback', throughTurnId: 1, reason: 'test' }, + ]); + const visible = filterForUI(events); + const summaries = visible.filter((e) => e.type === 'summary'); + expect(summaries).toHaveLength(0); + }); + + it('hides compact that was rolled back', () => { + const events = makeBaseEvents([ + { + type: 'compact', + uuid: 'c1', + startTurnId: 1, + endTurnId: 2, + }, + { type: 'rollback', throughTurnId: 1, reason: 'test' }, + ]); + const visible = filterForUI(events); + const compacts = visible.filter((e) => e.type === 'compact'); + expect(compacts).toHaveLength(0); + }); + + it('does NOT hide compact-covered turns (full output visible)', () => { + const events = makeBaseEvents([ + { + type: 'compact', + uuid: 'c1', + startTurnId: 1, + endTurnId: 2, + }, + ]); + const visible = filterForUI(events); + const turnIds = visible.filter((e) => 'turnId' in e).map((e) => (e as any).turnId); + expect(turnIds).toContain(1); + expect(turnIds).toContain(2); + }); +}); + +describe('sessionEventsToTurns with summary', () => { + it('renders summary as an item in the endTurnId turn', () => { + const events: SessionEvent[] = [ + { + type: 'session_meta', + sessionId: 's1', + projectPath: 'p', + cwd: '/tmp', + createdAt: new Date().toISOString(), + }, + { type: 'user', turnId: 1, content: 'hello' }, + { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, + { type: 'user', turnId: 2, content: 'more' }, + { type: 'assistant', turnId: 2, content: 'ok', toolCalls: [] }, + { + type: 'summary', + uuid: 'sum1', + startTurnId: 1, + endTurnId: 2, + summaryText: '[compacted history]', + }, + ]; + const turns = sessionEventsToTurns(events); + expect(turns).toHaveLength(2); + // Turn 2 should have the summary item + const turn2 = turns.find((t) => t.id === '2'); + expect(turn2).toBeDefined(); + const summaryItem = turn2!.items.find((i: any) => (i as any).type === 'summary'); + expect(summaryItem).toBeDefined(); + expect((summaryItem as any).content).toBe('[compacted history]'); + expect((summaryItem as any).startTurnId).toBe(1); + expect((summaryItem as any).endTurnId).toBe(2); + }); +}); diff --git a/packages/codingcode/test/session/fork.test.ts b/packages/codingcode/test/session/fork.test.ts index a7971b2e..bb2e696f 100644 --- a/packages/codingcode/test/session/fork.test.ts +++ b/packages/codingcode/test/session/fork.test.ts @@ -5,7 +5,8 @@ import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; -import { buildMessages } from '../../src/session/messages.js'; +import { filterForContext, buildContextMessages } from '../../src/context/service.js'; +import { readHistory } from '../../src/session/file-ops.js'; import type { SessionIndex, SessionEvent } from '../../src/session/types.js'; const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); @@ -67,7 +68,6 @@ function makeFixture(sessionId: string, slug: string) { title: 'fixture', currentTurnId: 3, usage: undefined, - promptEstimate: 0, permissionMode: 'default', }; writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); @@ -120,7 +120,6 @@ describe('forkSession', () => { model: 'test', title: 'fixture', usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; @@ -165,14 +164,14 @@ describe('forkSession', () => { model: 'test', title: 'fixture', usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; + // Fork at non-existent turnId so chain = all events (including summary + compact) const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 3); + return yield* svc.forkSession(state, 999); }) ); @@ -219,7 +218,6 @@ describe('forkSession', () => { model: 'test', title: 'fixture', usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; @@ -246,14 +244,20 @@ describe('forkSession', () => { ); // Source should be unaffected - const sourceMessages = buildMessages(fx.transcriptPath); + const { visible: srcVisible, compactedTurnIds: srcCompacted } = filterForContext( + readHistory(fx.transcriptPath) + ); + const sourceMessages = buildContextMessages(srcVisible, srcCompacted); const sourceUserContents = sourceMessages .filter((m) => m.role === 'user') .map((m) => m.content); expect(sourceUserContents).toEqual(['first', 'second', 'third']); // Fork should reflect the rollback - const forkMessages = buildMessages(newJsonlPath); + const { visible: forkVisible, compactedTurnIds: forkCompacted } = filterForContext( + readHistory(newJsonlPath) + ); + const forkMessages = buildContextMessages(forkVisible, forkCompacted); const forkUserContents = forkMessages.filter((m) => m.role === 'user').map((m) => m.content); expect(forkUserContents).toEqual(['first']); } finally { @@ -278,7 +282,6 @@ describe('forkSession', () => { model: 'test', title: 'fixture', usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; @@ -301,4 +304,86 @@ describe('forkSession', () => { rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); } }); + + it('fork preserves summary/compact uuid (no regeneration)', async () => { + const sessionId = randomUUID(); + const slug = randomUUID(); + const dir = join(PROJECT_BASE, slug, 'sessions'); + mkdirSync(dir, { recursive: true }); + const transcriptPath = join(dir, `${sessionId}.jsonl`); + const indexPath = join(dir, `${sessionId}.index.json`); + + const fixedSummaryUuid = '11111111-1111-1111-1111-111111111111'; + const fixedCompactUuid = '22222222-2222-2222-2222-222222222222'; + + const lines: any[] = [ + { + type: 'session_meta', + sessionId, + projectPath: slug, + cwd: '/tmp/test', + createdAt: new Date().toISOString(), + }, + { type: 'user', turnId: 1, content: 'q1' }, + { type: 'assistant', turnId: 1, content: 'a1', toolCalls: [] }, + { type: 'summary', uuid: fixedSummaryUuid, startTurnId: 1, endTurnId: 1, summaryText: 'sum' }, + { type: 'user', turnId: 2, content: 'q2' }, + { type: 'assistant', turnId: 2, content: 'a2', toolCalls: [] }, + { type: 'compact', uuid: fixedCompactUuid, startTurnId: 1, endTurnId: 2 }, + ]; + writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); + const idx: SessionIndex = { + sessionId, + projectPath: slug, + cwd: '/tmp/test', + model: 'test', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: lines.length - 1, + title: 'uuid-fixture', + currentTurnId: 2, + usage: undefined, + permissionMode: 'default', + }; + writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); + + try { + const state = { + sessionId, + cwd: '/tmp/test', + projectPath: slug, + transcriptPath, + indexPath, + messageCount: lines.length - 1, + currentTurnId: 2, + sessionMeta: null, + model: 'test', + title: 'uuid-fixture', + usage: undefined, + memorySnapshot: '', + }; + + const newSessionId = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.forkSession(state, 999); + }) + ); + + const forkedPath = join(dir, `${newSessionId}.jsonl`); + const forkedEvents = readEvents(forkedPath); + const forkedSummary = forkedEvents.find( + (e) => e.type === 'summary' + ) as any; + const forkedCompact = forkedEvents.find( + (e) => e.type === 'compact' + ) as any; + expect(forkedSummary).toBeDefined(); + expect(forkedCompact).toBeDefined(); + expect((forkedSummary! as any).uuid).toBe(fixedSummaryUuid); + expect((forkedCompact! as any).uuid).toBe(fixedCompactUuid); + } finally { + rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + } + }); }); diff --git a/packages/codingcode/test/session/index-write-error.test.ts b/packages/codingcode/test/session/index-write-error.test.ts new file mode 100644 index 00000000..0a1a18f4 --- /dev/null +++ b/packages/codingcode/test/session/index-write-error.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { AgentError } from '../../src/core/error.js'; +import * as fs from 'fs'; + +vi.mock('fs', async (importOriginal) => ({ + ...(await importOriginal()), + writeFileSync: vi.fn(() => { + throw new Error('index write failed'); + }), +})); + +describe('SessionService — index write error propagation', () => { + it('recordUser propagates SESSION_IO_ERROR when writeFileSync throws', async () => { + const state: any = { + sessionId: 'idx-err-user', + cwd: '/tmp', + projectPath: 'test', + transcriptPath: '/tmp/idx-err-user.jsonl', + indexPath: '/tmp/idx-err-user.index.json', + messageCount: 0, + currentTurnId: 1, + sessionMeta: { model: 'test', createdAt: new Date().toISOString() }, + title: 'idx-err', + usage: undefined, + memorySnapshot: '', + }; + + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.recordUser(state, 'hello'); + }).pipe(Effect.provide(SessionService.Default)) + ); + + expect(exit._tag).toBe('Failure'); + if (exit._tag === 'Failure') { + const msg = String(exit.cause); + expect(msg).toContain('SESSION_IO_ERROR'); + expect(msg).toContain('index write failed'); + } + }); + + it('recordAssistant propagates SESSION_IO_ERROR when writeFileSync throws', async () => { + const state: any = { + sessionId: 'idx-err-asst', + cwd: '/tmp', + projectPath: 'test', + transcriptPath: '/tmp/idx-err-asst.jsonl', + indexPath: '/tmp/idx-err-asst.index.json', + messageCount: 0, + currentTurnId: 1, + sessionMeta: { model: 'test', createdAt: new Date().toISOString() }, + title: 'idx-err', + usage: undefined, + memorySnapshot: '', + }; + + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.recordAssistant(state, 'hi', []); + }).pipe(Effect.provide(SessionService.Default)) + ); + + expect(exit._tag).toBe('Failure'); + if (exit._tag === 'Failure') { + const msg = String(exit.cause); + expect(msg).toContain('SESSION_IO_ERROR'); + expect(msg).toContain('index write failed'); + } + }); +}); diff --git a/packages/codingcode/test/session/index-write-sync.test.ts b/packages/codingcode/test/session/index-write-sync.test.ts new file mode 100644 index 00000000..21e8fd37 --- /dev/null +++ b/packages/codingcode/test/session/index-write-sync.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { randomUUID } from 'crypto'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { encodeProjectPath } from '../../src/core/path.js'; +import type { SessionIndex } from '../../src/session/types.js'; + +const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); +} + +describe('index write is synchronous', () => { + it('recordUser immediately updates index file', async () => { + const slug = randomUUID(); + const dir = join(PROJECT_BASE, slug); + mkdirSync(dir, { recursive: true }); + + try { + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, 'test-model'); + }) + ); + + const indexPath = state.indexPath; + + const before = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; + expect(before.messageCount).toBe(1); + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + yield* svc.recordUser(state, 'hello'); + }) + ); + + const after = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; + expect(after.messageCount).toBe(2); + expect(after.title).toBe('hello'); + } finally { + rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('recordAssistant immediately updates index file', async () => { + const slug = randomUUID(); + const dir = join(PROJECT_BASE, slug); + mkdirSync(dir, { recursive: true }); + + try { + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, 'test-model'); + }) + ); + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + yield* svc.recordUser(state, 'hello'); + }) + ); + + const indexPath = state.indexPath; + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + yield* svc.recordAssistant(state, 'reply', [ + { id: 'tc1', name: 'bash', arguments: { cmd: 'echo' } }, + ]); + }) + ); + + const updated = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; + expect(updated.messageCount).toBe(3); + } finally { + rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/codingcode/test/session/io-error.test.ts b/packages/codingcode/test/session/io-error.test.ts index aeb6652c..18293eee 100644 --- a/packages/codingcode/test/session/io-error.test.ts +++ b/packages/codingcode/test/session/io-error.test.ts @@ -25,7 +25,6 @@ describe('SessionService — SESSION_IO_ERROR', () => { title: 'io-err-sid'.slice(0, 8), usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; @@ -57,7 +56,6 @@ describe('SessionService — SESSION_IO_ERROR', () => { title: 'io-err-asst'.slice(0, 8), usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; @@ -88,7 +86,6 @@ describe('SessionService — SESSION_IO_ERROR', () => { title: 'io-err-eff'.slice(0, 8), usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; diff --git a/packages/codingcode/test/session/load-create.test.ts b/packages/codingcode/test/session/load-create.test.ts new file mode 100644 index 00000000..d8460446 --- /dev/null +++ b/packages/codingcode/test/session/load-create.test.ts @@ -0,0 +1,301 @@ +import { describe, it, expect } from 'vitest'; +import { mkdirSync, readFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { randomUUID } from 'crypto'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { AgentError } from '../../src/core/error.js'; +import { encodeProjectPath } from '../../src/core/path.js'; +import type { SessionIndex } from '../../src/session/types.js'; + +const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); +} + +function cleanup(dir: string) { + rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); +} + +describe('load — restores model from disk, not overwritten', () => { + it('load restores model from index.json, not overwritten by caller', async () => { + const slug = randomUUID(); + const dir = join(PROJECT_BASE, slug); + mkdirSync(dir, { recursive: true }); + + try { + const created = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, 'gpt-4o'); + }) + ); + const sid = created.sessionId; + + const loaded = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.load(dir, sid); + }) + ); + + expect(loaded.model).toBe('gpt-4o'); + expect(loaded.sessionId).toBe(sid); + expect(loaded.sessionMeta).not.toBeNull(); + } finally { + cleanup(dir); + } + }); + + it('load then rollbackToTurn preserves real model in index.json', async () => { + const slug = randomUUID(); + const dir = join(PROJECT_BASE, slug); + mkdirSync(dir, { recursive: true }); + + try { + const created = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, 'claude-3-5-sonnet'); + }) + ); + const sid = created.sessionId; + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + const state = yield* svc.load(dir, sid); + yield* svc.recordUser(state, 'first message'); + }) + ); + + const beforeRollback = JSON.parse(readFileSync(created.indexPath, 'utf8')) as SessionIndex; + expect(beforeRollback.model).toBe('claude-3-5-sonnet'); + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + const state = yield* svc.load(dir, sid); + yield* svc.rollbackToTurn(state, 1, 'test rollback'); + }) + ); + + const afterRollback = JSON.parse(readFileSync(created.indexPath, 'utf8')) as SessionIndex; + expect(afterRollback.model).toBe('claude-3-5-sonnet'); + } finally { + cleanup(dir); + } + }); + + it('load nonexistent session fails with SESSION_NOT_FOUND', async () => { + const slug = randomUUID(); + const dir = join(PROJECT_BASE, slug); + mkdirSync(dir, { recursive: true }); + + try { + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.load(dir, 'nonexistent-session-id'); + }).pipe(Effect.provide(SessionService.Default)) + ); + + expect(exit._tag).toBe('Failure'); + if (exit._tag === 'Failure') { + const msg = String(exit.cause); + expect(msg).toContain('SESSION_NOT_FOUND'); + } + } finally { + cleanup(dir); + } + }); + + it('load mismatched workspace fails with SESSION_WORKSPACE_MISMATCH', async () => { + const slug = randomUUID(); + const dir = join(PROJECT_BASE, slug); + mkdirSync(dir, { recursive: true }); + const otherDir = join(PROJECT_BASE, randomUUID()); + mkdirSync(otherDir, { recursive: true }); + + try { + const created = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, 'gpt-4o'); + }) + ); + + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.load(otherDir, created.sessionId); + }).pipe(Effect.provide(SessionService.Default)) + ); + + expect(exit._tag).toBe('Failure'); + if (exit._tag === 'Failure') { + const msg = String(exit.cause); + expect(msg).toContain('SESSION_WORKSPACE_MISMATCH'); + } + } finally { + cleanup(dir); + cleanup(otherDir); + } + }); +}); + +describe('create — generates sessionId internally', () => { + it('create without sessionId generates a new UUID', async () => { + const slug = randomUUID(); + const dir = join(PROJECT_BASE, slug); + mkdirSync(dir, { recursive: true }); + + try { + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, 'test-model'); + }) + ); + + expect(state.sessionId).toBeTruthy(); + expect(state.sessionId.length).toBeGreaterThan(8); + expect(state.model).toBe('test-model'); + expect(state.sessionMeta).not.toBeNull(); + } finally { + cleanup(dir); + } + }); + + it('create writes model to index.json immediately', async () => { + const slug = randomUUID(); + const dir = join(PROJECT_BASE, slug); + mkdirSync(dir, { recursive: true }); + + try { + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, 'my-special-model'); + }) + ); + + const idx = JSON.parse(readFileSync(state.indexPath, 'utf8')) as SessionIndex; + expect(idx.model).toBe('my-special-model'); + } finally { + cleanup(dir); + } + }); + + it('create returns default values for persisted fields', async () => { + const slug = randomUUID(); + const dir = join(PROJECT_BASE, slug); + mkdirSync(dir, { recursive: true }); + + try { + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, 'test-model'); + }) + ); + + expect(state.currentTurnId).toBe(0); + expect(state.usage).toBeUndefined(); + expect(state.memorySnapshot).toBe(''); + } finally { + cleanup(dir); + } + }); +}); + +describe('load restores persisted fields', () => { + it('load restores currentTurnId from index.json', async () => { + const slug = randomUUID(); + const dir = join(PROJECT_BASE, slug); + mkdirSync(dir, { recursive: true }); + + try { + const created = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, 'test-model'); + }) + ); + const sid = created.sessionId; + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + const state = yield* svc.load(dir, sid); + svc.incrementTurn(state); + yield* svc.recordUser(state, 'first'); + }) + ); + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + const state = yield* svc.load(dir, sid); + svc.incrementTurn(state); + yield* svc.recordUser(state, 'second'); + }) + ); + + const idx = JSON.parse(readFileSync(created.indexPath, 'utf8')) as SessionIndex; + expect(idx.currentTurnId).toBe(2); + + const loaded = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.load(dir, sid); + }) + ); + + expect(loaded.currentTurnId).toBe(2); + } finally { + cleanup(dir); + } + }); + + it('load restores usage from index.json', async () => { + const slug = randomUUID(); + const dir = join(PROJECT_BASE, slug); + mkdirSync(dir, { recursive: true }); + + try { + const created = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, 'test-model'); + }) + ); + const sid = created.sessionId; + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + const state = yield* svc.load(dir, sid); + yield* svc.recordUser(state, 'hello'); + yield* svc.recordAssistant(state, 'world', [ + { id: 'tc1', name: 'bash', arguments: { cmd: 'echo' } }, + ]); + }) + ); + + const loaded = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.load(dir, sid); + }) + ); + + const idx = JSON.parse(readFileSync(created.indexPath, 'utf8')) as SessionIndex; + expect(idx.usage).toEqual(loaded.usage); + } finally { + cleanup(dir); + } + }); +}); diff --git a/packages/codingcode/test/session/prompt-estimate.test.ts b/packages/codingcode/test/session/prompt-estimate.test.ts index bfd10ec0..c0b668a7 100644 --- a/packages/codingcode/test/session/prompt-estimate.test.ts +++ b/packages/codingcode/test/session/prompt-estimate.test.ts @@ -5,9 +5,8 @@ import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; -import { findSessionIndex } from '../../src/session/file-ops.js'; -import { findLastVisibleAssistantUsage, buildMessages } from '../../src/session/messages.js'; -import { estimateTokensForContent, estimateTokens } from '../../src/core/util.js'; +import { findLastVisibleAssistantUsage, estimatePromptTokens } from '../../src/context/service.js'; +import { estimateTokensForContent } from '../../src/core/util.js'; import { encodeProjectPath } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; @@ -76,7 +75,6 @@ function makeFixture( title: 'fixture', currentTurnId: 2, usage: usage ?? undefined, - promptEstimate: usage ? usage.prompt : estimateTokens(buildMessages(transcriptPath)), permissionMode: 'default', }; writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); @@ -162,19 +160,6 @@ describe('promptEstimate', () => { } }); - it('findSessionIndex reads promptEstimate from index.json', () => { - const sessionId = randomUUID(); - const slug = randomUUID(); - const _fx = makeFixture(sessionId, slug, { prompt: 500, completion: 200, total: 700 }); - try { - const idx = findSessionIndex(sessionId); - expect(idx).not.toBeNull(); - expect(idx!.promptEstimate).toBe(500); - } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); - } - }); - it('forkSession restores usage and promptEstimate from last visible assistant', async () => { const sessionId = randomUUID(); const slug = randomUUID(); @@ -193,7 +178,6 @@ describe('promptEstimate', () => { model: 'test-model', title: 'fixture', usage, - promptEstimate: usage.prompt, memorySnapshot: '', }; const newSessionId = await run( @@ -205,7 +189,6 @@ describe('promptEstimate', () => { const newIndexPath = join(fx.dir, `${newSessionId}.index.json`); const idx = JSON.parse(readFileSync(newIndexPath, 'utf8')) as SessionIndex; expect(idx.usage).toEqual(usage); - expect(idx.promptEstimate).toBe(usage.prompt); } finally { rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); } @@ -228,7 +211,6 @@ describe('promptEstimate', () => { model: 'test-model', title: 'fixture', usage: undefined, - promptEstimate: 0, memorySnapshot: '', }; const newSessionId = await run( @@ -239,7 +221,8 @@ describe('promptEstimate', () => { ); const newIndexPath = join(fx.dir, `${newSessionId}.index.json`); const idx = JSON.parse(readFileSync(newIndexPath, 'utf8')) as SessionIndex; - expect(idx.promptEstimate).toBeGreaterThan(0); + expect(idx.sessionId).toBe(newSessionId); + expect(estimatePromptTokens(join(fx.dir, `${newSessionId}.jsonl`))).toBeGreaterThan(0); } finally { rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); } @@ -276,240 +259,3 @@ describe('SessionService create sets model', () => { } }); }); - -describe('SessionService record methods update promptEstimate', () => { - it('recordUser increments promptEstimate', async () => { - const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); - mkdirSync(dir, { recursive: true }); - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, 'test-model'); - }) - ); - expect(state.promptEstimate).toBe(0); - - const before = state.promptEstimate; - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordUser(state, 'hello world'); - }) - ); - expect(state.promptEstimate).toBeGreaterThan(before); - } finally { - await new Promise((r) => setTimeout(r, 50)); - rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('recordAssistant without usage increments promptEstimate', async () => { - const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); - mkdirSync(dir, { recursive: true }); - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, 'test-model'); - }) - ); - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordUser(state, 'hello'); - }) - ); - const before = state.promptEstimate; - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordAssistant(state, 'reply', []); - }) - ); - expect(state.promptEstimate).toBeGreaterThan(before); - expect(state.usage).toBeUndefined(); - } finally { - await new Promise((r) => setTimeout(r, 50)); - rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('recordAssistant with usage sets promptEstimate to usage.prompt', async () => { - const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); - mkdirSync(dir, { recursive: true }); - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, 'test-model'); - }) - ); - - const usage = { prompt: 999, completion: 111, total: 1110 }; - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordAssistant(state, 'reply', [], usage); - }) - ); - expect(state.promptEstimate).toBe(999); - expect(state.usage).toEqual(usage); - } finally { - await new Promise((r) => setTimeout(r, 50)); - rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('recordToolResult increments promptEstimate', async () => { - const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); - mkdirSync(dir, { recursive: true }); - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, 'test-model'); - }) - ); - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordAssistant(state, 'use tool', [ - { id: 'tc1', name: 'bash', arguments: {} }, - ]); - }) - ); - const before = state.promptEstimate; - - const toolEvent = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.recordToolResult(state, 'bash', 'tc1', 'tool output here'); - }) - ); - expect(state.promptEstimate).toBeGreaterThan(before); - expect(toolEvent.output).toBe('tool output here'); - } finally { - await new Promise((r) => setTimeout(r, 50)); - rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('rollbackToTurn resets usage and recalculates promptEstimate', async () => { - const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); - mkdirSync(dir, { recursive: true }); - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, 'test-model'); - }) - ); - - const userEv = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.recordUser(state, 'hello world'); - }) - ); - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordAssistant(state, 'reply', [], { - prompt: 100, - completion: 50, - total: 150, - }); - }) - ); - expect(state.usage).toBeDefined(); - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.rollbackToTurn(state, userEv.turnId, 'test'); - }) - ); - expect(state.usage).toBeUndefined(); - expect(state.promptEstimate).toBeGreaterThanOrEqual(0); - } finally { - await new Promise((r) => setTimeout(r, 50)); - rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('rollbackToTurn recalculates promptEstimate from visible messages, not old usage', async () => { - const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); - mkdirSync(dir, { recursive: true }); - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, 'test-model'); - }) - ); - - // Turn 1 - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordUser(state, 'hello world'); - yield* svc.recordAssistant(state, 'reply one', [], { - prompt: 1000, - completion: 100, - total: 1100, - }); - }) - ); - - // Turn 2 - state.currentTurnId = 2; - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordUser(state, 'do more stuff'); - yield* svc.recordAssistant(state, 'reply two', [], { - prompt: 5000, - completion: 200, - total: 5200, - }); - }) - ); - - expect(state.promptEstimate).toBe(5000); - expect(state.usage).toBeDefined(); - - // Rollback to turn 1 — should hide turn 2 messages - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.rollbackToTurn(state, 1, 'test rollback'); - }) - ); - - // promptEstimate should restore from last visible assistant usage.prompt, not 5000 - expect(state.promptEstimate).toBeLessThan(5000); - expect(state.promptEstimate).toBe(1000); // turn 1 assistant usage.prompt - // usage should be restored from last visible assistant - expect(state.usage).toEqual({ prompt: 1000, completion: 100, total: 1100 }); - } finally { - await new Promise((r) => setTimeout(r, 50)); - rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/codingcode/test/session/rollback.test.ts b/packages/codingcode/test/session/rollback.test.ts index 9e41c94c..d251d9c7 100644 --- a/packages/codingcode/test/session/rollback.test.ts +++ b/packages/codingcode/test/session/rollback.test.ts @@ -3,7 +3,8 @@ import { mkdirSync, writeFileSync, rmSync, appendFileSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { randomUUID } from 'crypto'; -import { buildMessages } from '../../src/session/messages.js'; +import { filterForContext, buildContextMessages } from '../../src/context/service.js'; +import { readHistory } from '../../src/session/file-ops.js'; import type { SessionIndex } from '../../src/session/types.js'; const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); @@ -55,7 +56,6 @@ function makeFixture(sessionId: string, slug: string) { title: 'fixture', currentTurnId: 3, usage: undefined, - promptEstimate: 0, permissionMode: 'default', }; writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); @@ -79,7 +79,8 @@ describe('rollback', () => { reason: 'user rollback', }); - const messages = buildMessages(fx.transcriptPath); + const { visible, compactedTurnIds } = filterForContext(readHistory(fx.transcriptPath)); + const messages = buildContextMessages(visible, compactedTurnIds); const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); expect(userContents).toEqual([]); } finally { @@ -98,7 +99,8 @@ describe('rollback', () => { reason: 'user rollback', }); - const messages = buildMessages(fx.transcriptPath); + const { visible, compactedTurnIds } = filterForContext(readHistory(fx.transcriptPath)); + const messages = buildContextMessages(visible, compactedTurnIds); const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); expect(userContents).toEqual(['hello']); } finally { diff --git a/packages/codingcode/test/session/store-diff-rebuild.test.ts b/packages/codingcode/test/session/store-diff-rebuild.test.ts index 78c509d5..2a6d1922 100644 --- a/packages/codingcode/test/session/store-diff-rebuild.test.ts +++ b/packages/codingcode/test/session/store-diff-rebuild.test.ts @@ -1,6 +1,91 @@ import { describe, it, expect } from 'vitest'; import type { SessionEvent } from '../../src/session/types.js'; -import { sessionEventsToTurns } from '../../src/session/messages.js'; + +function createTurnScopedIdGenerator() { + const counters = new Map(); + return (prefix: string, turnId: number): string => { + const key = `${prefix}:${turnId}`; + const next = (counters.get(key) ?? 0) + 1; + counters.set(key, next); + return `${prefix}-${turnId}-${next}`; + }; +} + +function sessionEventsToTurns( + events: SessionEvent[] +): Array<{ id: string; items: object[]; status: string }> { + const turnsMap = new Map(); + const nextId = createTurnScopedIdGenerator(); + + for (const event of events) { + if (event.type === 'session_meta') continue; + if (event.type === 'compact' || event.type === 'rollback') continue; + + if (event.type === 'summary') { + let turn = turnsMap.get(event.endTurnId); + if (!turn) { + turn = { id: String(event.endTurnId), items: [], status: 'completed' }; + turnsMap.set(event.endTurnId, turn); + } + turn.items.push({ + id: `summary-${event.uuid}`, + type: 'summary', + content: event.summaryText, + startTurnId: event.startTurnId, + endTurnId: event.endTurnId, + }); + continue; + } + + let turn = turnsMap.get(event.turnId); + if (!turn) { + turn = { id: String(event.turnId), items: [], status: 'completed' }; + turnsMap.set(event.turnId, turn); + } + switch (event.type) { + case 'user': + turn.items.push({ + id: nextId('user', event.turnId), + type: 'message', + role: 'user', + content: event.content, + }); + break; + case 'assistant': + if (event.content) { + turn.items.push({ + id: nextId('assistant', event.turnId), + type: 'message', + role: 'assistant', + content: event.content, + }); + } + for (const tc of event.toolCalls ?? []) { + const args = tc.arguments ?? {}; + turn.items.push({ + id: tc.id, + type: 'tool_call', + name: tc.name, + args, + status: 'approved', + }); + } + break; + case 'tool_result': { + const item: Record = { + id: `result-${event.toolCallId}`, + type: 'tool_result', + callId: event.toolCallId, + name: event.toolName, + output: event.output, + }; + turn.items.push(item); + break; + } + } + } + return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); +} describe('sessionEventsToTurns', () => { it('parses edit_file tool_result without diff (diff is computed on frontend)', () => { diff --git a/packages/codingcode/test/session/types-export.test.ts b/packages/codingcode/test/session/types-export.test.ts new file mode 100644 index 00000000..dd156b2c --- /dev/null +++ b/packages/codingcode/test/session/types-export.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import type { SessionStoreState } from '../../src/session/types.js'; + +describe('SessionStoreState export', () => { + it('should be importable from session/types', () => { + const state: SessionStoreState = { + sessionId: 'test-sid', + cwd: '/tmp', + projectPath: 'proj', + transcriptPath: '/tmp/proj/sessions/test.jsonl', + indexPath: '/tmp/proj/sessions/test.index.json', + messageCount: 0, + sessionMeta: null, + model: 'gpt-4', + title: '', + currentTurnId: 0, + usage: undefined, + memorySnapshot: '', + }; + expect(state.sessionId).toBe('test-sid'); + expect(state.messageCount).toBe(0); + }); +}); diff --git a/packages/codingcode/test/session/ui-history-rollback.test.ts b/packages/codingcode/test/session/ui-history-rollback.test.ts index df23c620..79b0da82 100644 --- a/packages/codingcode/test/session/ui-history-rollback.test.ts +++ b/packages/codingcode/test/session/ui-history-rollback.test.ts @@ -3,8 +3,37 @@ import { mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { randomUUID } from 'crypto'; -import { buildMessages, applyVisibilityEvents, readUIHistory } from '../../src/session/messages.js'; -import type { SessionIndex } from '../../src/session/types.js'; +import { filterForContext, buildContextMessages } from '../../src/context/service.js'; +import { readHistory } from '../../src/session/file-ops.js'; +import type { SessionIndex, SessionEvent } from '../../src/session/types.js'; + +function filterForUI(events: SessionEvent[]): SessionEvent[] { + const rollbackHiddenTurnIds = new Set(); + const rollbackHiddenOpUuids = new Set(); + + for (const ev of events) { + if (ev.type !== 'rollback') continue; + for (const prior of events) { + if (prior === ev) break; + if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { + rollbackHiddenTurnIds.add(prior.turnId); + } + if (prior.type === 'summary' || prior.type === 'compact') { + if ((prior as any).endTurnId >= ev.throughTurnId) { + rollbackHiddenOpUuids.add((prior as any).uuid); + } + } + } + } + + return events.filter((ev) => { + if (ev.type === 'rollback') return false; + if (ev.type === 'summary' && rollbackHiddenOpUuids.has((ev as any).uuid)) return false; + if (ev.type === 'compact' && rollbackHiddenOpUuids.has((ev as any).uuid)) return false; + if ('turnId' in ev && rollbackHiddenTurnIds.has(ev.turnId)) return false; + return true; + }) as SessionEvent[]; +} const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); @@ -56,7 +85,6 @@ function makeFixture(sessionId: string, slug: string, extraEvents?: object[]) { title: 'fixture', currentTurnId: 3, usage: undefined, - promptEstimate: 0, permissionMode: 'default', }; writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); @@ -64,7 +92,7 @@ function makeFixture(sessionId: string, slug: string, extraEvents?: object[]) { return { dir, transcriptPath, indexPath }; } -describe('applyVisibilityEvents', () => { +describe('filterForContext', () => { it('marks rollback-hidden events', () => { const sessionId = randomUUID(); const slug = randomUUID(); @@ -83,16 +111,16 @@ describe('applyVisibilityEvents', () => { { type: 'assistant' as const, turnId: 2, content: 'bye', toolCalls: [] }, { type: 'rollback' as const, throughTurnId: 1, reason: 'test' }, ]; - const { hiddenTurnIds } = applyVisibilityEvents(events); - expect(hiddenTurnIds.has(2)).toBe(true); - expect(hiddenTurnIds.has(1)).toBe(true); + const { visible } = filterForContext(events); + const visibleTurnIds = visible.filter((e) => 'turnId' in e).map((e) => (e as any).turnId); + expect(visibleTurnIds).toEqual([]); } finally { rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); } }); }); -describe('buildMessages with visibility filtering', () => { +describe('buildContextMessages with visibility filtering', () => { it('visible turns match after rollback', () => { const sessionId = randomUUID(); const slug = randomUUID(); @@ -100,7 +128,8 @@ describe('buildMessages with visibility filtering', () => { { type: 'rollback', throughTurnId: 1, reason: 'test' }, ]); try { - const messages = buildMessages(fx.transcriptPath); + const { visible, compactedTurnIds } = filterForContext(readHistory(fx.transcriptPath)); + const messages = buildContextMessages(visible, compactedTurnIds); const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); expect(userContents).toEqual([]); } finally { @@ -145,13 +174,13 @@ describe('readUIHistory with visibility filtering', () => { title: 'test', currentTurnId: 2, usage: undefined, - promptEstimate: 0, permissionMode: 'default', }) ); - const turns = readUIHistory(sessionId); - expect(turns.length).toBe(0); + const visible = filterForUI(readHistory(tp)); + const turnIds = visible.filter((e) => 'turnId' in e).map((e) => (e as any).turnId); + expect(turnIds).toEqual([]); } finally { rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); } @@ -162,8 +191,9 @@ describe('readUIHistory with visibility filtering', () => { const slug = randomUUID(); const _fx = makeFixture(sessionId, slug); try { - const turns = readUIHistory(sessionId); - expect(turns.length).toBe(3); + const visible = filterForUI(readHistory(_fx.transcriptPath)); + const turnIds = visible.filter((e) => 'turnId' in e).map((e) => (e as any).turnId); + expect(new Set(turnIds)).toEqual(new Set([1, 2, 3])); } finally { rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); } diff --git a/packages/codingcode/test/session/usage-persist.test.ts b/packages/codingcode/test/session/usage-persist.test.ts index 65ea3579..3c7caeff 100644 --- a/packages/codingcode/test/session/usage-persist.test.ts +++ b/packages/codingcode/test/session/usage-persist.test.ts @@ -39,7 +39,6 @@ function makeFixture( title: 'test', currentTurnId: 0, usage: usage as any, - promptEstimate: 0, permissionMode: 'default', }; writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); diff --git a/packages/codingcode/test/session/view-assembly.test.ts b/packages/codingcode/test/session/view-assembly.test.ts index c6c413ed..874b7652 100644 --- a/packages/codingcode/test/session/view-assembly.test.ts +++ b/packages/codingcode/test/session/view-assembly.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { buildMessagesFromEvents } from '../../src/session/messages.js'; +import { filterForContext, buildContextMessages } from '../../src/context/service.js'; import type { SessionEvent } from '../../src/session/types.js'; +function toMessages(events: SessionEvent[]) { + const { visible, compactedTurnIds } = filterForContext(events); + return buildContextMessages(visible, compactedTurnIds); +} + function makeEvents(extra: SessionEvent[] = []): SessionEvent[] { const base: SessionEvent[] = [ { @@ -47,10 +52,10 @@ function makeEvents(extra: SessionEvent[] = []): SessionEvent[] { return [...base, ...extra]; } -describe('buildMessagesFromEvents', () => { +describe('buildContextMessages', () => { it('converts user/assistant/tool_result events to messages', () => { const events = makeEvents(); - const messages = buildMessagesFromEvents(events); + const messages = toMessages(events); expect(messages).toHaveLength(7); expect(messages[0]).toEqual({ role: 'user', content: 'hello' }); expect(messages[1]).toEqual({ role: 'assistant', content: 'hi there' }); @@ -72,7 +77,7 @@ describe('buildMessagesFromEvents', () => { summaryText: '[compacted]', }, ]); - const messages = buildMessagesFromEvents(events); + const messages = toMessages(events); const toolMessages = messages.filter((m) => m.role === 'tool'); expect(toolMessages).toHaveLength(0); const summaryMessages = messages.filter((m) => m.role === 'system'); @@ -88,7 +93,7 @@ describe('buildMessagesFromEvents', () => { reason: 'rollback', }, ]); - const messages = buildMessagesFromEvents(events); + const messages = toMessages(events); const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); expect(userContents).toEqual([]); const assistantContents = messages.filter((m) => m.role === 'assistant').map((m) => m.content); @@ -116,7 +121,7 @@ describe('buildMessagesFromEvents', () => { toolCalls: [{ id: 'tc1', name: 'bash', arguments: {} }], }, ]; - const messages = buildMessagesFromEvents(events); + const messages = toMessages(events); expect(messages).toHaveLength(1); expect(messages[0]).toEqual({ role: 'user', content: 'do something' }); }); @@ -163,7 +168,7 @@ describe('buildMessagesFromEvents', () => { toolCalls: [], }, ]; - const messages = buildMessagesFromEvents(events); + const messages = toMessages(events); expect(messages.filter((m) => m.role === 'assistant')).toHaveLength(1); expect((messages.find((m) => m.role === 'assistant') as any).content).toBe('done'); expect(messages.filter((m) => m.role === 'tool')).toHaveLength(0); @@ -211,7 +216,7 @@ describe('buildMessagesFromEvents', () => { toolCalls: [], }, ]; - const messages = buildMessagesFromEvents(events); + const messages = toMessages(events); const assistantContents = messages .filter((m) => m.role === 'assistant') .map((m) => (m as any).content); @@ -258,7 +263,7 @@ describe('buildMessagesFromEvents', () => { content: 'second', }, ]; - const messages = buildMessagesFromEvents(events); + const messages = toMessages(events); const userMsgs = messages.filter((m) => m.role === 'user'); expect(userMsgs).toHaveLength(1); expect((userMsgs[0] as any).content).toContain('first'); @@ -303,7 +308,7 @@ describe('buildMessagesFromEvents', () => { output: 'out2', }, ]; - const messages = buildMessagesFromEvents(events); + const messages = toMessages(events); expect(messages.filter((m) => m.role === 'tool')).toHaveLength(2); }); @@ -330,7 +335,7 @@ describe('buildMessagesFromEvents', () => { toolCalls: [], }, ]; - const messages = buildMessagesFromEvents(events); + const messages = toMessages(events); const assistantMsgs = messages.filter((m) => m.role === 'assistant'); expect(assistantMsgs).toHaveLength(1); expect((assistantMsgs[0] as any).content).toContain('reply1'); @@ -338,7 +343,7 @@ describe('buildMessagesFromEvents', () => { }); it('handles empty events list', () => { - const messages = buildMessagesFromEvents([]); + const messages = toMessages([]); expect(messages).toHaveLength(0); }); }); diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index 3ff92017..43a3d3ba 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -62,7 +62,6 @@ const mockSession = { title: 'child', usage: undefined, - promptEstimate: 0, memorySnapshot: '', }), incrementTurn: () => 0, @@ -415,7 +414,7 @@ describe('dispatch_agent tool', () => { } }); - it('should call session.create with plain UUID sessionId and parentSessionId in opts', async () => { + it('should call session.create with model and parentSessionId in opts', async () => { const createFn = vi.fn().mockReturnValue( Effect.succeed({ sessionId: 'child-456', @@ -428,7 +427,6 @@ describe('dispatch_agent tool', () => { sessionMeta: null, title: 'child', usage: undefined, - promptEstimate: 0, memorySnapshot: '', }) ); @@ -451,7 +449,6 @@ describe('dispatch_agent tool', () => { expect(createFn).toHaveBeenCalledWith( '/test', expect.any(String), - expect.any(String), expect.objectContaining({ parentSessionId: 'parent-1', agentName: 'explore' }) ); }); From 3d493e4ce91e7f3dca2dabc4e74c88367c0d8857 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 18 Jun 2026 21:56:48 +0800 Subject: [PATCH 4/7] Refactor the session file path handling logic to consistently use cwd to get the session path --- packages/codingcode/src/agent/agent.ts | 8 +- .../codingcode/src/client/direct/sessions.ts | 34 +++------ .../codingcode/src/client/http/sessions.ts | 26 ++++--- packages/codingcode/src/context/service.ts | 20 +++-- packages/codingcode/src/memory/index.ts | 17 ++--- .../codingcode/src/server/routes/messages.ts | 9 +-- .../codingcode/src/server/routes/sessions.ts | 58 +++++++------- packages/codingcode/src/session/file-ops.ts | 68 ++--------------- packages/codingcode/src/session/store.ts | 17 +---- .../compressor/compact-if-needed.test.ts | 20 ++--- .../test/session/load-create.test.ts | 2 +- .../test/session/session-jsonl-path.test.ts | 55 ++++++++++++++ .../test/session/usage-persist.test.ts | 76 ------------------- .../codingcode/test/subagent/dispatch.test.ts | 1 - packages/desktop/src/hooks/useAgent.ts | 6 +- packages/desktop/src/lib/core-api.ts | 13 ++-- 16 files changed, 164 insertions(+), 266 deletions(-) create mode 100644 packages/codingcode/test/session/session-jsonl-path.test.ts delete mode 100644 packages/codingcode/test/session/usage-persist.test.ts diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index c82d078a..1e43ddca 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -428,7 +428,7 @@ export function agentLoop( status: 'error', }); memory - .flushSessionToMemory(state.sessionId, llm) + .flushSessionToMemory(state.sessionId, llm, state.cwd) .catch((e) => logger.error('memory flush failed:', e)); return Result.err( new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded') @@ -508,7 +508,7 @@ export function agentLoop( yield* checkpoint.snapshotFinal(projectPath, state.sessionId, state.currentTurnId); memory - .flushSessionToMemory(state.sessionId, llm) + .flushSessionToMemory(state.sessionId, llm, state.cwd) .catch((e) => logger.error('memory flush failed:', e)); if (lastResult) return lastResult; @@ -529,7 +529,7 @@ export function agentLoop( status: 'maxSteps', }); memory - .flushSessionToMemory(state.sessionId, llm) + .flushSessionToMemory(state.sessionId, llm, state.cwd) .catch((e) => logger.error('memory flush failed:', e)); return Result.err(AgentError.maxStepsReached(effectiveMaxSteps)); }).pipe( @@ -555,7 +555,7 @@ export function agentLoop( yield* cp.snapshotFinal(projectPath, sessionId, state.currentTurnId).pipe(Effect.ignore); const mem = yield* MemoryService; mem - .flushSessionToMemory(state.sessionId, llm) + .flushSessionToMemory(state.sessionId, llm, state.cwd) .catch((e) => logger.error('memory flush failed:', e)); }) ) diff --git a/packages/codingcode/src/client/direct/sessions.ts b/packages/codingcode/src/client/direct/sessions.ts index 87b0242a..c5c6f6d9 100644 --- a/packages/codingcode/src/client/direct/sessions.ts +++ b/packages/codingcode/src/client/direct/sessions.ts @@ -1,6 +1,5 @@ import { Effect } from 'effect'; import { SessionService } from '../../session/store.js'; -import { WorkspaceService } from '../../core/workspace.js'; import { deleteSession } from '../../session/file-ops.js'; import type { PermissionMode } from '../../approval/types.js'; import type { @@ -20,11 +19,11 @@ export interface SessionClient { }): Promise<{ sessionId: string }>; resumeSession(input: { sessionId: string; cwd: string }): Promise; listSessions(input: { cwd: string }): Promise; - getSessionHistory(input: { sessionId: string }): Promise; + getSessionHistory(input: { sessionId: string; cwd: string }): Promise; - deleteSession(input: { sessionId: string }): Promise; - getSessionPermissionMode(input: { sessionId: string }): Promise; - setSessionPermissionMode(input: { sessionId: string; mode: PermissionMode }): Promise; + deleteSession(input: { sessionId: string; cwd: string }): Promise; + getSessionPermissionMode(input: { sessionId: string; cwd: string }): Promise; + setSessionPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode }): Promise; getCheckpointDiff(input: { sessionId: string; @@ -70,15 +69,6 @@ export interface SessionClient { }): Promise<{ sessionId: string; turns: SessionEvent[] }>; } -function getWorkspaceCwd(rt: AppRuntime): Promise { - return rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.getWorkspaceCwd(); - }) - ); -} - export function createDirectSessionClient(rt: AppRuntime): SessionClient { return { async createSession({ cwd }) { @@ -110,8 +100,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { ); }, - async getSessionHistory({ sessionId }) { - const cwd = await getWorkspaceCwd(rt); + async getSessionHistory({ sessionId, cwd }) { return rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; @@ -121,12 +110,11 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { ); }, - async deleteSession({ sessionId }) { - deleteSession(sessionId); + async deleteSession({ sessionId, cwd }) { + deleteSession(sessionId, cwd); }, - async getSessionPermissionMode({ sessionId }): Promise { - const cwd = await getWorkspaceCwd(rt); + async getSessionPermissionMode({ sessionId, cwd }): Promise { const mode = await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; @@ -137,8 +125,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { return mode as PermissionMode; }, - async setSessionPermissionMode({ sessionId, mode }) { - const cwd = await getWorkspaceCwd(rt); + async setSessionPermissionMode({ sessionId, cwd, mode }) { return rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; @@ -222,8 +209,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: null }, }; }, - async forkSession({ sessionId, atTurnId }) { - const cwd = await getWorkspaceCwd(rt); + async forkSession({ sessionId, cwd, atTurnId }) { const newSessionId = await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; diff --git a/packages/codingcode/src/client/http/sessions.ts b/packages/codingcode/src/client/http/sessions.ts index 9f182c52..767b83eb 100644 --- a/packages/codingcode/src/client/http/sessions.ts +++ b/packages/codingcode/src/client/http/sessions.ts @@ -16,10 +16,10 @@ export interface SessionClient { }): Promise<{ sessionId: string }>; resumeSession(input: { sessionId: string; cwd: string }): Promise; listSessions(input: { cwd: string }): Promise; - getSessionHistory(input: { sessionId: string }): Promise; - deleteSession(input: { sessionId: string }): Promise; - getSessionPermissionMode(input: { sessionId: string }): Promise; - setSessionPermissionMode(input: { sessionId: string; mode: PermissionMode }): Promise; + getSessionHistory(input: { sessionId: string; cwd: string }): Promise; + deleteSession(input: { sessionId: string; cwd: string }): Promise; + getSessionPermissionMode(input: { sessionId: string; cwd: string }): Promise; + setSessionPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode }): Promise; getCheckpointDiff(input: { sessionId: string; @@ -84,23 +84,25 @@ export function createHttpSessionClient( return apiGet(`/api/sessions${qs}`); }, - async getSessionHistory({ sessionId }) { - return apiGet(`/api/sessions/${sessionId}/history`); + async getSessionHistory({ sessionId, cwd }) { + return apiGet( + `/api/sessions/${sessionId}/history?cwd=${encodeURIComponent(cwd)}` + ); }, - async deleteSession({ sessionId }) { - await apiDelete(`/api/sessions/${sessionId}`); + async deleteSession({ sessionId, cwd }) { + await apiDelete(`/api/sessions/${sessionId}?cwd=${encodeURIComponent(cwd)}`); }, - async getSessionPermissionMode({ sessionId }) { + async getSessionPermissionMode({ sessionId, cwd }) { const data = await apiGet<{ mode: PermissionMode }>( - `/api/sessions/${sessionId}/permission-mode` + `/api/sessions/${sessionId}/permission-mode?cwd=${encodeURIComponent(cwd)}` ); return data.mode; }, - async setSessionPermissionMode({ sessionId, mode }) { - await apiPut(`/api/sessions/${sessionId}/permission-mode`, { mode }); + async setSessionPermissionMode({ sessionId, cwd, mode }) { + await apiPut(`/api/sessions/${sessionId}/permission-mode`, { cwd, mode }); }, async getCheckpointDiff({ sessionId, cwd, turnId }) { diff --git a/packages/codingcode/src/context/service.ts b/packages/codingcode/src/context/service.ts index 4c608810..49651a0e 100644 --- a/packages/codingcode/src/context/service.ts +++ b/packages/codingcode/src/context/service.ts @@ -1,10 +1,12 @@ import { Effect } from 'effect'; import { randomUUID } from 'crypto'; +import { join } from 'path'; +import { readFileSync, existsSync } from 'fs'; import type { ContextConfig } from '@codingcode/infra/config'; import type { Message } from '../core/types.js'; import { SessionService } from '../session/store.js'; import { estimateTokens, estimateMessageTokens } from '../core/util.js'; -import { resolveSessionJsonlPath, appendLine, readHistory } from '../session/file-ops.js'; +import { projectSessionsDir, appendLine, readHistory } from '../session/file-ops.js'; import { resolveLLM } from '../llm/llm-resolver.js'; import { LLMFactoryService } from '../llm/factory.js'; import { COMPACTION_SYSTEM_PROMPT } from './compaction-prompt.js'; @@ -239,11 +241,17 @@ export class ContextService extends Effect.Service()('Context', config: ContextConfig, contextWindow: number = 128000 ): BuildResult => { - const jsonlPath = resolveSessionJsonlPath(sessionId); + const jsonlPath = join(projectSessionsDir(encodedProjectPath), `${sessionId}.jsonl`); let events = session.readHistoryFile(jsonlPath); - const idx = session.findSessionIndexProxy(sessionId); - const currentTurnId = idx?.currentTurnId ?? 0; + let currentTurnId = 0; + const idxPath = join(projectSessionsDir(encodedProjectPath), `${sessionId}.index.json`); + if (existsSync(idxPath)) { + try { + const idx = JSON.parse(readFileSync(idxPath, 'utf8')); + currentTurnId = idx?.currentTurnId ?? 0; + } catch {} + } let { visible, compactedTurnIds } = filterForContext(events); @@ -377,6 +385,7 @@ export class ContextService extends Effect.Service()('Context', ); released += await tryCompaction( sessionId, + encodedProjectPath, config, llm, compactedEvents, @@ -404,6 +413,7 @@ export class ContextService extends Effect.Service()('Context', async function tryCompaction( sessionId: string, + encodedProjectPath: string, config: ContextConfig, llm: LLMClient | null, compactedEvents: SessionEvent[], @@ -451,7 +461,7 @@ export class ContextService extends Effect.Service()('Context', endTurnId, summaryText: summary, }; - appendLine(resolveSessionJsonlPath(sessionId), event); + appendLine(join(projectSessionsDir(encodedProjectPath), `${sessionId}.jsonl`), event); const summaryMsg: Message = { role: 'system', name: 'compacted_history', content: summary }; return Math.max(0, totalTokens - estimateMessageTokens(summaryMsg)); diff --git a/packages/codingcode/src/memory/index.ts b/packages/codingcode/src/memory/index.ts index bd086a14..7fe88ab8 100644 --- a/packages/codingcode/src/memory/index.ts +++ b/packages/codingcode/src/memory/index.ts @@ -1,6 +1,6 @@ import { Effect } from 'effect'; import type { LLMClient } from '../llm/client.js'; -import { findSessionIndex, resolveSessionDir } from '../session/file-ops.js'; +import { sessionJsonlPathFromCwd } from '../session/file-ops.js'; import type { SessionEvent } from '../session/types.js'; import { readMemoryFile, @@ -107,19 +107,15 @@ export class MemoryService extends Effect.Service()('Memory', { async function flushSessionToMemory( sessionId: string, - llm: LLMClient | null + llm: LLMClient | null, + sessionCwd: string ): Promise<{ written: boolean; bytes: number }> { if (!getMemoryEnabled()) { return { written: false, bytes: 0 }; } const cfg = getMemoryConfig(); - const sessionIndex = findSessionIndex(sessionId); - if (!sessionIndex) { - return { written: false, bytes: 0 }; - } - - const cwd = sessionIndex.cwd; + const cwd = sessionCwd; const projectPath = resolveMemoryPath(cwd); const projectContent = readMemoryFile(projectPath); @@ -129,11 +125,8 @@ export class MemoryService extends Effect.Service()('Memory', { let events: SessionEvent[] = []; try { const { readFileSync } = await import('node:fs'); - const { join } = await import('node:path'); - const sessionDir = resolveSessionDir(sessionId); - if (!sessionDir) return { written: false, bytes: 0 }; - const jsonlPath = join(sessionDir, `${sessionId}.jsonl`); + const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); const content = readFileSync(jsonlPath, 'utf-8'); events = content diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index 485cffdc..12ef5b84 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -4,8 +4,8 @@ import { sendMessage } from '../../agent/agent.js'; import { WorkspaceService } from '../../core/workspace.js'; import { toSseEvents } from '../adapter.js'; import { ApprovalService } from '../../approval/index.js'; -import { resolveSessionDir, getPermissionMode } from '../../session/file-ops.js'; -import { join } from 'path'; +import { sessionJsonlPathFromCwd, getPermissionMode } from '../../session/file-ops.js'; +import { existsSync } from 'fs'; import type { PermissionMode } from '../../approval/types.js'; import { LLMFactoryService } from '../../llm/factory.js'; import { errorResponse } from '../util.js'; @@ -43,9 +43,8 @@ export function createMessagesRouter(rt: ManagedRt): Hono { // Read session permissionMode if session exists let approvalOverride: any = undefined; if (sessionId !== '_') { - const dir = resolveSessionDir(sessionId); - if (dir) { - const idxPath = join(dir, `${sessionId}.index.json`); + const idxPath = sessionJsonlPathFromCwd(normalizedCwd, sessionId).replace('.jsonl', '.index.json'); + if (existsSync(idxPath)) { const mode = getPermissionMode(idxPath) as PermissionMode; const forked: any = await rt.runPromise( Effect.gen(function* () { diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index cdd835e8..dbda981b 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -1,10 +1,10 @@ import { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; -import { join } from 'path'; +import { existsSync } from 'fs'; import type { SessionStoreState } from '../../session/types.js'; import { SessionService } from '../../session/store.js'; import { - resolveSessionDir, + sessionJsonlPathFromCwd, getPermissionMode, setPermissionMode, readHistory, @@ -142,19 +142,17 @@ function sessionEventsToTurns( return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); } -function readUIHistory(sessionId: string): Array<{ id: string; items: object[]; status: string }> { - const dir = resolveSessionDir(sessionId); - if (!dir) return []; - const jsonlPath = join(dir, `${sessionId}.jsonl`); +function readUIHistory(sessionId: string, cwd: string): Array<{ id: string; items: object[]; status: string }> { + const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); + if (!existsSync(jsonlPath)) return []; const events = readHistory(jsonlPath); const visibleEvents = filterForUI(events); return sessionEventsToTurns(visibleEvents); } -function findUserMessageForTurn(sessionId: string, turnId: number): string { - const dir = resolveSessionDir(sessionId); - if (!dir) return ''; - const jsonlPath = join(dir, `${sessionId}.jsonl`); +function findUserMessageForTurn(sessionId: string, turnId: number, cwd: string): string { + const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); + if (!existsSync(jsonlPath)) return ''; const rawEvents = readHistory(jsonlPath); for (const ev of rawEvents) { if (ev.type === 'user' && (ev as any).turnId === turnId) { @@ -220,11 +218,7 @@ export function createSessionsRouter(rt: ManagedRt): Hono { } const state = result.value as SessionStoreState; if (body.initialPermissionMode) { - const dir = resolveSessionDir(state.sessionId); - if (dir) { - const idxPath = join(dir, `${state.sessionId}.index.json`); - setPermissionMode(state.sessionId, idxPath, body.initialPermissionMode); - } + setPermissionMode(state.sessionId, state.indexPath, body.initialPermissionMode); } return c.json({ sessionId: state.sessionId }); }); @@ -301,31 +295,35 @@ export function createSessionsRouter(rt: ManagedRt): Hono { router.delete('/:id', async (c) => { const sessionId = c.req.param('id'); - deleteSession(sessionId); + const cwd = c.req.query('cwd'); + if (!cwd) return c.json({ error: 'cwd required' }, 400); + deleteSession(sessionId, cwd); return c.json({ ok: true }); }); router.get('/:id/history', async (c) => { const sessionId = c.req.param('id'); - const turns = readUIHistory(sessionId); + const cwd = c.req.query('cwd'); + if (!cwd) return c.json({ error: 'cwd required' }, 400); + const turns = readUIHistory(sessionId, cwd); return c.json(turns); }); router.get('/:id/permission-mode', async (c) => { const sessionId = c.req.param('id'); - const dir = resolveSessionDir(sessionId); - if (!dir) return c.json({ mode: 'default' }); - const idxPath = join(dir, `${sessionId}.index.json`); + const cwd = c.req.query('cwd'); + if (!cwd) return c.json({ mode: 'default' }); + const idxPath = sessionJsonlPathFromCwd(cwd, sessionId).replace('.jsonl', '.index.json'); + if (!existsSync(idxPath)) return c.json({ mode: 'default' }); const mode = getPermissionMode(idxPath); return c.json({ mode }); }); router.put('/:id/permission-mode', async (c) => { const sessionId = c.req.param('id'); - const { mode } = await c.req.json<{ mode: string }>(); - const dir = resolveSessionDir(sessionId); - if (!dir) return c.json({ error: 'Session not found' }, 404); - const idxPath = join(dir, `${sessionId}.index.json`); + const { cwd, mode } = await c.req.json<{ cwd: string; mode: string }>(); + if (!cwd) return c.json({ error: 'cwd required' }, 400); + const idxPath = sessionJsonlPathFromCwd(cwd, sessionId).replace('.jsonl', '.index.json'); setPermissionMode(sessionId, idxPath, mode); const handle = activeApprovalForks.get(sessionId); if (handle) handle.setPermissionMode(mode); @@ -530,9 +528,9 @@ export function createSessionsRouter(rt: ManagedRt): Hono { Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); - const rolledBackMessage = findUserMessageForTurn(sessionId, body.throughTurnId); + const rolledBackMessage = findUserMessageForTurn(sessionId, body.throughTurnId, cwd); yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); - const turns = readUIHistory(sessionId); + const turns = readUIHistory(sessionId, cwd); const promptEstimate = estimatePromptTokens(state.transcriptPath); return { ok: true, turns, rolledBackMessage, promptEstimate }; }) as any @@ -559,9 +557,9 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const checkpoint = yield* CheckpointService; const codeResult = yield* checkpoint.rollbackCodeToTurn(cwd, sessionId, body.throughTurnId); const state = yield* session.load(cwd, sessionId); - const rolledBackMessage = findUserMessageForTurn(sessionId, body.throughTurnId); + const rolledBackMessage = findUserMessageForTurn(sessionId, body.throughTurnId, cwd); yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); - const turns = readUIHistory(sessionId); + const turns = readUIHistory(sessionId, cwd); const promptEstimate = estimatePromptTokens(state.transcriptPath); return { ok: true, @@ -619,8 +617,8 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); const newSessionId = yield* session.forkSession(state, atTurnId); - const turns = readUIHistory(newSessionId); - const newJsonlPath = join(resolveSessionDir(newSessionId)!, `${newSessionId}.jsonl`); + const turns = readUIHistory(newSessionId, cwd); + const newJsonlPath = sessionJsonlPathFromCwd(cwd, newSessionId); const promptEstimate = estimatePromptTokens(newJsonlPath); return { sessionId: newSessionId, turns, promptEstimate }; }) as any diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index 636c9a2f..3b1a5b64 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -8,13 +8,11 @@ import { openSync, readSync, closeSync, - statSync, unlinkSync, rmSync, } from 'fs'; import { homedir } from 'os'; import { join, dirname } from 'path'; -import { AgentError } from '../core/error.js'; import { normalizePath, encodeProjectPath } from '../core/path.js'; import type { SessionEvent, SessionMetaEvent, SessionIndex } from './types.js'; @@ -25,39 +23,10 @@ export function projectSessionsDir(encodedProjectPath: string): string { return join(PROJECT_BASE, encodedProjectPath, 'sessions'); } -export function resolveSessionDir(sessionId: string): string | null { - if (!existsSync(PROJECT_BASE)) return null; - for (const encoded of readdirSync(PROJECT_BASE)) { - const sessionsDir = join(PROJECT_BASE, encoded, 'sessions'); - if (!existsSync(sessionsDir)) continue; - try { - if (!statSync(sessionsDir).isDirectory()) continue; - } catch { - continue; - } - if (existsSync(join(sessionsDir, `${sessionId}.jsonl`))) return sessionsDir; - try { - for (const entry of readdirSync(sessionsDir)) { - const entryPath = join(sessionsDir, entry); - try { - if (!statSync(entryPath).isDirectory()) continue; - } catch { - continue; - } - const subagentDir = join(entryPath, 'subagents'); - if (existsSync(join(subagentDir, `${sessionId}.jsonl`))) return subagentDir; - } - } catch { - /* race: directory removed between existsSync and readdirSync */ - } - } - return null; -} - -export function resolveSessionJsonlPath(sessionId: string): string { - const dir = resolveSessionDir(sessionId); - if (!dir) throw new Error(`Session ${sessionId} not found`); - return join(dir, `${sessionId}.jsonl`); +export function sessionJsonlPathFromCwd(cwd: string, sessionId: string): string { + const projectPath = encodeProjectPath(normalizePath(cwd)); + const sessionsDir = projectSessionsDir(projectPath); + return join(sessionsDir, `${sessionId}.jsonl`); } export function ensureDirs(transcriptPath: string): void { @@ -114,26 +83,6 @@ function buildIndexFromMeta(meta: SessionMetaEvent, history: SessionEvent[]): Se }; } -export function findSessionIndex(sessionId: string): SessionIndex | null { - const dir = resolveSessionDir(sessionId); - if (!dir) return null; - const idxPath = join(dir, `${sessionId}.index.json`); - if (existsSync(idxPath)) { - try { - const index = JSON.parse(readFileSync(idxPath, 'utf8')) as SessionIndex; - if (index.sessionId === sessionId) return index; - } catch { - /* corrupt */ - } - } - const jsonlPath = join(dir, `${sessionId}.jsonl`); - if (!existsSync(jsonlPath)) return null; - const meta = quickReadMeta(jsonlPath); - if (meta?.sessionId !== sessionId) return null; - const h = readHistory(jsonlPath); - return buildIndexFromMeta(meta, h); -} - export function listSessions(projectPath?: string): SessionIndex[] { const results: SessionIndex[] = []; const encodedDirs = projectPath @@ -199,10 +148,7 @@ export function setPermissionMode(sessionId: string, indexPath: string, mode: st /* corrupt */ } } - if (!index) { - index = findSessionIndex(sessionId); - if (!index) throw new Error(`Session ${sessionId} not found`); - } + if (!index) throw new Error(`Session index not found: ${indexPath}`); index.permissionMode = mode; index.updatedAt = new Date().toISOString(); writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf8'); @@ -218,8 +164,8 @@ export function getPermissionMode(indexPath: string): string { } } -export function deleteSession(sessionId: string): void { - const dir = resolveSessionDir(sessionId); +export function deleteSession(sessionId: string, cwd: string): void { + const dir = dirname(sessionJsonlPathFromCwd(cwd, sessionId)); if (!dir) return; const jsonlPath = join(dir, `${sessionId}.jsonl`); const idxPath = join(dir, `${sessionId}.index.json`); diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index 9bc355b4..ca55e974 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -21,7 +21,6 @@ import { ensureDirs, readHistory, appendLine, - findSessionIndex, listSessions, setPermissionMode, getPermissionMode, @@ -29,15 +28,12 @@ import { countNonMetaEvents, truncateTitle, findFirstUserContent, - resolveSessionJsonlPath as _resolveSessionJsonlPath, + sessionJsonlPathFromCwd, } from './file-ops.js'; function assertResumeWorkspace(cwd: string, sessionId: string): void { - const index = findSessionIndex(sessionId); - if (!index) throw AgentError.sessionNotFound(sessionId); - if (encodeProjectPath(cwd) !== index.projectPath) { - throw AgentError.sessionWorkspaceMismatch(sessionId, index.cwd); - } + const expectedPath = sessionJsonlPathFromCwd(cwd, sessionId); + if (!existsSync(expectedPath)) throw AgentError.sessionNotFound(sessionId); } export class SessionService extends Effect.Service()('Session', { @@ -291,9 +287,6 @@ export class SessionService extends Effect.Service()('Session', const listSessionsFromCwd = (cwd?: string): Effect.Effect => Effect.sync(() => listSessions(cwd ? encodeProjectPath(cwd) : undefined)); - const findSessionIndexFromId = (sessionId: string): Effect.Effect => - Effect.sync(() => findSessionIndex(sessionId)); - const getSessionId = (state: SessionStoreState): string => state.sessionId; const getMessageCount = (state: SessionStoreState): number => state.messageCount; @@ -327,16 +320,12 @@ export class SessionService extends Effect.Service()('Session', renameSession, readHistory: readHistoryFromState, listSessions: listSessionsFromCwd, - findSessionIndex: findSessionIndexFromId, getSessionId, getMessageCount, setPermissionMode: setPermissionModeFromState, getPermissionMode: getPermissionModeFromState, incrementTurn, - resolveSessionJsonlPath: (sessionId: string): string => _resolveSessionJsonlPath(sessionId), readHistoryFile: (path: string): SessionEvent[] => readHistory(path), - findSessionIndexProxy: (sessionId: string): SessionIndex | null => - findSessionIndex(sessionId), appendLineProxy: (path: string, event: object): void => appendLine(path, event), }; }), diff --git a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts index d15014fb..acddd564 100644 --- a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts +++ b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts @@ -26,16 +26,8 @@ const { mockLLM } = vi.hoisted(() => ({ vi.mock('../../../src/session/file-ops.js', async (importOriginal) => { const actual = await importOriginal(); - const mockResolveSessionDir = vi.fn((_sessionId: string) => '/tmp/sessions'); return { ...(actual as any), - findSessionIndex: vi.fn(() => ({ currentTurnId: 10 })), - resolveSessionDir: mockResolveSessionDir, - resolveSessionJsonlPath: vi.fn((sessionId: string) => { - const dir = mockResolveSessionDir(sessionId); - if (!dir) throw new Error(`Session ${sessionId} not found`); - return `${dir}/${sessionId}.jsonl`; - }), readHistory: vi.fn(() => [ { type: 'user', content: 'a'.repeat(200), turnId: 1 }, { type: 'assistant', content: 'b'.repeat(200), turnId: 1 }, @@ -56,6 +48,14 @@ vi.mock('fs', async (importOriginal) => { return { ...(actual as any), appendFileSync: vi.fn(), + existsSync: vi.fn((p: string) => { + if (p.endsWith('.index.json')) return true; + return (actual as any).existsSync(p); + }), + readFileSync: vi.fn((p: string, encoding: BufferEncoding) => { + if (p.endsWith('.index.json')) return JSON.stringify({ currentTurnId: p.includes('ttl-session') ? 0 : 10 }); + return (actual as any).readFileSync(p, encoding); + }), }; }); @@ -65,7 +65,6 @@ vi.mock('../../../src/core/util.js', () => ({ estimateTokensForContent: vi.fn(), })); -import { findSessionIndex } from '../../../src/session/file-ops.js'; import { estimateTokens, estimateMessageTokens } from '../../../src/core/util.js'; const TestLayer = Layer.merge( @@ -96,7 +95,6 @@ function config(threshold: number, maxTokens = 10000) { describe('compactIfNeeded', () => { beforeEach(() => { - (findSessionIndex as any).mockReturnValue({ currentTurnId: 10 }); (estimateTokens as any).mockReturnValue(0); (estimateMessageTokens as any).mockReturnValue(50); }); @@ -153,8 +151,6 @@ describe('compactIfNeeded', () => { }); it('resets failure count after TTL expires', async () => { - (findSessionIndex as any).mockReturnValue({ currentTurnId: 0 }); - (estimateTokens as any).mockReturnValue(10000); const ctx = await getCtxService(); await ctx.compactIfNeeded('ttl-session', 'proj', [], 10000, config(0.5), null); diff --git a/packages/codingcode/test/session/load-create.test.ts b/packages/codingcode/test/session/load-create.test.ts index d8460446..fbbf2977 100644 --- a/packages/codingcode/test/session/load-create.test.ts +++ b/packages/codingcode/test/session/load-create.test.ts @@ -138,7 +138,7 @@ describe('load — restores model from disk, not overwritten', () => { expect(exit._tag).toBe('Failure'); if (exit._tag === 'Failure') { const msg = String(exit.cause); - expect(msg).toContain('SESSION_WORKSPACE_MISMATCH'); + expect(msg).toContain('SESSION_NOT_FOUND'); } } finally { cleanup(dir); diff --git a/packages/codingcode/test/session/session-jsonl-path.test.ts b/packages/codingcode/test/session/session-jsonl-path.test.ts new file mode 100644 index 00000000..d8585e70 --- /dev/null +++ b/packages/codingcode/test/session/session-jsonl-path.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { rmSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { sessionJsonlPathFromCwd, deleteSession } from '../../src/session/file-ops.js'; + +const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); +} + +describe('sessionJsonlPathFromCwd', () => { + it('returns path matching SessionService.create transcriptPath', async () => { + const cwd = '/tmp/test-jsonl-path'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(cwd, 'test-model'); + }) + ); + + try { + const result = sessionJsonlPathFromCwd(cwd, state.sessionId); + expect(result).toBe(state.transcriptPath); + expect(existsSync(result)).toBe(true); + } finally { + rmSync(join(PROJECT_BASE, state.projectPath), { recursive: true, force: true }); + } + }); + + it('deleteSession with cwd deletes correct files', async () => { + const cwd = '/tmp/test-jsonl-delete'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(cwd, 'test-model'); + }) + ); + + try { + expect(existsSync(state.transcriptPath)).toBe(true); + expect(existsSync(state.indexPath)).toBe(true); + + deleteSession(state.sessionId, cwd); + + expect(existsSync(state.transcriptPath)).toBe(false); + expect(existsSync(state.indexPath)).toBe(false); + } finally { + rmSync(join(PROJECT_BASE, state.projectPath), { recursive: true, force: true }); + } + }); +}); diff --git a/packages/codingcode/test/session/usage-persist.test.ts b/packages/codingcode/test/session/usage-persist.test.ts deleted file mode 100644 index 3c7caeff..00000000 --- a/packages/codingcode/test/session/usage-persist.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; -import { randomUUID } from 'crypto'; -import { findSessionIndex } from '../../src/session/file-ops.js'; -import type { SessionIndex } from '../../src/session/types.js'; - -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); - -function makeFixture( - sessionId: string, - slug: string, - usage?: { prompt: number; completion: number; total: number } -) { - const dir = join(PROJECT_BASE, slug, 'sessions'); - mkdirSync(dir, { recursive: true }); - const transcriptPath = join(dir, `${sessionId}.jsonl`); - const indexPath = join(dir, `${sessionId}.index.json`); - - const meta = { - type: 'session_meta', - sessionId, - projectPath: slug, - cwd: '/tmp/test', - - createdAt: new Date().toISOString(), - }; - writeFileSync(transcriptPath, JSON.stringify(meta) + '\n', 'utf8'); - - const idx: SessionIndex = { - sessionId, - projectPath: slug, - cwd: '/tmp/test', - model: 'test-model', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 0, - title: 'test', - currentTurnId: 0, - usage: usage as any, - permissionMode: 'default', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { dir, indexPath }; -} - -describe('session usage persist', () => { - it('findSessionIndex reads usage from index.json', () => { - const sessionId = randomUUID(); - const slug = randomUUID(); - const usage = { prompt: 1000, completion: 500, total: 1500 }; - const fx = makeFixture(sessionId, slug, usage); - try { - const idx = findSessionIndex(sessionId); - expect(idx).not.toBeNull(); - expect(idx!.usage).toEqual(usage); - } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); - } - }); - - it('findSessionIndex returns undefined usage when not present', () => { - const sessionId = randomUUID(); - const slug = randomUUID(); - const fx = makeFixture(sessionId, slug); - try { - const idx = findSessionIndex(sessionId); - expect(idx).not.toBeNull(); - expect(idx!.usage).toBeUndefined(); - } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); - } - }); -}); diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index 43a3d3ba..20b87d90 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -92,7 +92,6 @@ const mockSession = { readHistory: () => Effect.succeed([]), readMessages: () => Effect.succeed([]), listSessions: () => Effect.succeed([]), - findSessionIndex: () => Effect.succeed(null), getSessionId: () => 'test-session', getMessageCount: () => 0, setPermissionMode: () => Effect.void, diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index b215277a..a9cbd643 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -108,7 +108,7 @@ export function useAgentCore() { if (!currentThreadId) return; const thread = useAgentStore.getState().threads[currentThreadId]; if (!thread || thread.turns.length > 0) return; - getSessionHistory(currentThreadId) + getSessionHistory(currentThreadId, thread.cwd) .then((turns) => { if (turns && turns.length > 0) { setThreadTurns(currentThreadId, turns as any); @@ -518,12 +518,12 @@ export function useAgentRollback() { const deleteThread = useCallback( async (threadId: string) => { + const currentCwd = useWorkspaceStore.getState().rootPath; try { - await deleteSession(threadId); + await deleteSession(threadId, currentCwd); } catch (e) { console.error('Failed to delete session:', e); } - const currentCwd = useWorkspaceStore.getState().rootPath; if (currentCwd) { const sessions = await listSessions(currentCwd).catch(() => []); if (sessions) { diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index 6959166c..20426ff0 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -31,14 +31,15 @@ export function createSession( return clients.sessions.createSession({ cwd, initialPermissionMode }); } -export function deleteSession(sessionId: string): Promise { - return clients.sessions.deleteSession({ sessionId }); +export function deleteSession(sessionId: string, cwd: string): Promise { + return clients.sessions.deleteSession({ sessionId, cwd }); } export function getSessionHistory( - sessionId: string + sessionId: string, + cwd: string ): Promise> { - return clients.sessions.getSessionHistory({ sessionId }) as unknown as Promise< + return clients.sessions.getSessionHistory({ sessionId, cwd }) as unknown as Promise< Array<{ id: string; items: any[]; status: string }> >; } @@ -47,8 +48,8 @@ export function resumeSession(sessionId: string, cwd: string): Promise { return clients.sessions.resumeSession({ sessionId, cwd }); } -export function setSessionPermissionMode(sessionId: string, mode: string): Promise { - return clients.sessions.setSessionPermissionMode({ sessionId, mode: mode as any }); +export function setSessionPermissionMode(sessionId: string, cwd: string, mode: string): Promise { + return clients.sessions.setSessionPermissionMode({ sessionId, cwd, mode: mode as any }); } export function sendApprovalResponse( From c2aa45897aaac54de7bc7af09880ad77ac0d38c7 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 18 Jun 2026 23:48:38 +0800 Subject: [PATCH 5/7] Clean up obsolete configuration-related code and optimize context handling --- packages/codingcode/package.json | 1 - packages/codingcode/src/agent/agent.ts | 11 +- .../src/client/direct/agent-runtime.ts | 4 +- .../codingcode/src/client/direct/sessions.ts | 6 +- .../codingcode/src/client/http/sessions.ts | 6 +- packages/codingcode/src/context/config.ts | 6 - packages/codingcode/src/context/service.ts | 151 +++++------------- packages/codingcode/src/core/types.ts | 1 + .../codingcode/src/llm/providers/deepseek.ts | 2 +- .../codingcode/src/llm/providers/openai.ts | 2 +- .../codingcode/src/server/routes/messages.ts | 5 +- .../codingcode/src/server/routes/sessions.ts | 20 +-- packages/codingcode/src/session/file-ops.ts | 17 +- packages/codingcode/src/session/store.ts | 24 +-- .../test/context/append-turn-end.test.ts | 1 - .../test/context/budget-integration.test.ts | 12 +- .../test/context/compressor/behavior.test.ts | 24 +-- .../compressor/compact-if-needed.test.ts | 34 +--- .../test/server/compact-route.test.ts | 8 +- .../test/session/compute-paths.test.ts | 101 ++++++++++++ packages/codingcode/test/session/fork.test.ts | 8 +- .../test/session/prompt-estimate.test.ts | 75 +-------- .../record-tool-result-persist.test.ts | 6 - packages/desktop/src/lib/core-api.ts | 6 +- 24 files changed, 213 insertions(+), 318 deletions(-) delete mode 100644 packages/codingcode/src/context/config.ts create mode 100644 packages/codingcode/test/session/compute-paths.test.ts diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index 3b69a85f..b1811d69 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -20,7 +20,6 @@ "./core/result": "./src/core/result.ts", "./core/types": "./src/core/types.ts", "./context/context": "./src/context/context.ts", - "./context/config": "./src/context/config.ts", "./hooks/registry": "./src/hooks/registry.ts", "./tools/executor": "./src/tools/executor.ts", "./tools/tool-search-service": "./src/tools/tool-search-service.ts", diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 1e43ddca..c42bdf4f 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -13,7 +13,6 @@ import { ApprovalWaitService } from '../approval/async-confirm.js'; import { buildSystemPrompt } from './prompt.js'; import type { AgentEvent, RunStreamOptions } from './types.js'; import { resolveConfig } from './config.js'; -import { getContextConfig } from '../context/config.js'; import { TodoService } from './todo.js'; import { HookService } from '../hooks/registry.js'; import { SkillService } from '../skills/service.js'; @@ -254,7 +253,6 @@ export function agentLoop( const memorySection = memoryBlock ? `## Session Memory\n\n${memoryBlock}` : ''; const system = [basePrompt, memorySection].filter(Boolean).join('\n\n'); - const config = getContextConfig(); const maxOverflowRetries = REACTIVE_COMPACT_MAX_RETRIES; const effectiveMaxSteps = opts.maxStepsOverride ?? maxSteps; @@ -265,7 +263,7 @@ export function agentLoop( for (let attempt = 0; attempt <= maxOverflowRetries; attempt++) { const payload = yield* Effect.sync(() => - context.assemblePayload(state.sessionId, state.projectPath, config, llm.modelInfo.maxTokens) + context.assemblePayload(state.sessionId, state.projectPath, llm.modelInfo.maxTokens) ); messages = payload.messages; @@ -309,7 +307,6 @@ export function agentLoop( state.projectPath, messages, llm.modelInfo.maxTokens, - config, llm ), catch: (e) => new AgentError('LLM_FAILED', String(e)), @@ -359,11 +356,9 @@ export function agentLoop( context.compactWithLLM( state.sessionId, state.projectPath, - messages, - config, + llm.modelInfo.maxTokens, llm, - undefined, - llm.modelInfo.maxTokens + undefined ), catch: (e) => new AgentError('LLM_FAILED', String(e)), }); diff --git a/packages/codingcode/src/client/direct/agent-runtime.ts b/packages/codingcode/src/client/direct/agent-runtime.ts index 767d65cb..fa31080f 100644 --- a/packages/codingcode/src/client/direct/agent-runtime.ts +++ b/packages/codingcode/src/client/direct/agent-runtime.ts @@ -3,7 +3,6 @@ import { sendMessage } from '../../agent/agent.js'; import { ApprovalWaitService } from '../../approval/async-confirm.js'; import { parseApprovalResponse } from '../../approval/response.js'; import { ContextService } from '../../context/service.js'; -import { getContextConfig } from '../../context/config.js'; import type { StreamChunk } from '../types.js'; import { agentEventToStreamChunk } from '../direct.js'; import type { AppRuntime } from '../../layer.js'; @@ -109,9 +108,8 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu await rt.runPromise( Effect.gen(function* () { const context = yield* ContextService; - const { messages } = context.assemblePayload(sessionId, cwd, getContextConfig()); return yield* Effect.promise(() => - context.compactWithLLM(sessionId, cwd, messages, getContextConfig(), null) + context.compactWithLLM(sessionId, cwd, llm.modelInfo.maxTokens, null) ); }) ); diff --git a/packages/codingcode/src/client/direct/sessions.ts b/packages/codingcode/src/client/direct/sessions.ts index c5c6f6d9..e3f4532d 100644 --- a/packages/codingcode/src/client/direct/sessions.ts +++ b/packages/codingcode/src/client/direct/sessions.ts @@ -23,7 +23,11 @@ export interface SessionClient { deleteSession(input: { sessionId: string; cwd: string }): Promise; getSessionPermissionMode(input: { sessionId: string; cwd: string }): Promise; - setSessionPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode }): Promise; + setSessionPermissionMode(input: { + sessionId: string; + cwd: string; + mode: PermissionMode; + }): Promise; getCheckpointDiff(input: { sessionId: string; diff --git a/packages/codingcode/src/client/http/sessions.ts b/packages/codingcode/src/client/http/sessions.ts index 767b83eb..f1edd155 100644 --- a/packages/codingcode/src/client/http/sessions.ts +++ b/packages/codingcode/src/client/http/sessions.ts @@ -19,7 +19,11 @@ export interface SessionClient { getSessionHistory(input: { sessionId: string; cwd: string }): Promise; deleteSession(input: { sessionId: string; cwd: string }): Promise; getSessionPermissionMode(input: { sessionId: string; cwd: string }): Promise; - setSessionPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode }): Promise; + setSessionPermissionMode(input: { + sessionId: string; + cwd: string; + mode: PermissionMode; + }): Promise; getCheckpointDiff(input: { sessionId: string; diff --git a/packages/codingcode/src/context/config.ts b/packages/codingcode/src/context/config.ts deleted file mode 100644 index 3bd5c474..00000000 --- a/packages/codingcode/src/context/config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { loadConfig } from '@codingcode/infra/config'; -import type { ContextConfig } from '@codingcode/infra/config'; - -export function getContextConfig(): ContextConfig { - return loadConfig().context; -} diff --git a/packages/codingcode/src/context/service.ts b/packages/codingcode/src/context/service.ts index 49651a0e..98439b5e 100644 --- a/packages/codingcode/src/context/service.ts +++ b/packages/codingcode/src/context/service.ts @@ -2,7 +2,7 @@ import { Effect } from 'effect'; import { randomUUID } from 'crypto'; import { join } from 'path'; import { readFileSync, existsSync } from 'fs'; -import type { ContextConfig } from '@codingcode/infra/config'; +import { loadConfig } from '@codingcode/infra/config'; import type { Message } from '../core/types.js'; import { SessionService } from '../session/store.js'; import { estimateTokens, estimateMessageTokens } from '../core/util.js'; @@ -16,7 +16,6 @@ import type { ToolResultEvent, CompactEvent, SummaryEvent, - TokenUsage, } from '../session/types.js'; import type { LLMClient } from '../llm/client.js'; import type { BuildResult, CompressResult } from './types.js'; @@ -34,9 +33,8 @@ const COMPACTABLE_TOOLS = new Set([ const MICRO_COMPACT_THRESHOLD = 0.25; const MICRO_COMPACT_MIN_CHARS = 120; -const COMPACTION_THRESHOLD = 0.9; +const COMPACTION_THRESHOLD = 0.85; const KEEP_RECENT_TURNS = 1; -const REACTIVE_COMPACT_MAX_RETRIES = 3; // --- Internal: visibility computation for LLM context --- @@ -49,43 +47,28 @@ function applyVisibilityEvents(events: SessionEvent[]): { const hiddenOpUuids = new Set(); const compactedTurnIds = new Set(); - for (const ev of events) { - if (ev.type !== 'rollback') continue; - for (const prior of events) { - if (prior === ev) break; - if (prior.type === 'summary' || prior.type === 'compact') { - if (prior.endTurnId >= ev.throughTurnId) { - hiddenOpUuids.add(prior.uuid); - } + let minRollbackThrough = Infinity; + for (let i = events.length - 1; i >= 0; i--) { + const ev = events[i]!; + if (ev.type === 'rollback') { + if (ev.throughTurnId < minRollbackThrough) { + minRollbackThrough = ev.throughTurnId; } + continue; } - } - - for (const ev of events) { - switch (ev.type) { - case 'rollback': { - for (const prior of events) { - if (prior === ev) break; - if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { - hiddenTurnIds.add(prior.turnId); - } - } - break; - } - case 'summary': { - if (hiddenOpUuids.has(ev.uuid)) break; - for (let t = ev.startTurnId; t <= ev.endTurnId; t++) { - hiddenTurnIds.add(t); - } - break; - } - case 'compact': { - if (hiddenOpUuids.has(ev.uuid)) break; - for (let t = ev.startTurnId; t <= ev.endTurnId; t++) { - compactedTurnIds.add(t); - } - break; + if (ev.type === 'summary' || ev.type === 'compact') { + const op = ev as SummaryEvent | CompactEvent; + if (minRollbackThrough <= op.endTurnId) { + hiddenOpUuids.add(op.uuid); + } else if (ev.type === 'summary') { + for (let t = op.startTurnId; t <= op.endTurnId; t++) hiddenTurnIds.add(t); + } else { + for (let t = op.startTurnId; t <= op.endTurnId; t++) compactedTurnIds.add(t); } + continue; + } + if ('turnId' in ev && minRollbackThrough <= (ev as any).turnId) { + hiddenTurnIds.add((ev as any).turnId); } } @@ -125,13 +108,13 @@ export function buildContextMessages( const ev = event as AssistantEvent; const msg: Message = { role: 'assistant', content: event.content }; if (event.toolCalls && event.toolCalls.length > 0) { - (msg as any).tool_calls = event.toolCalls.map((tc: any) => ({ + msg.tool_calls = event.toolCalls.map((tc) => ({ id: tc.id, name: tc.name, arguments: tc.arguments, })); } - if (ev.usage) (msg as any).usage = ev.usage; + if (ev.usage) msg.usage = ev.usage; messages.push(msg); break; } @@ -150,7 +133,7 @@ export function buildContextMessages( content: output, tool_call_id: event.toolCallId, tool_name: event.toolName, - } as any); + }); break; } case 'summary': @@ -163,7 +146,7 @@ export function buildContextMessages( const validAssistantIds = new Set(); for (const m of messages) { if (m.role !== 'assistant') continue; - const tcs = (m as any).tool_calls as Array<{ id: string }> | undefined; + const tcs = m.tool_calls; if (!tcs || tcs.length === 0) continue; if (tcs.every((tc) => resolvedIds.has(tc.id))) { for (const tc of tcs) validAssistantIds.add(tc.id); @@ -172,12 +155,12 @@ export function buildContextMessages( const filtered = messages.filter((m) => { if (m.role === 'assistant') { - const tcs = (m as any).tool_calls as Array<{ id: string }> | undefined; + const tcs = m.tool_calls; if (!tcs || tcs.length === 0) return true; return tcs.every((tc) => resolvedIds.has(tc.id)); } if (m.role === 'tool') { - return validAssistantIds.has((m as any).tool_call_id); + return validAssistantIds.has(m.tool_call_id!); } return true; }); @@ -188,7 +171,7 @@ export function buildContextMessages( const prev = filtered[i - 1]!; if (curr.role === prev.role && curr.role !== 'system') { if (curr.role === 'tool') continue; - if (curr.role === 'assistant' && (curr as any).tool_calls?.length > 0) continue; + if (curr.role === 'assistant' && curr.tool_calls && curr.tool_calls.length > 0) continue; prev.content += '\n\n' + curr.content; filtered.splice(i, 1); } @@ -197,20 +180,6 @@ export function buildContextMessages( return filtered; } -/** Find the last visible assistant usage for token estimation */ -export function findLastVisibleAssistantUsage(path: string): TokenUsage | undefined { - const events = readHistory(path); - const { visible, compactedTurnIds } = filterForContext(events); - const messages = buildContextMessages(visible, compactedTurnIds); - for (let i = messages.length - 1; i >= 0; i--) { - const m = messages[i]!; - if (m.role !== 'assistant') continue; - const usage = (m as any).usage as TokenUsage | undefined; - if (usage) return usage; - } - return undefined; -} - /** Estimate prompt tokens for a session's jsonl file */ export function estimatePromptTokens(jsonlPath: string): number { const events = readHistory(jsonlPath); @@ -222,24 +191,10 @@ export class ContextService extends Effect.Service()('Context', effect: Effect.gen(function* () { const session = yield* SessionService; const factory = yield* LLMFactoryService; - const compactFailureTracker = new Map(); - const FAILURE_TTL_MS = 24 * 60 * 60 * 1000; - - function getFailures(sessionId: string): number { - const entry = compactFailureTracker.get(sessionId); - if (!entry) return 0; - if (Date.now() - entry.lastAttempt > FAILURE_TTL_MS) { - compactFailureTracker.delete(sessionId); - return 0; - } - return entry.count; - } - const assemblePayload = ( sessionId: string, encodedProjectPath: string, - config: ContextConfig, - contextWindow: number = 128000 + contextWindow: number ): BuildResult => { const jsonlPath = join(projectSessionsDir(encodedProjectPath), `${sessionId}.jsonl`); let events = session.readHistoryFile(jsonlPath); @@ -260,7 +215,6 @@ export class ContextService extends Effect.Service()('Context', const didCompact = applyOldTurnCompaction( visible, currentTurnId, - config, preEstimate, contextWindow, jsonlPath @@ -284,7 +238,6 @@ export class ContextService extends Effect.Service()('Context', function applyOldTurnCompaction( events: SessionEvent[], currentTurnId: number, - config: ContextConfig, promptEstimate: number, contextWindow: number, jsonlPath: string @@ -331,15 +284,9 @@ export class ContextService extends Effect.Service()('Context', encodedProjectPath: string, messages: Message[], modelMaxTokens: number, - config: ContextConfig, llm: LLMClient | null ): Promise => { const promptEstimate = estimateTokens(messages); - const failures = getFailures(sessionId); - if (failures >= 3) { - return { didCompress: false, released: 0, promptEstimate }; - } - const threshold = modelMaxTokens * COMPACTION_THRESHOLD; if (promptEstimate <= threshold) { return { didCompress: false, released: 0, promptEstimate }; @@ -348,45 +295,35 @@ export class ContextService extends Effect.Service()('Context', const result = await compactWithLLM( sessionId, encodedProjectPath, - messages, - config, + modelMaxTokens, llm, - promptEstimate, - modelMaxTokens + promptEstimate ); - if (result.didCompress) { - compactFailureTracker.set(sessionId, { count: 0, lastAttempt: Date.now() }); - } else { - compactFailureTracker.set(sessionId, { count: failures + 1, lastAttempt: Date.now() }); - } - return result; }; const compactWithLLM = async ( sessionId: string, encodedProjectPath: string, - messages: Message[], - config: ContextConfig, + modelMaxTokens: number, llm: LLMClient | null, - usage?: number, - modelMaxTokens?: number + usage?: number ): Promise => { let released = 0; + let preEstimate = usage; - const threshold = modelMaxTokens ? modelMaxTokens * COMPACTION_THRESHOLD : Infinity; + const threshold = modelMaxTokens * COMPACTION_THRESHOLD; if (usage === undefined || usage - released > threshold) { - const { compactedEvents, currentTurnId, compactedTurnIds } = assemblePayload( + const { compactedEvents, currentTurnId, compactedTurnIds, promptEstimate } = assemblePayload( sessionId, encodedProjectPath, - config, modelMaxTokens ); + preEstimate = promptEstimate; released += await tryCompaction( sessionId, encodedProjectPath, - config, llm, compactedEvents, currentTurnId, @@ -398,11 +335,11 @@ export class ContextService extends Effect.Service()('Context', return { didCompress: false, released: 0, - promptEstimate: usage ?? estimateTokens(messages), + promptEstimate: preEstimate ?? 0, }; } - const postPayload = assemblePayload(sessionId, encodedProjectPath, config, modelMaxTokens); + const postPayload = assemblePayload(sessionId, encodedProjectPath, modelMaxTokens); return { didCompress: true, released, @@ -414,7 +351,6 @@ export class ContextService extends Effect.Service()('Context', async function tryCompaction( sessionId: string, encodedProjectPath: string, - config: ContextConfig, llm: LLMClient | null, compactedEvents: SessionEvent[], currentTurnId: number, @@ -437,7 +373,7 @@ export class ContextService extends Effect.Service()('Context', const totalTokens = estimateTokens(msgs); let compactionLlm = await Effect.runPromise( - resolveLLM(config.compactionModel, llm).pipe( + resolveLLM(loadConfig().context.compactionModel, llm).pipe( Effect.provideService(LLMFactoryService, factory) ) ); @@ -445,7 +381,7 @@ export class ContextService extends Effect.Service()('Context', compactionLlm = llm; } - const summary = await callLLMForCompaction(msgs, compactionLlm, config); + const summary = await callLLMForCompaction(msgs, compactionLlm); if (!summary) return 0; const turnIds = targetEvents @@ -480,11 +416,10 @@ export class ContextService extends Effect.Service()('Context', async function callLLMForCompaction( transcript: Message[], - fallbackLlm: LLMClient | null, - config: ContextConfig + fallbackLlm: LLMClient | null ): Promise { const llm = await Effect.runPromise( - resolveLLM(config.compactionModel, fallbackLlm).pipe( + resolveLLM(loadConfig().context.compactionModel, fallbackLlm).pipe( Effect.provideService(LLMFactoryService, factory) ) ); diff --git a/packages/codingcode/src/core/types.ts b/packages/codingcode/src/core/types.ts index 6d79c06b..58abba64 100644 --- a/packages/codingcode/src/core/types.ts +++ b/packages/codingcode/src/core/types.ts @@ -13,6 +13,7 @@ export interface Message { tool_call_id?: string; tool_name?: string; name?: string; + usage?: { prompt: number; completion: number; total: number }; } export interface ToolCall { diff --git a/packages/codingcode/src/llm/providers/deepseek.ts b/packages/codingcode/src/llm/providers/deepseek.ts index bfb39ebd..97ec05c7 100644 --- a/packages/codingcode/src/llm/providers/deepseek.ts +++ b/packages/codingcode/src/llm/providers/deepseek.ts @@ -18,7 +18,7 @@ export class DeepSeekProvider implements LLMClient { return { provider: this.entry.provider, model: this.entry.model, - maxTokens: 64_000, + maxTokens: this.entry.context_window, supportsToolCalling: true, supportsStreaming: true, }; diff --git a/packages/codingcode/src/llm/providers/openai.ts b/packages/codingcode/src/llm/providers/openai.ts index 3e470e54..b3479a1c 100644 --- a/packages/codingcode/src/llm/providers/openai.ts +++ b/packages/codingcode/src/llm/providers/openai.ts @@ -18,7 +18,7 @@ export class OpenAIProvider implements LLMClient { return { provider: this.entry.provider, model: this.entry.model, - maxTokens: 128_000, + maxTokens: this.entry.context_window, supportsToolCalling: true, supportsStreaming: true, }; diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index 12ef5b84..9b4a7875 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -43,7 +43,10 @@ export function createMessagesRouter(rt: ManagedRt): Hono { // Read session permissionMode if session exists let approvalOverride: any = undefined; if (sessionId !== '_') { - const idxPath = sessionJsonlPathFromCwd(normalizedCwd, sessionId).replace('.jsonl', '.index.json'); + const idxPath = sessionJsonlPathFromCwd(normalizedCwd, sessionId).replace( + '.jsonl', + '.index.json' + ); if (existsSync(idxPath)) { const mode = getPermissionMode(idxPath) as PermissionMode; const forked: any = await rt.runPromise( diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index dbda981b..0b8074d4 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -12,7 +12,6 @@ import { } from '../../session/file-ops.js'; import type { SessionEvent, SummaryEvent, CompactEvent } from '../../session/types.js'; import { ContextService, estimatePromptTokens } from '../../context/service.js'; -import { getContextConfig } from '../../context/config.js'; import { CheckpointService } from '../../checkpoint/checkpoint-service.js'; import { WorkspaceService } from '../../core/workspace.js'; import { LLMFactoryService } from '../../llm/factory.js'; @@ -142,7 +141,10 @@ function sessionEventsToTurns( return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); } -function readUIHistory(sessionId: string, cwd: string): Array<{ id: string; items: object[]; status: string }> { +function readUIHistory( + sessionId: string, + cwd: string +): Array<{ id: string; items: object[]; status: string }> { const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); if (!existsSync(jsonlPath)) return []; const events = readHistory(jsonlPath); @@ -269,20 +271,10 @@ export function createSessionsRouter(rt: ManagedRt): Hono { if (client._tag === 'Right') llm = client.right; } - const { messages } = context.assemblePayload( - state.sessionId, - state.projectPath, - getContextConfig() - ); + const maxTokens = llm?.modelInfo.maxTokens ?? 128000; return yield* Effect.promise(() => - context.compactWithLLM( - state.sessionId, - state.projectPath, - messages, - getContextConfig(), - llm - ) + context.compactWithLLM(state.sessionId, state.projectPath, maxTokens, llm) ); }) ); diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index 3b1a5b64..24811e9b 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -14,7 +14,7 @@ import { import { homedir } from 'os'; import { join, dirname } from 'path'; import { normalizePath, encodeProjectPath } from '../core/path.js'; -import type { SessionEvent, SessionMetaEvent, SessionIndex } from './types.js'; +import type { SessionEvent, SessionMetaEvent, SessionIndex, SessionStoreState } from './types.js'; const CODINGCODE_DIR = join(homedir(), '.codingcode'); const PROJECT_BASE = join(CODINGCODE_DIR, 'project'); @@ -29,6 +29,21 @@ export function sessionJsonlPathFromCwd(cwd: string, sessionId: string): string return join(sessionsDir, `${sessionId}.jsonl`); } +export function computePaths( + cwd: string, + sessionId: string, + parentSessionId?: string +): Pick { + const normalizedCwd = normalizePath(cwd); + const projectPath = encodeProjectPath(normalizedCwd); + const sessionsDir = projectSessionsDir(projectPath); + const transcriptPath = parentSessionId + ? join(sessionsDir, parentSessionId, 'subagents', `${sessionId}.jsonl`) + : join(sessionsDir, `${sessionId}.jsonl`); + const indexPath = transcriptPath.replace('.jsonl', '.index.json'); + return { sessionId, cwd: normalizedCwd, projectPath, transcriptPath, indexPath }; +} + export function ensureDirs(transcriptPath: string): void { if (!existsSync(CODINGCODE_DIR)) mkdirSync(CODINGCODE_DIR, { recursive: true }); const dir = dirname(transcriptPath); diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index ca55e974..68852f4e 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'crypto'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join, dirname } from 'path'; import { AgentError } from '../core/error.js'; -import { normalizePath, encodeProjectPath } from '../core/path.js'; +import { encodeProjectPath } from '../core/path.js'; import type { SessionMetaEvent, UserEvent, @@ -17,7 +17,6 @@ import type { SessionStoreState, } from './types.js'; import { - projectSessionsDir, ensureDirs, readHistory, appendLine, @@ -29,6 +28,7 @@ import { truncateTitle, findFirstUserContent, sessionJsonlPathFromCwd, + computePaths, } from './file-ops.js'; function assertResumeWorkspace(cwd: string, sessionId: string): void { @@ -331,25 +331,7 @@ export class SessionService extends Effect.Service()('Session', }), }) {} -function computePaths( - cwd: string, - sessionId: string, - parentSessionId?: string -): Pick { - const normalizedCwd = normalizePath(cwd); - const projectPath = encodeProjectPath(normalizedCwd); - const sessionsDir = projectSessionsDir(projectPath); - const transcriptPath = parentSessionId - ? join(sessionsDir, parentSessionId, 'subagents', `${sessionId}.jsonl`) - : join(sessionsDir, `${sessionId}.jsonl`); - const indexPath = transcriptPath.replace('.jsonl', '.index.json'); - return { sessionId, cwd: normalizedCwd, projectPath, transcriptPath, indexPath }; -} - -function forkSessionImpl( - sourceJsonlPath: string, - atTurnId: number -): string { +function forkSessionImpl(sourceJsonlPath: string, atTurnId: number): string { const events = readHistory(sourceJsonlPath); const atIdx = events.findIndex((e) => e.type === 'user' && (e as any).turnId === atTurnId); diff --git a/packages/codingcode/test/context/append-turn-end.test.ts b/packages/codingcode/test/context/append-turn-end.test.ts index 240ced48..f6eac1cf 100644 --- a/packages/codingcode/test/context/append-turn-end.test.ts +++ b/packages/codingcode/test/context/append-turn-end.test.ts @@ -4,7 +4,6 @@ import { join } from 'path'; import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { estimateTokensForContent } from '../../src/core/util.js'; -import { getContextConfig } from '../../src/context/config.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ diff --git a/packages/codingcode/test/context/budget-integration.test.ts b/packages/codingcode/test/context/budget-integration.test.ts index 48c6e688..21da6d0f 100644 --- a/packages/codingcode/test/context/budget-integration.test.ts +++ b/packages/codingcode/test/context/budget-integration.test.ts @@ -11,12 +11,6 @@ import type { SessionEvent } from '../../src/session/types.js'; const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); -function makeConfig() { - return { - compactionModel: '', - }; -} - const TestLayer = Layer.merge( SessionService.Default, Layer.succeed(LLMFactoryService, { @@ -109,9 +103,8 @@ describe('assemblePayload integration', () => { }); it('returns messages and compactedEvents', async () => { - const config = makeConfig(); const ctx = await getCtxService(); - const result = ctx.assemblePayload(sessionId, projectSlug, config); + const result = ctx.assemblePayload(sessionId, projectSlug, 128000); expect(result.messages.length).toBeGreaterThan(0); expect(Array.isArray(result.compactedEvents)).toBe(true); @@ -120,9 +113,8 @@ describe('assemblePayload integration', () => { }); it('returns currentTurnId from session index', async () => { - const config = makeConfig(); const ctx = await getCtxService(); - const result = ctx.assemblePayload(sessionId, projectSlug, config); + const result = ctx.assemblePayload(sessionId, projectSlug, 128000); expect(result.currentTurnId).toBe(1); }); }); diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index 5514a173..4f5f7be0 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -7,7 +7,6 @@ import { Effect, Layer } from 'effect'; import { ContextService } from '../../../src/context/service.js'; import { SessionService } from '../../../src/session/store.js'; import { LLMFactoryService } from '../../../src/llm/factory.js'; -import type { ContextConfig } from '@codingcode/infra/config'; import type { LLMClient } from '../../../src/llm/client.js'; import { Result } from '../../../src/core/result.js'; import type { SessionIndex, SessionEvent, SummaryEvent } from '../../../src/session/types.js'; @@ -99,13 +98,6 @@ function readSummaryEvents(jsonlPath: string): SummaryEvent[] { .filter((ev): ev is SummaryEvent => ev.type === 'summary'); } -function tinyConfig(overrides: Partial = {}): ContextConfig { - return { - compactionModel: '', - ...overrides, - }; -} - function makeMockLLM(content: string): LLMClient { return { complete: () => Effect.succeed({ content, finishReason: 'stop' as const }), @@ -150,13 +142,11 @@ describe('compressor behavior', () => { it('writes summary event with five-section system summary', async () => { const fx = makeFixture({ numTurns: 5 }); try { - const cfg = tinyConfig(); const summary = '## Compacted History\n\n### Goal\nfix bug\n\n### Instructions\nbe careful\n\n### Discoveries\nrace condition\n\n### Accomplished\npatched\n\n### Relevant Files\nsrc/x.ts'; const llm = makeMockLLM(summary); const ctx = await getCtxService(); - const { messages } = ctx.assemblePayload(fx.sessionId, fx.slug, cfg); - await ctx.compactWithLLM(fx.sessionId, fx.slug, messages, cfg, llm); + await ctx.compactWithLLM(fx.sessionId, fx.slug, llm.modelInfo.maxTokens, llm); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries.length).toBe(1); expect(summaries[0]!.summaryText).toContain('### Goal'); @@ -170,10 +160,8 @@ describe('compressor behavior', () => { it('returns no-op when no LLM available', async () => { const fx = makeFixture({ numTurns: 5 }); try { - const cfg = tinyConfig(); const ctx = await getCtxService(); - const { messages } = ctx.assemblePayload(fx.sessionId, fx.slug, cfg); - const result = await ctx.compactWithLLM(fx.sessionId, fx.slug, messages, cfg, null); + const result = await ctx.compactWithLLM(fx.sessionId, fx.slug, 1000, null); expect(result.didCompress).toBe(false); expect(result.messages).toBeUndefined(); const summaries = readSummaryEvents(fx.transcriptPath); @@ -188,13 +176,11 @@ describe('compressor behavior', () => { it('appends summary event directly to JSONL after L5', async () => { const fx = makeFixture({ numTurns: 5 }); try { - const cfg = tinyConfig(); const llm = makeMockLLM( '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' ); const ctx = await getCtxService(); - const { messages } = ctx.assemblePayload(fx.sessionId, fx.slug, cfg); - await ctx.compactWithLLM(fx.sessionId, fx.slug, messages, cfg, llm); + await ctx.compactWithLLM(fx.sessionId, fx.slug, llm.modelInfo.maxTokens, llm); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries).toHaveLength(1); @@ -214,13 +200,11 @@ describe('compressor behavior', () => { readHistory(fx.transcriptPath) ); const before = estimateTokens(buildContextMessages(bVisible, bCompacted)); - const cfg = tinyConfig(); const llm = makeMockLLM( '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' ); const ctx = await getCtxService(); - const { messages } = ctx.assemblePayload(fx.sessionId, fx.slug, cfg); - const result = await ctx.compactWithLLM(fx.sessionId, fx.slug, messages, cfg, llm); + const result = await ctx.compactWithLLM(fx.sessionId, fx.slug, llm.modelInfo.maxTokens, llm); expect(result.didCompress).toBe(true); expect(result.promptEstimate).toBeGreaterThan(0); expect(result.promptEstimate).toBeLessThan(before); diff --git a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts index acddd564..dc1bbffe 100644 --- a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts +++ b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts @@ -53,7 +53,8 @@ vi.mock('fs', async (importOriginal) => { return (actual as any).existsSync(p); }), readFileSync: vi.fn((p: string, encoding: BufferEncoding) => { - if (p.endsWith('.index.json')) return JSON.stringify({ currentTurnId: p.includes('ttl-session') ? 0 : 10 }); + if (p.endsWith('.index.json')) + return JSON.stringify({ currentTurnId: p.includes('ttl-session') ? 0 : 10 }); return (actual as any).readFileSync(p, encoding); }), }; @@ -87,12 +88,6 @@ async function getCtxService(): Promise { ); } -function config(threshold: number, maxTokens = 10000) { - return { - compactionModel: '', - } as any; -} - describe('compactIfNeeded', () => { beforeEach(() => { (estimateTokens as any).mockReturnValue(0); @@ -102,7 +97,7 @@ describe('compactIfNeeded', () => { it('returns didCompress=false when promptEstimate is below threshold', async () => { (estimateTokens as any).mockReturnValue(100); const ctx = await getCtxService(); - const result = await ctx.compactIfNeeded('s1', 'proj', [], 10000, config(0.5), null); + const result = await ctx.compactIfNeeded('s1', 'proj', [], 10000, null); expect(result.didCompress).toBe(false); expect(result.released).toBe(0); expect(result.promptEstimate).toBe(100); @@ -111,7 +106,7 @@ describe('compactIfNeeded', () => { it('returns didCompress=false when promptEstimate equals threshold', async () => { (estimateTokens as any).mockReturnValue(5000); const ctx = await getCtxService(); - const result = await ctx.compactIfNeeded('s1', 'proj', [], 10000, config(0.5), null); + const result = await ctx.compactIfNeeded('s1', 'proj', [], 10000, null); expect(result.didCompress).toBe(false); expect(result.released).toBe(0); }); @@ -135,7 +130,6 @@ describe('compactIfNeeded', () => { }, ] as any, 10000, - config(0.5), null ); expect(result.didCompress).toBe(true); @@ -146,26 +140,8 @@ describe('compactIfNeeded', () => { it('does not return restoredFiles field (removed)', async () => { (estimateTokens as any).mockReturnValue(10000); const ctx = await getCtxService(); - const result = await ctx.compactIfNeeded('s1', 'proj', [], 10000, config(0.5), null); + const result = await ctx.compactIfNeeded('s1', 'proj', [], 10000, null); expect('restoredFiles' in result).toBe(false); }); - it('resets failure count after TTL expires', async () => { - (estimateTokens as any).mockReturnValue(10000); - const ctx = await getCtxService(); - await ctx.compactIfNeeded('ttl-session', 'proj', [], 10000, config(0.5), null); - await ctx.compactIfNeeded('ttl-session', 'proj', [], 10000, config(0.5), null); - await ctx.compactIfNeeded('ttl-session', 'proj', [], 10000, config(0.5), null); - - const blocked = await ctx.compactIfNeeded('ttl-session', 'proj', [], 10000, config(0.5), null); - expect(blocked.didCompress).toBe(false); - - const originalNow = Date.now; - vi.spyOn(Date, 'now').mockReturnValue(originalNow() + 25 * 60 * 60 * 1000); - - const afterTTL = await ctx.compactIfNeeded('ttl-session', 'proj', [], 10000, config(0.5), null); - expect(afterTTL.didCompress).toBe(false); - - vi.restoreAllMocks(); - }); }); diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts index c53ebabf..848dd96f 100644 --- a/packages/codingcode/test/server/compact-route.test.ts +++ b/packages/codingcode/test/server/compact-route.test.ts @@ -217,9 +217,9 @@ describe('POST /api/sessions/:id/compact (manual compact)', () => { expect(mockCompactWithLLM).toHaveBeenCalledTimes(1); const args = mockCompactWithLLM.mock.calls[0]; - // args[4] is the llm parameter — should not be null - expect(args?.[4]).not.toBeNull(); - expect(args?.[4].modelInfo.model).toBe('deepseek-chat'); + // args[3] is the llm parameter — should not be null + expect(args?.[3]).not.toBeNull(); + expect(args?.[3].modelInfo.model).toBe('deepseek-chat'); }); it('should return CompressResult from the API', async () => { @@ -279,6 +279,6 @@ describe('POST /api/sessions/:id/compact (manual compact)', () => { expect(mockCompactWithLLM).toHaveBeenCalledTimes(1); const args = mockCompactWithLLM.mock.calls[0]; - expect(args?.[4]).toBeNull(); + expect(args?.[3]).toBeNull(); }); }); diff --git a/packages/codingcode/test/session/compute-paths.test.ts b/packages/codingcode/test/session/compute-paths.test.ts new file mode 100644 index 00000000..981ef59d --- /dev/null +++ b/packages/codingcode/test/session/compute-paths.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { rmSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { randomUUID } from 'crypto'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { + computePaths, + sessionJsonlPathFromCwd, + projectSessionsDir, +} from '../../src/session/file-ops.js'; +import { normalizePath, encodeProjectPath } from '../../src/core/path.js'; + +const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); +} + +describe('computePaths', () => { + it('top-level: returns path matching sessionJsonlPathFromCwd', () => { + const cwd = '/tmp/test-compute-top-level'; + const sid = randomUUID(); + const result = computePaths(cwd, sid); + + expect(result.transcriptPath).toBe(sessionJsonlPathFromCwd(cwd, sid)); + expect(result.indexPath).toBe(result.transcriptPath.replace('.jsonl', '.index.json')); + expect(result.sessionId).toBe(sid); + expect(result.cwd).toBe(normalizePath(cwd)); + expect(result.projectPath).toBe(encodeProjectPath(normalizePath(cwd))); + }); + + it('subagent: path nested under parentSessionId/subagents/', () => { + const cwd = '/tmp/test-compute-subagent'; + const sid = randomUUID(); + const parentSid = randomUUID(); + const result = computePaths(cwd, sid, parentSid); + + const sessionsDir = projectSessionsDir(encodeProjectPath(normalizePath(cwd))); + const expectedTranscript = join(sessionsDir, parentSid, 'subagents', `${sid}.jsonl`); + expect(result.transcriptPath).toBe(expectedTranscript); + expect(result.indexPath).toBe(expectedTranscript.replace('.jsonl', '.index.json')); + expect(result.sessionId).toBe(sid); + }); + + it('e2e: SessionService.create returns state.transcriptPath matching computePaths', async () => { + const cwd = '/tmp/test-compute-e2e-top'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(cwd, 'test-model'); + }) + ); + + try { + const expected = computePaths(cwd, state.sessionId); + expect(state.transcriptPath).toBe(expected.transcriptPath); + expect(state.indexPath).toBe(expected.indexPath); + expect(state.projectPath).toBe(expected.projectPath); + expect(state.cwd).toBe(expected.cwd); + expect(existsSync(state.transcriptPath)).toBe(true); + } finally { + rmSync(join(PROJECT_BASE, state.projectPath), { recursive: true, force: true }); + } + }); + + it('e2e: create with parentSessionId writes file at nested subagent path', async () => { + const cwd = '/tmp/test-compute-e2e-sub'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(cwd, 'test-model'); + }) + ); + + try { + const childState = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(cwd, 'subagent-model', { + parentSessionId: state.sessionId, + }); + }) + ); + + try { + const expected = computePaths(cwd, childState.sessionId, state.sessionId); + expect(childState.transcriptPath).toBe(expected.transcriptPath); + expect(childState.indexPath).toBe(expected.indexPath); + expect(childState.projectPath).toBe(expected.projectPath); + expect(existsSync(childState.transcriptPath)).toBe(true); + expect(existsSync(childState.indexPath)).toBe(true); + } finally { + rmSync(join(PROJECT_BASE, childState.projectPath), { recursive: true, force: true }); + } + } finally { + rmSync(join(PROJECT_BASE, state.projectPath), { recursive: true, force: true }); + } + }); +}); diff --git a/packages/codingcode/test/session/fork.test.ts b/packages/codingcode/test/session/fork.test.ts index bb2e696f..3476fdd5 100644 --- a/packages/codingcode/test/session/fork.test.ts +++ b/packages/codingcode/test/session/fork.test.ts @@ -372,12 +372,8 @@ describe('forkSession', () => { const forkedPath = join(dir, `${newSessionId}.jsonl`); const forkedEvents = readEvents(forkedPath); - const forkedSummary = forkedEvents.find( - (e) => e.type === 'summary' - ) as any; - const forkedCompact = forkedEvents.find( - (e) => e.type === 'compact' - ) as any; + const forkedSummary = forkedEvents.find((e) => e.type === 'summary') as any; + const forkedCompact = forkedEvents.find((e) => e.type === 'compact') as any; expect(forkedSummary).toBeDefined(); expect(forkedCompact).toBeDefined(); expect((forkedSummary! as any).uuid).toBe(fixedSummaryUuid); diff --git a/packages/codingcode/test/session/prompt-estimate.test.ts b/packages/codingcode/test/session/prompt-estimate.test.ts index c0b668a7..49a73536 100644 --- a/packages/codingcode/test/session/prompt-estimate.test.ts +++ b/packages/codingcode/test/session/prompt-estimate.test.ts @@ -5,7 +5,7 @@ import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; -import { findLastVisibleAssistantUsage, estimatePromptTokens } from '../../src/context/service.js'; +import { estimatePromptTokens } from '../../src/context/service.js'; import { estimateTokensForContent } from '../../src/core/util.js'; import { encodeProjectPath } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; @@ -87,79 +87,6 @@ function run(eff: Effect.Effect): Promise { } describe('promptEstimate', () => { - it('findLastVisibleAssistantUsage reads usage from visible assistant event', () => { - const sessionId = randomUUID(); - const slug = randomUUID(); - const usage = { prompt: 1200, completion: 300, total: 1500 }; - const lastUsage = { prompt: 1300, completion: 350, total: 1650 }; - const fx = makeFixture(sessionId, slug, usage); - try { - const result = findLastVisibleAssistantUsage(fx.transcriptPath); - expect(result).toEqual(lastUsage); - } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); - } - }); - - it('findLastVisibleAssistantUsage returns undefined when no assistant usage', () => { - const sessionId = randomUUID(); - const slug = randomUUID(); - const fx = makeFixture(sessionId, slug, undefined); - try { - const result = findLastVisibleAssistantUsage(fx.transcriptPath); - expect(result).toBeUndefined(); - } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); - } - }); - - it('findLastVisibleAssistantUsage skips rolled-back assistant events', () => { - const sessionId = randomUUID(); - const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug, 'sessions'); - mkdirSync(dir, { recursive: true }); - const transcriptPath = join(dir, `${sessionId}.jsonl`); - - const usage1 = { prompt: 100, completion: 50, total: 150 }; - const usage2 = { prompt: 200, completion: 100, total: 300 }; - const lines: any[] = [ - { - type: 'session_meta', - sessionId, - projectPath: slug, - cwd: '/tmp/test', - createdAt: new Date().toISOString(), - }, - { - type: 'assistant', - turnId: 1, - content: 'first', - toolCalls: [], - usage: usage1, - }, - { - type: 'rollback', - throughTurnId: 1, - reason: 'test', - }, - { - type: 'assistant', - turnId: 2, - content: 'second', - toolCalls: [], - usage: usage2, - }, - ]; - writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - - try { - const result = findLastVisibleAssistantUsage(transcriptPath); - expect(result).toEqual(usage2); - } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); - } - }); - it('forkSession restores usage and promptEstimate from last visible assistant', async () => { const sessionId = randomUUID(); const slug = randomUUID(); diff --git a/packages/codingcode/test/session/record-tool-result-persist.test.ts b/packages/codingcode/test/session/record-tool-result-persist.test.ts index a8a3cf0f..47c12da0 100644 --- a/packages/codingcode/test/session/record-tool-result-persist.test.ts +++ b/packages/codingcode/test/session/record-tool-result-persist.test.ts @@ -2,12 +2,6 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; -vi.mock('../../src/context/config.js', () => ({ - getContextConfig: vi.fn(() => ({ - compactionModel: '', - })), -})); - function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); } diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index 20426ff0..af926453 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -48,7 +48,11 @@ export function resumeSession(sessionId: string, cwd: string): Promise { return clients.sessions.resumeSession({ sessionId, cwd }); } -export function setSessionPermissionMode(sessionId: string, cwd: string, mode: string): Promise { +export function setSessionPermissionMode( + sessionId: string, + cwd: string, + mode: string +): Promise { return clients.sessions.setSessionPermissionMode({ sessionId, cwd, mode: mode as any }); } From 7b17ae1a7267659cc78ae3366658c487ec84e698 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 19 Jun 2026 01:34:50 +0800 Subject: [PATCH 6/7] Clean up the code formatting and fix several functional issues --- packages/codingcode/src/context/service.ts | 7 +- packages/codingcode/src/memory/index.ts | 35 +- .../test/client/http/sessions.test.ts | 8 +- .../test/context/compressor/behavior.test.ts | 8 +- .../compressor/compact-if-needed.test.ts | 1 - packages/codingcode/test/memory/index.test.ts | 25 +- packages/desktop/src/agent/AgentSidebar.tsx | 29 +- packages/desktop/src/agent/AgentWorkspace.tsx | 1 + packages/desktop/src/agent/MessageStream.tsx | 52 ++- packages/desktop/src/agent/ProjectStrip.tsx | 28 +- packages/desktop/src/hooks/useAgent.ts | 4 + packages/desktop/src/shared/MessageItem.tsx | 146 ++++++--- .../desktop/test/fork-button-portal.test.tsx | 303 ++++++++++++++++++ packages/desktop/test/thread-delete.test.ts | 212 ++++++++++++ 14 files changed, 712 insertions(+), 147 deletions(-) create mode 100644 packages/desktop/test/fork-button-portal.test.tsx create mode 100644 packages/desktop/test/thread-delete.test.ts diff --git a/packages/codingcode/src/context/service.ts b/packages/codingcode/src/context/service.ts index 98439b5e..aa6c73e0 100644 --- a/packages/codingcode/src/context/service.ts +++ b/packages/codingcode/src/context/service.ts @@ -315,11 +315,8 @@ export class ContextService extends Effect.Service()('Context', const threshold = modelMaxTokens * COMPACTION_THRESHOLD; if (usage === undefined || usage - released > threshold) { - const { compactedEvents, currentTurnId, compactedTurnIds, promptEstimate } = assemblePayload( - sessionId, - encodedProjectPath, - modelMaxTokens - ); + const { compactedEvents, currentTurnId, compactedTurnIds, promptEstimate } = + assemblePayload(sessionId, encodedProjectPath, modelMaxTokens); preEstimate = promptEstimate; released += await tryCompaction( sessionId, diff --git a/packages/codingcode/src/memory/index.ts b/packages/codingcode/src/memory/index.ts index 7fe88ab8..5e9e9ef5 100644 --- a/packages/codingcode/src/memory/index.ts +++ b/packages/codingcode/src/memory/index.ts @@ -113,30 +113,29 @@ export class MemoryService extends Effect.Service()('Memory', { if (!getMemoryEnabled()) { return { written: false, bytes: 0 }; } - const cfg = getMemoryConfig(); + if (!sessionCwd) { + return { written: false, bytes: 0 }; + } - const cwd = sessionCwd; - const projectPath = resolveMemoryPath(cwd); + let events: SessionEvent[]; + try { + const { readFileSync } = await import('node:fs'); + const jsonlPath = sessionJsonlPathFromCwd(sessionCwd, sessionId); + const content = readFileSync(jsonlPath, 'utf-8'); + events = content + .split('\n') + .filter((l) => l.trim() && !l.includes('"type":"session_meta"')) + .map((l) => JSON.parse(l) as SessionEvent); + } catch { + return { written: false, bytes: 0 }; + } + const cfg = getMemoryConfig(); + const projectPath = resolveMemoryPath(sessionCwd); const projectContent = readMemoryFile(projectPath); const currentAuto = extractAutoBlock(projectContent); try { - let events: SessionEvent[] = []; - try { - const { readFileSync } = await import('node:fs'); - - const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); - - const content = readFileSync(jsonlPath, 'utf-8'); - events = content - .split('\n') - .filter((l) => l.trim() && !l.includes('"type":"session_meta"')) - .map((l) => JSON.parse(l) as SessionEvent); - } catch { - return { written: false, bytes: 0 }; - } - const transcript = buildStructuredTranscript(events); const types = getEffectiveTypes(cfg); diff --git a/packages/codingcode/test/client/http/sessions.test.ts b/packages/codingcode/test/client/http/sessions.test.ts index 040a52f1..65b93487 100644 --- a/packages/codingcode/test/client/http/sessions.test.ts +++ b/packages/codingcode/test/client/http/sessions.test.ts @@ -11,13 +11,17 @@ describe('createHttpSessionClient.setSessionPermissionMode', () => { const request = createRequestHelpers('http://localhost:8080'); const client = createHttpSessionClient(request); - await client.setSessionPermissionMode({ sessionId: 'sess-123', mode: 'acceptEdits' as any }); + await client.setSessionPermissionMode({ + sessionId: 'sess-123', + cwd: '/test', + mode: 'acceptEdits' as any, + }); expect(fetchSpy).toHaveBeenCalledWith( 'http://localhost:8080/api/sessions/sess-123/permission-mode', expect.objectContaining({ method: 'PUT', - body: JSON.stringify({ mode: 'acceptEdits' }), + body: JSON.stringify({ cwd: '/test', mode: 'acceptEdits' }), }) ); diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index 4f5f7be0..d4d504ca 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -76,7 +76,6 @@ function makeFixture(opts: FixtureOptions) { title: 'fixture', currentTurnId: opts.currentTurnId ?? opts.numTurns, usage: undefined, - promptEstimate: 0, permissionMode: 'default', }; writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); @@ -204,7 +203,12 @@ describe('compressor behavior', () => { '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' ); const ctx = await getCtxService(); - const result = await ctx.compactWithLLM(fx.sessionId, fx.slug, llm.modelInfo.maxTokens, llm); + const result = await ctx.compactWithLLM( + fx.sessionId, + fx.slug, + llm.modelInfo.maxTokens, + llm + ); expect(result.didCompress).toBe(true); expect(result.promptEstimate).toBeGreaterThan(0); expect(result.promptEstimate).toBeLessThan(before); diff --git a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts index dc1bbffe..2f95358d 100644 --- a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts +++ b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts @@ -143,5 +143,4 @@ describe('compactIfNeeded', () => { const result = await ctx.compactIfNeeded('s1', 'proj', [], 10000, null); expect('restoredFiles' in result).toBe(false); }); - }); diff --git a/packages/codingcode/test/memory/index.test.ts b/packages/codingcode/test/memory/index.test.ts index 762d7e9e..af9b972c 100644 --- a/packages/codingcode/test/memory/index.test.ts +++ b/packages/codingcode/test/memory/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Effect, Layer } from 'effect'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -32,6 +32,14 @@ function cleanup() { beforeEach(async () => { cleanup(); fs.mkdirSync(tmpDir, { recursive: true }); + const { getMemoryConfig } = await import('../../src/memory/config.js'); + vi.mocked(getMemoryConfig).mockReturnValue({ + enabled: false, + model: '', + promptMaxBytes: 8192, + extraTypes: [], + disabledTypes: [], + }); service = await Effect.runPromise( Effect.gen(function* () { return yield* MemoryService; @@ -47,7 +55,6 @@ vi.mock('../../src/memory/config.js', () => ({ getMemoryConfig: vi.fn(() => ({ enabled: false, model: '', - maxBytes: 16384, promptMaxBytes: 8192, extraTypes: [], disabledTypes: [], @@ -72,7 +79,6 @@ describe('Memory Index', () => { vi.mocked(getMemoryConfig).mockReturnValue({ enabled: true, model: '', - maxBytes: 16384, promptMaxBytes: 8192, extraTypes: [], disabledTypes: [], @@ -87,7 +93,6 @@ describe('Memory Index', () => { vi.mocked(getMemoryConfig).mockReturnValue({ enabled: true, model: '', - maxBytes: 16384, promptMaxBytes: 8192, extraTypes: [], disabledTypes: [], @@ -115,7 +120,6 @@ describe('Memory Index', () => { vi.mocked(getMemoryConfig).mockReturnValue({ enabled: true, model: '', - maxBytes: 16384, promptMaxBytes: 100, extraTypes: [], disabledTypes: [], @@ -139,7 +143,7 @@ describe('Memory Index', () => { describe('flushSessionToMemory', () => { it('returns early when memory disabled', async () => { - const result = await service.flushSessionToMemory('fake-session-id', null); + const result = await service.flushSessionToMemory('fake-session-id', null, tmpDir); expect(result.written).toBe(false); }); @@ -148,13 +152,12 @@ describe('Memory Index', () => { vi.mocked(getMemoryConfig).mockReturnValue({ enabled: true, model: '', - maxBytes: 16384, promptMaxBytes: 8192, extraTypes: [], disabledTypes: [], } as any); - const result = await service.flushSessionToMemory('nonexistent-session', null); + const result = await service.flushSessionToMemory('nonexistent-session', null, tmpDir); expect(result.written).toBe(false); }); @@ -163,14 +166,12 @@ describe('Memory Index', () => { vi.mocked(getMemoryConfig).mockReturnValue({ enabled: true, model: '', - maxBytes: 16384, promptMaxBytes: 8192, extraTypes: [], disabledTypes: [], } as any); - // This will fail to find session, so returns false - const result = await service.flushSessionToMemory('session', null); + const result = await service.flushSessionToMemory('session', null, tmpDir); expect(result.written).toBe(false); }); }); @@ -213,7 +214,7 @@ describe('Memory Index', () => { it('flushSessionToMemory returns early when runtime disabled', async () => { service.setMemoryEnabled(false); - const result = await service.flushSessionToMemory('any-session', null); + const result = await service.flushSessionToMemory('any-session', null, tmpDir); expect(result.written).toBe(false); }); }); diff --git a/packages/desktop/src/agent/AgentSidebar.tsx b/packages/desktop/src/agent/AgentSidebar.tsx index 9c889208..d7667831 100644 --- a/packages/desktop/src/agent/AgentSidebar.tsx +++ b/packages/desktop/src/agent/AgentSidebar.tsx @@ -3,7 +3,7 @@ import { Plus, Search, Zap, Settings } from 'lucide-react'; import { useUIStore } from '../stores/ui.store'; import { useWorkspaceStore } from '../stores/workspace.store'; import { useAgentStore } from '../stores/agent.store'; -import { api } from '../lib/api'; +import { useAgentRollback } from '../hooks/useAgent'; function normalizeCwd(p: string): string { return p.replace(/\\/g, '/').replace(/^([A-Z]):/, (_, l: string) => `${l.toLowerCase()}:`); @@ -24,8 +24,8 @@ export default function AgentSidebar() { const currentThreadId = useAgentStore((s) => s.currentThreadId); const rootPath = useWorkspaceStore((s) => s.rootPath); const workspace = useWorkspaceStore(); - const setCurrentThread = useAgentStore((s) => s.setCurrentThread); const setView = useUIStore((s) => s.setView); + const { deleteThread } = useAgentRollback(); // Subscribe to raw threads, derive list with useMemo for stable reference const rawThreads = useAgentStore((s) => s.threads); @@ -39,29 +39,8 @@ export default function AgentSidebar() { const [hoveredThreadId, setHoveredThreadId] = useState(null); - const handleDelete = async (threadId: string) => { - await api(`/api/sessions/${threadId}`, { method: 'DELETE' }).catch((e) => { - console.error('Failed to delete session:', e); - }); - const rootPath = useWorkspaceStore.getState().rootPath; - if (rootPath) { - try { - const sessions = await api(`/api/sessions?cwd=${encodeURIComponent(rootPath)}`); - const threads = sessions.map((s: any) => ({ - id: s.sessionId, - projectId: '', - title: s.title ?? s.sessionId.slice(0, 8), - cwd: s.cwd ?? '', - turns: [], - createdAt: new Date(s.createdAt).getTime(), - updatedAt: new Date(s.updatedAt).getTime(), - })); - useAgentStore.getState().loadThreads(threads); - } catch {} - } - if (threadId === currentThreadId) { - setCurrentThread(null); - } + const handleDelete = (threadId: string) => { + void deleteThread(threadId); }; // Find current project name diff --git a/packages/desktop/src/agent/AgentWorkspace.tsx b/packages/desktop/src/agent/AgentWorkspace.tsx index 89784e4c..fdc3455e 100644 --- a/packages/desktop/src/agent/AgentWorkspace.tsx +++ b/packages/desktop/src/agent/AgentWorkspace.tsx @@ -320,6 +320,7 @@ function InputBox({ }; setSessionPermissionMode( currentThreadId, + workspace.rootPath || '', POLICY_TO_CORE_MODE[next] ?? 'default' ).catch((e) => { console.error('Failed to sync permission mode:', e); diff --git a/packages/desktop/src/agent/MessageStream.tsx b/packages/desktop/src/agent/MessageStream.tsx index 05fda801..5ebcda4f 100644 --- a/packages/desktop/src/agent/MessageStream.tsx +++ b/packages/desktop/src/agent/MessageStream.tsx @@ -216,6 +216,7 @@ function TurnDiffPanel({ export default function MessageStream({ threadId }: MessageStreamProps) { const turns = useAgentStore((s) => s.threads[threadId]?.turns ?? []); const setCurrentThread = useAgentStore((s) => s.setCurrentThread); + const upsertThread = useAgentStore((s) => s.upsertThread); const { approveTool, rejectTool } = useAgentApproval(); const { loadCheckpointDiff, @@ -338,17 +339,47 @@ export default function MessageStream({ threadId }: MessageStreamProps) { : undefined, onForkFromHere: async () => { const lastItem = turn.items[turn.items.length - 1]; - if (lastItem) { - const userMsg = turn.items.find( - (i) => i.type === 'message' && (i as any).role === 'user' - ); - const userContent = userMsg && 'content' in userMsg ? (userMsg as any).content : ''; - const newSessionId = await forkThread(threadId, Number(turn.id)); - if (newSessionId) { - setCurrentThread(newSessionId); - if (userContent) setPendingInput(userContent); - } + if (!lastItem) { + console.warn('[fork] turn.items is empty, turnId=', turn.id); + return; + } + const atTurnId = Number(turn.id); + if (!Number.isFinite(atTurnId)) { + console.error('[fork] turn.id is not numeric:', turn.id, 'turn:', turn); + return; + } + const userMsg = turn.items.find( + (i) => i.type === 'message' && (i as any).role === 'user' + ); + const userContent = userMsg && 'content' in userMsg ? (userMsg as any).content : ''; + let newSessionId: string | undefined; + try { + newSessionId = await forkThread(threadId, atTurnId); + } catch (err) { + console.error('[fork] forkThread threw:', err, { + threadId, + atTurnId, + }); + throw err; + } + if (!newSessionId) { + console.error('[fork] forkThread returned no sessionId', { + threadId, + atTurnId, + }); + return; } + upsertThread({ + id: newSessionId, + projectId: '', + title: newSessionId.slice(0, 8), + cwd: useAgentStore.getState().threads[threadId]?.cwd ?? '', + turns: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }); + setCurrentThread(newSessionId); + if (userContent) setPendingInput(userContent); }, }); } @@ -363,6 +394,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) { forkThread, setCurrentThread, setPendingInput, + upsertThread, ]); const getItemKey = useCallback( diff --git a/packages/desktop/src/agent/ProjectStrip.tsx b/packages/desktop/src/agent/ProjectStrip.tsx index 140b9e5c..07fc17d8 100644 --- a/packages/desktop/src/agent/ProjectStrip.tsx +++ b/packages/desktop/src/agent/ProjectStrip.tsx @@ -3,7 +3,7 @@ import { Settings, ChevronLeft, ChevronRight } from 'lucide-react'; import { useUIStore } from '../stores/ui.store'; import { useWorkspaceStore } from '../stores/workspace.store'; import { useAgentStore } from '../stores/agent.store'; -import { API_BASE, api } from '../lib/api'; +import { useAgentRollback } from '../hooks/useAgent'; import type { Project, Thread } from '@shared/types'; function normalizeCwd(p: string): string { @@ -142,32 +142,12 @@ export default function ProjectStrip() { const setCurrentThread = useAgentStore((s) => s.setCurrentThread); const setView = useUIStore((s) => s.setView); const toggleSidebar = useUIStore((s) => s.toggleSidebar); + const { deleteThread } = useAgentRollback(); const [hoveredId, setHoveredId] = useState(null); - const handleDelete = async (threadId: string) => { - await api(`/api/sessions/${threadId}`, { method: 'DELETE' }).catch((e) => { - console.error('Failed to delete session:', e); - }); - const rootPath = useWorkspaceStore.getState().rootPath; - if (rootPath) { - try { - const sessions = await api(`/api/sessions?cwd=${encodeURIComponent(rootPath)}`); - const threads = sessions.map((s: any) => ({ - id: s.sessionId, - projectId: '', - title: s.title ?? s.sessionId.slice(0, 8), - cwd: s.cwd ?? '', - turns: [], - createdAt: new Date(s.createdAt).getTime(), - updatedAt: new Date(s.updatedAt).getTime(), - })); - useAgentStore.getState().loadThreads(threads); - } catch {} - } - if (threadId === currentThreadId) { - setCurrentThread(null); - } + const handleDelete = (threadId: string) => { + void deleteThread(threadId); }; const handleSelectProject = (id: string) => { diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index a9cbd643..1ec405a1 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -519,6 +519,7 @@ export function useAgentRollback() { const deleteThread = useCallback( async (threadId: string) => { const currentCwd = useWorkspaceStore.getState().rootPath; + const wasCurrent = useAgentStore.getState().currentThreadId === threadId; try { await deleteSession(threadId, currentCwd); } catch (e) { @@ -548,6 +549,9 @@ export function useAgentRollback() { } } } + if (wasCurrent) { + useAgentStore.getState().setCurrentThread(null); + } }, [loadThreads, setThreadUsage] ); diff --git a/packages/desktop/src/shared/MessageItem.tsx b/packages/desktop/src/shared/MessageItem.tsx index 9f82bc49..414e0362 100644 --- a/packages/desktop/src/shared/MessageItem.tsx +++ b/packages/desktop/src/shared/MessageItem.tsx @@ -1,4 +1,5 @@ -import { useState, useRef, useLayoutEffect, memo } from 'react'; +import { useState, useRef, useLayoutEffect, useEffect, useCallback, memo } from 'react'; +import { createPortal } from 'react-dom'; import { Copy, Check } from 'lucide-react'; import type { Item } from '@shared/types'; import ToolCallCard from './ToolCallCard'; @@ -18,6 +19,9 @@ interface MessageItemProps { toolResult?: Item & { type: 'tool_result' }; } +const MENU_WIDTH = 130; +const MENU_HEIGHT_EST = 70; + const MessageItem = memo(function MessageItem({ item, threadId, @@ -33,28 +37,49 @@ const MessageItem = memo(function MessageItem({ const [rollbackMenuOpen, setRollbackMenuOpen] = useState(false); const rollbackBtnRef = useRef(null); const rollbackMenuRef = useRef(null); - const [menuFlip, setMenuFlip] = useState<{ vertical?: boolean; horizontal?: boolean }>({}); + const [menuPos, setMenuPos] = useState<{ + top: number; + left: number; + placement: 'up' | 'down'; + } | null>(null); const { copiedId, copy } = useCopyToClipboard(); const messageContent = item.type === 'message' ? item.content : null; const isCopied = copiedId === `msg-${item.id}`; - // Dynamically flip menu if it would overflow the viewport - useLayoutEffect(() => { - if (!rollbackMenuOpen || !rollbackMenuRef.current || !rollbackBtnRef.current) return; - const menuRect = rollbackMenuRef.current.getBoundingClientRect(); - const flip: { vertical?: boolean; horizontal?: boolean } = {}; - // Account for title bar overlay (~36px on Windows, ~28px on macOS) - const safeTop = 40; - if (menuRect.top < safeTop) { - flip.vertical = true; + const updateMenuPos = useCallback(() => { + if (!rollbackBtnRef.current) return; + const rect = rollbackBtnRef.current.getBoundingClientRect(); + const GAP = 4; + let top = rect.top - MENU_HEIGHT_EST - GAP; + let placement: 'up' | 'down' = 'up'; + if (top < 40) { + top = rect.bottom + GAP; + placement = 'down'; } - if (menuRect.right > window.innerWidth) { - flip.horizontal = true; + const left = Math.max(4, rect.right - MENU_WIDTH); + setMenuPos({ top, left, placement }); + }, []); + + useLayoutEffect(() => { + if (!rollbackMenuOpen) { + setMenuPos(null); + return; } - setMenuFlip(flip); - }, [rollbackMenuOpen]); + updateMenuPos(); + }, [rollbackMenuOpen, updateMenuPos]); + + useEffect(() => { + if (!rollbackMenuOpen) return; + const handler = () => updateMenuPos(); + window.addEventListener('resize', handler); + window.addEventListener('scroll', handler, true); + return () => { + window.removeEventListener('resize', handler); + window.removeEventListener('scroll', handler, true); + }; + }, [rollbackMenuOpen, updateMenuPos]); if (item.type === 'message') { const content = item.content; @@ -79,39 +104,64 @@ const MessageItem = memo(function MessageItem({ > ↩ - {rollbackMenuOpen && ( -
- {onRollbackHere && ( - - )} - {onForkFromHere && ( - - )} -
- )} + {rollbackMenuOpen && + menuPos && + createPortal( +
+ {onRollbackHere && ( + + )} + {onForkFromHere && ( + + )} +
, + document.body + )} )} diff --git a/packages/desktop/test/fork-button-portal.test.tsx b/packages/desktop/test/fork-button-portal.test.tsx new file mode 100644 index 00000000..925e946e --- /dev/null +++ b/packages/desktop/test/fork-button-portal.test.tsx @@ -0,0 +1,303 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { act, render, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { useAgentStore } from '../src/stores/agent.store'; +import MessageStream from '../src/agent/MessageStream'; +import type { Turn } from '../shared/types'; + +const forkThreadMock = vi.fn(); +const previewRollbackMock = vi.fn(); + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: (options: { count: number }) => { + const count = options.count; + return { + getTotalSize: () => count * 60, + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + key: `row-${index}`, + index, + start: index * 60, + size: 60, + })), + measureElement: vi.fn(), + scrollToEnd: vi.fn(), + }; + }, +})); + +vi.mock('../src/hooks/useAgent', () => ({ + useAgentApproval: () => ({ approveTool: vi.fn(), rejectTool: vi.fn() }), + useAgentRollback: () => ({ + loadCheckpointDiff: vi.fn().mockResolvedValue({ turnId: 0, files: [] }), + revertFile: vi.fn(), + revertFiles: vi.fn(), + previewRollback: (...args: unknown[]) => { + previewRollbackMock(...args); + return Promise.resolve({}); + }, + rollbackCtx: vi.fn(), + rollbackBoth: vi.fn(), + undoCodeRollback: vi.fn(), + forkThread: (...args: unknown[]) => forkThreadMock(...args), + initRollbackState: vi.fn(), + deleteThread: vi.fn(), + revertedFilesByTurnId: {}, + }), +})); + +vi.mock('../src/hooks/useCopyToClipboard', () => ({ + useCopyToClipboard: () => ({ copiedId: null, copy: vi.fn() }), +})); + +function makeTurn(id: string, items: Turn['items']): Turn { + return { id, items, status: 'completed' }; +} + +function setThread(threadId: string, turns: Turn[]) { + act(() => { + useAgentStore.setState((s) => { + s.threads[threadId] = { + id: threadId, + projectId: '', + title: threadId, + cwd: '/test/cwd', + turns, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + }); + }); +} + +function mockGetBoundingClientRect(rect: Partial) { + const baseRect: DOMRect = { + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON() { + return {}; + }, + }; + const merged = { ...baseRect, ...rect }; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + return merged; + }); +} + +beforeEach(() => { + cleanup(); + vi.restoreAllMocks(); + forkThreadMock.mockReset(); + previewRollbackMock.mockReset(); + forkThreadMock.mockResolvedValue('new-session-id-1234'); + Object.defineProperty(window, 'innerWidth', { value: 1000, writable: true }); + Object.defineProperty(window, 'innerHeight', { value: 800, writable: true }); + useAgentStore.setState({ + currentThreadId: null, + threads: {}, + approvalPolicy: 'ask-all', + model: '', + models: [], + contextUsage: null, + todoByThreadId: {}, + pendingInput: null, + usageByThreadId: {}, + isCompressing: false, + automations: [], + }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('fork button via portal', () => { + it('renders the rollback menu in document.body (not inside the virtual row)', async () => { + setThread('t1', [ + makeTurn('1', [ + { id: 'u1', type: 'message', role: 'user', content: 'hi' }, + { id: 'a1', type: 'message', role: 'assistant', content: 'hello' }, + ]), + ]); + mockGetBoundingClientRect({ top: 200, right: 600, bottom: 220, width: 20, height: 20 }); + + const { container } = render(); + const triggerBtn = container.querySelector('button[title="回退到此"]') as HTMLElement; + expect(triggerBtn).toBeTruthy(); + + await act(async () => { + fireEvent.click(triggerBtn); + }); + + const menu = document.body.querySelector('[data-testid="rollback-menu"]') as HTMLElement; + expect(menu).toBeTruthy(); + expect(menu.style.position).toBe('fixed'); + expect(menu.style.zIndex).toBe('100'); + }); + + it('places menu at expected fixed coordinates from getBoundingClientRect', async () => { + setThread('t1', [ + makeTurn('1', [ + { id: 'u1', type: 'message', role: 'user', content: 'hi' }, + { id: 'a1', type: 'message', role: 'assistant', content: 'hello' }, + ]), + ]); + mockGetBoundingClientRect({ top: 200, right: 600, bottom: 220, width: 20, height: 20 }); + + const { container } = render(); + const triggerBtn = container.querySelector('button[title="回退到此"]') as HTMLElement; + await act(async () => { + fireEvent.click(triggerBtn); + }); + + const menu = document.body.querySelector('[data-testid="rollback-menu"]') as HTMLElement; + expect(menu).toBeTruthy(); + expect(menu.style.position).toBe('fixed'); + expect(menu.getAttribute('data-placement')).toBe('up'); + }); + + it('flips menu below the trigger when there is no room above', async () => { + setThread('t1', [ + makeTurn('1', [ + { id: 'u1', type: 'message', role: 'user', content: 'hi' }, + { id: 'a1', type: 'message', role: 'assistant', content: 'hello' }, + ]), + ]); + mockGetBoundingClientRect({ top: 10, right: 600, bottom: 30, width: 20, height: 20 }); + + const { container } = render(); + const triggerBtn = container.querySelector('button[title="回退到此"]') as HTMLElement; + await act(async () => { + fireEvent.click(triggerBtn); + }); + + const menu = document.body.querySelector('[data-testid="rollback-menu"]') as HTMLElement; + expect(menu).toBeTruthy(); + expect(menu.getAttribute('data-placement')).toBe('down'); + }); + + it('updates menu position on scroll events', async () => { + setThread('t1', [ + makeTurn('1', [ + { id: 'u1', type: 'message', role: 'user', content: 'hi' }, + { id: 'a1', type: 'message', role: 'assistant', content: 'hello' }, + ]), + ]); + let rect: Partial = { top: 200, right: 600, bottom: 220, width: 20, height: 20 }; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + return { + ...{ + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }, + ...rect, + } as DOMRect; + }); + + const { container } = render(); + const triggerBtn = container.querySelector('button[title="回退到此"]') as HTMLElement; + await act(async () => { + fireEvent.click(triggerBtn); + }); + const menu = document.body.querySelector('[data-testid="rollback-menu"]') as HTMLElement; + const initialTop = menu.style.top; + expect(initialTop).toBeTruthy(); + + rect = { top: 400, right: 600, bottom: 420, width: 20, height: 20 }; + await act(async () => { + window.dispatchEvent(new Event('scroll')); + }); + + const updatedMenu = document.body.querySelector('[data-testid="rollback-menu"]') as HTMLElement; + expect(updatedMenu.style.top).not.toBe(initialTop); + }); + + it('clicking fork triggers forkThread with the correct threadId and numeric turnId', async () => { + setThread('t1', [ + makeTurn('1', [ + { id: 'u1', type: 'message', role: 'user', content: 'hello world' }, + { id: 'a1', type: 'message', role: 'assistant', content: 'reply' }, + ]), + ]); + mockGetBoundingClientRect({ top: 200, right: 600, bottom: 220, width: 20, height: 20 }); + + const { container } = render(); + const triggerBtn = container.querySelector('button[title="回退到此"]') as HTMLElement; + await act(async () => { + fireEvent.click(triggerBtn); + }); + const forkBtn = document.body.querySelector('[data-testid="fork-menu-item"]') as HTMLElement; + expect(forkBtn).toBeTruthy(); + expect(forkBtn.textContent).toBe('Fork from here'); + + await act(async () => { + fireEvent.click(forkBtn); + }); + + expect(forkThreadMock).toHaveBeenCalledTimes(1); + expect(forkThreadMock).toHaveBeenCalledWith('t1', 1); + }); + + it('does not throw when forkThread rejects (try/catch surfaces error to console)', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + forkThreadMock.mockRejectedValueOnce(new Error('network down')); + + setThread('t1', [ + makeTurn('1', [ + { id: 'u1', type: 'message', role: 'user', content: 'hi' }, + { id: 'a1', type: 'message', role: 'assistant', content: 'hello' }, + ]), + ]); + mockGetBoundingClientRect({ top: 200, right: 600, bottom: 220, width: 20, height: 20 }); + + const { container } = render(); + const triggerBtn = container.querySelector('button[title="回退到此"]') as HTMLElement; + await act(async () => { + fireEvent.click(triggerBtn); + }); + const forkBtn = document.body.querySelector('[data-testid="fork-menu-item"]') as HTMLElement; + await act(async () => { + fireEvent.click(forkBtn); + }); + + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + it('menu is in document.body, not inside MessageStream DOM tree', async () => { + setThread('t1', [ + makeTurn('1', [ + { id: 'u1', type: 'message', role: 'user', content: 'hi' }, + { id: 'a1', type: 'message', role: 'assistant', content: 'hello' }, + ]), + ]); + mockGetBoundingClientRect({ top: 200, right: 600, bottom: 220, width: 20, height: 20 }); + + const { container } = render(); + const triggerBtn = container.querySelector('button[title="回退到此"]') as HTMLElement; + await act(async () => { + fireEvent.click(triggerBtn); + }); + + const menu = document.body.querySelector('[data-testid="rollback-menu"]'); + expect(container.contains(menu)).toBe(false); + expect(document.body.contains(menu)).toBe(true); + }); +}); diff --git a/packages/desktop/test/thread-delete.test.ts b/packages/desktop/test/thread-delete.test.ts new file mode 100644 index 00000000..9a2b290e --- /dev/null +++ b/packages/desktop/test/thread-delete.test.ts @@ -0,0 +1,212 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { useAgentStore } from '../src/stores/agent.store'; +import { useWorkspaceStore } from '../src/stores/workspace.store'; +import { useAgentRollback } from '../src/hooks/useAgent'; + +const { deleteSessionMock, listSessionsMock } = vi.hoisted(() => ({ + deleteSessionMock: vi.fn(), + listSessionsMock: vi.fn(), +})); + +vi.mock('../src/lib/core-api', () => ({ + deleteSession: deleteSessionMock, + listSessions: listSessionsMock, + // other named exports are stubs (useAgentRollback only uses these two in deleteThread) + getCheckpointDiff: vi.fn(), + revertFile: vi.fn(), + revertFiles: vi.fn(), + previewRollbackDiff: vi.fn(), + rollbackCodeToTurn: vi.fn(), + rollbackContext: vi.fn(), + rollbackBothToTurn: vi.fn(), + undoLastCodeRollback: vi.fn(), + getRollbackState: vi.fn(), + forkSession: vi.fn(), + listModels: vi.fn(), + switchModel: vi.fn(), + listAgents: vi.fn(), + createSession: vi.fn(), + getSessionHistory: vi.fn(), + resumeSession: vi.fn(), + setSessionPermissionMode: vi.fn(), + sendApprovalResponse: vi.fn(), + getMemoryConfig: vi.fn(), + setMemoryEnabled: vi.fn(), + setMemoryTypeDisabled: vi.fn(), + createMemoryExtraType: vi.fn(), + updateMemoryExtraType: vi.fn(), + deleteMemoryExtraType: vi.fn(), + setMemoryModel: vi.fn(), + setAgentConfig: vi.fn(), + getAgentConfig: vi.fn(), + setCompactionModel: vi.fn(), + listMcpServers: vi.fn(), + setMcpDisabled: vi.fn(), + resetMcpDisabled: vi.fn(), + createMcpServer: vi.fn(), + updateMcpServer: vi.fn(), + deleteMcpServer: vi.fn(), + setAgentDisabled: vi.fn(), + resetAgentDisabled: vi.fn(), + createAgent: vi.fn(), + updateAgent: vi.fn(), + deleteAgent: vi.fn(), + getSubagentEnabled: vi.fn(), + setSubagentEnabled: vi.fn(), + resetSubagentEnabled: vi.fn(), + listSkills: vi.fn(), + toggleSkill: vi.fn(), + listHooks: vi.fn(), + createHook: vi.fn(), + updateHook: vi.fn(), + deleteHook: vi.fn(), + listAutomations: vi.fn(), + createAutomation: vi.fn(), + updateAutomation: vi.fn(), + runAutomationOnce: vi.fn(), +})); + +function resetStores({ rootPath = '/test/cwd' }: { rootPath?: string } = {}) { + useAgentStore.setState({ + currentThreadId: null, + threads: {}, + approvalPolicy: 'ask-all', + model: '', + models: [], + contextUsage: null, + todoByThreadId: {}, + pendingInput: null, + usageByThreadId: {}, + isCompressing: false, + automations: [], + }); + useWorkspaceStore.setState({ + rootPath, + name: 'test', + projects: [], + currentProjectId: '', + git: { branch: 'main', isDirty: false, staged: [], unstaged: [] }, + }); +} + +beforeEach(() => { + deleteSessionMock.mockReset(); + listSessionsMock.mockReset(); + deleteSessionMock.mockResolvedValue(undefined); + listSessionsMock.mockResolvedValue([]); + resetStores(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('useAgentRollback().deleteThread', () => { + it('calls deleteSession with the workspace rootPath as cwd', async () => { + const { result } = renderHook(() => useAgentRollback()); + await act(async () => { + await result.current.deleteThread('thread-1'); + }); + expect(deleteSessionMock).toHaveBeenCalledTimes(1); + expect(deleteSessionMock).toHaveBeenCalledWith('thread-1', '/test/cwd'); + }); + + it('refreshes the thread list after a successful delete', async () => { + listSessionsMock.mockResolvedValue([ + { + sessionId: 's-1', + title: 'one', + cwd: '/test/cwd', + createdAt: '2024-01-01', + updatedAt: '2024-01-02', + }, + ]); + const { result } = renderHook(() => useAgentRollback()); + await act(async () => { + await result.current.deleteThread('thread-1'); + }); + expect(listSessionsMock).toHaveBeenCalledWith('/test/cwd'); + const threads = useAgentStore.getState().threads; + expect(Object.keys(threads)).toEqual(['s-1']); + expect(threads['s-1']?.title).toBe('one'); + }); + + it('does not call listSessions when rootPath is empty', async () => { + resetStores({ rootPath: '' }); + const { result } = renderHook(() => useAgentRollback()); + await act(async () => { + await result.current.deleteThread('thread-1'); + }); + expect(deleteSessionMock).toHaveBeenCalledWith('thread-1', ''); + expect(listSessionsMock).not.toHaveBeenCalled(); + }); + + it('clears currentThreadId when the deleted thread is the current one', async () => { + act(() => { + useAgentStore.setState((s) => { + s.currentThreadId = 'thread-current'; + }); + }); + const { result } = renderHook(() => useAgentRollback()); + await act(async () => { + await result.current.deleteThread('thread-current'); + }); + expect(useAgentStore.getState().currentThreadId).toBeNull(); + }); + + it('leaves currentThreadId unchanged when deleting a non-current thread', async () => { + act(() => { + useAgentStore.setState((s) => { + s.currentThreadId = 'thread-keep'; + }); + }); + const { result } = renderHook(() => useAgentRollback()); + await act(async () => { + await result.current.deleteThread('thread-other'); + }); + expect(useAgentStore.getState().currentThreadId).toBe('thread-keep'); + }); + + it('still clears currentThreadId even if the server delete fails', async () => { + deleteSessionMock.mockRejectedValueOnce(new Error('network down')); + act(() => { + useAgentStore.setState((s) => { + s.currentThreadId = 'thread-current'; + }); + }); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useAgentRollback()); + await act(async () => { + await result.current.deleteThread('thread-current'); + }); + expect(useAgentStore.getState().currentThreadId).toBeNull(); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + it('still refreshes the list even if the server delete fails', async () => { + deleteSessionMock.mockRejectedValueOnce(new Error('network down')); + listSessionsMock.mockResolvedValue([ + { + sessionId: 's-2', + title: 'two', + cwd: '/test/cwd', + createdAt: '2024-01-01', + updatedAt: '2024-01-02', + }, + ]); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useAgentRollback()); + await act(async () => { + await result.current.deleteThread('thread-1'); + }); + expect(listSessionsMock).toHaveBeenCalled(); + expect(Object.keys(useAgentStore.getState().threads)).toEqual(['s-2']); + errSpy.mockRestore(); + }); +}); From a2fd3fcf207a01f5d0c0e8be466ad8c0a854d814 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 19 Jun 2026 13:15:07 +0800 Subject: [PATCH 7/7] Refactor token usage management for session compression and rollback --- packages/codingcode/src/context/service.ts | 10 +- .../codingcode/src/server/routes/sessions.ts | 5 +- packages/codingcode/src/session/store.ts | 21 +- .../compressor/compact-if-needed.test.ts | 3 +- .../test/session/store-compact-usage.test.ts | 142 +++++++++ .../test/session/store-rollback-usage.test.ts | 173 +++++++++++ packages/desktop/src/agent/AgentSidebar.tsx | 1 + packages/desktop/src/agent/AgentWorkspace.tsx | 4 +- packages/desktop/src/hooks/useAgent.ts | 17 +- packages/desktop/src/lib/core-api.ts | 9 +- packages/desktop/src/stores/agent.store.ts | 6 + .../desktop/test/compact-usage-reset.test.ts | 154 ++++++++++ packages/desktop/test/global-store.test.ts | 24 ++ .../desktop/test/rollback-usage-reset.test.ts | 287 ++++++++++++++++++ .../test/sidebar-thread-switch.test.tsx | 129 ++++++++ 15 files changed, 969 insertions(+), 16 deletions(-) create mode 100644 packages/codingcode/test/session/store-compact-usage.test.ts create mode 100644 packages/codingcode/test/session/store-rollback-usage.test.ts create mode 100644 packages/desktop/test/compact-usage-reset.test.ts create mode 100644 packages/desktop/test/rollback-usage-reset.test.ts create mode 100644 packages/desktop/test/sidebar-thread-switch.test.tsx diff --git a/packages/codingcode/src/context/service.ts b/packages/codingcode/src/context/service.ts index aa6c73e0..c419ba04 100644 --- a/packages/codingcode/src/context/service.ts +++ b/packages/codingcode/src/context/service.ts @@ -387,14 +387,8 @@ export class ContextService extends Effect.Service()('Context', const startTurnId = Math.min(...turnIds); const endTurnId = Math.max(...turnIds); - const event: SummaryEvent = { - type: 'summary', - uuid: randomUUID(), - startTurnId, - endTurnId, - summaryText: summary, - }; - appendLine(join(projectSessionsDir(encodedProjectPath), `${sessionId}.jsonl`), event); + const state = await Effect.runPromise(session.load(encodedProjectPath, sessionId)); + await Effect.runPromise(session.appendSummary(state, summary, startTurnId, endTurnId)); const summaryMsg: Message = { role: 'system', name: 'compacted_history', content: summary }; return Math.max(0, totalTokens - estimateMessageTokens(summaryMsg)); diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 0b8074d4..2da7902f 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -524,7 +524,8 @@ export function createSessionsRouter(rt: ManagedRt): Hono { yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); const turns = readUIHistory(sessionId, cwd); const promptEstimate = estimatePromptTokens(state.transcriptPath); - return { ok: true, turns, rolledBackMessage, promptEstimate }; + const usage = state.usage; + return { ok: true, turns, rolledBackMessage, promptEstimate, usage }; }) as any ); if (!result.ok) { @@ -553,12 +554,14 @@ export function createSessionsRouter(rt: ManagedRt): Hono { yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); const turns = readUIHistory(sessionId, cwd); const promptEstimate = estimatePromptTokens(state.transcriptPath); + const usage = state.usage; return { ok: true, turns, codeResult, rolledBackMessage, promptEstimate, + usage, }; }) as any ); diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index 68852f4e..4043a7b6 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -237,8 +237,8 @@ export class SessionService extends Effect.Service()('Session', }; appendLine(state.transcriptPath, event); state.messageCount++; - updateIndex(state); state.usage = undefined; + updateIndex(state); return event; }, catch: (e) => @@ -260,6 +260,25 @@ export class SessionService extends Effect.Service()('Session', }; appendLine(state.transcriptPath, event); state.messageCount++; + + const events = readHistory(state.transcriptPath); + const minRollbackThrough = events.reduce( + (min, ev) => (ev.type === 'rollback' && ev.throughTurnId < min ? ev.throughTurnId : min), + Infinity + ); + let lastUsage: TokenUsage | undefined; + for (let i = events.length - 1; i >= 0; i--) { + const ev = events[i]!; + if ('turnId' in ev && minRollbackThrough <= (ev as { turnId: number }).turnId) { + continue; + } + if (ev.type === 'assistant' && (ev as AssistantEvent).usage) { + lastUsage = (ev as AssistantEvent).usage; + break; + } + } + state.usage = lastUsage; + updateIndex(state); return event; }); diff --git a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts index 2f95358d..12ea8069 100644 --- a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts +++ b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts @@ -48,8 +48,9 @@ vi.mock('fs', async (importOriginal) => { return { ...(actual as any), appendFileSync: vi.fn(), + writeFileSync: vi.fn(), existsSync: vi.fn((p: string) => { - if (p.endsWith('.index.json')) return true; + if (p.endsWith('.index.json') || p.endsWith('.jsonl')) return true; return (actual as any).existsSync(p); }), readFileSync: vi.fn((p: string, encoding: BufferEncoding) => { diff --git a/packages/codingcode/test/session/store-compact-usage.test.ts b/packages/codingcode/test/session/store-compact-usage.test.ts new file mode 100644 index 00000000..6ce55efa --- /dev/null +++ b/packages/codingcode/test/session/store-compact-usage.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { randomUUID } from 'crypto'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { encodeProjectPath } from '../../src/core/path.js'; +import type { SessionIndex } from '../../src/session/types.js'; + +const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); +} + +function makeFixture( + sessionId: string, + slug: string, + turns: Array<{ + user: string; + assistant: string; + usage: { prompt: number; completion: number; total: number } | undefined; + }> +) { + const dir = join(PROJECT_BASE, slug, 'sessions'); + mkdirSync(dir, { recursive: true }); + const transcriptPath = join(dir, `${sessionId}.jsonl`); + const indexPath = join(dir, `${sessionId}.index.json`); + + const lines: any[] = [ + { + type: 'session_meta', + sessionId, + projectPath: slug, + cwd: '/tmp/test', + createdAt: new Date().toISOString(), + }, + ]; + turns.forEach((t, i) => { + const turnId = i + 1; + lines.push({ type: 'user', turnId, content: t.user }); + lines.push({ + type: 'assistant', + turnId, + content: t.assistant, + toolCalls: [], + usage: t.usage, + }); + }); + + writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); + + const idx: SessionIndex = { + sessionId, + projectPath: slug, + cwd: '/tmp/test', + model: 'test-model', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: lines.length, + title: 'fixture', + currentTurnId: turns.length, + usage: turns[turns.length - 1]?.usage, + permissionMode: 'default', + }; + writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); + + return { dir, transcriptPath, indexPath }; +} + +function buildState( + sessionId: string, + transcriptPath: string, + indexPath: string, + initialUsage: { prompt: number; completion: number; total: number } | undefined, + currentTurnId: number +) { + return { + sessionId, + cwd: '/tmp/test', + projectPath: encodeProjectPath('/tmp/test'), + transcriptPath, + indexPath, + messageCount: 0, + currentTurnId, + sessionMeta: { + type: 'session_meta' as const, + sessionId, + projectPath: encodeProjectPath('/tmp/test'), + cwd: '/tmp/test', + createdAt: new Date().toISOString(), + }, + model: 'test-model', + title: 'fixture', + usage: initialUsage, + memorySnapshot: '', + }; +} + +describe('SessionService.appendSummary - state.usage reset (used by tryCompaction)', () => { + it('clears state.usage and persists the cleared value to the session index', async () => { + const sessionId = randomUUID(); + const slug = randomUUID(); + const usage1 = { prompt: 100, completion: 50, total: 150 }; + const fx = makeFixture(sessionId, slug, [{ user: 'q1', assistant: 'a1', usage: usage1 }]); + try { + const state = buildState(sessionId, fx.transcriptPath, fx.indexPath, usage1, 1); + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.appendSummary(state, 'compacted summary', 1, 1); + }) + ); + expect(state.usage).toBeUndefined(); + const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; + expect(idx.usage).toBeUndefined(); + } finally { + rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + } + }); + + it('preserves state.usage when called with state that has no prior usage', async () => { + const sessionId = randomUUID(); + const slug = randomUUID(); + const fx = makeFixture(sessionId, slug, [{ user: 'q1', assistant: 'a1', usage: undefined }]); + try { + const state = buildState(sessionId, fx.transcriptPath, fx.indexPath, undefined, 1); + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.appendSummary(state, 'compacted summary', 1, 1); + }) + ); + expect(state.usage).toBeUndefined(); + const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; + expect(idx.usage).toBeUndefined(); + } finally { + rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + } + }); +}); diff --git a/packages/codingcode/test/session/store-rollback-usage.test.ts b/packages/codingcode/test/session/store-rollback-usage.test.ts new file mode 100644 index 00000000..6e8ab913 --- /dev/null +++ b/packages/codingcode/test/session/store-rollback-usage.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest'; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { randomUUID } from 'crypto'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { encodeProjectPath } from '../../src/core/path.js'; +import type { SessionIndex } from '../../src/session/types.js'; + +const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); +} + +function makeFixture( + sessionId: string, + slug: string, + turns: Array<{ + user: string; + assistant: string; + usage: { prompt: number; completion: number; total: number } | undefined; + }> +) { + const dir = join(PROJECT_BASE, slug, 'sessions'); + mkdirSync(dir, { recursive: true }); + const transcriptPath = join(dir, `${sessionId}.jsonl`); + const indexPath = join(dir, `${sessionId}.index.json`); + + const lines: any[] = [ + { + type: 'session_meta', + sessionId, + projectPath: slug, + cwd: '/tmp/test', + createdAt: new Date().toISOString(), + }, + ]; + turns.forEach((t, i) => { + const turnId = i + 1; + lines.push({ type: 'user', turnId, content: t.user }); + lines.push({ + type: 'assistant', + turnId, + content: t.assistant, + toolCalls: [], + usage: t.usage, + }); + }); + + writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); + + const idx: SessionIndex = { + sessionId, + projectPath: slug, + cwd: '/tmp/test', + model: 'test-model', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: lines.length, + title: 'fixture', + currentTurnId: turns.length, + usage: turns[turns.length - 1]?.usage, + permissionMode: 'default', + }; + writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); + + return { dir, transcriptPath, indexPath }; +} + +function buildState( + sessionId: string, + transcriptPath: string, + indexPath: string, + initialUsage: { prompt: number; completion: number; total: number } | undefined, + currentTurnId: number +) { + return { + sessionId, + cwd: '/tmp/test', + projectPath: encodeProjectPath('/tmp/test'), + transcriptPath, + indexPath, + messageCount: 0, + currentTurnId, + sessionMeta: { + type: 'session_meta' as const, + sessionId, + projectPath: encodeProjectPath('/tmp/test'), + cwd: '/tmp/test', + createdAt: new Date().toISOString(), + }, + model: 'test-model', + title: 'fixture', + usage: initialUsage, + memorySnapshot: '', + }; +} + +describe('SessionService.rollbackToTurn - state.usage reset', () => { + it('clears state.usage when rollback leaves no assistant events', async () => { + const sessionId = randomUUID(); + const slug = randomUUID(); + const usage1 = { prompt: 100, completion: 50, total: 150 }; + const fx = makeFixture(sessionId, slug, [{ user: 'q1', assistant: 'a1', usage: usage1 }]); + try { + const state = buildState(sessionId, fx.transcriptPath, fx.indexPath, usage1, 1); + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.rollbackToTurn(state, 1, 'user rollback'); + }) + ); + expect(state.usage).toBeUndefined(); + const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; + expect(idx.usage).toBeUndefined(); + } finally { + rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + } + }); + + it('restores state.usage to the last visible assistant event after partial rollback', async () => { + const sessionId = randomUUID(); + const slug = randomUUID(); + const usage1 = { prompt: 100, completion: 50, total: 150 }; + const usage2 = { prompt: 800, completion: 400, total: 1200 }; + const usage3 = { prompt: 1500, completion: 600, total: 2100 }; + const fx = makeFixture(sessionId, slug, [ + { user: 'q1', assistant: 'a1', usage: usage1 }, + { user: 'q2', assistant: 'a2', usage: usage2 }, + { user: 'q3', assistant: 'a3', usage: usage3 }, + ]); + try { + const state = buildState(sessionId, fx.transcriptPath, fx.indexPath, usage3, 3); + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.rollbackToTurn(state, 2, 'user rollback'); + }) + ); + expect(state.usage).toEqual(usage1); + const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; + expect(idx.usage).toEqual(usage1); + } finally { + rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + } + }); + + it('picks the last visible assistant usage, skipping assistant events without usage', async () => { + const sessionId = randomUUID(); + const slug = randomUUID(); + const usage1 = { prompt: 100, completion: 50, total: 150 }; + const usage2 = { prompt: 800, completion: 400, total: 1200 }; + const fx = makeFixture(sessionId, slug, [ + { user: 'q1', assistant: 'a1', usage: usage1 }, + { user: 'q2', assistant: 'a2', usage: undefined }, + { user: 'q3', assistant: 'a3', usage: usage2 }, + ]); + try { + const state = buildState(sessionId, fx.transcriptPath, fx.indexPath, usage2, 3); + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.rollbackToTurn(state, 3, 'user rollback'); + }) + ); + expect(state.usage).toEqual(usage1); + } finally { + rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + } + }); +}); diff --git a/packages/desktop/src/agent/AgentSidebar.tsx b/packages/desktop/src/agent/AgentSidebar.tsx index d7667831..6c6f9ebd 100644 --- a/packages/desktop/src/agent/AgentSidebar.tsx +++ b/packages/desktop/src/agent/AgentSidebar.tsx @@ -25,6 +25,7 @@ export default function AgentSidebar() { const rootPath = useWorkspaceStore((s) => s.rootPath); const workspace = useWorkspaceStore(); const setView = useUIStore((s) => s.setView); + const setCurrentThread = useAgentStore((s) => s.setCurrentThread); const { deleteThread } = useAgentRollback(); // Subscribe to raw threads, derive list with useMemo for stable reference diff --git a/packages/desktop/src/agent/AgentWorkspace.tsx b/packages/desktop/src/agent/AgentWorkspace.tsx index fdc3455e..db4bfed2 100644 --- a/packages/desktop/src/agent/AgentWorkspace.tsx +++ b/packages/desktop/src/agent/AgentWorkspace.tsx @@ -15,6 +15,7 @@ function ContextIndicator({ threadId }: { threadId: string }) { const contextUsage = useAgentStore((s) => s.contextUsage); const usage = useAgentStore((s) => s.usageByThreadId[threadId]); const setContextUsage = useAgentStore((s) => s.setContextUsage); + const clearThreadUsage = useAgentStore((s) => s.clearThreadUsage); const isCompressing = useAgentStore((s) => s.isCompressing); const startCompressing = useAgentStore((s) => s.startCompressing); const stopCompressing = useAgentStore((s) => s.stopCompressing); @@ -74,11 +75,12 @@ function ContextIndicator({ threadId }: { threadId: string }) { body: JSON.stringify({ cwd: '' }), } ); - if (res.promptEstimate != null && contextUsage) { + if (res.didCompress && contextUsage) { setContextUsage({ used: res.promptEstimate, contextWindow: contextUsage.contextWindow, }); + clearThreadUsage(threadId); } } catch (e) { console.error('Failed to compact session:', e); diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index 1ec405a1..dae383e2 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -58,6 +58,7 @@ export function useAgentCore() { const setModels = useAgentStore((s) => s.setModels); const setContextUsage = useAgentStore((s) => s.setContextUsage); const setThreadUsage = useAgentStore((s) => s.setThreadUsage); + const clearThreadUsage = useAgentStore((s) => s.clearThreadUsage); const workspace = useWorkspaceStore(); const currentThreadId = useAgentStore((s) => s.currentThreadId); const approvalPolicy = useAgentStore((s) => s.approvalPolicy); @@ -206,6 +207,7 @@ export function useAgentCore() { contextWindow: contextUsage.contextWindow, }); } + clearThreadUsage(threadId); } return null; case 'done': @@ -215,7 +217,7 @@ export function useAgentCore() { return null; } }, - [applyTodoUpdate, updateTurnId, setThreadUsage, setContextUsage] + [applyTodoUpdate, updateTurnId, setThreadUsage, setContextUsage, clearThreadUsage] ); const sendMessage = useCallback( @@ -442,6 +444,7 @@ export function useAgentRollback() { const res = await rollbackContext(threadId, cwd, throughTurnId); clearRunningTurns(threadId); setThreadTurns(threadId, res.turns as Turn[]); + setThreadUsage(threadId, res.usage ?? { prompt: 0, completion: 0, total: 0 }); if (res.rolledBackMessage) { setPendingInput(res.rolledBackMessage); } @@ -455,7 +458,14 @@ export function useAgentRollback() { } return res; }, - [workspace.rootPath, setThreadTurns, clearRunningTurns, setPendingInput, setContextUsage] + [ + workspace.rootPath, + setThreadTurns, + setThreadUsage, + clearRunningTurns, + setPendingInput, + setContextUsage, + ] ); const rollbackBoth = useCallback( @@ -463,6 +473,7 @@ export function useAgentRollback() { const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; const res = await rollbackBothToTurn(threadId, cwd, throughTurnId); setThreadTurns(threadId, res.turns as Turn[]); + setThreadUsage(threadId, res.usage ?? { prompt: 0, completion: 0, total: 0 }); if (res.rolledBackMessage) { setPendingInput(res.rolledBackMessage); } @@ -476,7 +487,7 @@ export function useAgentRollback() { } return res; }, - [workspace.rootPath, setThreadTurns, setPendingInput, setContextUsage] + [workspace.rootPath, setThreadTurns, setThreadUsage, setPendingInput, setContextUsage] ); const undoCodeRollback = useCallback( diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index af926453..97ce6cc1 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -357,7 +357,13 @@ export function rollbackContext( sessionId: string, cwd: string, throughTurnId: number -): Promise<{ ok: boolean; turns: any[]; rolledBackMessage?: string; promptEstimate?: number }> { +): Promise<{ + ok: boolean; + turns: any[]; + rolledBackMessage?: string; + promptEstimate?: number; + usage?: { prompt: number; completion: number; total: number }; +}> { return clients.sessions.rollbackContext({ sessionId, cwd, throughTurnId }) as any; } @@ -371,6 +377,7 @@ export function rollbackBothToTurn( codeResult: CodeRollbackResult; rolledBackMessage?: string; promptEstimate?: number; + usage?: { prompt: number; completion: number; total: number }; }> { return clients.sessions.rollbackBothToTurn({ sessionId, cwd, throughTurnId }) as any; } diff --git a/packages/desktop/src/stores/agent.store.ts b/packages/desktop/src/stores/agent.store.ts index 8b17bbef..512872aa 100644 --- a/packages/desktop/src/stores/agent.store.ts +++ b/packages/desktop/src/stores/agent.store.ts @@ -62,6 +62,7 @@ interface AgentActions { threadId: string, usage: { prompt: number; completion: number; total: number } ) => void; + clearThreadUsage: (threadId: string) => void; loadThreads: (threads: Thread[]) => void; updateToolCallStatus: ( threadId: string, @@ -156,6 +157,11 @@ export const useAgentStore = create()( s.usageByThreadId[threadId] = usage; }), + clearThreadUsage: (threadId) => + set((s) => { + delete s.usageByThreadId[threadId]; + }), + loadThreads: (threads) => { const incomingIds = new Set(threads.map((t) => t.id)); set((s) => { diff --git a/packages/desktop/test/compact-usage-reset.test.ts b/packages/desktop/test/compact-usage-reset.test.ts new file mode 100644 index 00000000..b394a84e --- /dev/null +++ b/packages/desktop/test/compact-usage-reset.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useAgentStore } from '../src/stores/agent.store'; +import { useWorkspaceStore } from '../src/stores/workspace.store'; + +// Reconstruct the side-effect block from useAgentCore.streamChunkToItem for the +// 'reactive_compact' case. This mirrors the actual implementation so we can +// exercise it without rendering the full hook. +function handleReactiveCompact(threadId: string, event: { promptEstimate: number }): void { + const contextUsage = useAgentStore.getState().contextUsage; + if (contextUsage) { + useAgentStore.getState().setContextUsage({ + used: event.promptEstimate, + contextWindow: contextUsage.contextWindow, + }); + } + useAgentStore.getState().clearThreadUsage(threadId); +} + +// Reconstruct the manual /compact handler from AgentWorkspace.ContextIndicator. +// Mirrors the onClick body so we can drive it without rendering the component. +async function runManualCompact( + threadId: string, + response: { didCompress: boolean; promptEstimate: number; released: number } +): Promise { + // mirrors: if (res.didCompress && contextUsage) { setContextUsage(...); clearThreadUsage(threadId); } + const contextUsage = useAgentStore.getState().contextUsage; + if (response.didCompress && contextUsage) { + useAgentStore.getState().setContextUsage({ + used: response.promptEstimate, + contextWindow: contextUsage.contextWindow, + }); + useAgentStore.getState().clearThreadUsage(threadId); + } +} + +beforeEach(() => { + useAgentStore.setState({ + currentThreadId: 'thread-1', + threads: { + 'thread-1': { + id: 'thread-1', + projectId: '', + title: 't1', + cwd: '/test/cwd', + turns: [], + createdAt: 0, + updatedAt: 0, + }, + }, + approvalPolicy: 'ask-all', + model: 'model-1', + models: [{ id: 'model-1', provider: 'p', name: 'm1', context_window: 128000 } as any], + contextUsage: { used: 50000, contextWindow: 128000 }, + todoByThreadId: {}, + pendingInput: null, + usageByThreadId: {}, + isCompressing: false, + automations: [], + }); + useWorkspaceStore.setState({ + rootPath: '/test/cwd', + name: 'test', + projects: [], + currentProjectId: '', + git: { branch: 'main', isDirty: false, staged: [], unstaged: [] }, + }); +}); + +describe('reactive_compact streaming handler', () => { + it('clears usageByThreadId for the affected thread only', () => { + useAgentStore + .getState() + .setThreadUsage('thread-1', { prompt: 3000, completion: 500, total: 3500 }); + useAgentStore + .getState() + .setThreadUsage('thread-2', { prompt: 800, completion: 400, total: 1200 }); + + handleReactiveCompact('thread-1', { promptEstimate: 1200 }); + + expect(useAgentStore.getState().usageByThreadId['thread-1']).toBeUndefined(); + expect(useAgentStore.getState().usageByThreadId['thread-2']).toEqual({ + prompt: 800, + completion: 400, + total: 1200, + }); + }); + + it('updates contextUsage.used to the new promptEstimate', () => { + useAgentStore.getState().setContextUsage({ used: 95000, contextWindow: 128000 }); + + handleReactiveCompact('thread-1', { promptEstimate: 1200 }); + + expect(useAgentStore.getState().contextUsage).toEqual({ + used: 1200, + contextWindow: 128000, + }); + }); + + it('does not throw when contextUsage is null (e.g., model not loaded)', () => { + useAgentStore.getState().setContextUsage(null); + useAgentStore + .getState() + .setThreadUsage('thread-1', { prompt: 3000, completion: 500, total: 3500 }); + + expect(() => handleReactiveCompact('thread-1', { promptEstimate: 1200 })).not.toThrow(); + expect(useAgentStore.getState().usageByThreadId['thread-1']).toBeUndefined(); + }); +}); + +describe('manual /compact button handler (ContextIndicator)', () => { + it('clears usageByThreadId when didCompress is true', async () => { + useAgentStore + .getState() + .setThreadUsage('thread-1', { prompt: 3000, completion: 500, total: 3500 }); + + await runManualCompact('thread-1', { + didCompress: true, + promptEstimate: 1200, + released: 5000, + }); + + expect(useAgentStore.getState().usageByThreadId['thread-1']).toBeUndefined(); + expect(useAgentStore.getState().contextUsage).toEqual({ + used: 1200, + contextWindow: 128000, + }); + }); + + it('does NOT update state when didCompress is false (below threshold)', async () => { + useAgentStore + .getState() + .setThreadUsage('thread-1', { prompt: 3000, completion: 500, total: 3500 }); + useAgentStore.getState().setContextUsage({ used: 50000, contextWindow: 128000 }); + + await runManualCompact('thread-1', { + didCompress: false, + promptEstimate: 45000, + released: 0, + }); + + expect(useAgentStore.getState().usageByThreadId['thread-1']).toEqual({ + prompt: 3000, + completion: 500, + total: 3500, + }); + expect(useAgentStore.getState().contextUsage).toEqual({ + used: 50000, + contextWindow: 128000, + }); + }); +}); diff --git a/packages/desktop/test/global-store.test.ts b/packages/desktop/test/global-store.test.ts index 529a0e85..b2277764 100644 --- a/packages/desktop/test/global-store.test.ts +++ b/packages/desktop/test/global-store.test.ts @@ -589,6 +589,30 @@ describe('global store - token usage', () => { useAgentStore.getState().setCurrentThread('t1'); expect(useAgentStore.getState().contextUsage).toBeNull(); }); + + it('clearThreadUsage removes the entry for a single thread', () => { + useAgentStore.getState().setThreadUsage('t1', { prompt: 1000, completion: 500, total: 1500 }); + useAgentStore.getState().setThreadUsage('t2', { prompt: 800, completion: 400, total: 1200 }); + useAgentStore.getState().clearThreadUsage('t1'); + expect(useAgentStore.getState().usageByThreadId['t1']).toBeUndefined(); + expect(useAgentStore.getState().usageByThreadId['t2']).toEqual({ + prompt: 800, + completion: 400, + total: 1200, + }); + expect('t1' in useAgentStore.getState().usageByThreadId).toBe(false); + expect('t2' in useAgentStore.getState().usageByThreadId).toBe(true); + }); + + it('clearThreadUsage is a no-op for a threadId with no entry', () => { + useAgentStore.getState().setThreadUsage('t1', { prompt: 1000, completion: 500, total: 1500 }); + useAgentStore.getState().clearThreadUsage('t-does-not-exist'); + expect(useAgentStore.getState().usageByThreadId['t1']).toEqual({ + prompt: 1000, + completion: 500, + total: 1500, + }); + }); }); describe('global store - compressing state', () => { diff --git a/packages/desktop/test/rollback-usage-reset.test.ts b/packages/desktop/test/rollback-usage-reset.test.ts new file mode 100644 index 00000000..99e72129 --- /dev/null +++ b/packages/desktop/test/rollback-usage-reset.test.ts @@ -0,0 +1,287 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { useAgentStore } from '../src/stores/agent.store'; +import { useWorkspaceStore } from '../src/stores/workspace.store'; +import { useAgentRollback } from '../src/hooks/useAgent'; + +const { rollbackContextMock, rollbackBothToTurnMock } = vi.hoisted(() => ({ + rollbackContextMock: vi.fn(), + rollbackBothToTurnMock: vi.fn(), +})); + +vi.mock('../src/lib/core-api', () => ({ + rollbackContext: rollbackContextMock, + rollbackBothToTurn: rollbackBothToTurnMock, + deleteSession: vi.fn(), + listSessions: vi.fn(), + getCheckpointDiff: vi.fn(), + revertFile: vi.fn(), + revertFiles: vi.fn(), + previewRollbackDiff: vi.fn(), + rollbackCodeToTurn: vi.fn(), + undoLastCodeRollback: vi.fn(), + getRollbackState: vi.fn(), + forkSession: vi.fn(), + listModels: vi.fn(), + switchModel: vi.fn(), + listAgents: vi.fn(), + createSession: vi.fn(), + getSessionHistory: vi.fn(), + resumeSession: vi.fn(), + setSessionPermissionMode: vi.fn(), + sendApprovalResponse: vi.fn(), + getMemoryConfig: vi.fn(), + setMemoryEnabled: vi.fn(), + setMemoryTypeDisabled: vi.fn(), + createMemoryExtraType: vi.fn(), + updateMemoryExtraType: vi.fn(), + deleteMemoryExtraType: vi.fn(), + setMemoryModel: vi.fn(), + setAgentConfig: vi.fn(), + getAgentConfig: vi.fn(), + setCompactionModel: vi.fn(), + listMcpServers: vi.fn(), + setMcpDisabled: vi.fn(), + resetMcpDisabled: vi.fn(), + createMcpServer: vi.fn(), + updateMcpServer: vi.fn(), + deleteMcpServer: vi.fn(), + setAgentDisabled: vi.fn(), + resetAgentDisabled: vi.fn(), + createAgent: vi.fn(), + updateAgent: vi.fn(), + deleteAgent: vi.fn(), + getSubagentEnabled: vi.fn(), + setSubagentEnabled: vi.fn(), + resetSubagentEnabled: vi.fn(), + listSkills: vi.fn(), + toggleSkill: vi.fn(), + listHooks: vi.fn(), + createHook: vi.fn(), + updateHook: vi.fn(), + deleteHook: vi.fn(), + listAutomations: vi.fn(), + createAutomation: vi.fn(), + updateAutomation: vi.fn(), + runAutomationOnce: vi.fn(), +})); + +function resetStores() { + useAgentStore.setState({ + currentThreadId: 'thread-1', + threads: { + 'thread-1': { + id: 'thread-1', + projectId: '', + title: 't1', + cwd: '/test/cwd', + turns: [], + createdAt: 0, + updatedAt: 0, + }, + }, + approvalPolicy: 'ask-all', + model: 'model-1', + models: [{ id: 'model-1', provider: 'p', name: 'm1', context_window: 128000 } as any], + contextUsage: null, + todoByThreadId: {}, + pendingInput: null, + usageByThreadId: {}, + isCompressing: false, + automations: [], + }); + useWorkspaceStore.setState({ + rootPath: '/test/cwd', + name: 'test', + projects: [], + currentProjectId: '', + git: { branch: 'main', isDirty: false, staged: [], unstaged: [] }, + }); +} + +beforeEach(() => { + rollbackContextMock.mockReset(); + rollbackBothToTurnMock.mockReset(); + resetStores(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('useAgentRollback().rollbackCtx - per-thread usage from server', () => { + it('adopts the server usage when the rolled-back state still has prior assistant usage', async () => { + act(() => { + useAgentStore.getState().setThreadUsage('thread-1', { + prompt: 3000, + completion: 500, + total: 3500, + }); + }); + + rollbackContextMock.mockResolvedValue({ + turns: [], + rolledBackMessage: null, + promptEstimate: 1200, + usage: { prompt: 800, completion: 400, total: 1200 }, + }); + + const { result } = renderHook(() => useAgentRollback()); + await act(async () => { + await result.current.rollbackCtx('thread-1', 2); + }); + + expect(useAgentStore.getState().usageByThreadId['thread-1']).toEqual({ + prompt: 800, + completion: 400, + total: 1200, + }); + expect(useAgentStore.getState().contextUsage).toEqual({ + used: 1200, + contextWindow: 128000, + }); + }); + + it('falls back to zeros when the server returns no usage (first-round rollback)', async () => { + act(() => { + useAgentStore.getState().setThreadUsage('thread-1', { + prompt: 3000, + completion: 500, + total: 3500, + }); + }); + + rollbackContextMock.mockResolvedValue({ + turns: [], + rolledBackMessage: 'first prompt', + promptEstimate: 0, + }); + + const { result } = renderHook(() => useAgentRollback()); + await act(async () => { + await result.current.rollbackCtx('thread-1', 1); + }); + + expect(useAgentStore.getState().usageByThreadId['thread-1']).toEqual({ + prompt: 0, + completion: 0, + total: 0, + }); + expect(useAgentStore.getState().contextUsage).toEqual({ + used: 0, + contextWindow: 128000, + }); + }); + + it('uses promptEstimate for contextUsage.used when usage is also provided', async () => { + rollbackContextMock.mockResolvedValue({ + turns: [], + rolledBackMessage: null, + promptEstimate: 1234, + usage: { prompt: 800, completion: 400, total: 1200 }, + }); + + const { result } = renderHook(() => useAgentRollback()); + await act(async () => { + await result.current.rollbackCtx('thread-1', 2); + }); + + expect(useAgentStore.getState().contextUsage?.used).toBe(1234); + expect(useAgentStore.getState().usageByThreadId['thread-1']).toEqual({ + prompt: 800, + completion: 400, + total: 1200, + }); + }); + + it('refills the rolled-back message into pendingInput', async () => { + rollbackContextMock.mockResolvedValue({ + turns: [], + rolledBackMessage: 'first prompt', + promptEstimate: 0, + usage: undefined, + }); + + const { result } = renderHook(() => useAgentRollback()); + await act(async () => { + await result.current.rollbackCtx('thread-1', 1); + }); + + expect(useAgentStore.getState().pendingInput).toBe('first prompt'); + }); +}); + +describe('useAgentRollback().rollbackBoth - per-thread usage from server', () => { + it('adopts the server usage when the rolled-back state still has prior assistant usage', async () => { + act(() => { + useAgentStore.getState().setThreadUsage('thread-1', { + prompt: 3000, + completion: 500, + total: 3500, + }); + }); + + rollbackBothToTurnMock.mockResolvedValue({ + turns: [], + rolledBackMessage: null, + codeResult: { + reverted: false, + throughTurnId: 0, + affectedTurns: [], + selectedFiles: [], + restoreEntry: null, + }, + promptEstimate: 1200, + usage: { prompt: 800, completion: 400, total: 1200 }, + }); + + const { result } = renderHook(() => useAgentRollback()); + await act(async () => { + await result.current.rollbackBoth('thread-1', 2); + }); + + expect(useAgentStore.getState().usageByThreadId['thread-1']).toEqual({ + prompt: 800, + completion: 400, + total: 1200, + }); + expect(useAgentStore.getState().contextUsage).toEqual({ + used: 1200, + contextWindow: 128000, + }); + }); + + it('falls back to zeros when the server returns no usage', async () => { + rollbackBothToTurnMock.mockResolvedValue({ + turns: [], + rolledBackMessage: null, + codeResult: { + reverted: false, + throughTurnId: 0, + affectedTurns: [], + selectedFiles: [], + restoreEntry: null, + }, + promptEstimate: 0, + }); + + const { result } = renderHook(() => useAgentRollback()); + await act(async () => { + await result.current.rollbackBoth('thread-1', 1); + }); + + expect(useAgentStore.getState().usageByThreadId['thread-1']).toEqual({ + prompt: 0, + completion: 0, + total: 0, + }); + expect(useAgentStore.getState().contextUsage).toEqual({ + used: 0, + contextWindow: 128000, + }); + }); +}); diff --git a/packages/desktop/test/sidebar-thread-switch.test.tsx b/packages/desktop/test/sidebar-thread-switch.test.tsx new file mode 100644 index 00000000..3571a4e7 --- /dev/null +++ b/packages/desktop/test/sidebar-thread-switch.test.tsx @@ -0,0 +1,129 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { act, render, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { useAgentStore } from '../src/stores/agent.store'; +import { useUIStore } from '../src/stores/ui.store'; +import { useWorkspaceStore } from '../src/stores/workspace.store'; +import AgentSidebar from '../src/agent/AgentSidebar'; + +const deleteThreadMock = vi.fn(); + +vi.mock('../src/hooks/useAgent', () => ({ + useAgentRollback: () => ({ + deleteThread: deleteThreadMock, + }), + useAgentApproval: () => ({ approveTool: vi.fn(), rejectTool: vi.fn() }), +})); + +function resetStores({ currentThreadId = null as string | null } = {}) { + useAgentStore.setState({ + currentThreadId, + threads: {}, + approvalPolicy: 'ask-all', + model: '', + models: [], + contextUsage: null, + todoByThreadId: {}, + pendingInput: null, + usageByThreadId: {}, + isCompressing: false, + automations: [], + }); + useUIStore.setState({ + view: 'agent', + sidebarCollapsed: false, + settingsInitialTab: null, + }); + useWorkspaceStore.setState({ + rootPath: '/test/cwd', + name: 'test', + projects: [], + currentProjectId: '', + git: { branch: 'main', isDirty: false, staged: [], unstaged: [] }, + }); +} + +function seedThreads() { + act(() => { + useAgentStore.setState((s) => { + s.threads = { + 't-1': { + id: 't-1', + projectId: '', + title: 'first', + cwd: '/test/cwd', + turns: [], + createdAt: 1000, + updatedAt: 1000, + }, + 't-2': { + id: 't-2', + projectId: '', + title: 'second', + cwd: '/test/cwd', + turns: [], + createdAt: 2000, + updatedAt: 2000, + }, + }; + }); + }); +} + +beforeEach(() => { + cleanup(); + deleteThreadMock.mockReset(); + resetStores(); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('AgentSidebar session switching and new session', () => { + it('clicking the "新对话" button sets currentThreadId to null', () => { + seedThreads(); + act(() => { + useAgentStore.setState((s) => { + s.currentThreadId = 't-1'; + }); + }); + const { getByText } = render(); + const newBtn = getByText('新对话'); + act(() => { + fireEvent.click(newBtn); + }); + expect(useAgentStore.getState().currentThreadId).toBeNull(); + }); + + it('clicking a session item in the list sets currentThreadId to that session', () => { + seedThreads(); + act(() => { + useAgentStore.setState((s) => { + s.currentThreadId = 't-1'; + }); + }); + const { getByText } = render(); + const secondSessionBtn = getByText('second'); + act(() => { + fireEvent.click(secondSessionBtn); + }); + expect(useAgentStore.getState().currentThreadId).toBe('t-2'); + }); + + it('clicking a session item does not throw and does not invoke deleteThread', () => { + seedThreads(); + const { getByText } = render(); + const sessionBtn = getByText('first'); + expect(() => { + act(() => { + fireEvent.click(sessionBtn); + }); + }).not.toThrow(); + expect(deleteThreadMock).not.toHaveBeenCalled(); + }); +});