From 48d3c5f06fe022642adcc1eb6f4e15334e4cf968 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 12 Dec 2025 16:08:52 +0900 Subject: [PATCH 1/2] feat: Add responses.compact-wired session feature --- .changeset/tall-tips-vanish.md | 6 + examples/memory/.gitignore | 1 + examples/memory/oai-compact.ts | 115 ++++++++ examples/memory/package.json | 1 + packages/agents-core/src/index.ts | 8 +- packages/agents-core/src/memory/session.ts | 35 +++ packages/agents-core/src/runImplementation.ts | 45 ++- packages/agents-core/src/types/aliases.ts | 3 + packages/agents-core/src/types/protocol.ts | 20 ++ .../test/runImplementation.test.ts | 84 ++++++ packages/agents-openai/src/index.ts | 4 + .../src/memory/openaiConversationsSession.ts | 11 +- .../openaiResponsesCompactionSession.ts | 278 ++++++++++++++++++ .../src/memory/openaiSessionApi.ts | 14 + .../src/openaiChatCompletionsConverter.ts | 4 + .../agents-openai/src/openaiResponsesModel.ts | 30 ++ .../openaiResponsesCompactionSession.test.ts | 41 +++ 17 files changed, 691 insertions(+), 9 deletions(-) create mode 100644 .changeset/tall-tips-vanish.md create mode 100644 examples/memory/oai-compact.ts create mode 100644 packages/agents-openai/src/memory/openaiResponsesCompactionSession.ts create mode 100644 packages/agents-openai/src/memory/openaiSessionApi.ts create mode 100644 packages/agents-openai/test/openaiResponsesCompactionSession.test.ts diff --git a/.changeset/tall-tips-vanish.md b/.changeset/tall-tips-vanish.md new file mode 100644 index 00000000..296918fb --- /dev/null +++ b/.changeset/tall-tips-vanish.md @@ -0,0 +1,6 @@ +--- +'@openai/agents-openai': patch +'@openai/agents-core': patch +--- + +feat: Add responses.compact-wired session feature diff --git a/examples/memory/.gitignore b/examples/memory/.gitignore index 9a1c3101..317c5a90 100644 --- a/examples/memory/.gitignore +++ b/examples/memory/.gitignore @@ -1,2 +1,3 @@ tmp/ *.db +.agents-sessions/ diff --git a/examples/memory/oai-compact.ts b/examples/memory/oai-compact.ts new file mode 100644 index 00000000..e44ad90a --- /dev/null +++ b/examples/memory/oai-compact.ts @@ -0,0 +1,115 @@ +import { + Agent, + OpenAIResponsesCompactionSession, + run, + withTrace, +} from '@openai/agents'; +import { fetchImageData } from './tools'; +import { FileSession } from './sessions'; + +async function main() { + const session = new OpenAIResponsesCompactionSession({ + // This compaction decorator handles only compaction logic. + // The underlying session is responsible for storing the history. + underlyingSession: new FileSession(), + // Set a low threshold to observe compaction in action. + compactionThreshold: 3, + model: 'gpt-4.1', + }); + + const agent = new Agent({ + name: 'Assistant', + model: 'gpt-4.1', + instructions: + 'Keep answers short. This example demonstrates responses.compact with a custom session. For every user turn, call fetch_image_data with the provided label. Do not include raw image bytes or data URLs in your final answer.', + modelSettings: { toolChoice: 'required' }, + tools: [fetchImageData], + }); + + // To see compaction debug logs, run with: + // DEBUG=openai-agents:openai:compaction pnpm -C examples/memory start:oai-compact + await withTrace('memory:compactSession:main', async () => { + const prompts = [ + 'Call fetch_image_data with label "alpha". Then explain compaction in 1 sentence.', + 'Call fetch_image_data with label "beta". Then add a fun fact about space in 1 sentence.', + 'Call fetch_image_data with label "gamma". Then add a fun fact about oceans in 1 sentence.', + 'Call fetch_image_data with label "delta". Then add a fun fact about volcanoes in 1 sentence.', + 'Call fetch_image_data with label "epsilon". Then add a fun fact about deserts in 1 sentence.', + ]; + + for (const prompt of prompts) { + const result = await run(agent, prompt, { session, stream: true }); + console.log(`\nUser: ${prompt}`); + + for await (const event of result.toStream()) { + if (event.type === 'raw_model_stream_event') { + continue; + } + if (event.type === 'agent_updated_stream_event') { + continue; + } + if (event.type !== 'run_item_stream_event') { + continue; + } + + if (event.item.type === 'tool_call_item') { + const toolName = (event.item as any).rawItem?.name; + console.log(`-- Tool called: ${toolName ?? '(unknown)'}`); + } else if (event.item.type === 'tool_call_output_item') { + console.log( + `-- Tool output: ${formatToolOutputForLog((event.item as any).output)}`, + ); + } else if (event.item.type === 'message_output_item') { + console.log(`Assistant: ${event.item.content.trim()}`); + } + } + } + + const compactedHistory = await session.getItems(); + console.log('\nStored history after compaction:'); + for (const item of compactedHistory) { + console.log(`- ${item.type}`); + } + }); +} + +function formatToolOutputForLog(output: unknown): string { + if (output === null) { + return 'null'; + } + if (output === undefined) { + return 'undefined'; + } + if (typeof output === 'string') { + return output.length > 200 ? `${output.slice(0, 200)}…` : output; + } + if (Array.isArray(output)) { + const parts = output.map((part) => formatToolOutputPartForLog(part)); + return `[${parts.join(', ')}]`; + } + if (typeof output === 'object') { + const keys = Object.keys(output as Record).sort(); + return `{${keys.slice(0, 10).join(', ')}${keys.length > 10 ? ', …' : ''}}`; + } + return String(output); +} + +function formatToolOutputPartForLog(part: unknown): string { + if (!part || typeof part !== 'object') { + return String(part); + } + const record = part as Record; + const type = typeof record.type === 'string' ? record.type : 'unknown'; + if (type === 'text' && typeof record.text === 'string') { + return `text(${record.text.length} chars)`; + } + if (type === 'image' && typeof record.image === 'string') { + return `image(${record.image.length} chars)`; + } + return type; +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/examples/memory/package.json b/examples/memory/package.json index a46a1d19..269d3696 100644 --- a/examples/memory/package.json +++ b/examples/memory/package.json @@ -11,6 +11,7 @@ "start:memory-hitl": "tsx memory-hitl.ts", "start:oai": "tsx oai.ts", "start:oai-hitl": "tsx oai-hitl.ts", + "start:oai-compact": "tsx oai-compact.ts", "start:file": "tsx file.ts", "start:file-hitl": "tsx file-hitl.ts", "start:prisma": "pnpm prisma db push --schema ./prisma/schema.prisma && pnpm prisma generate --schema ./prisma/schema.prisma && tsx prisma.ts" diff --git a/packages/agents-core/src/index.ts b/packages/agents-core/src/index.ts index b993c732..e59545c3 100644 --- a/packages/agents-core/src/index.ts +++ b/packages/agents-core/src/index.ts @@ -178,7 +178,13 @@ export type { StreamEventGenericItem, } from './types'; export { RequestUsage, Usage } from './usage'; -export type { Session, SessionInputCallback } from './memory/session'; +export type { + Session, + SessionInputCallback, + OpenAIResponsesCompactionArgs, + OpenAIResponsesCompactionAwareSession, +} from './memory/session'; +export { isOpenAIResponsesCompactionAwareSession } from './memory/session'; export { MemorySession } from './memory/memorySession'; /** diff --git a/packages/agents-core/src/memory/session.ts b/packages/agents-core/src/memory/session.ts index 9afbf14f..0795a369 100644 --- a/packages/agents-core/src/memory/session.ts +++ b/packages/agents-core/src/memory/session.ts @@ -43,3 +43,38 @@ export interface Session { */ clearSession(): Promise; } + +/** + * Session subtype that can run compaction logic after a completed turn is persisted. + */ +export type OpenAIResponsesCompactionArgs = { + /** + * The `response.id` from a completed OpenAI Responses API turn, if available. + * + * When undefined, compaction should be skipped. + */ + responseId: string | undefined; +}; + +export interface OpenAIResponsesCompactionAwareSession extends Session { + /** + * Invoked by the runner after it persists a completed turn into the session. + * + * Implementations may decide to call `responses.compact` (or an equivalent API) and replace the + * stored history. + * + * This hook is best-effort. Implementations should consider handling transient failures and + * deciding whether to retry or skip compaction for the current turn. + */ + runCompaction(args: OpenAIResponsesCompactionArgs): Promise | void; +} + +export function isOpenAIResponsesCompactionAwareSession( + session: Session | undefined, +): session is OpenAIResponsesCompactionAwareSession { + return ( + !!session && + typeof (session as OpenAIResponsesCompactionAwareSession).runCompaction === + 'function' + ); +} diff --git a/packages/agents-core/src/runImplementation.ts b/packages/agents-core/src/runImplementation.ts index bd66582e..34a7e6c8 100644 --- a/packages/agents-core/src/runImplementation.ts +++ b/packages/agents-core/src/runImplementation.ts @@ -59,7 +59,11 @@ import type { ApplyPatchResult } from './editor'; import { RunState } from './runState'; import { isZodObject } from './utils'; import * as ProviderData from './types/providerData'; -import type { Session, SessionInputCallback } from './memory/session'; +import { + isOpenAIResponsesCompactionAwareSession, + type Session, + type SessionInputCallback, +} from './memory/session'; // Represents a single handoff function call that still needs to be executed after the model turn. type ToolRunHandoff = { @@ -1360,12 +1364,25 @@ export async function executeFunctionToolCalls( // Emit agent_tool_end even on error to maintain consistent event lifecycle const errorResult = String(error); - runner.emit('agent_tool_end', state._context, agent, toolRun.tool, errorResult, { - toolCall: toolRun.toolCall, - }); - agent.emit('agent_tool_end', state._context, toolRun.tool, errorResult, { - toolCall: toolRun.toolCall, - }); + runner.emit( + 'agent_tool_end', + state._context, + agent, + toolRun.tool, + errorResult, + { + toolCall: toolRun.toolCall, + }, + ); + agent.emit( + 'agent_tool_end', + state._context, + toolRun.tool, + errorResult, + { + toolCall: toolRun.toolCall, + }, + ); throw error; } @@ -2270,6 +2287,16 @@ function shouldStripIdForType(type: string): boolean { } } +async function runCompactionOnSession( + session: Session | undefined, + responseId: string | undefined, +): Promise { + if (isOpenAIResponsesCompactionAwareSession(session)) { + // Called after a completed turn is persisted so compaction can consider the latest stored state. + await session.runCompaction({ responseId }); + } +} + /** * @internal * Persist full turn (input + outputs) for non-streaming runs. @@ -2299,10 +2326,12 @@ export async function saveToSession( if (itemsToSave.length === 0) { state._currentTurnPersistedItemCount = alreadyPersisted + newRunItems.length; + await runCompactionOnSession(session, result.lastResponseId); return; } const sanitizedItems = normalizeItemsForSessionPersistence(itemsToSave); await session.addItems(sanitizedItems); + await runCompactionOnSession(session, result.lastResponseId); state._currentTurnPersistedItemCount = alreadyPersisted + newRunItems.length; } @@ -2344,10 +2373,12 @@ export async function saveStreamResultToSession( if (itemsToSave.length === 0) { state._currentTurnPersistedItemCount = alreadyPersisted + newRunItems.length; + await runCompactionOnSession(session, result.lastResponseId); return; } const sanitizedItems = normalizeItemsForSessionPersistence(itemsToSave); await session.addItems(sanitizedItems); + await runCompactionOnSession(session, result.lastResponseId); state._currentTurnPersistedItemCount = alreadyPersisted + newRunItems.length; } diff --git a/packages/agents-core/src/types/aliases.ts b/packages/agents-core/src/types/aliases.ts index 828495d3..3bd39944 100644 --- a/packages/agents-core/src/types/aliases.ts +++ b/packages/agents-core/src/types/aliases.ts @@ -12,6 +12,7 @@ import { ApplyPatchCallItem, ApplyPatchCallResultItem, ReasoningItem, + CompactionItem, UnknownItem, } from './protocol'; @@ -42,6 +43,7 @@ export type AgentOutputItem = | ShellCallResultItem | ApplyPatchCallResultItem | ReasoningItem + | CompactionItem | UnknownItem; /** @@ -61,4 +63,5 @@ export type AgentInputItem = | ShellCallResultItem | ApplyPatchCallResultItem | ReasoningItem + | CompactionItem | UnknownItem; diff --git a/packages/agents-core/src/types/protocol.ts b/packages/agents-core/src/types/protocol.ts index 8d292b97..581bf1ec 100644 --- a/packages/agents-core/src/types/protocol.ts +++ b/packages/agents-core/src/types/protocol.ts @@ -685,6 +685,24 @@ export const ReasoningItem = SharedBase.extend({ export type ReasoningItem = z.infer; +export const CompactionItem = ItemBase.extend({ + type: z.literal('compaction'), + /** + * Encrypted payload returned by the compaction endpoint. + */ + encrypted_content: z.string(), + /** + * Identifier for the compaction item. + */ + id: z.string().optional(), + /** + * Identifier for the generator of this compaction item. + */ + created_by: z.string().optional(), +}); + +export type CompactionItem = z.infer; + /** * This is a catch all for items that are not part of the protocol. * @@ -715,6 +733,7 @@ export const OutputModelItem = z.discriminatedUnion('type', [ ShellCallResultItem, ApplyPatchCallResultItem, ReasoningItem, + CompactionItem, UnknownItem, ]); @@ -734,6 +753,7 @@ export const ModelItem = z.union([ ShellCallResultItem, ApplyPatchCallResultItem, ReasoningItem, + CompactionItem, UnknownItem, ]); diff --git a/packages/agents-core/test/runImplementation.test.ts b/packages/agents-core/test/runImplementation.test.ts index 68ffb987..6ba7b201 100644 --- a/packages/agents-core/test/runImplementation.test.ts +++ b/packages/agents-core/test/runImplementation.test.ts @@ -550,6 +550,90 @@ describe('saveToSession', () => { expect(latest.type).toBe('function_call_result'); expect(latest.callId).toBe(approvalCall.callId); }); + + it('propagates lastResponseId to sessions after persisting items', async () => { + class TrackingSession implements Session { + items: AgentInputItem[] = []; + events: string[] = []; + + async getSessionId(): Promise { + return 'session'; + } + + async getItems(): Promise { + return [...this.items]; + } + + async addItems(items: AgentInputItem[]): Promise { + this.events.push(`addItems:${items.length}`); + this.items.push(...items); + } + + async popItem(): Promise { + return undefined; + } + + async clearSession(): Promise { + this.items = []; + } + + async runCompaction(args: { + responseId: string | undefined; + }): Promise { + this.events.push(`runCompaction:${args.responseId}`); + } + } + + const textAgent = new Agent({ + name: 'Recorder', + outputType: 'text', + instructions: 'capture', + }); + const agent = textAgent as unknown as Agent< + UnknownContext, + AgentOutputType + >; + const session = new TrackingSession(); + const context = new RunContext(undefined as UnknownContext); + const state = new RunState< + UnknownContext, + Agent + >(context, 'hello', agent, 10); + + state._modelResponses.push({ + output: [], + usage: new Usage(), + responseId: 'resp_123', + }); + state._generatedItems = [ + new MessageOutputItem( + { + type: 'message', + role: 'assistant', + id: 'msg_123', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'here is the reply', + }, + ], + providerData: {}, + }, + textAgent, + ), + ]; + state._currentStep = { + type: 'next_step_final_output', + output: 'here is the reply', + }; + + const result = new RunResult(state); + await saveToSession(session, toInputItemList(state._originalInput), result); + + expect(session.events).toEqual(['addItems:2', 'runCompaction:resp_123']); + expect(session.items).toHaveLength(2); + }); }); describe('prepareInputItemsWithSession', () => { diff --git a/packages/agents-openai/src/index.ts b/packages/agents-openai/src/index.ts index e741ff01..aecacfe3 100644 --- a/packages/agents-openai/src/index.ts +++ b/packages/agents-openai/src/index.ts @@ -23,3 +23,7 @@ export { startOpenAIConversationsSession, type OpenAIConversationsSessionOptions, } from './memory/openaiConversationsSession'; +export { + OpenAIResponsesCompactionSession, + type OpenAIResponsesCompactionSessionOptions, +} from './memory/openaiResponsesCompactionSession'; diff --git a/packages/agents-openai/src/memory/openaiConversationsSession.ts b/packages/agents-openai/src/memory/openaiConversationsSession.ts index d7a04d45..aad7c20b 100644 --- a/packages/agents-openai/src/memory/openaiConversationsSession.ts +++ b/packages/agents-openai/src/memory/openaiConversationsSession.ts @@ -5,6 +5,10 @@ import { convertToOutputItem, getInputItems } from '../openaiResponsesModel'; import { protocol } from '@openai/agents-core'; import type { ConversationItem as APIConversationItem } from 'openai/resources/conversations/items'; import type { Message as APIConversationMessage } from 'openai/resources/conversations/conversations'; +import { + OPENAI_SESSION_API, + type OpenAISessionApiTagged, +} from './openaiSessionApi'; export type OpenAIConversationsSessionOptions = { conversationId?: string; @@ -23,7 +27,12 @@ export async function startOpenAIConversationsSession( return response.id; } -export class OpenAIConversationsSession implements Session { +export class OpenAIConversationsSession + implements Session, OpenAISessionApiTagged<'conversations'> +{ + // Marks this session as backed by the Conversations API so Responses-only integrations can reject it. + readonly [OPENAI_SESSION_API] = 'conversations' as const; + #client: OpenAI; #conversationId?: string; diff --git a/packages/agents-openai/src/memory/openaiResponsesCompactionSession.ts b/packages/agents-openai/src/memory/openaiResponsesCompactionSession.ts new file mode 100644 index 00000000..62266ab1 --- /dev/null +++ b/packages/agents-openai/src/memory/openaiResponsesCompactionSession.ts @@ -0,0 +1,278 @@ +import OpenAI from 'openai'; +import { getLogger, MemorySession, UserError } from '@openai/agents-core'; +import type { + AgentInputItem, + OpenAIResponsesCompactionArgs, + OpenAIResponsesCompactionAwareSession as OpenAIResponsesCompactionSessionLike, + Session, +} from '@openai/agents-core'; +import { DEFAULT_OPENAI_MODEL, getDefaultOpenAIClient } from '../defaults'; +import { + OPENAI_SESSION_API, + type OpenAISessionApiTagged, +} from './openaiSessionApi'; + +const DEFAULT_COMPACTION_THRESHOLD = 10; +const logger = getLogger('openai-agents:openai:compaction'); + +export type OpenAIResponsesCompactionSessionOptions = { + /** + * OpenAI client used to call `responses.compact`. + * + * When omitted, the session will use `getDefaultOpenAIClient()` if configured. Otherwise it + * creates a new `OpenAI()` instance via `new OpenAI()`. + */ + client?: OpenAI; + /** + * Session store that receives items and holds the compacted history. + * + * The underlying session is the source of truth for persisted items. Compaction clears the + * underlying session and writes the output items returned by `responses.compact`. + * + * This must not be an `OpenAIConversationsSession`, because compaction relies on the Responses + * API `previous_response_id` flow. + * + * Defaults to an in-memory session for demos. + */ + underlyingSession?: Session & { [OPENAI_SESSION_API]?: 'responses' }; + /** + * Compaction threshold based on the number of compaction items currently stored in the + * underlying session. Defaults to 10. + * + * This is a heuristic intended to avoid calling `responses.compact` too frequently in small demos. + * Tune this based on your latency/cost budget and how quickly your session grows. + * + * The default counter excludes user messages and `compaction` items, so tool calls, assistant + * messages, and other non-user items contribute to the threshold by default. + */ + compactionThreshold?: number; + /** + * The OpenAI model to use for `responses.compact`. + * + * Defaults to `DEFAULT_OPENAI_MODEL`. The value must resemble an OpenAI model name (for example + * `gpt-*`, `o*`, or a fine-tuned `ft:gpt-*` identifier), otherwise the constructor throws. + */ + model?: OpenAI.ResponsesModel; + /** + * Returns the number of items that should contribute to the compaction threshold. + * + * This function is used to decide when to call `responses.compact`, and it is also used to keep + * an incremental count as new items are appended to the underlying session. + * + * Defaults to counting every stored item except `compaction` items and user messages. + */ + countCompactionItems?: (items: AgentInputItem[]) => number; +}; + +/** + * Session decorator that triggers `responses.compact` when the stored history grows. + * + * This session is intended to be passed to `run()` so the runner can automatically supply the + * latest `responseId` and invoke compaction after each completed turn is persisted. + * + * To debug compaction decisions, enable the `debug` logger for + * `openai-agents:openai:compaction` (for example, `DEBUG=openai-agents:openai:compaction`). + */ +export class OpenAIResponsesCompactionSession + implements + OpenAIResponsesCompactionSessionLike, + OpenAISessionApiTagged<'responses'> +{ + readonly [OPENAI_SESSION_API] = 'responses' as const; + + private readonly client: OpenAI; + private readonly underlyingSession: Session; + private readonly compactionThreshold: number; + private readonly model: OpenAI.ResponsesModel; + private responseId?: string; + private readonly countCompactionItems: (items: AgentInputItem[]) => number; + private compactionCandidateCount: number | undefined; + + constructor(options: OpenAIResponsesCompactionSessionOptions) { + this.client = resolveClient(options); + if (isOpenAIConversationsSessionDelegate(options.underlyingSession)) { + throw new UserError( + 'OpenAIResponsesCompactionSession does not support OpenAIConversationsSession as an underlying session.', + ); + } + this.underlyingSession = options.underlyingSession ?? new MemorySession(); + this.compactionThreshold = + options.compactionThreshold ?? DEFAULT_COMPACTION_THRESHOLD; + const model = (options.model ?? DEFAULT_OPENAI_MODEL).trim(); + + assertSupportedOpenAIResponsesCompactionModel(model); + this.model = model; + + this.countCompactionItems = + options.countCompactionItems ?? defaultCountCompactionItems; + this.compactionCandidateCount = undefined; + } + + async runCompaction(args: OpenAIResponsesCompactionArgs) { + this.responseId = args.responseId ?? undefined; + + if (!this.responseId) { + logger.debug('skip: missing responseId'); + return; + } + + const candidateCount = await this.ensureCandidateCountInitialized(); + if (candidateCount < this.compactionThreshold) { + logger.debug('skip: below threshold %o', { + responseId: this.responseId, + candidateCount, + threshold: this.compactionThreshold, + }); + return; + } + + logger.debug('compact: start %o', { + responseId: this.responseId, + model: this.model, + candidateCount, + threshold: this.compactionThreshold, + }); + + const compacted = await this.client.responses.compact({ + previous_response_id: this.responseId, + model: this.model, + }); + + await this.underlyingSession.clearSession(); + const outputItems = (compacted.output ?? []) as AgentInputItem[]; + if (outputItems.length > 0) { + await this.underlyingSession.addItems(outputItems); + } + this.compactionCandidateCount = this.countCompactionItems(outputItems); + + logger.debug('compact: done %o', { + responseId: this.responseId, + outputItemCount: outputItems.length, + candidateCount: this.compactionCandidateCount, + }); + } + + async getSessionId(): Promise { + return this.underlyingSession.getSessionId(); + } + + async getItems(limit?: number): Promise { + return this.underlyingSession.getItems(limit); + } + + async addItems(items: AgentInputItem[]) { + if (items.length === 0) { + return; + } + + await this.underlyingSession.addItems(items); + if (this.compactionCandidateCount !== undefined) { + this.compactionCandidateCount += this.countCompactionItems(items); + } + } + + async popItem() { + const popped = await this.underlyingSession.popItem(); + if (!popped) { + return popped; + } + if (this.compactionCandidateCount !== undefined) { + this.compactionCandidateCount = Math.max( + 0, + this.compactionCandidateCount - this.countCompactionItems([popped]), + ); + } + return popped; + } + + async clearSession() { + await this.underlyingSession.clearSession(); + this.compactionCandidateCount = 0; + } + + private async ensureCandidateCountInitialized(): Promise { + if (this.compactionCandidateCount !== undefined) { + logger.debug('candidates: cached %o', { + candidateCount: this.compactionCandidateCount, + }); + return this.compactionCandidateCount; + } + const history = await this.underlyingSession.getItems(); + this.compactionCandidateCount = this.countCompactionItems(history); + logger.debug('candidates: initialized %o', { + historyLength: history.length, + candidateCount: this.compactionCandidateCount, + }); + return this.compactionCandidateCount; + } +} + +function resolveClient( + options: OpenAIResponsesCompactionSessionOptions, +): OpenAI { + if (options.client) { + return options.client; + } + + const defaultClient = getDefaultOpenAIClient(); + if (defaultClient) { + return defaultClient; + } + + return new OpenAI(); +} + +function defaultCountCompactionItems(items: AgentInputItem[]): number { + return items.filter((item) => { + if (item.type === 'compaction') { + return false; + } + return !(item.type === 'message' && item.role === 'user'); + }).length; +} + +function assertSupportedOpenAIResponsesCompactionModel(model: string): void { + if (!isOpenAIModelName(model)) { + throw new Error( + `Unsupported model for OpenAI responses compaction: ${JSON.stringify(model)}`, + ); + } +} + +function isOpenAIModelName(model: string): boolean { + const trimmed = model.trim(); + if (!trimmed) { + return false; + } + // The OpenAI SDK does not ship a runtime allowlist of model names. + // This check relies on common model naming conventions and intentionally allows unknown `gpt-*` variants. + // Fine-tuned model IDs typically look like: ft:gpt-4o-mini:org:project:suffix. + const withoutFineTunePrefix = trimmed.startsWith('ft:') + ? trimmed.slice('ft:'.length) + : trimmed; + const root = withoutFineTunePrefix.split(':', 1)[0]; + + // Allow unknown `gpt-*` variants to avoid needing updates whenever new models ship. + if (root.startsWith('gpt-')) { + return true; + } + // Allow the `o*` reasoning models + if (/^o\d[a-z0-9-]*$/i.test(root)) { + return true; + } + + return false; +} + +function isOpenAIConversationsSessionDelegate( + underlyingSession: Session | undefined, +): underlyingSession is Session & OpenAISessionApiTagged<'conversations'> { + return ( + !!underlyingSession && + typeof underlyingSession === 'object' && + OPENAI_SESSION_API in underlyingSession && + (underlyingSession as OpenAISessionApiTagged<'conversations'>)[ + OPENAI_SESSION_API + ] === 'conversations' + ); +} diff --git a/packages/agents-openai/src/memory/openaiSessionApi.ts b/packages/agents-openai/src/memory/openaiSessionApi.ts new file mode 100644 index 00000000..d3169304 --- /dev/null +++ b/packages/agents-openai/src/memory/openaiSessionApi.ts @@ -0,0 +1,14 @@ +/** + * Branding symbol used to tag OpenAI-backed sessions with the underlying API family they rely on. + * + * This enables runtime checks (and some type narrowing) to prevent mixing sessions that are not + * compatible with each other (e.g., using a Conversations-based session where a Responses-only + * feature is required). + */ +export const OPENAI_SESSION_API: unique symbol = Symbol('OPENAI_SESSION_API'); + +export type OpenAISessionAPI = 'responses' | 'conversations'; + +export type OpenAISessionApiTagged = { + readonly [OPENAI_SESSION_API]: API; +}; diff --git a/packages/agents-openai/src/openaiChatCompletionsConverter.ts b/packages/agents-openai/src/openaiChatCompletionsConverter.ts index 2a5d2bd3..5c25095d 100644 --- a/packages/agents-openai/src/openaiChatCompletionsConverter.ts +++ b/packages/agents-openai/src/openaiChatCompletionsConverter.ts @@ -300,6 +300,10 @@ export function itemsToMessages( result.push({ ...item.providerData, } as any); + } else if (item.type === 'compaction') { + throw new UserError( + 'Compaction items are not supported for chat completions. Please use the Responses API when working with compaction.', + ); } else { const exhaustive = item satisfies never; // ensures that the type is exhaustive throw new Error(`Unknown item type: ${JSON.stringify(exhaustive)}`); diff --git a/packages/agents-openai/src/openaiResponsesModel.ts b/packages/agents-openai/src/openaiResponsesModel.ts index 373e750c..94c3f953 100644 --- a/packages/agents-openai/src/openaiResponsesModel.ts +++ b/packages/agents-openai/src/openaiResponsesModel.ts @@ -1231,6 +1231,19 @@ function getInputItems( ); } + if (item.type === 'compaction') { + const encryptedContent = + (item as any).encrypted_content ?? (item as any).encryptedContent; + if (typeof encryptedContent !== 'string') { + throw new UserError('Compaction item missing encrypted_content'); + } + return { + type: 'compaction', + id: item.id ?? undefined, + encrypted_content: encryptedContent, + } as OpenAI.Responses.ResponseInputItem; + } + if (item.type === 'unknown') { return { ...camelOrSnakeToSnakeCase(item.providerData), // place here to prioritize the below fields @@ -1524,6 +1537,23 @@ function convertToOutputItem( providerData, }; return output; + } else if (item.type === 'compaction') { + const { encrypted_content, created_by, ...providerData } = item as { + encrypted_content?: string; + created_by?: string; + id?: string; + }; + if (typeof encrypted_content !== 'string') { + throw new UserError('Compaction item missing encrypted_content'); + } + const output: protocol.CompactionItem = { + type: 'compaction', + id: item.id ?? undefined, + encrypted_content, + created_by, + providerData, + }; + return output; } return { diff --git a/packages/agents-openai/test/openaiResponsesCompactionSession.test.ts b/packages/agents-openai/test/openaiResponsesCompactionSession.test.ts new file mode 100644 index 00000000..ad56576d --- /dev/null +++ b/packages/agents-openai/test/openaiResponsesCompactionSession.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { OpenAIResponsesCompactionSession } from '../src'; + +describe('OpenAIResponsesCompactionSession', () => { + it('rejects non-OpenAI model names', () => { + expect(() => { + new OpenAIResponsesCompactionSession({ + client: {} as any, + model: 'yet-another-model', + }); + }).toThrow(/Unsupported model/); + }); + + it('allows unknown gpt-* model names', () => { + expect(() => { + new OpenAIResponsesCompactionSession({ + client: {} as any, + model: 'gpt-9999-super-new-model', + }); + }).not.toThrow(); + }); + + it('allows fine-tuned gpt-* model ids', () => { + expect(() => { + new OpenAIResponsesCompactionSession({ + client: {} as any, + model: 'ft:gpt-4.1-nano-2025-04-14:org:proj:suffix', + }); + }).not.toThrow(); + }); + + it('allows o* model names', () => { + expect(() => { + new OpenAIResponsesCompactionSession({ + client: {} as any, + model: 'o1-pro', + }); + }).not.toThrow(); + }); +}); From 014b9263e242f4628455b12930d4a597238aedd7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 16 Dec 2025 10:59:35 +0900 Subject: [PATCH 2/2] revisit the design: - removed compactionThreshold, countCompactionItems - instead, added general callback named shouldTriggerCompaction - added compactionCandidateItems, sessionItems, and responseId for custom shouldTriggerCompaction logic --- examples/memory/oai-compact.ts | 24 ++- packages/agents-core/src/memory/session.ts | 10 +- packages/agents-core/src/runImplementation.ts | 9 +- .../test/runImplementation.test.ts | 78 ++++++++ packages/agents-openai/src/index.ts | 1 + .../openaiResponsesCompactionSession.ts | 172 ++++++++++++------ .../openaiResponsesCompactionSession.test.ts | 126 ++++++++++++- 7 files changed, 353 insertions(+), 67 deletions(-) diff --git a/examples/memory/oai-compact.ts b/examples/memory/oai-compact.ts index e44ad90a..8fa78d16 100644 --- a/examples/memory/oai-compact.ts +++ b/examples/memory/oai-compact.ts @@ -9,17 +9,22 @@ import { FileSession } from './sessions'; async function main() { const session = new OpenAIResponsesCompactionSession({ + model: 'gpt-5.2', // This compaction decorator handles only compaction logic. // The underlying session is responsible for storing the history. underlyingSession: new FileSession(), - // Set a low threshold to observe compaction in action. - compactionThreshold: 3, - model: 'gpt-4.1', + // (optional customization) This example demonstrates the simplest compaction logic, + // but you can also estimate the context window size using sessionItems (all items) + // and trigger compaction at the optimal time. + shouldTriggerCompaction: ({ compactionCandidateItems }) => { + // Set a low threshold to observe compaction in action. + return compactionCandidateItems.length >= 4; + }, }); const agent = new Agent({ name: 'Assistant', - model: 'gpt-4.1', + model: 'gpt-5.2', instructions: 'Keep answers short. This example demonstrates responses.compact with a custom session. For every user turn, call fetch_image_data with the provided label. Do not include raw image bytes or data URLs in your final answer.', modelSettings: { toolChoice: 'required' }, @@ -66,10 +71,19 @@ async function main() { } const compactedHistory = await session.getItems(); - console.log('\nStored history after compaction:'); + console.log('\nHitory including both compaction and newer items:'); for (const item of compactedHistory) { console.log(`- ${item.type}`); } + + // You can manually run compaction this way: + await session.runCompaction({ force: true }); + + const finalHistory = await session.getItems(); + console.log('\nStored history after final compaction:'); + for (const item of finalHistory) { + console.log(`- ${item.type}`); + } }); } diff --git a/packages/agents-core/src/memory/session.ts b/packages/agents-core/src/memory/session.ts index 0795a369..1e8ae454 100644 --- a/packages/agents-core/src/memory/session.ts +++ b/packages/agents-core/src/memory/session.ts @@ -51,9 +51,13 @@ export type OpenAIResponsesCompactionArgs = { /** * The `response.id` from a completed OpenAI Responses API turn, if available. * - * When undefined, compaction should be skipped. + * When omitted, implementations may fall back to a cached value or throw. */ - responseId: string | undefined; + responseId?: string | undefined; + /** + * When true, compaction should run regardless of any internal thresholds or hooks. + */ + force?: boolean; }; export interface OpenAIResponsesCompactionAwareSession extends Session { @@ -66,7 +70,7 @@ export interface OpenAIResponsesCompactionAwareSession extends Session { * This hook is best-effort. Implementations should consider handling transient failures and * deciding whether to retry or skip compaction for the current turn. */ - runCompaction(args: OpenAIResponsesCompactionArgs): Promise | void; + runCompaction(args?: OpenAIResponsesCompactionArgs): Promise | void; } export function isOpenAIResponsesCompactionAwareSession( diff --git a/packages/agents-core/src/runImplementation.ts b/packages/agents-core/src/runImplementation.ts index 34a7e6c8..2417a459 100644 --- a/packages/agents-core/src/runImplementation.ts +++ b/packages/agents-core/src/runImplementation.ts @@ -2291,10 +2291,13 @@ async function runCompactionOnSession( session: Session | undefined, responseId: string | undefined, ): Promise { - if (isOpenAIResponsesCompactionAwareSession(session)) { - // Called after a completed turn is persisted so compaction can consider the latest stored state. - await session.runCompaction({ responseId }); + if (!isOpenAIResponsesCompactionAwareSession(session)) { + return; } + // Called after a completed turn is persisted so compaction can consider the latest stored state. + await session.runCompaction( + typeof responseId === 'undefined' ? undefined : { responseId }, + ); } /** diff --git a/packages/agents-core/test/runImplementation.test.ts b/packages/agents-core/test/runImplementation.test.ts index 6ba7b201..8dda7497 100644 --- a/packages/agents-core/test/runImplementation.test.ts +++ b/packages/agents-core/test/runImplementation.test.ts @@ -634,6 +634,84 @@ describe('saveToSession', () => { expect(session.events).toEqual(['addItems:2', 'runCompaction:resp_123']); expect(session.items).toHaveLength(2); }); + + it('invokes runCompaction when responseId is undefined', async () => { + class TrackingSession implements Session { + items: AgentInputItem[] = []; + events: string[] = []; + + async getSessionId(): Promise { + return 'session'; + } + + async getItems(): Promise { + return [...this.items]; + } + + async addItems(items: AgentInputItem[]): Promise { + this.events.push(`addItems:${items.length}`); + this.items.push(...items); + } + + async popItem(): Promise { + return undefined; + } + + async clearSession(): Promise { + this.items = []; + } + + async runCompaction(args?: { responseId?: string }): Promise { + this.events.push(`runCompaction:${String(args?.responseId)}`); + } + } + + const textAgent = new Agent({ + name: 'Recorder', + outputType: 'text', + instructions: 'capture', + }); + const agent = textAgent as unknown as Agent< + UnknownContext, + AgentOutputType + >; + const session = new TrackingSession(); + const context = new RunContext(undefined as UnknownContext); + const state = new RunState< + UnknownContext, + Agent + >(context, 'hello', agent, 10); + + state._modelResponses = []; + state._generatedItems = [ + new MessageOutputItem( + { + type: 'message', + role: 'assistant', + id: 'msg_123', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'here is the reply', + }, + ], + providerData: {}, + }, + textAgent, + ), + ]; + state._currentStep = { + type: 'next_step_final_output', + output: 'here is the reply', + }; + + const result = new RunResult(state); + await saveToSession(session, toInputItemList(state._originalInput), result); + + expect(session.events).toEqual(['addItems:2', 'runCompaction:undefined']); + expect(session.items).toHaveLength(2); + }); }); describe('prepareInputItemsWithSession', () => { diff --git a/packages/agents-openai/src/index.ts b/packages/agents-openai/src/index.ts index aecacfe3..7af3d33c 100644 --- a/packages/agents-openai/src/index.ts +++ b/packages/agents-openai/src/index.ts @@ -26,4 +26,5 @@ export { export { OpenAIResponsesCompactionSession, type OpenAIResponsesCompactionSessionOptions, + type OpenAIResponsesCompactionDecisionContext, } from './memory/openaiResponsesCompactionSession'; diff --git a/packages/agents-openai/src/memory/openaiResponsesCompactionSession.ts b/packages/agents-openai/src/memory/openaiResponsesCompactionSession.ts index 62266ab1..afc29e82 100644 --- a/packages/agents-openai/src/memory/openaiResponsesCompactionSession.ts +++ b/packages/agents-openai/src/memory/openaiResponsesCompactionSession.ts @@ -15,6 +15,23 @@ import { const DEFAULT_COMPACTION_THRESHOLD = 10; const logger = getLogger('openai-agents:openai:compaction'); +export type OpenAIResponsesCompactionDecisionContext = { + /** + * The `response.id` from a completed OpenAI Responses API turn, if available. + */ + responseId: string | undefined; + /** + * Items considered compaction candidates (excludes user and compaction items). + * The array must not be mutated. + */ + compactionCandidateItems: AgentInputItem[]; + /** + * All stored items retrieved from the underlying session, if available. + * The array must not be mutated. + */ + sessionItems: AgentInputItem[]; +}; + export type OpenAIResponsesCompactionSessionOptions = { /** * OpenAI client used to call `responses.compact`. @@ -35,17 +52,6 @@ export type OpenAIResponsesCompactionSessionOptions = { * Defaults to an in-memory session for demos. */ underlyingSession?: Session & { [OPENAI_SESSION_API]?: 'responses' }; - /** - * Compaction threshold based on the number of compaction items currently stored in the - * underlying session. Defaults to 10. - * - * This is a heuristic intended to avoid calling `responses.compact` too frequently in small demos. - * Tune this based on your latency/cost budget and how quickly your session grows. - * - * The default counter excludes user messages and `compaction` items, so tool calls, assistant - * messages, and other non-user items contribute to the threshold by default. - */ - compactionThreshold?: number; /** * The OpenAI model to use for `responses.compact`. * @@ -54,14 +60,17 @@ export type OpenAIResponsesCompactionSessionOptions = { */ model?: OpenAI.ResponsesModel; /** - * Returns the number of items that should contribute to the compaction threshold. - * - * This function is used to decide when to call `responses.compact`, and it is also used to keep - * an incremental count as new items are appended to the underlying session. + * Custom decision hook that determines whether to call `responses.compact`. * - * Defaults to counting every stored item except `compaction` items and user messages. + * The default implementation compares the length of + * {@link OpenAIResponsesCompactionDecisionContext.compactionCandidateItems} to an internal threshold + * (10). Override this to support token-based triggers or other heuristics using + * {@link OpenAIResponsesCompactionDecisionContext.compactionCandidateItems} or + * {@link OpenAIResponsesCompactionDecisionContext.sessionItems}. */ - countCompactionItems?: (items: AgentInputItem[]) => number; + shouldTriggerCompaction?: ( + context: OpenAIResponsesCompactionDecisionContext, + ) => boolean | Promise; }; /** @@ -82,11 +91,13 @@ export class OpenAIResponsesCompactionSession private readonly client: OpenAI; private readonly underlyingSession: Session; - private readonly compactionThreshold: number; private readonly model: OpenAI.ResponsesModel; private responseId?: string; - private readonly countCompactionItems: (items: AgentInputItem[]) => number; - private compactionCandidateCount: number | undefined; + private readonly shouldTriggerCompaction: ( + context: OpenAIResponsesCompactionDecisionContext, + ) => boolean | Promise; + private compactionCandidateItems: AgentInputItem[] | undefined; + private sessionItems: AgentInputItem[] | undefined; constructor(options: OpenAIResponsesCompactionSessionOptions) { this.client = resolveClient(options); @@ -96,32 +107,39 @@ export class OpenAIResponsesCompactionSession ); } this.underlyingSession = options.underlyingSession ?? new MemorySession(); - this.compactionThreshold = - options.compactionThreshold ?? DEFAULT_COMPACTION_THRESHOLD; const model = (options.model ?? DEFAULT_OPENAI_MODEL).trim(); assertSupportedOpenAIResponsesCompactionModel(model); this.model = model; - this.countCompactionItems = - options.countCompactionItems ?? defaultCountCompactionItems; - this.compactionCandidateCount = undefined; + this.shouldTriggerCompaction = + options.shouldTriggerCompaction ?? defaultShouldTriggerCompaction; + this.compactionCandidateItems = undefined; + this.sessionItems = undefined; } - async runCompaction(args: OpenAIResponsesCompactionArgs) { - this.responseId = args.responseId ?? undefined; + async runCompaction(args: OpenAIResponsesCompactionArgs = {}) { + this.responseId = args.responseId ?? this.responseId ?? undefined; if (!this.responseId) { - logger.debug('skip: missing responseId'); - return; + throw new UserError( + 'OpenAIResponsesCompactionSession.runCompaction requires a responseId from the last completed turn.', + ); } - const candidateCount = await this.ensureCandidateCountInitialized(); - if (candidateCount < this.compactionThreshold) { - logger.debug('skip: below threshold %o', { + const { compactionCandidateItems, sessionItems } = + await this.ensureCompactionCandidates(); + const shouldTriggerCompaction = + args.force === true + ? true + : await this.shouldTriggerCompaction({ + responseId: this.responseId, + compactionCandidateItems, + sessionItems, + }); + if (!shouldTriggerCompaction) { + logger.debug('skip: decision hook %o', { responseId: this.responseId, - candidateCount, - threshold: this.compactionThreshold, }); return; } @@ -129,8 +147,6 @@ export class OpenAIResponsesCompactionSession logger.debug('compact: start %o', { responseId: this.responseId, model: this.model, - candidateCount, - threshold: this.compactionThreshold, }); const compacted = await this.client.responses.compact({ @@ -143,12 +159,13 @@ export class OpenAIResponsesCompactionSession if (outputItems.length > 0) { await this.underlyingSession.addItems(outputItems); } - this.compactionCandidateCount = this.countCompactionItems(outputItems); + this.compactionCandidateItems = selectCompactionCandidateItems(outputItems); + this.sessionItems = outputItems; logger.debug('compact: done %o', { responseId: this.responseId, outputItemCount: outputItems.length, - candidateCount: this.compactionCandidateCount, + candidateCount: this.compactionCandidateItems.length, }); } @@ -166,8 +183,17 @@ export class OpenAIResponsesCompactionSession } await this.underlyingSession.addItems(items); - if (this.compactionCandidateCount !== undefined) { - this.compactionCandidateCount += this.countCompactionItems(items); + if (this.compactionCandidateItems) { + const candidates = selectCompactionCandidateItems(items); + if (candidates.length > 0) { + this.compactionCandidateItems = [ + ...this.compactionCandidateItems, + ...candidates, + ]; + } + } + if (this.sessionItems) { + this.sessionItems = [...this.sessionItems, ...items]; } } @@ -176,34 +202,62 @@ export class OpenAIResponsesCompactionSession if (!popped) { return popped; } - if (this.compactionCandidateCount !== undefined) { - this.compactionCandidateCount = Math.max( - 0, - this.compactionCandidateCount - this.countCompactionItems([popped]), - ); + if (this.sessionItems) { + const index = this.sessionItems.lastIndexOf(popped); + if (index >= 0) { + this.sessionItems.splice(index, 1); + } else { + this.sessionItems = await this.underlyingSession.getItems(); + } + } + if (this.compactionCandidateItems) { + const isCandidate = selectCompactionCandidateItems([popped]).length > 0; + if (isCandidate) { + const index = this.compactionCandidateItems.indexOf(popped); + if (index >= 0) { + this.compactionCandidateItems.splice(index, 1); + } else { + // Fallback when the popped item reference differs from stored candidates. + this.compactionCandidateItems = selectCompactionCandidateItems( + await this.underlyingSession.getItems(), + ); + } + } } return popped; } async clearSession() { await this.underlyingSession.clearSession(); - this.compactionCandidateCount = 0; + this.compactionCandidateItems = []; + this.sessionItems = []; } - private async ensureCandidateCountInitialized(): Promise { - if (this.compactionCandidateCount !== undefined) { + private async ensureCompactionCandidates(): Promise<{ + compactionCandidateItems: AgentInputItem[]; + sessionItems: AgentInputItem[]; + }> { + if (this.compactionCandidateItems && this.sessionItems) { logger.debug('candidates: cached %o', { - candidateCount: this.compactionCandidateCount, + candidateCount: this.compactionCandidateItems.length, }); - return this.compactionCandidateCount; + return { + compactionCandidateItems: [...this.compactionCandidateItems], + sessionItems: [...this.sessionItems], + }; } const history = await this.underlyingSession.getItems(); - this.compactionCandidateCount = this.countCompactionItems(history); + const compactionCandidates = selectCompactionCandidateItems(history); + this.compactionCandidateItems = compactionCandidates; + this.sessionItems = history; logger.debug('candidates: initialized %o', { historyLength: history.length, - candidateCount: this.compactionCandidateCount, + candidateCount: compactionCandidates.length, }); - return this.compactionCandidateCount; + return { + compactionCandidateItems: [...compactionCandidates], + sessionItems: [...history], + }; } } @@ -222,13 +276,21 @@ function resolveClient( return new OpenAI(); } -function defaultCountCompactionItems(items: AgentInputItem[]): number { +function defaultShouldTriggerCompaction({ + compactionCandidateItems, +}: OpenAIResponsesCompactionDecisionContext): boolean { + return compactionCandidateItems.length >= DEFAULT_COMPACTION_THRESHOLD; +} + +function selectCompactionCandidateItems( + items: AgentInputItem[], +): AgentInputItem[] { return items.filter((item) => { if (item.type === 'compaction') { return false; } return !(item.type === 'message' && item.role === 'user'); - }).length; + }); } function assertSupportedOpenAIResponsesCompactionModel(model: string): void { diff --git a/packages/agents-openai/test/openaiResponsesCompactionSession.test.ts b/packages/agents-openai/test/openaiResponsesCompactionSession.test.ts index ad56576d..528e4129 100644 --- a/packages/agents-openai/test/openaiResponsesCompactionSession.test.ts +++ b/packages/agents-openai/test/openaiResponsesCompactionSession.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +import { MemorySession } from '@openai/agents-core'; +import { UserError } from '@openai/agents-core'; import { OpenAIResponsesCompactionSession } from '../src'; @@ -38,4 +41,125 @@ describe('OpenAIResponsesCompactionSession', () => { }); }).not.toThrow(); }); + + it('skips compaction when the decision hook declines', async () => { + const compact = vi.fn(); + const session = new OpenAIResponsesCompactionSession({ + client: { responses: { compact } } as any, + shouldTriggerCompaction: () => false, + }); + + await session.addItems([ + { + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello' }], + }, + ]); + + await session.runCompaction({ responseId: 'resp_1' }); + expect(compact).not.toHaveBeenCalled(); + }); + + it('allows custom compaction decisions using the stored history', async () => { + const compact = vi.fn().mockResolvedValue({ + output: [ + { + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'compacted output' }], + }, + ], + }); + const underlyingSession = new MemorySession(); + const decisionHistoryLengths: number[] = []; + const session = new OpenAIResponsesCompactionSession({ + client: { responses: { compact } } as any, + underlyingSession, + shouldTriggerCompaction: async ({ compactionCandidateItems }) => { + decisionHistoryLengths.push(compactionCandidateItems.length); + const estimatedTokens = compactionCandidateItems.reduce( + (total, item) => total + JSON.stringify(item).length, + 0, + ); + return estimatedTokens > 40; + }, + }); + + await session.addItems([ + { + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'This reply is intentionally long to trigger compaction.', + }, + ], + }, + ]); + + await session.runCompaction({ responseId: 'resp_2' }); + + expect(compact).toHaveBeenCalledTimes(1); + expect(compact).toHaveBeenCalledWith({ + previous_response_id: 'resp_2', + model: 'gpt-4.1', + }); + expect(decisionHistoryLengths).toEqual([1]); + + const storedItems = await session.getItems(); + expect(storedItems).toEqual([ + { + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'compacted output' }], + }, + ]); + }); + + it('provides compaction candidates to the decision hook', async () => { + const compact = vi.fn(); + const receivedCandidates: unknown[][] = []; + const session = new OpenAIResponsesCompactionSession({ + client: { responses: { compact } } as any, + shouldTriggerCompaction: async ({ compactionCandidateItems }) => { + receivedCandidates.push(compactionCandidateItems); + return false; + }, + }); + + const userItem = { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }; + const assistantItem = { + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'world' }], + }; + + await session.addItems([userItem, assistantItem] as any); + await session.runCompaction({ responseId: 'resp_3' }); + + expect(receivedCandidates).toEqual([[assistantItem]]); + expect(compact).not.toHaveBeenCalled(); + }); + + it('throws when runCompaction is called without a responseId', async () => { + const compact = vi.fn(); + const session = new OpenAIResponsesCompactionSession({ + client: { responses: { compact } } as any, + }); + + await expect(session.runCompaction({} as any)).rejects.toBeInstanceOf( + UserError, + ); + }); });