Skip to content

Commit 5fcb668

Browse files
RulaKhaledclaude
andcommitted
fix(server-utils): Skip finished thread streams when matching a request
A create() sent with the signal of a thread stream whose request failed was still matched to that stream, so its span never ended. A finished stream sends no more requests, so skip it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 2e6e182 commit 5fcb668

3 files changed

Lines changed: 36 additions & 12 deletions

File tree

‎dev-packages/node-integration-tests/suites/tracing/anthropic/v1/scenario-threads.mjs‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ function startMockAnthropicServer() {
7878
const answersTool = req.body.messages.some(
7979
message => Array.isArray(message.content) && message.content.some(block => block.type === 'tool_result'),
8080
);
81+
if (mode === 'thread_stream_failed_signal_reuse' && !answersTool) {
82+
res.status(400).json({ type: 'error', error: { type: 'invalid_request_error', message: 'Bad thread' } });
83+
return;
84+
}
8185
const message = reply(mode, answersTool ? 'final' : 'tools');
8286

8387
if (!req.body.stream) {
@@ -86,7 +90,7 @@ function startMockAnthropicServer() {
8690
}
8791

8892
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
89-
// The body closes after the first text delta, without `message_stop`.
93+
// The body closes after the first tool input delta, without `message_stop`.
9094
const events = mode === 'thread_stream_truncated' ? streamEvents(message).slice(0, 3) : streamEvents(message);
9195
events.forEach((event, index) => {
9296
setTimeout(() => {
@@ -115,6 +119,17 @@ const tools = [
115119
];
116120
const params = { model: MODEL, max_tokens: 100, messages: [{ role: 'user', content: 'Weather in Vienna?' }] };
117121

122+
async function streamFollowUp(client, signal) {
123+
const answer = { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'sunny' }] };
124+
const followUp = await client.beta.messages.create(
125+
{ ...params, messages: [...params.messages, answer], stream: true },
126+
{ signal },
127+
);
128+
for await (const _event of followUp) {
129+
// consume
130+
}
131+
}
132+
118133
const MODES = {
119134
async runner_stream_eager(client) {
120135
for await (const stream of client.beta.messages.toolRunner({
@@ -152,14 +167,12 @@ const MODES = {
152167
async thread_stream_signal_reuse(client) {
153168
const stream = client.beta.messages.thread({ model: MODEL, max_tokens: 100 }).stream(params.messages);
154169
await stream.finalMessage();
155-
const answer = { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'sunny' }] };
156-
const followUp = await client.beta.messages.create(
157-
{ ...params, messages: [...params.messages, answer], stream: true },
158-
{ signal: stream.controller.signal },
159-
);
160-
for await (const _event of followUp) {
161-
// consume
162-
}
170+
await streamFollowUp(client, stream.controller.signal);
171+
},
172+
async thread_stream_failed_signal_reuse(client) {
173+
const stream = client.beta.messages.thread({ model: MODEL, max_tokens: 100 }).stream(params.messages);
174+
await stream.finalMessage().catch(() => {});
175+
await streamFollowUp(client, stream.controller.signal);
163176
},
164177
async thread_stream_abort(client) {
165178
const stream = client.beta.messages.thread({ model: MODEL, max_tokens: 100 }).stream(params.messages);

‎dev-packages/node-integration-tests/suites/tracing/anthropic/v1/test.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,8 @@ describe('Anthropic integration (1.x)', () => {
165165
const spans = await getSpans();
166166

167167
expect(spans.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_CHAT)).toHaveLength(
168-
MODES.flatMap(({ turns }) => turns).length + STATUS_ONLY_MODES.length,
168+
// Plus the failed request and the follow-up of `thread_stream_failed_signal_reuse`.
169+
MODES.flatMap(({ turns }) => turns).length + STATUS_ONLY_MODES.length + 2,
169170
);
170171
expect(spans.filter(span => span.name.startsWith('anthropic.'))).toEqual([]);
171172
});
@@ -207,6 +208,15 @@ describe('Anthropic integration (1.x)', () => {
207208
expect(chatSpans).toHaveLength(1);
208209
expect(chatSpans[0]!.status).toBe(status);
209210
});
211+
212+
test("ends the span of a request sent with a failed stream's signal", async () => {
213+
const chatSpans = await chatSpansUnder('thread_stream_failed_signal_reuse');
214+
const followUp = spanForCall(chatSpans, 'msg_thread_stream_failed_signal_reuse_final');
215+
216+
expect(chatSpans.map(span => span.status).sort()).toEqual(['error', 'ok']);
217+
expect(followUp.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(5);
218+
expect(followUp.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('Sunny.');
219+
});
210220
});
211221
});
212222

‎packages/server-utils/src/integrations/anthropic.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ function isMessageStream(value: unknown): value is MessageStreamEmitter {
211211
return !!value && typeof (value as MessageStreamEmitter).on === 'function';
212212
}
213213

214-
type ThreadStream = MessageStreamEmitter & { controller?: { signal?: AbortSignal } };
214+
type ThreadStream = MessageStreamEmitter & { ended?: boolean; controller?: { signal?: AbortSignal } };
215215

216216
/**
217217
* The streams of threads and tool runners on 1.2+, by the signal they send each `create()` call with. Such a
@@ -232,7 +232,8 @@ function wrapThreadStreamRequest(
232232
const signal = (data.arguments?.[1] as { signal?: AbortSignal } | undefined)?.signal;
233233
const stream = signal && threadStreams.get(signal);
234234
const request = data.result as { _responseProps?: () => Promise<unknown> } | undefined;
235-
if (!stream || typeof request?._responseProps !== 'function') {
235+
// A finished stream sends no more requests, so a later call with its signal is the caller's own.
236+
if (!stream || stream.ended || typeof request?._responseProps !== 'function') {
236237
return false;
237238
}
238239

0 commit comments

Comments
 (0)