File size: 11,165 Bytes
f0634fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentLoopService, type PromptHandle } from '#/agent/loop/loop';
import type { ContentPart } from '#human/llm/message';
import { IAgentTitlePromptSource } from '#/session/sessionTitle/agentTitlePromptSource';
import { AgentTitlePromptSourceService } from '#/session/sessionTitle/agentTitlePromptSourceService';

const USER_ORIGIN: ContextMessage['origin'] = { kind: 'user' };

function userMessage(
  id: string,
  text: string,
  origin: ContextMessage['origin'] = USER_ORIGIN,
): ContextMessage {
  return {
    id,
    role: 'user',
    content: [{ type: 'text', text }],
    toolCalls: [],
    origin,
  };
}

function assistantMessage(id: string, parts: ContentPart[]): ContextMessage {
  return { id, role: 'assistant', content: parts, toolCalls: [] };
}

function toolMessage(id: string, text: string): ContextMessage {
  return { id, role: 'tool', content: [{ type: 'text', text }], toolCalls: [] };
}

interface MockQueueState {
  active?: PromptHandle;
  pending: {
    id: string;
    message: ContextMessage;
    tracked: true;
    createdAt: string;
    userMessageId: string;
  }[];
}

describe('AgentTitlePromptSource', () => {
  let disposables: DisposableStore;
  let ix: TestInstantiationService;
  let liveMessages: readonly ContextMessage[];
  let queue: MockQueueState;

  beforeEach(() => {
    liveMessages = [];
    queue = { active: undefined, pending: [] };
    disposables = new DisposableStore();
    ix = createServices(disposables, {
      additionalServices: (reg) => {
        reg.definePartialInstance(IAgentContextMemoryService, { get: () => liveMessages });
        reg.definePartialInstance(IAgentLoopService, {
          snapshot: () => ({
            state: 'idle' as const,
            activeTurnId: undefined,
            activePromptId: queue.active?.id,
            queue: queue.pending.map((item) => ({
              message: { role: 'user' as const, content: [...item.message.content] },
              meta: {
                promptId: item.id,
                tracked: item.tracked,
                createdAt: item.createdAt,
                userMessageId: item.userMessageId,
              },
            })),
            notificationCount: 0,
            paused: false,
            hasPendingRequests: queue.pending.length > 0,
            turn: undefined,
            activeTraceId: undefined,
          }),
          promptHandle: (id: string) => (queue.active?.id === id ? queue.active : undefined),
        });
        reg.define(IAgentTitlePromptSource, AgentTitlePromptSourceService);
      },
    });
  });

  afterEach(() => {
    disposables.dispose();
  });

  it('uses client display text consistently for titles, first turns and digests', async () => {
    liveMessages = [userMessage('annotated', '<browser_ref id="internal">Save</browser_ref>', { kind: 'user', clientMetadata: [{ display_text: 'Save button · Rename to Save changes' }] }), assistantMessage('reply', [{ type: 'text', text: 'Done' }])];
    const source = ix.get(IAgentTitlePromptSource);
    await expect(source.firstUserPrompts(3)).resolves.toEqual(['Save button · Rename to Save changes']);
    await expect(source.firstTurnExcerpt()).resolves.toEqual({ user: 'Save button · Rename to Save changes', assistant: 'Done' });
    await expect(source.digestExcerpt()).resolves.toEqual({ turns: [{ user: 'Save button · Rename to Save changes', assistant: 'Done' }] });
  });

  it('returns the first three prompts from the live context and queue in order', async () => {
    liveMessages = [userMessage('one', '第一条')];
    queue = {
      active: undefined,
      pending: [
        {
          id: 'two',
          userMessageId: 'two',
          createdAt: '2026-01-01T00:00:00.000Z',
          tracked: true,
          message: userMessage('two', '第二条'),
        },
        {
          id: 'three',
          userMessageId: 'three',
          createdAt: '2026-01-01T00:00:01.000Z',
          tracked: true,
          message: userMessage('three', '第三条'),
        },
      ],
    };

    await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual([
      '第一条',
      '第二条',
      '第三条',
    ]);
  });

  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 () => {
    liveMessages = [
      userMessage('skill', 'expanded skill instructions', {
        kind: 'skill_activation',
        activationId: 'skill-1',
        skillName: 'compact',
        trigger: 'user-slash',
      }),
      userMessage('plugin', 'expanded plugin instructions', {
        kind: 'plugin_command',
        activationId: 'plugin-1',
        pluginId: 'example-plugin',
        commandName: 'run',
        trigger: 'user-slash',
      }),
    ];

    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', '同一条'),
        launched: Promise.resolve(undefined),
        completion: new Promise(() => {}),
      },
      pending: [],
    };

    await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual(['同一条']);
  });

  it('firstTurnExcerpt pairs the opening prompt with the turn’s final assistant text', async () => {
    liveMessages = [
      userMessage('u1', '帮我写一个快排'),
      assistantMessage('a1-think', [{ type: 'think', think: '让我想想' }]),
      assistantMessage('a1-text', [{ type: 'text', text: '好的,先写一版' }]),
      toolMessage('t1', 'tool output'),
      assistantMessage('a2', [
        { type: 'text', text: '这是最终版实现' },
        { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } },
      ]),
      userMessage('u2', '再加个单测'),
      assistantMessage('a3', [{ type: 'text', text: '第二轮的回复' }]),
    ];

    await expect(ix.get(IAgentTitlePromptSource).firstTurnExcerpt()).resolves.toEqual({
      user: '帮我写一个快排',
      assistant: '这是最终版实现',
    });
  });

  it('firstTurnExcerpt reports a missing assistant reply until the turn ends', async () => {
    liveMessages = [userMessage('u1', '刚发的问题')];

    await expect(ix.get(IAgentTitlePromptSource).firstTurnExcerpt()).resolves.toEqual({
      user: '刚发的问题',
      assistant: undefined,
    });
  });

  it('digestExcerpt counts a queued prompt already appended to the context only once', async () => {
    liveMessages = [
      userMessage('one', '最早的问题'),
      assistantMessage('a1', [{ type: 'text', text: '第一轮回答' }]),
      userMessage('two', '进行中的问题'),
    ];
    queue = {
      active: {
        id: 'two',
        userMessageId: 'two',
        createdAt: '2026-01-01T00:00:01.000Z',
        state: 'running',
        message: userMessage('two', '进行中的问题'),
        launched: Promise.resolve(undefined),
        completion: new Promise(() => {}),
      },
      pending: [],
    };

    await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({
      turns: [
        { user: '最早的问题', assistant: '第一轮回答' },
        { user: '进行中的问题', assistant: undefined },
      ],
    });
  });

  it('digestExcerpt pairs every prompt with its own turn’s final assistant text', async () => {
    liveMessages = [
      userMessage('u1', '最初的目标'),
      assistantMessage('a1', [{ type: 'text', text: '第一轮回答' }]),
      userMessage('u2', '中途追问'),
      assistantMessage('a2', [{ type: 'text', text: '中间回答' }]),
      userMessage('u3', '最近的要求'),
      assistantMessage('a3', [{ type: 'think', think: '思考中' }]),
      assistantMessage('a4', [{ type: 'text', text: '最新正文' }]),
    ];

    await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({
      turns: [
        { user: '最初的目标', assistant: '第一轮回答' },
        { user: '中途追问', assistant: '中间回答' },
        { user: '最近的要求', assistant: '最新正文' },
      ],
    });
  });

  it('digestExcerpt covers every turn, even with a dangling tool-only span', async () => {
    liveMessages = [
      userMessage('u1', '最初的目标'),
      assistantMessage('a1', [{ type: 'text', text: '第一轮回答' }]),
      userMessage('u2', '第二个话题'),
      assistantMessage('a2', [{ type: 'think', think: '只在思考' }]),
      userMessage('u3', '第三个话题'),
      assistantMessage('a3', [{ type: 'text', text: '第三轮回答' }]),
      userMessage('u4', '最新的话题'),
      assistantMessage('a4', [{ type: 'text', text: '最新回答' }]),
    ];

    await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({
      turns: [
        { user: '最初的目标', assistant: '第一轮回答' },
        { user: '第二个话题', assistant: undefined },
        { user: '第三个话题', assistant: '第三轮回答' },
        { user: '最新的话题', assistant: '最新回答' },
      ],
    });
  });

  it('digestExcerpt keeps a single-prompt conversation and dangling questions', async () => {
    liveMessages = [
      userMessage('u1', '唯一的问题'),
      assistantMessage('a1', [{ type: 'text', text: '唯一的回答' }]),
      userMessage('u2', '还没得到回复的新问题'),
    ];

    await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({
      turns: [
        { user: '唯一的问题', assistant: '唯一的回答' },
        { user: '还没得到回复的新问题', assistant: undefined },
      ],
    });

    liveMessages = [userMessage('u1', '唯一的问题')];
    await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({
      turns: [{ user: '唯一的问题', assistant: undefined }],
    });

    liveMessages = [];
    await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({ turns: [] });
  });
});