diff --git a/packages/claude-sdk/src/private/AgentRun.ts b/packages/claude-sdk/src/private/AgentRun.ts index 6a0ac50..d1266a5 100644 --- a/packages/claude-sdk/src/private/AgentRun.ts +++ b/packages/claude-sdk/src/private/AgentRun.ts @@ -7,7 +7,7 @@ import { AnthropicBeta } from '../public/enums'; import type { AnyToolDefinition, ILogger, RunAgentQuery, SdkMessage } from '../public/types'; import { AgentChannel } from './AgentChannel'; import { ApprovalState } from './ApprovalState'; -import type { ConversationHistory } from './ConversationHistory'; +import type { ConversationStore } from './ConversationStore'; import { AGENT_SDK_PREFIX } from './consts'; import { MessageStream } from './MessageStream'; import { calculateCost, getContextWindow } from './pricing'; @@ -17,12 +17,12 @@ export class AgentRun { readonly #client: Anthropic; readonly #logger: ILogger | undefined; readonly #options: RunAgentQuery; - readonly #history: ConversationHistory; + readonly #history: ConversationStore; readonly #channel: AgentChannel; readonly #approval: ApprovalState; readonly #abortController: AbortController; - public constructor(client: Anthropic, logger: ILogger | undefined, options: RunAgentQuery, history: ConversationHistory) { + public constructor(client: Anthropic, logger: ILogger | undefined, options: RunAgentQuery, history: ConversationStore) { this.#client = client; this.#logger = logger; this.#options = options; diff --git a/packages/claude-sdk/src/private/AnthropicAgent.ts b/packages/claude-sdk/src/private/AnthropicAgent.ts index fa93c27..df2b8fe 100644 --- a/packages/claude-sdk/src/private/AnthropicAgent.ts +++ b/packages/claude-sdk/src/private/AnthropicAgent.ts @@ -3,14 +3,14 @@ import versionJson from '@shellicar/build-version/version'; import { IAnthropicAgent } from '../public/interfaces'; import type { AnthropicAgentOptions, ILogger, RunAgentQuery, RunAgentResult } from '../public/types'; import { AgentRun } from './AgentRun'; -import { ConversationHistory } from './ConversationHistory'; +import { ConversationStore } from './ConversationStore'; import { customFetch } from './http/customFetch'; import { TokenRefreshingAnthropic } from './http/TokenRefreshingAnthropic'; export class AnthropicAgent extends IAnthropicAgent { readonly #client: TokenRefreshingAnthropic; readonly #logger: ILogger | undefined; - readonly #history: ConversationHistory; + readonly #history: ConversationStore; public constructor(options: AnthropicAgentOptions) { super(); @@ -24,7 +24,7 @@ export class AnthropicAgent extends IAnthropicAgent { logger: options.logger, defaultHeaders, }); - this.#history = new ConversationHistory(options.historyFile); + this.#history = new ConversationStore(options.historyFile); } public runAgent(options: RunAgentQuery): RunAgentResult { diff --git a/packages/claude-sdk/src/private/ConversationHistory.ts b/packages/claude-sdk/src/private/Conversation.ts similarity index 57% rename from packages/claude-sdk/src/private/ConversationHistory.ts rename to packages/claude-sdk/src/private/Conversation.ts index 8630a47..a55e801 100644 --- a/packages/claude-sdk/src/private/ConversationHistory.ts +++ b/packages/claude-sdk/src/private/Conversation.ts @@ -1,16 +1,15 @@ -import { readFileSync, renameSync, writeFileSync } from 'node:fs'; import type { Anthropic } from '@anthropic-ai/sdk'; -type HistoryItem = { +export type HistoryItem = { id?: string; msg: Anthropic.Beta.Messages.BetaMessageParam; }; -function hasCompactionBlock(msg: Anthropic.Beta.Messages.BetaMessageParam): boolean { +export function hasCompactionBlock(msg: Anthropic.Beta.Messages.BetaMessageParam): boolean { return Array.isArray(msg.content) && msg.content.some((b) => b.type === 'compaction'); } -function trimToLastCompaction(items: HistoryItem[]): HistoryItem[] { +export function trimToLastCompaction(items: HistoryItem[]): HistoryItem[] { for (let i = items.length - 1; i >= 0; i--) { const item = items[i]; if (item && hasCompactionBlock(item.msg)) { @@ -20,34 +19,31 @@ function trimToLastCompaction(items: HistoryItem[]): HistoryItem[] { return items; } -export class ConversationHistory { +/** + * Pure in-memory conversation state. + * + * Knows nothing about files or I/O. Enforces role-alternation merge and + * compaction-triggered clear. ConversationStore wraps this to add persistence. + */ +export class Conversation { readonly #items: HistoryItem[] = []; - readonly #historyFile: string | undefined; - - public constructor(historyFile?: string) { - this.#historyFile = historyFile; - if (historyFile) { - try { - const raw = readFileSync(historyFile, 'utf-8'); - const msgs = raw - .split('\n') - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as Anthropic.Beta.Messages.BetaMessageParam); - this.#items.push(...trimToLastCompaction(msgs.map((msg) => ({ msg })))); - } catch { - // No history file yet - } - } - } public get messages(): Anthropic.Beta.Messages.BetaMessageParam[] { return this.#items.map((item) => item.msg); } /** - * Append a message to the conversation history. - * @param msg The message to append. - * @param opts Optional. `id` tags the message for later removal via `remove(id)`. + * Populate from pre-parsed items without applying merge or compaction logic. + * Only ConversationStore should call this, during construction from a persisted file. + */ + public load(items: HistoryItem[]): void { + this.#items.push(...items); + } + + /** + * Append a message, enforcing role-alternation and compaction-clear semantics. + * @param msg The message to append. + * @param opts Optional. `id` tags the message for later removal via `remove(id)`. */ public push(msg: Anthropic.Beta.Messages.BetaMessageParam, opts?: { id?: string }): void { if (hasCompactionBlock(msg)) { @@ -64,11 +60,10 @@ export class ConversationHistory { } else { this.#items.push({ id: opts?.id, msg }); } - this.#save(); } /** - * Remove a previously pushed message by its tag. + * Remove the last message tagged with `id`. * Returns `true` if found and removed, `false` if no message with that id exists. */ public remove(id: string): boolean { @@ -77,16 +72,6 @@ export class ConversationHistory { return false; } this.#items.splice(idx, 1); - this.#save(); return true; } - - #save(): void { - if (!this.#historyFile) { - return; - } - const tmp = `${this.#historyFile}.tmp`; - writeFileSync(tmp, this.#items.map((item) => JSON.stringify(item.msg)).join('\n')); - renameSync(tmp, this.#historyFile); - } } diff --git a/packages/claude-sdk/src/private/ConversationStore.ts b/packages/claude-sdk/src/private/ConversationStore.ts new file mode 100644 index 0000000..fa57013 --- /dev/null +++ b/packages/claude-sdk/src/private/ConversationStore.ts @@ -0,0 +1,56 @@ +import { readFileSync, renameSync, writeFileSync } from 'node:fs'; +import type { Anthropic } from '@anthropic-ai/sdk'; +import { Conversation, trimToLastCompaction } from './Conversation'; + +/** + * File-backed conversation store. + * + * Wraps Conversation (pure data) and persists to a JSONL file after every + * mutation. When no historyFile is provided it behaves as an in-memory store + * identical to bare Conversation. + */ +export class ConversationStore { + readonly #conversation: Conversation; + readonly #historyFile: string | undefined; + + public constructor(historyFile?: string) { + this.#historyFile = historyFile; + this.#conversation = new Conversation(); + if (historyFile) { + try { + const raw = readFileSync(historyFile, 'utf-8'); + const msgs = raw + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Anthropic.Beta.Messages.BetaMessageParam); + this.#conversation.load(trimToLastCompaction(msgs.map((msg) => ({ msg })))); + } catch { + // No history file yet — start fresh. + } + } + } + + public get messages(): Anthropic.Beta.Messages.BetaMessageParam[] { + return this.#conversation.messages; + } + + public push(msg: Anthropic.Beta.Messages.BetaMessageParam, opts?: { id?: string }): void { + this.#conversation.push(msg, opts); + this.#save(); + } + + public remove(id: string): boolean { + const result = this.#conversation.remove(id); + this.#save(); + return result; + } + + #save(): void { + if (!this.#historyFile) { + return; + } + const tmp = `${this.#historyFile}.tmp`; + writeFileSync(tmp, this.#conversation.messages.map((msg) => JSON.stringify(msg)).join('\n')); + renameSync(tmp, this.#historyFile); + } +} diff --git a/packages/claude-sdk/test/Conversation.spec.ts b/packages/claude-sdk/test/Conversation.spec.ts new file mode 100644 index 0000000..526dca6 --- /dev/null +++ b/packages/claude-sdk/test/Conversation.spec.ts @@ -0,0 +1,241 @@ +import type { Anthropic } from '@anthropic-ai/sdk'; +import { describe, expect, it } from 'vitest'; +import { Conversation } from '../src/private/Conversation.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type Role = Anthropic.Beta.Messages.BetaMessageParam['role']; + +function msg(role: Role, text: string): Anthropic.Beta.Messages.BetaMessageParam { + return { role, content: [{ type: 'text', text }] }; +} + +function compactionMsg(): Anthropic.Beta.Messages.BetaMessageParam { + return { + role: 'user', + content: [{ type: 'compaction', summary: 'summary', llm_identifier: 'claude-3-5-sonnet-20241022' }], + } as unknown as Anthropic.Beta.Messages.BetaMessageParam; +} + +function texts(conversation: Conversation): (string | undefined)[] { + return conversation.messages.map((m) => (m.content as { text: string }[])[0]?.text); +} + +// --------------------------------------------------------------------------- +// push / messages +// --------------------------------------------------------------------------- + +describe('Conversation.push / messages', () => { + it('starts empty', () => { + const actual = new Conversation().messages.length; + expect(actual).toBe(0); + }); + + it('appends messages in order', () => { + const c = new Conversation(); + c.push(msg('user', 'hello')); + c.push(msg('assistant', 'hi')); + c.push(msg('user', 'bye')); + const expected = ['hello', 'hi', 'bye']; + const actual = texts(c); + expect(actual).toEqual(expected); + }); + + it('message count after multiple pushes', () => { + const c = new Conversation(); + c.push(msg('user', 'a')); + c.push(msg('assistant', 'b')); + c.push(msg('user', 'c')); + const expected = 3; + const actual = c.messages.length; + expect(actual).toBe(expected); + }); + + it('merges consecutive user messages into one entry', () => { + const c = new Conversation(); + c.push(msg('user', 'part one')); + c.push(msg('user', 'part two')); + const expected = 1; + const actual = c.messages.length; + expect(actual).toBe(expected); + }); + + it('merged user message has combined content', () => { + const c = new Conversation(); + c.push(msg('user', 'part one')); + c.push(msg('user', 'part two')); + const content = c.messages[0]?.content as { text: string }[]; + const expected = ['part one', 'part two']; + const actual = content.map((b) => b.text); + expect(actual).toEqual(expected); + }); + + it('does not merge consecutive assistant messages', () => { + const c = new Conversation(); + c.push(msg('assistant', 'first')); + c.push(msg('assistant', 'second')); + const expected = 2; + const actual = c.messages.length; + expect(actual).toBe(expected); + }); + + it('clears all prior messages on compaction', () => { + const c = new Conversation(); + c.push(msg('user', 'old')); + c.push(msg('assistant', 'old reply')); + c.push(compactionMsg()); + const expected = 1; + const actual = c.messages.length; + expect(actual).toBe(expected); + }); + + it('keeps the compaction message after clear', () => { + const c = new Conversation(); + c.push(msg('user', 'old')); + c.push(compactionMsg()); + const expected = 'compaction'; + const actual = (c.messages[0]?.content as { type: string }[])[0]?.type; + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// id tagging and remove +// --------------------------------------------------------------------------- + +describe('Conversation id tagging and remove', () => { + it('remove returns true when id exists', () => { + const c = new Conversation(); + c.push(msg('user', 'hello')); + c.push(msg('assistant', 'context'), { id: 'ctx-1' }); + const expected = true; + const actual = c.remove('ctx-1'); + expect(actual).toBe(expected); + }); + + it('remove decreases message count', () => { + const c = new Conversation(); + c.push(msg('user', 'hello')); + c.push(msg('assistant', 'context'), { id: 'ctx-1' }); + c.remove('ctx-1'); + const expected = 1; + const actual = c.messages.length; + expect(actual).toBe(expected); + }); + + it('remove leaves remaining messages intact', () => { + const c = new Conversation(); + c.push(msg('user', 'hello')); + c.push(msg('assistant', 'context'), { id: 'ctx-1' }); + c.push(msg('user', 'follow up')); + c.remove('ctx-1'); + const expected = ['hello', 'follow up']; + const actual = texts(c); + expect(actual).toEqual(expected); + }); + + it('remove returns false when id is not found', () => { + const c = new Conversation(); + c.push(msg('user', 'hello')); + const expected = false; + const actual = c.remove('nonexistent'); + expect(actual).toBe(expected); + }); + + it('remove does not change message count when id is not found', () => { + const c = new Conversation(); + c.push(msg('user', 'hello')); + c.remove('nonexistent'); + const expected = 1; + const actual = c.messages.length; + expect(actual).toBe(expected); + }); + + it('remove targets the last message with the given id', () => { + const c = new Conversation(); + c.push(msg('assistant', 'first tagged'), { id: 'dup' }); + c.push(msg('user', 'separator')); + c.push(msg('assistant', 'second tagged'), { id: 'dup' }); + c.remove('dup'); + const expected = ['first tagged', 'separator']; + const actual = texts(c); + expect(actual).toEqual(expected); + }); + + it('merged user messages lose their id tag', () => { + const c = new Conversation(); + c.push(msg('user', 'first'), { id: 'tagged' }); + c.push(msg('user', 'second')); // triggers merge — tag on 'first' is dropped + const expected = false; + const actual = c.remove('tagged'); + expect(actual).toBe(expected); + }); + + it('merged message content is preserved even after tag is lost', () => { + const c = new Conversation(); + c.push(msg('user', 'first'), { id: 'tagged' }); + c.push(msg('user', 'second')); + c.remove('tagged'); + const expected = 1; + const actual = c.messages.length; + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// compaction edge cases +// --------------------------------------------------------------------------- + +describe('Conversation compaction edge cases', () => { + it('compaction clears tagged messages', () => { + const c = new Conversation(); + c.push(msg('user', 'old'), { id: 'old-ctx' }); + c.push(compactionMsg()); + const expected = false; + const actual = c.remove('old-ctx'); + expect(actual).toBe(expected); + }); + + it('only compaction message remains after compaction clears history', () => { + const c = new Conversation(); + c.push(msg('user', 'old'), { id: 'old-ctx' }); + c.push(compactionMsg()); + const expected = 1; + const actual = c.messages.length; + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// load (raw initialization — bypasses merge and compaction logic) +// --------------------------------------------------------------------------- + +describe('Conversation.load', () => { + it('populates messages from raw items', () => { + const c = new Conversation(); + c.load([{ msg: msg('user', 'loaded') }, { msg: msg('assistant', 'reply') }]); + const expected = 2; + const actual = c.messages.length; + expect(actual).toBe(expected); + }); + + it('does not apply merge logic during load', () => { + // Two consecutive user messages loaded directly should remain separate. + const c = new Conversation(); + c.load([{ msg: msg('user', 'a') }, { msg: msg('user', 'b') }]); + const expected = 2; + const actual = c.messages.length; + expect(actual).toBe(expected); + }); + + it('loaded messages appear before subsequent pushes', () => { + const c = new Conversation(); + c.load([{ msg: msg('user', 'loaded') }]); + c.push(msg('assistant', 'pushed')); + const expected = ['loaded', 'pushed']; + const actual = texts(c); + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/claude-sdk/test/ConversationHistory.spec.ts b/packages/claude-sdk/test/ConversationHistory.spec.ts deleted file mode 100644 index 0d960fc..0000000 --- a/packages/claude-sdk/test/ConversationHistory.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { Anthropic } from '@anthropic-ai/sdk'; -import { describe, expect, it } from 'vitest'; -import { ConversationHistory } from '../src/private/ConversationHistory.js'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -type Role = Anthropic.Beta.Messages.BetaMessageParam['role']; - -function msg(role: Role, text: string): Anthropic.Beta.Messages.BetaMessageParam { - return { role, content: [{ type: 'text', text }] }; -} - -function compactionMsg(): Anthropic.Beta.Messages.BetaMessageParam { - return { - role: 'user', - content: [{ type: 'compaction', summary: 'summary', llm_identifier: 'claude-3-5-sonnet-20241022' }], - } as unknown as Anthropic.Beta.Messages.BetaMessageParam; -} - -// --------------------------------------------------------------------------- -// push + messages -// --------------------------------------------------------------------------- - -describe('ConversationHistory.push / messages', () => { - it('appends messages in order', () => { - const h = new ConversationHistory(); - h.push(msg('user', 'hello')); - h.push(msg('assistant', 'hi')); - h.push(msg('user', 'bye')); - - const msgs = h.messages; - expect(msgs).toHaveLength(3); - expect((msgs[0]?.content as { text: string }[])[0]?.text).toBe('hello'); - expect((msgs[1]?.content as { text: string }[])[0]?.text).toBe('hi'); - expect((msgs[2]?.content as { text: string }[])[0]?.text).toBe('bye'); - }); - - it('merges consecutive user messages into one', () => { - const h = new ConversationHistory(); - h.push(msg('user', 'part one')); - h.push(msg('user', 'part two')); - - const msgs = h.messages; - expect(msgs).toHaveLength(1); - expect(msgs[0]?.role).toBe('user'); - const content = msgs[0]?.content as { text: string }[]; - expect(content).toHaveLength(2); - expect(content[0]?.text).toBe('part one'); - expect(content[1]?.text).toBe('part two'); - }); - - it('does NOT merge consecutive assistant messages', () => { - // assistant→assistant is not typical but the class should not merge them - const h = new ConversationHistory(); - h.push(msg('assistant', 'first')); - h.push(msg('assistant', 'second')); - - expect(h.messages).toHaveLength(2); - }); - - it('clears history when a compaction block is pushed', () => { - const h = new ConversationHistory(); - h.push(msg('user', 'old message 1')); - h.push(msg('assistant', 'old reply')); - expect(h.messages).toHaveLength(2); - - h.push(compactionMsg()); - - // Only the compaction message should remain - const msgs = h.messages; - expect(msgs).toHaveLength(1); - expect((msgs[0]?.content as { type: string }[])[0]?.type).toBe('compaction'); - }); - - it('starts empty with no history file', () => { - const h = new ConversationHistory(); - expect(h.messages).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// push with id / remove -// --------------------------------------------------------------------------- - -describe('ConversationHistory id tagging + remove', () => { - it('tags a message and remove() finds it', () => { - const h = new ConversationHistory(); - h.push(msg('user', 'hello')); - h.push(msg('assistant', 'context injection'), { id: 'ctx-1' }); - h.push(msg('user', 'follow up')); - - expect(h.messages).toHaveLength(3); - const removed = h.remove('ctx-1'); - expect(removed).toBe(true); - expect(h.messages).toHaveLength(2); - expect((h.messages[0]?.content as { text: string }[])[0]?.text).toBe('hello'); - expect((h.messages[1]?.content as { text: string }[])[0]?.text).toBe('follow up'); - }); - - it('remove() returns false when id is not found', () => { - const h = new ConversationHistory(); - h.push(msg('user', 'hello')); - expect(h.remove('nonexistent')).toBe(false); - expect(h.messages).toHaveLength(1); - }); - - it('remove() targets the LAST message with the given id', () => { - const h = new ConversationHistory(); - h.push(msg('assistant', 'first tagged'), { id: 'dup' }); - // A non-user message in between so there's no merge issue - h.push(msg('user', 'separator')); - h.push(msg('assistant', 'second tagged'), { id: 'dup' }); - - // Should remove the last one - expect(h.remove('dup')).toBe(true); - const msgs = h.messages; - expect(msgs).toHaveLength(2); - expect((msgs[0]?.content as { text: string }[])[0]?.text).toBe('first tagged'); - expect((msgs[1]?.content as { text: string }[])[0]?.text).toBe('separator'); - }); - - it('merging consecutive user messages drops the id tag', () => { - const h = new ConversationHistory(); - h.push(msg('user', 'first'), { id: 'tagged' }); - h.push(msg('user', 'second')); // triggers merge — tag on 'first' is dropped - - // The merged message should NOT be findable by the old id - expect(h.remove('tagged')).toBe(false); - // But content is merged - expect(h.messages).toHaveLength(1); - }); -}); - -// --------------------------------------------------------------------------- -// compaction interaction with id/remove -// --------------------------------------------------------------------------- - -describe('ConversationHistory compaction edge cases', () => { - it('compaction clears tagged messages too', () => { - const h = new ConversationHistory(); - h.push(msg('user', 'old'), { id: 'old-ctx' }); - h.push(compactionMsg()); - - // Everything before compaction is gone - expect(h.remove('old-ctx')).toBe(false); - expect(h.messages).toHaveLength(1); - }); -});