Summary
RequestContext.protocolTagSanitized has exactly one reader — pipeline.ts:746, inside processStreamWithLogging — so it only ever produces a ProtocolTagSanitizedEvent and a debugLogger.warn on the streaming route. Since #13600 the field is also written on the non-streaming route, where nothing reads it: pipeline.execute() returns without touching the context, so a trailing orphan thinking tag is deleted from the model's answer with no event and no log line.
Source: review thread R2-2 on #13600. The count half of that finding was fixed in 7df8f5c4322ea63a2cd2c054729daee4687a613e; the reader half is filed here because fixing it means adding an emission site to execute(), beyond that PR's per-finding repair ceiling.
The route is live
convertOpenAITextToParts (converter.ts:1249) gates the trailing-tag filter on responseParsingOptions?.contentOnlyThinkingTagLeaks, and provider/default.ts:275 returns contentOnlyThinkingTagLeaks: true for every model. getResponseParsingOptions is not streaming-gated, and taggedThinkingParser is built only when streaming — so createRequestContext(request, isStreaming=false) still carries the flag.
convertOpenAIResponseToLlm is called from pipeline.ts:517, inside execute(). Real non-streaming call sites include hooks/promptHookRunner.ts:328, baseLlmClient.ts:557 and :732, client.ts:5729, and cli/src/serve/hosted-hook-model.ts:74.
Before that PR the field had exactly one producer and it was streaming-only, so this gap is new rather than pre-existing.
Measured
# pipeline.execute, non-streaming, content 'Answer.\n</thinking>', finish_reason 'stop'
BASE 464f486e20 : returnedParts=[{text:"Answer.\n</thinking>"}] contextProtocolTagSanitized=(absent) sanitizedEvents=[]
PR 3179bf197f : returnedParts=[{text:"Answer."}] contextProtocolTagSanitized={"tagName":"thinking","toolCallCount":0}
sanitizedEvents=[] <- logProtocolTagSanitized never called
A second-order consequence of the same missing reader: the toolCallCount backfill added in 7df8f5c432 lives in convertOpenAIChunkToLlm, so a non-streaming response that carries functionCall parts (built at converter.ts:1428-1444) still stamps toolCallCount: 0.
Proposed scope
Hoist logPendingProtocolTagSanitized out of processStreamWithLogging into a pipeline-level helper and call it in execute() after convertOpenAIResponseToLlm, passing then clearing context.protocolTagSanitized, so one emission site serves both routes. Add a pipeline.test.ts case running pipeline.execute against a mocked non-streaming completion whose content ends in an orphan closer and asserting logProtocolTagSanitized was called once with tagName: 'thinking'. The harness already exists: pipeline.test.ts:46 imports it, :83 mocks it, and :2513/:2539 assert its call count for the streaming route.
Facts a fix must not violate:
RequestContext['protocolTagSanitized'] declares toolCallCount as required, not optional (types.ts:128-131), so it cannot be dropped to express "unknown".
telemetry/loggers.ts:972 renders the count into a sentence ("preserved N tool call(s)"), so a shared emission site must not pass a count that contradicts the response's own functionCall parts.
pipeline.ts:746-749 reads and clears the field immediately after each convertOpenAIChunkToLlm call, and pipeline.ts:799-804 parks it only on a finish-reason response; a deferred stamp must land inside the same call that carries finish_reason.
Related family-level decision: #10559
Summary
RequestContext.protocolTagSanitizedhas exactly one reader —pipeline.ts:746, insideprocessStreamWithLogging— so it only ever produces aProtocolTagSanitizedEventand adebugLogger.warnon the streaming route. Since #13600 the field is also written on the non-streaming route, where nothing reads it:pipeline.execute()returns without touching the context, so a trailing orphan thinking tag is deleted from the model's answer with no event and no log line.Source: review thread R2-2 on #13600. The count half of that finding was fixed in
7df8f5c4322ea63a2cd2c054729daee4687a613e; the reader half is filed here because fixing it means adding an emission site toexecute(), beyond that PR's per-finding repair ceiling.The route is live
convertOpenAITextToParts(converter.ts:1249) gates the trailing-tag filter onresponseParsingOptions?.contentOnlyThinkingTagLeaks, andprovider/default.ts:275returnscontentOnlyThinkingTagLeaks: truefor every model.getResponseParsingOptionsis not streaming-gated, andtaggedThinkingParseris built only when streaming — socreateRequestContext(request, isStreaming=false)still carries the flag.convertOpenAIResponseToLlmis called frompipeline.ts:517, insideexecute(). Real non-streaming call sites includehooks/promptHookRunner.ts:328,baseLlmClient.ts:557and:732,client.ts:5729, andcli/src/serve/hosted-hook-model.ts:74.Before that PR the field had exactly one producer and it was streaming-only, so this gap is new rather than pre-existing.
Measured
A second-order consequence of the same missing reader: the
toolCallCountbackfill added in7df8f5c432lives inconvertOpenAIChunkToLlm, so a non-streaming response that carriesfunctionCallparts (built atconverter.ts:1428-1444) still stampstoolCallCount: 0.Proposed scope
Hoist
logPendingProtocolTagSanitizedout ofprocessStreamWithLogginginto a pipeline-level helper and call it inexecute()afterconvertOpenAIResponseToLlm, passing then clearingcontext.protocolTagSanitized, so one emission site serves both routes. Add apipeline.test.tscase runningpipeline.executeagainst a mocked non-streaming completion whose content ends in an orphan closer and assertinglogProtocolTagSanitizedwas called once withtagName: 'thinking'. The harness already exists:pipeline.test.ts:46imports it,:83mocks it, and:2513/:2539assert its call count for the streaming route.Facts a fix must not violate:
RequestContext['protocolTagSanitized']declarestoolCallCountas required, not optional (types.ts:128-131), so it cannot be dropped to express "unknown".telemetry/loggers.ts:972renders the count into a sentence ("preserved N tool call(s)"), so a shared emission site must not pass a count that contradicts the response's ownfunctionCallparts.pipeline.ts:746-749reads and clears the field immediately after eachconvertOpenAIChunkToLlmcall, andpipeline.ts:799-804parks it only on a finish-reason response; a deferred stamp must land inside the same call that carriesfinish_reason.Related family-level decision: #10559