mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-30 19:45:39 +00:00
fix: bound session title prompt history
This commit is contained in:
parent
1b6655754e
commit
c4f5f3fa44
4 changed files with 55 additions and 94 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(['同一条']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue