You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/04 08:57:08 UTC
(maka) branch main updated: fix(runtime): preserve cross-step replay chronology (#4750)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new a6fd57ba8d fix(runtime): preserve cross-step replay chronology (#4750)
a6fd57ba8d is described below
commit a6fd57ba8d06a03b8f2408fa64e92e1dd630237b
Author: Haoqing Wang <78...@users.noreply.github.com>
AuthorDate: Fri Sep 4 16:57:04 2026 +0800
fix(runtime): preserve cross-step replay chronology (#4750)
Runtime replay parked assistant text by step but flushed later client tool calls first, allowing a later client tool call and result to move ahead of earlier text. Preserve durable step order while retaining immediately contiguous same-step grouping.
Fixes #4749.
Generated-by: OpenAI Codex
---
.../runtime/src/__tests__/ai-sdk-backend.test.ts | 186 +++++++++++++++++++++
packages/runtime/src/ai-sdk-backend.ts | 40 +++--
2 files changed, 216 insertions(+), 10 deletions(-)
diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts
index f9693fd817..052516d52d 100644
--- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts
+++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts
@@ -3335,6 +3335,133 @@ describe('AiSdkBackend model history', () => {
assert.match(JSON.stringify(assistant), /Maka shipped the feature/);
});
+ test('preserves pending assistant steps before a following client tool step', async () => {
+ const prompt = await replayPrompt([
+ runtimeTextEvent({
+ id: 'rt-user',
+ turnId: 'turn-prev',
+ role: 'user',
+ author: 'user',
+ text: 'inspect the workspace',
+ }),
+ runtimeEvent({
+ id: 'rt-progress-a',
+ turnId: 'turn-prev',
+ role: 'model',
+ author: 'agent',
+ refs: { providerEventId: 'progress-step-a' },
+ content: { kind: 'text', text: 'I found the relevant package.' },
+ }),
+ runtimeEvent({
+ id: 'rt-progress-b',
+ turnId: 'turn-prev',
+ role: 'model',
+ author: 'agent',
+ refs: { providerEventId: 'progress-step-b' },
+ content: { kind: 'text', text: 'I will inspect its configuration.' },
+ }),
+ clientToolCallEvent('rt-read-call', 'tool-step'),
+ clientToolResultEvent('rt-read-result'),
+ ]);
+
+ assert.deepEqual(
+ prompt.slice(0, 5).map((message) => ({
+ role: message.role,
+ types: message.content.map((part) => part.type),
+ text: message.content.find((part) => part.type === 'text')?.text,
+ })),
+ [
+ { role: 'user', types: ['text'], text: 'inspect the workspace' },
+ {
+ role: 'assistant',
+ types: ['text'],
+ text: 'I found the relevant package.',
+ },
+ {
+ role: 'assistant',
+ types: ['text'],
+ text: 'I will inspect its configuration.',
+ },
+ { role: 'assistant', types: ['tool-call'], text: undefined },
+ { role: 'tool', types: ['tool-result'], text: undefined },
+ ],
+ );
+ });
+
+ test('groups a client tool only with the immediately pending matching step', async () => {
+ const contiguousPrompt = await replayPrompt([
+ runtimeTextEvent({
+ id: 'rt-user',
+ turnId: 'turn-prev',
+ role: 'user',
+ author: 'user',
+ text: 'read the file',
+ }),
+ runtimeEvent({
+ id: 'rt-progress',
+ turnId: 'turn-prev',
+ role: 'model',
+ author: 'agent',
+ refs: { providerEventId: 'shared-step' },
+ content: { kind: 'text', text: 'I will read the file now.' },
+ }),
+ clientToolCallEvent('rt-read-call', 'shared-step'),
+ clientToolResultEvent('rt-read-result'),
+ ]);
+ assert.deepEqual(
+ contiguousPrompt.slice(0, 3).map((message) => ({
+ role: message.role,
+ types: message.content.map((part) => part.type),
+ })),
+ [
+ { role: 'user', types: ['text'] },
+ { role: 'assistant', types: ['text', 'tool-call'] },
+ { role: 'tool', types: ['tool-result'] },
+ ],
+ );
+
+ const interruptedPrompt = await replayPrompt([
+ runtimeTextEvent({
+ id: 'rt-user',
+ turnId: 'turn-prev',
+ role: 'user',
+ author: 'user',
+ text: 'read the file',
+ }),
+ runtimeEvent({
+ id: 'rt-progress-a',
+ turnId: 'turn-prev',
+ role: 'model',
+ author: 'agent',
+ refs: { providerEventId: 'shared-step' },
+ content: { kind: 'text', text: 'I will read the file now.' },
+ }),
+ runtimeEvent({
+ id: 'rt-progress-b',
+ turnId: 'turn-prev',
+ role: 'model',
+ author: 'agent',
+ refs: { providerEventId: 'intervening-step' },
+ content: { kind: 'text', text: 'Another step was persisted.' },
+ }),
+ clientToolCallEvent('rt-read-call', 'shared-step'),
+ clientToolResultEvent('rt-read-result'),
+ ]);
+ assert.deepEqual(
+ interruptedPrompt.slice(0, 5).map((message) => ({
+ role: message.role,
+ types: message.content.map((part) => part.type),
+ })),
+ [
+ { role: 'user', types: ['text'] },
+ { role: 'assistant', types: ['text'] },
+ { role: 'assistant', types: ['text'] },
+ { role: 'assistant', types: ['tool-call'] },
+ { role: 'tool', types: ['tool-result'] },
+ ],
+ );
+ });
+
test('falls back to grounded text when Open Responses cannot replay a hosted tool pair', async () => {
const model = completionModel();
const backend = createTestAiSdkBackend({
@@ -16040,6 +16167,65 @@ function runtimeEvent(input: {
};
}
+function clientToolCallEvent(id: string, stepId: string): RuntimeEvent {
+ return runtimeEvent({
+ id,
+ turnId: 'turn-prev',
+ role: 'model',
+ author: 'agent',
+ refs: { stepId },
+ content: {
+ kind: 'function_call',
+ id: 'read-1',
+ name: 'Read',
+ args: { path: 'notes.md' },
+ },
+ });
+}
+
+function clientToolResultEvent(id: string): RuntimeEvent {
+ return runtimeEvent({
+ id,
+ turnId: 'turn-prev',
+ role: 'tool',
+ author: 'tool',
+ content: {
+ kind: 'function_response',
+ id: 'read-1',
+ name: 'Read',
+ result: { kind: 'text', text: 'file contents' },
+ isError: false,
+ },
+ });
+}
+
+async function replayPrompt(
+ runtimeContext: RuntimeEvent[],
+): Promise<Array<{ role: string; content: any[] }>> {
+ const model = completionModel();
+ const backend = createTestAiSdkBackend({
+ sessionId: 'session-1',
+ header: header(),
+ appendMessage: async () => {},
+ connection: connection(),
+ apiKey: 'sk-test',
+ modelId: 'mock-model-id',
+ modelFactory: () => model,
+ tools: [],
+ newId: idGenerator(),
+ now: monotonicClock(),
+ });
+ await drain(
+ backend.send({
+ turnId: 'turn-current',
+ text: 'continue',
+ context: [],
+ runtimeContext,
+ }),
+ );
+ return compactPrompt(model) as Array<{ role: string; content: any[] }>;
+}
+
function compactPrompt(model: MockLanguageModelV4): unknown {
return model.doStreamCalls[0]?.prompt.map((message) => ({
role: message.role,
diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts
index 8f641c56dd..8fc7daf013 100644
--- a/packages/runtime/src/ai-sdk-backend.ts
+++ b/packages/runtime/src/ai-sdk-backend.ts
@@ -3899,6 +3899,10 @@ export class AiSdkBackend implements AgentBackend {
>();
const reasoningByStep = new Map<string, ThinkingItem[]>();
const textByStep = new Map<string, TextItem>();
+ const pendingStepOrder = new Set<string>();
+ const rememberPendingStep = (stepId: string) => {
+ pendingStepOrder.add(stepId);
+ };
const replaySupport = this.modelAdapter.runtimeEventReplaySupport();
const reasoningReplay = (item: ThinkingItem): ReplayReasoning | undefined => {
@@ -4157,6 +4161,7 @@ export class AiSdkBackend implements AgentBackend {
if (stepId !== undefined) {
reasoningByStep.delete(stepId);
textByStep.delete(stepId);
+ pendingStepOrder.delete(stepId);
}
await emitStep(reasoning, text, group);
group = [];
@@ -4173,18 +4178,28 @@ export class AiSdkBackend implements AgentBackend {
bufferedCalls = [];
await emitGroupedCalls(calls);
};
+ const flushPendingStep = async (stepId: string) => {
+ const text = textByStep.get(stepId);
+ const reasoning = reasoningByStep.get(stepId);
+ textByStep.delete(stepId);
+ reasoningByStep.delete(stepId);
+ pendingStepOrder.delete(stepId);
+ await emitStep(reasoning, text, []);
+ };
+ const flushPendingStepsBefore = async (stepId: string | undefined) => {
+ const pendingStepIds = [...pendingStepOrder];
+ const lastPendingStepId = pendingStepIds.at(-1);
+ const earlierStepIds =
+ stepId !== undefined && lastPendingStepId === stepId
+ ? pendingStepIds.slice(0, -1)
+ : pendingStepIds;
+ if (earlierStepIds.length === 0) return;
+ await flushLooseCalls();
+ for (const pendingStepId of earlierStepIds) await flushPendingStep(pendingStepId);
+ };
const flushPendingSteps = async () => {
await flushLooseCalls();
- for (const [stepId, text] of textByStep) {
- textByStep.delete(stepId);
- const reasoning = reasoningByStep.get(stepId);
- reasoningByStep.delete(stepId);
- await emitStep(reasoning, text, []);
- }
- for (const [stepId, reasoning] of reasoningByStep) {
- reasoningByStep.delete(stepId);
- await emitStep(reasoning, undefined, []);
- }
+ for (const stepId of [...pendingStepOrder]) await flushPendingStep(stepId);
};
for (const item of admitProviderReasoningReplayItems(
@@ -4193,6 +4208,7 @@ export class AiSdkBackend implements AgentBackend {
)) {
switch (item.kind) {
case 'tool_call':
+ await flushPendingStepsBefore(item.stepId);
if (item.toolName !== 'apply_patch') {
bufferedCalls.push(item);
break;
@@ -4248,6 +4264,7 @@ export class AiSdkBackend implements AgentBackend {
ts: downgradedCall.ts,
});
}
+ rememberPendingStep(downgradedCall.stepId);
} else {
await flushPendingSteps();
push({ role: 'assistant', content: [{ type: 'text', text: replayFact }] }, [
@@ -4262,6 +4279,7 @@ export class AiSdkBackend implements AgentBackend {
const stepReasoning = reasoningByStep.get(item.stepId) ?? [];
stepReasoning.push(item);
reasoningByStep.set(item.stepId, stepReasoning);
+ rememberPendingStep(item.stepId);
} else {
// Legacy standalone reasoning (pure-reasoning turn): emit on its own.
await flushPendingSteps();
@@ -4297,11 +4315,13 @@ export class AiSdkBackend implements AgentBackend {
if (thisCalls.length > 0) {
await emitStep(reasoningByStep.get(stepId), item, thisCalls);
reasoningByStep.delete(stepId);
+ pendingStepOrder.delete(stepId);
} else {
// Runtime-owned settlement persists assistant facts before the
// matching tool calls. Hold the step closer until those calls
// arrive; a terminal text-only step flushes below.
textByStep.set(stepId, item);
+ rememberPendingStep(stepId);
}
} else {
// Legacy per-turn assistant text: standalone after any tool block.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org