fix: thinking signature capture, abort-aware retry delay, usage tracking

This commit is contained in:
2026-03-01 12:13:14 +01:00
parent 72410b2973
commit 2ddaad422f
3 changed files with 248 additions and 9 deletions

View File

@@ -1399,3 +1399,200 @@ describe('async iterator return() cleanup', () => {
}
});
});
// ── Thinking block signature capture ──
describe('Anthropic thinking block signature', () => {
let accumulator: AnthropicStreamAccumulator;
beforeEach(() => {
accumulator = createAnthropicStreamAccumulator();
});
it('captures signature from content_block_stop for thinking blocks', () => {
// Start thinking block
parseAnthropicStreamEvent({
event: 'content_block_start',
data: JSON.stringify({
type: 'content_block_start',
index: 0,
content_block: { type: 'thinking', thinking: '' },
}),
}, accumulator);
// Accumulate thinking
parseAnthropicStreamEvent({
event: 'content_block_delta',
data: JSON.stringify({
type: 'content_block_delta',
index: 0,
delta: { type: 'thinking_delta', thinking: 'Let me reason...' },
}),
}, accumulator);
// content_block_stop with signature
parseAnthropicStreamEvent({
event: 'content_block_stop',
data: JSON.stringify({
type: 'content_block_stop',
index: 0,
content_block: {
type: 'thinking',
thinking: 'Let me reason...',
signature: 'ErUBCkYIAxgCIkD+ybfICm10kSig...',
},
}),
}, accumulator);
const tb = accumulator.thinkingBlocks.get(0);
expect(tb).toBeDefined();
expect(tb!.text).toBe('Let me reason...');
expect(tb!.signature).toBe('ErUBCkYIAxgCIkD+ybfICm10kSig...');
});
it('leaves signature undefined when content_block_stop has no signature', () => {
// Start thinking block
parseAnthropicStreamEvent({
event: 'content_block_start',
data: JSON.stringify({
type: 'content_block_start',
index: 0,
content_block: { type: 'thinking', thinking: '' },
}),
}, accumulator);
// content_block_stop without signature
parseAnthropicStreamEvent({
event: 'content_block_stop',
data: JSON.stringify({
type: 'content_block_stop',
index: 0,
}),
}, accumulator);
const tb = accumulator.thinkingBlocks.get(0);
expect(tb).toBeDefined();
expect(tb!.signature).toBeUndefined();
});
it('does not affect tool_call blocks on content_block_stop', () => {
// Start tool_use block
parseAnthropicStreamEvent({
event: 'content_block_start',
data: JSON.stringify({
type: 'content_block_start',
index: 0,
content_block: { type: 'tool_use', id: 'toolu_1', name: 'search_posts' },
}),
}, accumulator);
// Tool argument fragment
parseAnthropicStreamEvent({
event: 'content_block_delta',
data: JSON.stringify({
type: 'content_block_delta',
index: 0,
delta: { type: 'input_json_delta', partial_json: '{"query":"test"}' },
}),
}, accumulator);
// content_block_stop (no signature for tool blocks)
parseAnthropicStreamEvent({
event: 'content_block_stop',
data: JSON.stringify({
type: 'content_block_stop',
index: 0,
}),
}, accumulator);
// Tool call should be unaffected
const tc = accumulator.toolCalls.get(0);
expect(tc).toBeDefined();
expect(tc!.arguments).toBe('{"query":"test"}');
});
it('full thinking sequence produces signature on accumulator', () => {
// Full realistic sequence: thinking block -> text block -> tool_use
// Thinking at index 0
parseAnthropicStreamEvent({
event: 'content_block_start',
data: JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } }),
}, accumulator);
parseAnthropicStreamEvent({
event: 'content_block_delta',
data: JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'Step 1. ' } }),
}, accumulator);
parseAnthropicStreamEvent({
event: 'content_block_delta',
data: JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'Step 2.' } }),
}, accumulator);
parseAnthropicStreamEvent({
event: 'content_block_stop',
data: JSON.stringify({ type: 'content_block_stop', index: 0, content_block: { type: 'thinking', thinking: 'Step 1. Step 2.', signature: 'sig_abc123' } }),
}, accumulator);
expect(accumulator.thinkingBlocks.get(0)).toEqual({ text: 'Step 1. Step 2.', signature: 'sig_abc123' });
});
});
// ── withRetry abort-aware delay ──
describe('withRetry abort during delay', () => {
it('rejects quickly when signal is aborted during retry delay', async () => {
const controller = new AbortController();
const error429 = Object.assign(new Error('Rate limited'), { statusCode: 429 });
const fn = vi.fn().mockRejectedValue(error429);
const promise = withRetry(fn, {
maxRetries: 3,
signal: controller.signal,
});
// First attempt fails immediately, then enters retry delay.
// Wait a small amount for the first attempt to fail and delay to start.
await new Promise(r => setTimeout(r, 50));
expect(fn).toHaveBeenCalledTimes(1);
// Abort during the delay
controller.abort();
// Should reject with abort error, not wait for delay to finish
await expect(promise).rejects.toThrow();
// Should NOT have made a second attempt — aborted during delay
expect(fn).toHaveBeenCalledTimes(1);
});
it('does not abort delay when no signal is provided', async () => {
vi.useFakeTimers();
const error429 = Object.assign(new Error('Rate limited'), { statusCode: 429 });
const fn = vi.fn()
.mockRejectedValueOnce(error429)
.mockResolvedValue('ok');
const promise = withRetry(fn, { maxRetries: 3 });
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
it('works normally when signal is not aborted', async () => {
vi.useFakeTimers();
const controller = new AbortController();
const error429 = Object.assign(new Error('Rate limited'), { statusCode: 429 });
const fn = vi.fn()
.mockRejectedValueOnce(error429)
.mockResolvedValue('ok');
const promise = withRetry(fn, {
maxRetries: 3,
signal: controller.signal,
});
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
});