fix: bound session title prompt history

This commit is contained in:
7Sageer 2026-07-30 14:51:22 +08:00
parent 1b6655754e
commit c4f5f3fa44
4 changed files with 55 additions and 94 deletions

View file

@ -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';

View file

@ -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<readonly string[]> {
if (!Number.isSafeInteger(limit) || limit <= 0) return [];
await this.wire.flush();
const reducer = createContextTranscriptReducer();
for await (const record of this.appendLog.read<WireRecord>(
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<string>();
@ -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;

View file

@ -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

View file

@ -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<IAgentPromptService['list']>;
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: <R>() => (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(['同一条']);
});
});