mirror of https://github.com/openclaw/openclaw.git
fix(acp): preserve final assistant message snapshot before end_turn (#44597)
Process messageData via handleDeltaEvent for both delta and final states before resolving the turn, so ACP clients no longer drop the last visible assistant text when the gateway sends the final message body on the terminal chat event. Closes #15377 Based on #17615 Co-authored-by: PJ Eby <3527052+pjeby@users.noreply.github.com>
This commit is contained in:
parent
2201d533fd
commit
17c954c46e
|
|
@ -662,6 +662,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn.
|
||||
- Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix.
|
||||
- Voice-call/OpenAI TTS config parity: add missing `speed`, `instructions`, and `baseUrl` fields to the OpenAI TTS config schema and gate `instructions` to supported models so voice-call overrides validate and route cleanly through core TTS. (#39226) Thanks @ademczuk.
|
||||
- ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby.
|
||||
|
||||
## 2026.3.2
|
||||
|
||||
|
|
|
|||
|
|
@ -1020,3 +1020,144 @@ describe("acp prompt size hardening", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("acp final chat snapshots", () => {
|
||||
async function createSnapshotHarness() {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const sessionUpdate = connection.__sessionUpdateMock;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "chat.send") {
|
||||
return new Promise(() => {});
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
await agent.loadSession(createLoadSessionRequest("snapshot-session"));
|
||||
sessionUpdate.mockClear();
|
||||
const promptPromise = agent.prompt(createPromptRequest("snapshot-session", "hello"));
|
||||
const runId = sessionStore.getSession("snapshot-session")?.activeRunId;
|
||||
if (!runId) {
|
||||
throw new Error("Expected ACP prompt run to be active");
|
||||
}
|
||||
return { agent, sessionUpdate, promptPromise, runId, sessionStore };
|
||||
}
|
||||
|
||||
it("emits final snapshot text before resolving end_turn", async () => {
|
||||
const { agent, sessionUpdate, promptPromise, runId, sessionStore } =
|
||||
await createSnapshotHarness();
|
||||
|
||||
await agent.handleGatewayEvent({
|
||||
event: "chat",
|
||||
payload: {
|
||||
sessionKey: "snapshot-session",
|
||||
runId,
|
||||
state: "final",
|
||||
stopReason: "end_turn",
|
||||
message: {
|
||||
content: [{ type: "text", text: "FINAL TEXT SHOULD BE EMITTED" }],
|
||||
},
|
||||
},
|
||||
} as unknown as EventFrame);
|
||||
|
||||
await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "snapshot-session",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "FINAL TEXT SHOULD BE EMITTED" },
|
||||
},
|
||||
});
|
||||
expect(sessionStore.getSession("snapshot-session")?.activeRunId).toBeNull();
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("does not duplicate text when final repeats the last delta snapshot", async () => {
|
||||
const { agent, sessionUpdate, promptPromise, runId, sessionStore } =
|
||||
await createSnapshotHarness();
|
||||
|
||||
await agent.handleGatewayEvent({
|
||||
event: "chat",
|
||||
payload: {
|
||||
sessionKey: "snapshot-session",
|
||||
runId,
|
||||
state: "delta",
|
||||
message: {
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
},
|
||||
},
|
||||
} as unknown as EventFrame);
|
||||
|
||||
await agent.handleGatewayEvent({
|
||||
event: "chat",
|
||||
payload: {
|
||||
sessionKey: "snapshot-session",
|
||||
runId,
|
||||
state: "final",
|
||||
stopReason: "end_turn",
|
||||
message: {
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
},
|
||||
},
|
||||
} as unknown as EventFrame);
|
||||
|
||||
await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
||||
const chunks = sessionUpdate.mock.calls.filter(
|
||||
(call: unknown[]) =>
|
||||
(call[0] as Record<string, unknown>)?.update &&
|
||||
(call[0] as Record<string, Record<string, unknown>>).update?.sessionUpdate ===
|
||||
"agent_message_chunk",
|
||||
);
|
||||
expect(chunks).toHaveLength(1);
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("emits only the missing tail when the final snapshot extends prior deltas", async () => {
|
||||
const { agent, sessionUpdate, promptPromise, runId, sessionStore } =
|
||||
await createSnapshotHarness();
|
||||
|
||||
await agent.handleGatewayEvent({
|
||||
event: "chat",
|
||||
payload: {
|
||||
sessionKey: "snapshot-session",
|
||||
runId,
|
||||
state: "delta",
|
||||
message: {
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
},
|
||||
} as unknown as EventFrame);
|
||||
|
||||
await agent.handleGatewayEvent({
|
||||
event: "chat",
|
||||
payload: {
|
||||
sessionKey: "snapshot-session",
|
||||
runId,
|
||||
state: "final",
|
||||
stopReason: "max_tokens",
|
||||
message: {
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
},
|
||||
},
|
||||
} as unknown as EventFrame);
|
||||
|
||||
await expect(promptPromise).resolves.toEqual({ stopReason: "max_tokens" });
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "snapshot-session",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "Hello" },
|
||||
},
|
||||
});
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "snapshot-session",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: " world" },
|
||||
},
|
||||
});
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -800,9 +800,15 @@ export class AcpGatewayAgent implements Agent {
|
|||
return;
|
||||
}
|
||||
|
||||
if (state === "delta" && messageData) {
|
||||
const shouldHandleMessageSnapshot = messageData && (state === "delta" || state === "final");
|
||||
if (shouldHandleMessageSnapshot) {
|
||||
// Gateway chat events can carry the latest full assistant snapshot on both
|
||||
// incremental updates and the terminal final event. Process the snapshot
|
||||
// first so ACP clients never drop the last visible assistant text.
|
||||
await this.handleDeltaEvent(pending.sessionId, messageData);
|
||||
return;
|
||||
if (state === "delta") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (state === "final") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue