Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
26 changes: 26 additions & 0 deletions packages/agent-core/src/agent/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,32 @@ export class ContextMemory {
toolCallIds: closed.slice(0, 5),
});
}
// A `step.begin` that never produced content or tool calls (e.g. the
// process was killed before the first `content.part`) leaves an empty
// assistant message at the tail of the replayed history. Providers reject
// empty assistant messages (400), bricking every subsequent resume.
// Repair here, at replay time, so the record write path stays untouched;
// the torn records are re-dropped idempotently on every resume. #1404
let droppedEmptyTail = 0;
while (this._history.length > 0) {
const last = this._history[this._history.length - 1]!;
if (last.role !== 'assistant' || last.content.length > 0 || last.toolCalls.length > 0) {
break;
}
this._history.pop();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist the empty-tail repair across later resumes

This repair only pops the blank assistant from the in-memory _history; the torn step.begin record remains in wire.jsonl. If a user resumes once, sends another prompt, and then resumes the same session again, replay rebuilds the old empty assistant before the newly appended records, so it is no longer at the tail and this loop stops before removing it. That makes the resumed raw context/transcript contain the blank assistant again instead of being self-healing; the repair needs to be persisted/tombstoned or applied during replay reduction wherever these empty interrupted-step messages appear.

Useful? React with 👍 / 👎.

droppedEmptyTail += 1;
}
if (droppedEmptyTail > 0) {
// tokenCountCoveredMessageCount counts a prefix of `_history`; clamp it
// to the shortened history so the covered/pending split stays exact.
this.tokenCountCoveredMessageCount = Math.min(
this.tokenCountCoveredMessageCount,
this._history.length,
);
this.agent.log.warn('dropped empty assistant messages left by an interrupted turn', {
droppedEmptyTail,
});
}
}

// Synthesize interrupted tool results for any still-open tool calls, closing
Expand Down
115 changes: 115 additions & 0 deletions packages/agent-core/test/agent/finish-resume.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// finishResume — replay-time repair for turns killed between `step.begin` and
// the first content/tool record (issue #1404: empty assistant at the tail of
// history bricks every subsequent resume with provider 400s).
import { describe, expect, it } from 'vitest';

import { testAgent } from './harness/agent';

const PROVIDER = { type: 'kimi', apiKey: 'test-key', model: 'kimi-code' } as const;
const CAPS = {
image_in: true,
video_in: true,
audio_in: false,
thinking: true,
tool_use: true,
max_context_tokens: 256_000,
} as const;

function seedAgent() {
const ctx = testAgent();
ctx.configure({ provider: PROVIDER, modelCapabilities: CAPS });
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'user one' }]);
ctx.agent.context.appendLoopEvent({ type: 'step.begin', uuid: 's1', turnId: 't1', step: 1 });
ctx.agent.context.appendLoopEvent({
type: 'content.part',
uuid: 'p1',
turnId: 't1',
step: 1,
stepUuid: 's1',
part: { type: 'text', text: 'assistant one' },
});
ctx.agent.context.appendLoopEvent({ type: 'step.end', uuid: 's1', turnId: 't1', step: 1 });
return ctx;
}

describe('finishResume — empty assistant tail repair (#1404)', () => {
it('drops a trailing assistant message left empty by a killed turn', () => {
const ctx = seedAgent();
const before = ctx.agent.context.history.length;
// Turn dies right after step.begin: an empty assistant is appended and
// never receives any content or tool calls.
ctx.agent.context.appendLoopEvent({ type: 'step.begin', uuid: 'dead', turnId: 't2', step: 2 });
expect(ctx.agent.context.history.at(-1)?.role).toBe('assistant');
expect(ctx.agent.context.history.at(-1)?.content).toHaveLength(0);
expect(ctx.agent.context.history.at(-1)?.toolCalls).toHaveLength(0);

ctx.agent.context.finishResume();

const history = ctx.agent.context.history;
expect(history.length).toBe(before);
const last = history.at(-1);
expect(last?.role).toBe('assistant');
expect(last?.content.map((p) => (p.type === 'text' ? p.text : '')).join('')).toBe(
'assistant one',
);
});

it('drops several consecutive empty assistant tails', () => {
const ctx = seedAgent();
ctx.agent.context.appendLoopEvent({ type: 'step.begin', uuid: 'dead1', turnId: 't2', step: 2 });
ctx.agent.context.appendLoopEvent({ type: 'step.begin', uuid: 'dead2', turnId: 't2', step: 3 });

ctx.agent.context.finishResume();

const last = ctx.agent.context.history.at(-1);
expect(last?.content.map((p) => (p.type === 'text' ? p.text : '')).join('')).toBe(
'assistant one',
);
});

it('keeps a trailing assistant that has content', () => {
const ctx = seedAgent();
ctx.agent.context.appendLoopEvent({ type: 'step.begin', uuid: 's2', turnId: 't2', step: 2 });
ctx.agent.context.appendLoopEvent({
type: 'content.part',
uuid: 'p2',
turnId: 't2',
step: 2,
stepUuid: 's2',
part: { type: 'text', text: 'partial but real content' },
});

ctx.agent.context.finishResume();

const last = ctx.agent.context.history.at(-1);
expect(last?.role).toBe('assistant');
expect(last?.content.map((p) => (p.type === 'text' ? p.text : '')).join('')).toBe(
'partial but real content',
);
});

it('keeps a trailing assistant that has tool calls (exchange closes with an error result)', () => {
const ctx = seedAgent();
ctx.agent.context.appendLoopEvent({ type: 'step.begin', uuid: 's2', turnId: 't2', step: 2 });
ctx.agent.context.appendLoopEvent({
type: 'tool.call',
uuid: 'c1',
turnId: 't2',
step: 2,
stepUuid: 's2',
toolCallId: 'call-1',
name: 'bash',
args: { command: 'sleep 1' },
});

ctx.agent.context.finishResume();

const history = ctx.agent.context.history;
const assistant = history.findLast((m) => m.role === 'assistant');
expect(assistant?.toolCalls.map((c) => c.id)).toContain('call-1');
// The dangling call is closed in place by finishResume with an interrupted
// tool result, so the exchange stays well-formed for the provider.
expect(history.at(-1)?.role).toBe('tool');
expect(history.at(-1)?.isError).toBe(true);
});
});
8 changes: 6 additions & 2 deletions packages/agent-core/test/agent/harness/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,9 @@ export class AgentTestContext {
}));
}

async expectResumeMatches(): Promise<void> {
async expectResumeMatches(
mutateExpected?: (snapshot: ResumeStateSnapshot) => ResumeStateSnapshot,
): Promise<void> {
const resumed = testAgent({
kaos: createResumeNoSideEffectKaos(this.agent.config.cwd, this.agent.kaos.pathClass()),
runtime: {
Expand All @@ -757,8 +759,10 @@ export class AgentTestContext {

await resumed.agent.resume();

let expected = resumeStateSnapshot(this.agent);
if (mutateExpected !== undefined) expected = mutateExpected(expected);
// oxlint-disable-next-line jest/no-standalone-expect
expect(resumeStateSnapshot(resumed.agent)).toEqual(resumeStateSnapshot(this.agent));
expect(resumeStateSnapshot(resumed.agent)).toEqual(expected);
}

private takeUntilRpc(method: string): Promise<{
Expand Down
19 changes: 18 additions & 1 deletion packages/agent-core/test/agent/turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -887,7 +887,24 @@ describe('Agent turn flow', () => {
expect(ctx.newEvents()).toMatchInlineSnapshot(
`[emit] error { "code": "internal", "message": "Unexpected generate call #1", "name": "Error", "retryable": false, "details": { "turnId": 0 } }`,
);
await ctx.expectResumeMatches();
// The failed turn leaves an empty assistant message at the tail of live
// history (step.begin with no content/tool calls). finishResume repairs it
// at replay time (#1404), so the resumed history legitimately lacks the
// empty tail the pre-fix verbatim match used to require.
await ctx.expectResumeMatches((snapshot) => ({
...snapshot,
context: {
...snapshot.context,
history: snapshot.context.history.filter(
(message) =>
!(
message.role === 'assistant' &&
message.content.length === 0 &&
message.toolCalls.length === 0
),
),
},
}));
});

it('keeps manual swarm mode active after a turn completes normally', async () => {
Expand Down