fix(acp): preserve hidden thought replay on session load

This commit is contained in:
Vincent Koc 2026-03-22 19:48:04 -07:00
parent feea4763fb
commit 32fdd21c80
2 changed files with 72 additions and 33 deletions

View File

@ -242,7 +242,7 @@ describe("acp session UX bridge behavior", () => {
sessionStore.clearAllSessionsForTest();
});
it("replays user and assistant text history on loadSession and returns initial controls", async () => {
it("replays user text, assistant text, and hidden assistant thinking on loadSession", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const sessionUpdate = connection.__sessionUpdateMock;
@ -283,7 +283,13 @@ describe("acp session UX bridge behavior", () => {
return {
messages: [
{ role: "user", content: [{ type: "text", text: "Question" }] },
{ role: "assistant", content: [{ type: "text", text: "Answer" }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "Internal loop about NO_REPLY" },
{ type: "text", text: "Answer" },
],
},
{ role: "system", content: [{ type: "text", text: "ignore me" }] },
{ role: "assistant", content: [{ type: "image", image: "skip" }] },
],
@ -332,6 +338,13 @@ describe("acp session UX bridge behavior", () => {
content: { type: "text", text: "Question" },
},
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "agent:main:work",
update: {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "Internal loop about NO_REPLY" },
},
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "agent:main:work",
update: {

View File

@ -134,6 +134,11 @@ type GatewayChatContentBlock = {
thinking?: string;
};
type ReplayChunk = {
sessionUpdate: "user_message_chunk" | "agent_message_chunk" | "agent_thought_chunk";
text: string;
};
const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120;
const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000;
@ -261,25 +266,51 @@ function buildSessionPresentation(params: {
return { configOptions, modes };
}
function extractReplayText(content: unknown): string | undefined {
if (typeof content === "string") {
return content.length > 0 ? content : undefined;
function extractReplayChunks(message: GatewayTranscriptMessage): ReplayChunk[] {
const role = typeof message.role === "string" ? message.role : "";
if (role !== "user" && role !== "assistant") {
return [];
}
if (!Array.isArray(content)) {
return undefined;
if (typeof message.content === "string") {
return message.content.length > 0
? [
{
sessionUpdate: role === "user" ? "user_message_chunk" : "agent_message_chunk",
text: message.content,
},
]
: [];
}
const text = content
.map((block) => {
if (!block || typeof block !== "object" || Array.isArray(block)) {
return "";
}
const typedBlock = block as { type?: unknown; text?: unknown };
return typedBlock.type === "text" && typeof typedBlock.text === "string"
? typedBlock.text
: "";
})
.join("");
return text.length > 0 ? text : undefined;
if (!Array.isArray(message.content)) {
return [];
}
const replayChunks: ReplayChunk[] = [];
for (const block of message.content) {
if (!block || typeof block !== "object" || Array.isArray(block)) {
continue;
}
const typedBlock = block as GatewayChatContentBlock;
if (typedBlock.type === "text" && typeof typedBlock.text === "string" && typedBlock.text) {
replayChunks.push({
sessionUpdate: role === "user" ? "user_message_chunk" : "agent_message_chunk",
text: typedBlock.text,
});
continue;
}
if (
role === "assistant" &&
typedBlock.type === "thinking" &&
typeof typedBlock.thinking === "string" &&
typedBlock.thinking
) {
replayChunks.push({
sessionUpdate: "agent_thought_chunk",
text: typedBlock.thinking,
});
}
}
return replayChunks;
}
function buildSessionMetadata(params: {
@ -1045,21 +1076,16 @@ export class AcpGatewayAgent implements Agent {
transcript: ReadonlyArray<GatewayTranscriptMessage>,
): Promise<void> {
for (const message of transcript) {
const role = typeof message.role === "string" ? message.role : "";
if (role !== "user" && role !== "assistant") {
continue;
const replayChunks = extractReplayChunks(message);
for (const chunk of replayChunks) {
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: chunk.sessionUpdate,
content: { type: "text", text: chunk.text },
},
});
}
const text = extractReplayText(message.content);
if (!text) {
continue;
}
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: role === "user" ? "user_message_chunk" : "agent_message_chunk",
content: { type: "text", text },
},
});
}
}