mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
3 Commits
v0.95.1-pr
...
pr-661
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
affc731e3a | ||
|
|
63b708176e | ||
|
|
dc640e2dcf |
129
src/acp-agent.ts
129
src/acp-agent.ts
@@ -165,6 +165,29 @@ type Session = {
|
||||
/** Accumulated task list for the session, keyed by task ID. Task IDs are
|
||||
* per-session, so this state must not be shared across sessions. */
|
||||
taskState: TaskState;
|
||||
/** Assistant message ids whose text/thinking content was already emitted as
|
||||
* `agent_message_chunk` / `agent_thought_chunk` via `stream_event` partial
|
||||
* messages. Used by the top-level `assistant` case to decide whether to
|
||||
* skip text/thinking blocks (the default, when streaming worked) or fall
|
||||
* back to emitting the assembled blocks (when the underlying SDK / model
|
||||
* proxy never produced `content_block_delta` for them). */
|
||||
textStreamedMessageIds: Set<string>;
|
||||
/** Keys of assembled text/thinking blocks the fallback path has already
|
||||
* forwarded this turn, used to dedup re-delivered blocks without dropping
|
||||
* genuinely new ones. A single assistant message id is frequently
|
||||
* delivered as several `assistant` messages, each carrying a *different*
|
||||
* subset of content blocks (e.g. an empty `thinking` block, then the real
|
||||
* `text` block); deduping at message-id granularity would emit the first
|
||||
* block, flag the id, then silently drop the later text. Keying on the
|
||||
* block content instead lets distinct blocks that share an id all through
|
||||
* while still collapsing identical re-deliveries (incremental + consolidated
|
||||
* messages, or the same message delivered twice). */
|
||||
fallbackEmittedBlockKeys: Set<string>;
|
||||
/** Id of the assistant message currently being streamed via `stream_event`
|
||||
* partial messages, captured at `message_start` so subsequent
|
||||
* `content_block_delta` events can record their parent into
|
||||
* `textStreamedMessageIds`. */
|
||||
currentStreamingMessageId?: string;
|
||||
};
|
||||
|
||||
/** Compute a stable fingerprint of the session-defining params so we can
|
||||
@@ -743,6 +766,14 @@ export class ClaudeAcpAgent implements Agent {
|
||||
cachedWriteTokens: 0,
|
||||
};
|
||||
|
||||
// Assistant message ids and fallback block keys are turn-scoped — clear
|
||||
// before a new turn so the sets don't grow unboundedly over a long-lived
|
||||
// session, and so that an id reused across turns (uncommon but possible)
|
||||
// is re-evaluated.
|
||||
session.textStreamedMessageIds.clear();
|
||||
session.fallbackEmittedBlockKeys.clear();
|
||||
session.currentStreamingMessageId = undefined;
|
||||
|
||||
let lastAssistantTotalUsage: number | null = null;
|
||||
let lastAssistantUsage: UsageSnapshot | null = null;
|
||||
let lastAssistantModel: string | null = null;
|
||||
@@ -1056,6 +1087,31 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
case "stream_event": {
|
||||
// Track which assistant message ids have produced streamed text /
|
||||
// thinking deltas so the `assistant` case below can fall back to
|
||||
// emitting the assembled content when streaming didn't (e.g. when
|
||||
// the SDK is talking to a model proxy that never produces
|
||||
// `content_block_delta` events for the second model call in a
|
||||
// tool-use loop).
|
||||
//
|
||||
// This runs for every stream_event (not gated on
|
||||
// `parent_tool_use_id === null` like the usage block below) because
|
||||
// subagent assistant messages flow through the same content filter.
|
||||
// `currentStreamingMessageId` is a single field, which is safe
|
||||
// because the SDK serializes the event stream and `message_start`
|
||||
// always precedes its own `content_block_delta`s, so the captured
|
||||
// id is always the parent of the deltas that follow.
|
||||
if (message.event.type === "message_start") {
|
||||
session.currentStreamingMessageId = message.event.message.id;
|
||||
} else if (
|
||||
message.event.type === "content_block_delta" &&
|
||||
(message.event.delta.type === "text_delta" ||
|
||||
message.event.delta.type === "thinking_delta") &&
|
||||
session.currentStreamingMessageId
|
||||
) {
|
||||
session.textStreamedMessageIds.add(session.currentStreamingMessageId);
|
||||
}
|
||||
|
||||
if (
|
||||
message.parent_tool_use_id === null &&
|
||||
(message.event.type === "message_start" || message.event.type === "message_delta")
|
||||
@@ -1227,14 +1283,77 @@ export class ClaudeAcpAgent implements Agent {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
|
||||
// Text and thinking blocks are normally emitted from the
|
||||
// `stream_event` case above as `content_block_delta` arrives,
|
||||
// so the assembled message replays them here. We skip them in
|
||||
// that case to avoid duplication. However, if streaming never
|
||||
// produced any text/thinking delta for this assistant message
|
||||
// (e.g. because the underlying model proxy returned the response
|
||||
// as a single non-streamed block, which is common with OpenAI-
|
||||
// compatible gateways translating to the Anthropic protocol), the
|
||||
// delta path emitted nothing — filtering the assembled blocks out
|
||||
// would drop the assistant's final text on the floor while still
|
||||
// returning `stopReason: "end_turn"`. Detect that case via
|
||||
// `textStreamedMessageIds` and emit the assembled blocks as a
|
||||
// fallback.
|
||||
//
|
||||
// The fallback dedup must be per content block, not per message
|
||||
// id: such gateways frequently deliver one assistant response as
|
||||
// several `assistant` messages that *share a message id* but each
|
||||
// carry a *different* block (e.g. an empty `thinking` block, then
|
||||
// the real `text` block). Flagging the whole id after the first
|
||||
// block would emit that block, then silently drop the real text
|
||||
// arriving under the same id. So we key the dedup on the block
|
||||
// contents, which lets distinct blocks through while still
|
||||
// collapsing identical re-deliveries (an incremental block
|
||||
// followed by a consolidated message, or the same message
|
||||
// delivered twice).
|
||||
const assistantMessageId =
|
||||
message.type === "assistant" ? message.message.id : undefined;
|
||||
const liveStreamed =
|
||||
!!assistantMessageId && session.textStreamedMessageIds.has(assistantMessageId);
|
||||
|
||||
let emittedFallbackBlock = false;
|
||||
const content =
|
||||
message.type === "assistant"
|
||||
? // Handled by stream events above
|
||||
message.message.content.filter(
|
||||
(item) => !["text", "thinking"].includes(item.type),
|
||||
)
|
||||
? message.message.content.filter((item) => {
|
||||
if (item.type !== "text" && item.type !== "thinking") {
|
||||
return true;
|
||||
}
|
||||
// Already delivered live via streaming deltas — drop the
|
||||
// assembled copy to avoid duplication. Note this is
|
||||
// all-or-nothing per message id: a single delta of either
|
||||
// kind flags the id, so if a gateway streamed (say)
|
||||
// thinking deltas but delivered text only in the assembled
|
||||
// message under that same id, the assembled text is
|
||||
// dropped here. That mixed mode hasn't been observed —
|
||||
// gateways stream either everything or nothing — and it is
|
||||
// not a regression (the old code dropped it unconditionally).
|
||||
if (liveStreamed) {
|
||||
return false;
|
||||
}
|
||||
// Fallback path: emit each assembled block once, keyed on
|
||||
// its contents so re-delivered blocks dedup but distinct
|
||||
// blocks sharing a message id are all forwarded.
|
||||
const blockText = item.type === "text" ? item.text : item.thinking;
|
||||
const key = `${assistantMessageId ?? "<unknown>"}\u0000${item.type}\u0000${blockText}`;
|
||||
if (session.fallbackEmittedBlockKeys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
session.fallbackEmittedBlockKeys.add(key);
|
||||
emittedFallbackBlock = true;
|
||||
return true;
|
||||
})
|
||||
: message.message.content;
|
||||
|
||||
if (emittedFallbackBlock) {
|
||||
this.logger.log(
|
||||
`[claude-agent-acp] No streamed text/thinking deltas seen for ` +
|
||||
`assistant message ${assistantMessageId ?? "<unknown>"}; ` +
|
||||
`emitting assembled blocks as fallback.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const notification of toAcpNotifications(
|
||||
content,
|
||||
message.message.role,
|
||||
@@ -2258,6 +2377,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
contextWindowSize:
|
||||
inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
|
||||
taskState,
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1544,6 +1544,8 @@ describe("stop reason propagation", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1688,6 +1690,8 @@ describe("stop reason propagation", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
|
||||
const response = await agent.prompt({
|
||||
@@ -1806,6 +1810,409 @@ describe("stop reason propagation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("assistant text fallback when streaming yields no text deltas", () => {
|
||||
function createCapturingAgent() {
|
||||
const notifications: SessionNotification[] = [];
|
||||
const mockClient = {
|
||||
sessionUpdate: async (n: SessionNotification) => {
|
||||
notifications.push(n);
|
||||
},
|
||||
extNotification: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
return { agent, notifications };
|
||||
}
|
||||
|
||||
function injectSession(agent: ClaudeAcpAgent, messages: any[]) {
|
||||
const input = new Pushable<any>();
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage, done } = await iter.next();
|
||||
if (!done && userMessage) {
|
||||
yield {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
yield* messages;
|
||||
}
|
||||
agent.sessions["test-session"] = {
|
||||
query: messageGenerator() as any,
|
||||
input,
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
modelInfos: [],
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeResult() {
|
||||
return {
|
||||
type: "result" as const,
|
||||
subtype: "success" as const,
|
||||
stop_reason: null,
|
||||
is_error: false,
|
||||
result: "",
|
||||
errors: [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
};
|
||||
}
|
||||
|
||||
function makeAssistantMessage(messageId: string, text: string) {
|
||||
return makeAssistantMessageWithBlocks(messageId, [{ type: "text", text }]);
|
||||
}
|
||||
|
||||
// Same as makeAssistantMessage but with arbitrary content blocks, so tests
|
||||
// can reproduce gateways that split a single response (one message id) into
|
||||
// several `assistant` messages each carrying a different block.
|
||||
function makeAssistantMessageWithBlocks(messageId: string, content: any[]) {
|
||||
return {
|
||||
type: "assistant" as const,
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
id: messageId,
|
||||
type: "message" as const,
|
||||
role: "assistant" as const,
|
||||
model: "claude-test",
|
||||
content,
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("emits the assembled text when no content_block_delta was streamed", async () => {
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "msg_no_stream";
|
||||
const finalText = "final answer never streamed";
|
||||
injectSession(agent, [
|
||||
makeAssistantMessage(messageId, finalText),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "go" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
const textChunks = notifications
|
||||
.map((n) => n.update)
|
||||
.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === finalText,
|
||||
);
|
||||
expect(textChunks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("does not re-emit text already streamed via content_block_delta", async () => {
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "msg_streamed";
|
||||
const streamedText = "streamed answer";
|
||||
injectSession(agent, [
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
event: {
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: messageId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-test",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
event: {
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: streamedText },
|
||||
},
|
||||
},
|
||||
makeAssistantMessage(messageId, streamedText),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "go" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
const textChunks = notifications
|
||||
.map((n) => n.update)
|
||||
.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === streamedText,
|
||||
);
|
||||
// Only the streamed content_block_delta should produce a chunk; the
|
||||
// assembled message must not duplicate it.
|
||||
expect(textChunks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("fallback emits exactly once when the same assistant message is delivered twice", async () => {
|
||||
// Regression test: the SDK occasionally delivers the same assembled
|
||||
// assistant message twice (observed in production logs). Without
|
||||
// recording the id into `textStreamedMessageIds` after the first
|
||||
// fallback emit, the second delivery would re-fire the fallback and
|
||||
// ACP clients would see duplicate text chunks. After the dedupe fix
|
||||
// the second delivery must be a no-op.
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "msg_delivered_twice";
|
||||
const finalText = "final answer delivered twice";
|
||||
injectSession(agent, [
|
||||
makeAssistantMessage(messageId, finalText),
|
||||
makeAssistantMessage(messageId, finalText),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "go" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
const textChunks = notifications
|
||||
.map((n) => n.update)
|
||||
.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === finalText,
|
||||
);
|
||||
expect(textChunks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("textStreamedMessageIds is cleared at the start of each new turn", async () => {
|
||||
// Regression test: assistant message ids are turn-scoped. Without
|
||||
// per-turn clear, the set would accumulate over the session's lifetime
|
||||
// and a (rare) reuse of an id across turns would be misclassified as
|
||||
// "already streamed", silently dropping the fallback emit. The two
|
||||
// turns below use the same messageId; both should fallback-emit
|
||||
// exactly once because the set is reset on each `prompt()` entry.
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "msg_reused_across_turns";
|
||||
const finalText = "answer for both turns";
|
||||
injectSession(agent, [
|
||||
makeAssistantMessage(messageId, finalText),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
makeAssistantMessage(messageId, finalText),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const first = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "turn 1" }],
|
||||
});
|
||||
const second = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "turn 2" }],
|
||||
});
|
||||
|
||||
expect(first.stopReason).toBe("end_turn");
|
||||
expect(second.stopReason).toBe("end_turn");
|
||||
const textChunks = notifications
|
||||
.map((n) => n.update)
|
||||
.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === finalText,
|
||||
);
|
||||
// Two turns each fallback-emit once.
|
||||
expect(textChunks.length).toBe(2);
|
||||
});
|
||||
|
||||
it("emits the text block when it arrives in a later same-id message than the thinking block", async () => {
|
||||
// Production regression (OpenAI-compatible gpt-5 gateway): a single
|
||||
// assistant response (one message id) is delivered as *separate*
|
||||
// `assistant` messages — an empty `thinking` block first, then the real
|
||||
// `text` block — both sharing the same id and neither streamed via
|
||||
// content_block_delta. Deduping at message-id granularity flagged the id
|
||||
// on the thinking block and then dropped the text, so the client saw the
|
||||
// fallback fire but received an empty answer. The fallback must key on the
|
||||
// block contents so the later text block is still emitted.
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "resp_split_blocks";
|
||||
const finalText = "=== Ticket Review Summary === approved";
|
||||
injectSession(agent, [
|
||||
makeAssistantMessageWithBlocks(messageId, [
|
||||
{ type: "thinking", thinking: "", signature: "sig" },
|
||||
]),
|
||||
makeAssistantMessageWithBlocks(messageId, [{ type: "text", text: finalText }]),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "go" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
const textChunks = notifications
|
||||
.map((n) => n.update)
|
||||
.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === finalText,
|
||||
);
|
||||
expect(textChunks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("emits distinct text blocks that share a message id", async () => {
|
||||
// A gateway may split a multi-block answer (thinking + text + a second
|
||||
// text) across same-id messages. Every distinct text block must reach the
|
||||
// client exactly once.
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "resp_multi_text";
|
||||
const firstText = "我先直接执行审批并返回工单结果。";
|
||||
const secondText = "Ticket approved.";
|
||||
injectSession(agent, [
|
||||
makeAssistantMessageWithBlocks(messageId, [
|
||||
{ type: "thinking", thinking: "considering options", signature: "sig" },
|
||||
]),
|
||||
makeAssistantMessageWithBlocks(messageId, [{ type: "text", text: firstText }]),
|
||||
makeAssistantMessageWithBlocks(messageId, [{ type: "text", text: secondText }]),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "go" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
const updates = notifications.map((n) => n.update);
|
||||
const firstChunks = updates.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === firstText,
|
||||
);
|
||||
const secondChunks = updates.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === secondText,
|
||||
);
|
||||
expect(firstChunks.length).toBe(1);
|
||||
expect(secondChunks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("does not duplicate text when an incremental block is followed by a consolidated message", async () => {
|
||||
// Some gateways deliver an incremental per-block `assistant` message and
|
||||
// then a consolidated `assistant` message (same id) that repeats that
|
||||
// block alongside others. The repeated block must not be emitted twice.
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "resp_consolidated";
|
||||
const thinking = "step-by-step reasoning";
|
||||
const finalText = "the answer";
|
||||
injectSession(agent, [
|
||||
makeAssistantMessageWithBlocks(messageId, [{ type: "text", text: finalText }]),
|
||||
makeAssistantMessageWithBlocks(messageId, [
|
||||
{ type: "thinking", thinking, signature: "sig" },
|
||||
{ type: "text", text: finalText },
|
||||
]),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "go" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
const updates = notifications.map((n) => n.update);
|
||||
const textChunks = updates.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === finalText,
|
||||
);
|
||||
const thoughtChunks = updates.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_thought_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === thinking,
|
||||
);
|
||||
expect(textChunks.length).toBe(1);
|
||||
expect(thoughtChunks.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/close", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
@@ -1847,6 +2254,8 @@ describe("session/close", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -1931,6 +2340,8 @@ describe("session/delete", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -2032,6 +2443,8 @@ describe("getOrCreateSession param change detection", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -2267,6 +2680,8 @@ describe("usage_update computation", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3167,6 +3582,8 @@ describe("emitRawSDKMessages", () => {
|
||||
emitRawSDKMessages,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3395,6 +3812,8 @@ describe("result origin handling", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3570,6 +3989,8 @@ describe("memory_recall handling", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3800,6 +4221,8 @@ describe("post-error recovery", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
return { interrupt };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user