Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/fix-interrupted-thinking-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/kosong": patch
---

Fix resumed sessions failing after an assistant response is interrupted during thinking.
21 changes: 19 additions & 2 deletions packages/agent-core/src/agent/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export class ContextMemory {
private openSteps: Map<string, ContextMessage> = new Map();
private pendingToolResultIds = new Set<string>();
private deferredMessages: ContextMessage[] = [];
private interruptedSteps = new WeakSet<ContextMessage>();
private hasInterruptedSteps = false;
private _lastAssistantAt: number | null = null;
// Signature of the last logged set of projection repairs, so a repair that
// recurs identically on every send is logged once rather than per step.
Expand Down Expand Up @@ -177,6 +179,8 @@ export class ContextMemory {
this.openSteps.clear();
this.pendingToolResultIds.clear();
this.deferredMessages = [];
this.interruptedSteps = new WeakSet();
this.hasInterruptedSteps = false;
this._lastAssistantAt = null;
this.agent.microCompaction.reset();
this.agent.injection.onContextClear();
Expand Down Expand Up @@ -450,6 +454,11 @@ export class ContextMemory {
}

project(messages: readonly ContextMessage[], options?: ProjectOptions): Message[] {
const resumableMessages = this.hasInterruptedSteps
? messages.map((message) =>
this.interruptedSteps.has(message) ? { ...message, partial: true } : message,
)
: messages;
// Shape for the current model BEFORE projecting: a model without the
// dynamically-loaded-tools capability must not see dynamic-tool schema
// messages or loadable-tools announcements (the canonical history keeps
Expand All @@ -458,8 +467,8 @@ export class ContextMemory {
// setModel never rewrites history, so a mid-session switch
// degrades/upgrades losslessly.
const shaped = this.agent.toolSelectEnabled
? this.agent.tools.shapeDynamicToolHistory(messages)
: stripDynamicToolContext(messages);
? this.agent.tools.shapeDynamicToolHistory(resumableMessages)
: stripDynamicToolContext(resumableMessages);
const anomalies: ProjectionAnomaly[] = [];
const result = project(this.agent.microCompaction.compact(shaped), {
...options,
Expand Down Expand Up @@ -597,6 +606,14 @@ export class ContextMemory {
}

finishResume(): void {
for (const message of this.openSteps.values()) {
if (message.toolCalls.length > 0) continue;
// A replayed step without step.end is an incomplete model response. Keep
// it in canonical history for diagnostics, but exclude it from provider
// projections just like the v2 context fold does with open steps.
this.interruptedSteps.add(message);
this.hasInterruptedSteps = true;
}
this.openSteps.clear();
const closed = this.closePendingToolResults();
if (closed.length > 0) {
Expand Down
44 changes: 44 additions & 0 deletions packages/agent-core/test/agent/resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,50 @@ const MOCK_PROVIDER = {
} as const;

describe('Agent resume', () => {
it('omits an assistant step interrupted after non-empty thinking from the next request', async () => {
const persistence = new RecordingAgentPersistence([
{
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text: 'Write a plan' }],
toolCalls: [],
origin: { kind: 'user' },
},
},
{
type: 'context.append_loop_event',
event: {
type: 'step.begin',
uuid: 'interrupted-step',
turnId: '0',
step: 1,
},
},
{
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: 'interrupted-thinking',
turnId: '0',
step: 1,
stepUuid: 'interrupted-step',
part: { type: 'think', think: 'The plan is written. I should request approval.' },
},
},
]);
const ctx = testAgent({ persistence });

await ctx.agent.resume();

expect(ctx.agent.context.messages).toHaveLength(1);
expect(ctx.agent.context.messages[0]).toMatchObject({
role: 'user',
content: [{ type: 'text', text: 'Write a plan' }],
toolCalls: [],
});
});

it('does not append metadata when resuming records that include legacy app version', async () => {
const persistence = new RecordingAgentPersistence([
{
Expand Down
25 changes: 20 additions & 5 deletions packages/kosong/src/providers/openai-legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,14 @@ function convertMessage(
return result;
}

function isAssistantMessageWithoutContentOrToolCalls(message: OpenAIMessage): boolean {
return (
message.role === 'assistant' &&
message.content === undefined &&
message.tool_calls === undefined
);
}

// Chat Completions has no url-based audio/video content part (only base64
// `input_audio`), so unlike images these cannot be reattached as user input.
// Note the omission inline in the tool message text instead.
Expand Down Expand Up @@ -311,7 +319,13 @@ function convertHistoryMessages(
if (msg.role !== 'tool') {
appendToolResultMediaMessage(messages, pendingToolResultMedia);
}
messages.push(convertMessage(msg, reasoningKey, toolMessageConversion));
const converted = convertMessage(msg, reasoningKey, toolMessageConversion);
// `reasoning_content` is supplemental: OpenAI-compatible APIs still
// require an assistant message to carry content or tool calls. A process
// interrupted mid-thinking can leave this invalid shape in history.
if (!isAssistantMessageWithoutContentOrToolCalls(converted)) {
messages.push(converted);
Comment thread
morluto marked this conversation as resolved.
}
if (msg.role === 'tool') {
pendingToolResultMedia.push(...toolResultImageParts(msg));
}
Expand Down Expand Up @@ -553,10 +567,11 @@ export class OpenAILegacyChatProvider implements ChatProvider {
history,
OPENAI_CHAT_TOOL_CALL_ID_POLICY,
);
const reasoningKey = this._reasoningKeyDialect.outboundKey();
messages.push(
...convertHistoryMessages(
normalizedHistory,
this._reasoningKeyDialect.outboundKey(),
reasoningKey,
this._toolMessageConversion,
),
);
Expand Down Expand Up @@ -594,10 +609,10 @@ export class OpenAILegacyChatProvider implements ChatProvider {
effort !== 'off' &&
kwargs['reasoning_effort'] === undefined
) {
const hasThinkPart = history.some((message) =>
message.content.some((part) => part.type === 'think'),
const hasReasoningContent = messages.some(
(message) => message[reasoningKey] !== undefined,
);
if (hasThinkPart) {
if (hasReasoningContent) {
reasoningEffort = 'medium';
}
}
Expand Down
35 changes: 34 additions & 1 deletion packages/kosong/test/openai-legacy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1361,6 +1361,32 @@ describe('OpenAILegacyChatProvider', () => {
});

describe('default reasoning protocol (no explicit reasoningKey)', () => {
it('omits an assistant message with only non-empty reasoning from request history', async () => {
const provider = createProvider({ model: 'deepseek-reasoner' });
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Write a plan' }], toolCalls: [] },
{
role: 'assistant',
content: [
{
type: 'think',
think: 'The plan is written. I should request approval.',
},
],
toolCalls: [],
},
{ role: 'user', content: [{ type: 'text', text: 'Continue' }], toolCalls: [] },
];

const body = await captureRequestBody(provider, '', [], history);

expect(body['messages']).toEqual([
{ role: 'user', content: 'Write a plan' },
{ role: 'user', content: 'Continue' },
]);
expect(body['reasoning_effort']).toBeUndefined();
});

it('serializes ThinkPart back to reasoning_content even without reasoningKey', async () => {
// The whole point of issue #69: a hand-written config.toml never sets
// reasoningKey, but the round-trip must still work against DeepSeek-style
Expand Down Expand Up @@ -1404,9 +1430,16 @@ describe('OpenAILegacyChatProvider', () => {
const body = await captureRequestBody(provider, '', [], history);
const messages = body['messages'] as Record<string, unknown>[];

expect(messages[0]).toMatchObject({
expect(messages[0]).toEqual({
role: 'assistant',
reasoning_content: '',
tool_calls: [
{
type: 'function',
id: 'call_1',
function: { name: 'lookup', arguments: '{"q":"test"}' },
},
],
});
});

Expand Down