Skip to content

Commit 467d4eb

Browse files
JoaoDiasAblyclaude
andcommitted
fix: call clearScope on error path in Vercel decoder
The lifecycle tracker's clearScope was called on finish and abort but not on error. If error is the terminal event in a turn (no subsequent finish), the scope entry leaks. clearScope is idempotent so adding it defensively is harmless. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent e109e59 commit 467d4eb

2 files changed

Lines changed: 129 additions & 2 deletions

File tree

src/vercel/codec/decoder.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,8 @@ const decodeFinish = (r: VercelHeaderReader, turnId: string, lifecycle: Lifecycl
277277
);
278278
};
279279

280-
const decodeError = (data: unknown): Out[] => {
280+
const decodeError = (data: unknown, turnId: string, lifecycle: LifecycleTracker<AI.UIMessageChunk>): Out[] => {
281+
lifecycle.clearScope(turnId);
281282
const errorText = typeof data === 'string' ? data : '';
282283
return event({ type: 'error', errorText });
283284
};
@@ -523,7 +524,7 @@ const decodeDiscretePayload = (input: MessagePayload, lifecycle: LifecycleTracke
523524
return decodeFinish(r, turnId, lifecycle);
524525
}
525526
case 'error': {
526-
return decodeError(input.data);
527+
return decodeError(input.data, turnId, lifecycle);
527528
}
528529
case 'abort': {
529530
return decodeAbort(input.data, turnId, lifecycle);

test/vercel/codec/decoder.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,132 @@ describe('Vercel decoder', () => {
681681
expect(eventTypesOf(outputs)).toContain('start-step');
682682
expect(eventTypesOf(outputs)).not.toContain('start'); // start already emitted for this turn
683683
});
684+
685+
it('clears lifecycle scope after finish', () => {
686+
const decoder = createDecoder();
687+
688+
// Stream content — synthesizes start + start-step
689+
decoder.decode(
690+
withHeaders(
691+
{ action: 'message.create', serial: 's1', name: 'text', data: '' },
692+
{
693+
[HEADER_STREAM]: 'true',
694+
[HEADER_STATUS]: 'streaming',
695+
[HEADER_STREAM_ID]: 'txt-1',
696+
[HEADER_TURN_ID]: 'turn-1',
697+
[`${D}id`]: 'txt-1',
698+
},
699+
),
700+
);
701+
702+
// finish — clears scope
703+
decoder.decode(
704+
withHeaders(
705+
{ action: 'message.create', name: 'finish', data: '' },
706+
{ [HEADER_STREAM]: 'false', [HEADER_TURN_ID]: 'turn-1', [`${D}finishReason`]: 'stop' },
707+
),
708+
);
709+
710+
// New content on same turn — should re-synthesize start + start-step
711+
const outputs = decoder.decode(
712+
withHeaders(
713+
{ action: 'message.create', serial: 's2', name: 'text', data: '' },
714+
{
715+
[HEADER_STREAM]: 'true',
716+
[HEADER_STATUS]: 'streaming',
717+
[HEADER_STREAM_ID]: 'txt-2',
718+
[HEADER_TURN_ID]: 'turn-1',
719+
[`${D}id`]: 'txt-2',
720+
},
721+
),
722+
);
723+
expect(eventTypesOf(outputs)).toContain('start');
724+
expect(eventTypesOf(outputs)).toContain('start-step');
725+
});
726+
727+
it('clears lifecycle scope after abort', () => {
728+
const decoder = createDecoder();
729+
730+
// Stream content — synthesizes start + start-step
731+
decoder.decode(
732+
withHeaders(
733+
{ action: 'message.create', serial: 's1', name: 'text', data: '' },
734+
{
735+
[HEADER_STREAM]: 'true',
736+
[HEADER_STATUS]: 'streaming',
737+
[HEADER_STREAM_ID]: 'txt-1',
738+
[HEADER_TURN_ID]: 'turn-1',
739+
[`${D}id`]: 'txt-1',
740+
},
741+
),
742+
);
743+
744+
// abort — clears scope
745+
decoder.decode(
746+
withHeaders(
747+
{ action: 'message.create', name: 'abort', data: 'cancelled' },
748+
{ [HEADER_STREAM]: 'false', [HEADER_TURN_ID]: 'turn-1' },
749+
),
750+
);
751+
752+
// New content on same turn — should re-synthesize start + start-step
753+
const outputs = decoder.decode(
754+
withHeaders(
755+
{ action: 'message.create', serial: 's2', name: 'text', data: '' },
756+
{
757+
[HEADER_STREAM]: 'true',
758+
[HEADER_STATUS]: 'streaming',
759+
[HEADER_STREAM_ID]: 'txt-2',
760+
[HEADER_TURN_ID]: 'turn-1',
761+
[`${D}id`]: 'txt-2',
762+
},
763+
),
764+
);
765+
expect(eventTypesOf(outputs)).toContain('start');
766+
expect(eventTypesOf(outputs)).toContain('start-step');
767+
});
768+
769+
it('clears lifecycle scope after error', () => {
770+
const decoder = createDecoder();
771+
772+
// Stream content — synthesizes start + start-step
773+
decoder.decode(
774+
withHeaders(
775+
{ action: 'message.create', serial: 's1', name: 'text', data: '' },
776+
{
777+
[HEADER_STREAM]: 'true',
778+
[HEADER_STATUS]: 'streaming',
779+
[HEADER_STREAM_ID]: 'txt-1',
780+
[HEADER_TURN_ID]: 'turn-1',
781+
[`${D}id`]: 'txt-1',
782+
},
783+
),
784+
);
785+
786+
// error — clears scope
787+
decoder.decode(
788+
withHeaders(
789+
{ action: 'message.create', name: 'error', data: 'something broke' },
790+
{ [HEADER_STREAM]: 'false', [HEADER_TURN_ID]: 'turn-1' },
791+
),
792+
);
793+
794+
// New content on same turn — should re-synthesize start + start-step
795+
const outputs = decoder.decode(
796+
withHeaders(
797+
{ action: 'message.create', serial: 's2', name: 'text', data: '' },
798+
{
799+
[HEADER_STREAM]: 'true',
800+
[HEADER_STATUS]: 'streaming',
801+
[HEADER_STREAM_ID]: 'txt-2',
802+
[HEADER_TURN_ID]: 'turn-1',
803+
[`${D}id`]: 'txt-2',
804+
},
805+
),
806+
);
807+
expect(eventTypesOf(outputs)).toContain('start');
808+
expect(eventTypesOf(outputs)).toContain('start-step');
809+
});
684810
});
685811

686812
// -- first-contact update -------------------------------------------------

0 commit comments

Comments
 (0)