From c4f5f3fa44e8fc080f7c081d5bfaf2eb56d35575 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 14:51:22 +0800 Subject: [PATCH] fix: bound session title prompt history --- .../sessionTitle/agentTitlePromptSource.ts | 2 +- .../agentTitlePromptSourceService.ts | 31 +---- .../sessionTitle/sessionTitleService.ts | 6 +- .../agentTitlePromptSourceService.test.ts | 110 +++++++----------- 4 files changed, 55 insertions(+), 94 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts index 00c8870e3..87a5f6269 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts @@ -2,7 +2,7 @@ * `sessionTitle` domain (L6) — title prompt projection contract. * * Defines the Agent-scoped `IAgentTitlePromptSource` used to read the first - * active natural-language prompts from authoritative conversation history. + * active natural-language prompts from the live conversation context. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts index e327825e8..03239b51e 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts @@ -1,25 +1,18 @@ /** * `sessionTitle` domain (L6) — `IAgentTitlePromptSource` implementation. * - * Projects the first active natural-language prompts from the Agent's - * authoritative `wire` journal through `storage`, merging the live - * `contextMemory` tail and `prompt` queue so submissions waiting behind an - * active turn are visible. Bound at Agent scope. + * Reads the first active natural-language prompts from the live `contextMemory` + * window, merging the `prompt` queue so submissions waiting behind an active + * turn are visible. The window may be post-compaction — acceptable for title + * generation: compaction keeps the head user messages, and a title derived + * from the surviving tail is a fine degradation. Bound at Agent scope. */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { - createContextTranscriptReducer, - mergeContextTranscriptWithLive, -} from '#/agent/contextMemory/contextTranscript'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; -import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; -import { IWireService } from '#/wire/wire'; import { IAgentTitlePromptSource } from './agentTitlePromptSource'; @@ -29,23 +22,11 @@ export class AgentTitlePromptSourceService implements IAgentTitlePromptSource { constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentPromptService private readonly prompt: IAgentPromptService, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, - @IAppendLogStore private readonly appendLog: IAppendLogStore, - @IWireService private readonly wire: IWireService, ) {} async firstUserPrompts(limit: number): Promise { if (!Number.isSafeInteger(limit) || limit <= 0) return []; - await this.wire.flush(); - const reducer = createContextTranscriptReducer(); - for await (const record of this.appendLog.read( - this.scopeContext.scope(), - AGENT_WIRE_RECORD_KEY, - )) { - reducer.add(record); - } - const transcript = mergeContextTranscriptWithLive(reducer.result(), this.context.get()); const queue = this.prompt.list(); const result: string[] = []; const seenMessageIds = new Set(); @@ -60,7 +41,7 @@ export class AgentTitlePromptSourceService implements IAgentTitlePromptSource { if (text !== undefined) result.push(text); }; - for (const message of transcript.messages) add(message); + for (const message of this.context.get()) add(message); if (queue.active !== undefined) add(queue.active.message); for (const item of queue.pending) add(item.message); return result; diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index 97e98ff10..6ce28b58e 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -1,9 +1,9 @@ /** * `sessionTitle` domain (L6) — `ISessionTitleService` implementation. * - * Generates the session's title from the first active prompts projected by - * the main Agent's authoritative conversation journal through the managed - * platform `/tools` `chat_title` endpoint, persists it through + * Generates the session's title from the first active prompts in the main + * Agent's live conversation context through the managed platform `/tools` + * `chat_title` endpoint, persists it through * `sessionMetadata`, and rebroadcasts `session.meta.updated`. * Generation is on demand only: `generateTitle()` is the single entry point * (the kap-server route), gated by the `auto-title` experimental flag and a diff --git a/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts index dad4b5121..174f86496 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts @@ -1,8 +1,7 @@ /** - * Scenario: the Agent-scoped title prompt projection reads the durable wire - * journal, follows conversation undo, and includes prompts still waiting in - * the live prompt queue. Wiring: the real source with contract-level fakes - * for storage, wire flush, context, and prompt queue. + * Scenario: the Agent-scoped title prompt projection reads the live context + * window and includes prompts still waiting in the live prompt queue. Wiring: + * the real source with contract-level fakes for context and prompt queue. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -12,43 +11,32 @@ import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentPromptService } from '#/agent/prompt/prompt'; -import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAgentTitlePromptSource } from '#/session/sessionTitle/agentTitlePromptSource'; import { AgentTitlePromptSourceService } from '#/session/sessionTitle/agentTitlePromptSourceService'; -import { IWireService } from '#/wire/wire'; -import type { WireRecord } from '#/wire/record'; -function promptRecord( +const USER_ORIGIN: ContextMessage['origin'] = { kind: 'user' }; + +function userMessage( id: string, text: string, - origin: ContextMessage['origin'] = { kind: 'user' }, -): WireRecord { + origin: ContextMessage['origin'] = USER_ORIGIN, +): ContextMessage { return { - type: 'context.append_message', - message: { - id, - role: 'user', - content: [{ type: 'text', text }], - toolCalls: [], - origin, - }, + id, + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin, }; } -function undoRecord(count: number): WireRecord { - return { type: 'context.undo', count }; -} - describe('AgentTitlePromptSource', () => { let disposables: DisposableStore; let ix: TestInstantiationService; - let records: WireRecord[]; let liveMessages: readonly ContextMessage[]; let queue: ReturnType; beforeEach(() => { - records = []; liveMessages = []; queue = { active: undefined, pending: [] }; disposables = new DisposableStore(); @@ -56,16 +44,6 @@ describe('AgentTitlePromptSource', () => { additionalServices: (reg) => { reg.definePartialInstance(IAgentContextMemoryService, { get: () => liveMessages }); reg.definePartialInstance(IAgentPromptService, { list: () => queue }); - reg.defineInstance( - IAgentScopeContext, - makeAgentScopeContext({ agentId: 'main', agentScope: 'sessions/sess-1/agents/main' }), - ); - reg.definePartialInstance(IAppendLogStore, { - read: () => (async function* () { - yield* records as R[]; - })(), - }); - reg.definePartialInstance(IWireService, { flush: async () => undefined }); reg.define(IAgentTitlePromptSource, AgentTitlePromptSourceService); }, }); @@ -75,8 +53,8 @@ describe('AgentTitlePromptSource', () => { disposables.dispose(); }); - it('returns the first three prompts from the journal and live queue in order', async () => { - records = [promptRecord('one', '第一条')]; + it('returns the first three prompts from the live context and queue in order', async () => { + liveMessages = [userMessage('one', '第一条')]; queue = { active: undefined, pending: [ @@ -85,26 +63,14 @@ describe('AgentTitlePromptSource', () => { userMessageId: 'two', createdAt: '2026-01-01T00:00:00.000Z', state: 'pending', - message: { - id: 'two', - role: 'user', - content: [{ type: 'text', text: '第二条' }], - toolCalls: [], - origin: { kind: 'user' }, - }, + message: userMessage('two', '第二条'), }, { id: 'three', userMessageId: 'three', createdAt: '2026-01-01T00:00:01.000Z', state: 'pending', - message: { - id: 'three', - role: 'user', - content: [{ type: 'text', text: '第三条' }], - toolCalls: [], - origin: { kind: 'user' }, - }, + message: userMessage('three', '第三条'), }, ], }; @@ -116,31 +82,29 @@ describe('AgentTitlePromptSource', () => { ]); }); - it('excludes a prompt removed by conversation undo', async () => { - records = [promptRecord('one', 'A'), undoRecord(1), promptRecord('two', 'B')]; - - await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual(['B']); - }); - - it('rebuilds the same prompt list from persisted records without a live context', async () => { - records = [promptRecord('one', '第一条'), promptRecord('two', '第二条')]; - liveMessages = []; + it('keeps the head user messages of a compacted window, skipping elision and summary', async () => { + liveMessages = [ + userMessage('head', '开场提问'), + userMessage('elision', '... omitted ...', { kind: 'injection', variant: 'compaction_elision' }), + userMessage('tail', '最近的追问'), + userMessage('summary', ' compaction summary ', { kind: 'compaction_summary' }), + ]; await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual([ - '第一条', - '第二条', + '开场提问', + '最近的追问', ]); }); it('returns no title prompts when history contains only slash activations', async () => { - records = [ - promptRecord('skill', 'expanded skill instructions', { + liveMessages = [ + userMessage('skill', 'expanded skill instructions', { kind: 'skill_activation', activationId: 'skill-1', skillName: 'compact', trigger: 'user-slash', }), - promptRecord('plugin', 'expanded plugin instructions', { + userMessage('plugin', 'expanded plugin instructions', { kind: 'plugin_command', activationId: 'plugin-1', pluginId: 'example-plugin', @@ -151,4 +115,20 @@ describe('AgentTitlePromptSource', () => { await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual([]); }); + + it('counts a queued prompt already appended to the context only once', async () => { + liveMessages = [userMessage('one', '同一条')]; + queue = { + active: { + id: 'one', + userMessageId: 'one', + createdAt: '2026-01-01T00:00:00.000Z', + state: 'running', + message: userMessage('one', '同一条'), + }, + pending: [], + }; + + await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual(['同一条']); + }); });