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/2] 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 03ecf75..b58d029 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 11b57ff..767d65c 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 c7dafe8..3913f88 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 10691bc..66f0118 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 bbcfeda..bd086a1 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 7996f0d..b78409a 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 156ca67..7d8f450 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 34a47d1..fce4e05 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 9865666..a7241ba 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 2867ef1..a7e89bf 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 430cf95..142bb0f 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 362d7e1..aa5f84d 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 895ef37..e9a9639 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 e898637..d3a1c29 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 5d103e2..d99faf3 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 0f00c49..f8898fc 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 1f75558..99dd9af 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 1002b5d..3833b54 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 3b4ea8a..3bc6ac4 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 20d10ef..699d38f 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 77d9a9f..cfa7fc7 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 31a07b5..6138e22 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 0000000..f148667 --- /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 38470e1..b0272c9 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 60feb6f..494aa69 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 073c6d9..3a96e02 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 bb39916..6a417db 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 55b0471..36275df 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 02bbf5d..a6345a0 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 e1f1437..e791a97 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 4b475ca..05fda80 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 3c6c4b0..446f363 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 a6bd9f3..529a0e8 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 38bc7a8..32f7a58 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/2] 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 c563509..67c3357 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 b58d029..440fc87 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 fae8de5..3619a8e 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 3913f88..fe7e969 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 87fe877..433a28d 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 fce4e05..4652c82 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 a7241ba..36dcf45 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 54f675a..892d2fc 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 a7e89bf..30bff01 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 142bb0f..7e5ad54 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 aa5f84d..2310b2e 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 e9a9639..ca43a46 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 d3a1c29..acf45e6 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 d99faf3..178dd8b 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 f8898fc..33da365 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 99dd9af..c6fe6d0 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 e528d1c..72f3372 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 a16a6be..a8fdad3 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 ff0d6bc..240ced4 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 6a68a19..b860264 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 3bc6ac4..787898a 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 699d38f..d15014f 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 809bd37..deb981d 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 6138e22..761037e 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 f148667..b626273 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 89170a5..5e7504b 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 5d0e020..0000000 --- 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 494aa69..a7971b2 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 3a96e02..aeb6652 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 6a417db..bfd10ec 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 f6e4cdc..a8a3cf0 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 642d963..9e41c94 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 36275df..78c509d 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 5d787d8..df23c62 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 82a26fc..eee8de7 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 2f838a5..65ea357 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 a6345a0..c6c413e 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 e791a97..3ff9201 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 09d6b3f..b877443 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 81154b4..43d976b 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', + ]); }); });