Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/steer-seeded-turn-duplicate-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix a message sent while the agent was running sometimes appearing twice in the chat.
6 changes: 5 additions & 1 deletion packages/agent-core-v2/src/agent/loop/loopService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1240,7 +1240,11 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
nudge.consumed = true;
if (nudge.contextMessage !== undefined && nudge.contextMessage.content.length > 0) {
this.materializeMessage(nudge.contextMessage);
if (nudge.promptIds !== undefined && nudge.promptIds.length > 0) {
if (
nudge.promptIds !== undefined &&
nudge.promptIds.length > 0 &&
nudge.contextMessage.id !== this.active?.prompt.message.id
) {
void this.dispatcher.dispatch(
new TurnSteer({
agentId: this.scopeContext.agentId,
Expand Down
60 changes: 55 additions & 5 deletions packages/agent-core-v2/test/agent/prompt/promptService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';

import { IEventBus } from '#/app/event/eventBus';
import { IFileService } from '#/app/file/fileService';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types';
import { IAgentLoopService, type PromptHandle } from '#/agent/loop/loop';
import { TurnSteer } from '#/agent/loop/turnOps';
Expand Down Expand Up @@ -296,9 +297,22 @@ describe('prompt queue', () => {
expect(PromptSteered.schema.parse(steered[0]).promptIds).toEqual([one.id, two.id]);
hold.release();
await loop.settled();
expect(events[0]?.origin).toMatchObject({ kind: 'user', clientMetadata: [first, second] });
expect(events[0]?.input).toEqual([{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }]);
expect(events[0]?.messageId).toBe(steered[0]?.messageId);
expect(events).toHaveLength(0);
const merged = ctx
.get(IAgentContextMemoryService)
.get()
.find(
(entry) =>
entry.role === 'user' &&
entry.content.some((part) => part.type === 'text' && part.text === 'one') &&
entry.content.some((part) => part.type === 'text' && part.text === 'two'),
);
expect(merged?.origin).toMatchObject({ kind: 'user', clientMetadata: [first, second] });
expect(merged?.content).toEqual([
{ type: 'text', text: 'one' },
{ type: 'text', text: 'two' },
]);
expect(merged?.id).toBe(steered[0]?.messageId);
});

it('keeps plain inputs beside composer metadata in a mixed steer', async () => {
Expand All @@ -318,8 +332,17 @@ describe('prompt queue', () => {
expect(events).toHaveLength(0);
hold.release();
await loop.settled();
expect(events[0]?.origin).toMatchObject({ clientMetadata: [{ display_text: '[literal](example.md)' }, metadata, { display_text: 'last instruction' }] });
expect(events[0]?.input).toEqual([{ type: 'text', text: '[literal](example.md)' }, { type: 'text', text: 'browser wire' }, { type: 'text', text: 'last instruction' }]);
expect(events).toHaveLength(0);
const merged = ctx
.get(IAgentContextMemoryService)
.get()
.find(
(entry) =>
entry.role === 'user' &&
entry.content.some((part) => part.type === 'text' && part.text === 'browser wire'),
);
expect(merged?.origin).toMatchObject({ clientMetadata: [{ display_text: '[literal](example.md)' }, metadata, { display_text: 'last instruction' }] });
expect(merged?.content).toEqual([{ type: 'text', text: '[literal](example.md)' }, { type: 'text', text: 'browser wire' }, { type: 'text', text: 'last instruction' }]);
});

it('publishes prompt identities before each steered user message', async () => {
Expand Down Expand Up @@ -360,6 +383,33 @@ describe('prompt queue', () => {
expect(materialized[1]?.promptIds).toEqual([three.id]);
});

it('does not publish turn.steer when an unconsumed steer seeds the next turn after cancel', async () => {
setup();
const hold = holdNextStep();
ctx.mockNextResponse({ type: 'text', text: 'seeded turn' });
const steerEvents: TurnSteer[] = [];
ctx.get(IEventBus).subscribe(TurnSteer, (event) => steerEvents.push(event));

await enqueue(loop, { message: message('active') });
await hold.started;
const stop = await enqueue(loop, { message: message('stop') });
await loop.steer([stop.id]);
loop.cancel();
hold.release();
await loop.settled();

expect(steerEvents).toHaveLength(0);
const materializedStops = ctx
.get(IAgentContextMemoryService)
.get()
.filter(
(entry) =>
entry.role === 'user' &&
entry.content.some((part) => part.type === 'text' && part.text === 'stop'),
);
expect(materializedStops).toHaveLength(1);
});

it('aborts pending prompts and settles completion', async () => {
setup();
const hold = holdNextStep();
Expand Down
Loading