@@ -4,6 +4,7 @@ import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
44import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js" ;
55import { emitAgentEvent } from "../infra/agent-events.js" ;
66import { createInlineCodeState } from "../markdown/code-spans.js" ;
7+ import { getGlobalHookRunner } from "../plugins/hook-runner-global.js" ;
78import {
89 isMessagingToolDuplicateNormalized ,
910 normalizeTextForComparison ,
@@ -16,6 +17,7 @@ import type {
1617import { appendRawStream } from "./pi-embedded-subscribe.raw-stream.js" ;
1718import {
1819 extractAssistantText ,
20+ extractAssistantTextWithThinkingTags ,
1921 extractAssistantThinking ,
2022 extractThinkingFromTaggedStream ,
2123 extractThinkingFromTaggedText ,
@@ -52,6 +54,8 @@ function emitReasoningEnd(ctx: EmbeddedPiSubscribeContext) {
5254 return ;
5355 }
5456 ctx . state . reasoningStreamOpen = false ;
57+ ctx . state . partialBlockState . thinking = false ;
58+ ( ctx . state . partialBlockState as { thinkingTransitioned ?: boolean } ) . thinkingTransitioned = false ;
5559 void ctx . params . onReasoningEnd ?.( ) ;
5660}
5761
@@ -179,8 +183,25 @@ export function handleMessageUpdate(
179183 const evtType = typeof assistantRecord ?. type === "string" ? assistantRecord . type : "" ;
180184
181185 if ( evtType === "thinking_start" || evtType === "thinking_delta" || evtType === "thinking_end" ) {
186+ // Mark that native thinking events are driving the lifecycle, so tagged
187+ // <think> transitions in text deltas don't duplicate hooks.
188+ ctx . state . nativeThinkingActive = true ;
182189 if ( evtType === "thinking_start" || evtType === "thinking_delta" ) {
183- ctx . state . reasoningStreamOpen = true ;
190+ if ( ! ctx . state . reasoningStreamOpen ) {
191+ ctx . state . reasoningStreamOpen = true ;
192+ ctx . state . thinkingStartedAt = Date . now ( ) ;
193+ // Emit thinking_start hook on first thinking event
194+ const hookRunner = ctx . hookRunner ?? getGlobalHookRunner ( ) ;
195+ if ( hookRunner ?. hasHooks ( "thinking_start" ) ) {
196+ void hookRunner . runThinkingStart (
197+ { runId : ctx . params . runId } ,
198+ {
199+ agentId : ctx . params . agentId ,
200+ sessionKey : ctx . params . sessionKey ,
201+ } ,
202+ ) ;
203+ }
204+ }
184205 }
185206 const thinkingDelta = typeof assistantRecord ?. delta === "string" ? assistantRecord . delta : "" ;
186207 const thinkingContent =
@@ -194,16 +215,46 @@ export function handleMessageUpdate(
194215 delta : thinkingDelta ,
195216 content : thinkingContent ,
196217 } ) ;
197- if ( ctx . state . streamReasoning ) {
218+ if ( ctx . state . streamReasoning || ctx . params . onAgentEvent ) {
198219 // Prefer full partial-message thinking when available; fall back to event payloads.
199220 const partialThinking = extractAssistantThinking ( msg ) ;
200221 ctx . emitReasoningStream ( partialThinking || thinkingContent || thinkingDelta ) ;
201222 }
202223 if ( evtType === "thinking_end" ) {
224+ // Ensure plugins always get a matching thinking_start before thinking_end
203225 if ( ! ctx . state . reasoningStreamOpen ) {
204226 ctx . state . reasoningStreamOpen = true ;
227+ ctx . state . thinkingStartedAt = Date . now ( ) ;
228+ const hookRunner = ctx . hookRunner ?? getGlobalHookRunner ( ) ;
229+ if ( hookRunner ?. hasHooks ( "thinking_start" ) ) {
230+ void hookRunner . runThinkingStart (
231+ { runId : ctx . params . runId } ,
232+ {
233+ agentId : ctx . params . agentId ,
234+ sessionKey : ctx . params . sessionKey ,
235+ } ,
236+ ) ;
237+ }
205238 }
206239 emitReasoningEnd ( ctx ) ;
240+ // Emit thinking_end hook
241+ const hookRunner = ctx . hookRunner ?? getGlobalHookRunner ( ) ;
242+ if ( hookRunner ?. hasHooks ( "thinking_end" ) ) {
243+ const fullThinking = extractAssistantThinking ( msg ) ;
244+ void hookRunner . runThinkingEnd (
245+ {
246+ runId : ctx . params . runId ,
247+ text : fullThinking || thinkingContent || undefined ,
248+ durationMs : ctx . state . thinkingStartedAt
249+ ? Date . now ( ) - ctx . state . thinkingStartedAt
250+ : undefined ,
251+ } ,
252+ {
253+ agentId : ctx . params . agentId ,
254+ sessionKey : ctx . params . sessionKey ,
255+ } ,
256+ ) ;
257+ }
207258 }
208259 return ;
209260 }
@@ -253,7 +304,7 @@ export function handleMessageUpdate(
253304 }
254305 }
255306
256- if ( ctx . state . streamReasoning ) {
307+ if ( ctx . state . streamReasoning || ctx . params . onAgentEvent ) {
257308 // Handle partial <think> tags: stream whatever reasoning is visible so far.
258309 ctx . emitReasoningStream ( extractThinkingFromTaggedStream ( ctx . state . deltaBuffer ) ) ;
259310 }
@@ -265,16 +316,78 @@ export function handleMessageUpdate(
265316 inlineCode : createInlineCodeState ( ) ,
266317 } )
267318 . trim ( ) ;
268- if ( next ) {
269- const wasThinking = ctx . state . partialBlockState . thinking ;
270- const visibleDelta = chunk ? ctx . stripBlockTags ( chunk , ctx . state . partialBlockState ) : "" ;
271- if ( ! wasThinking && ctx . state . partialBlockState . thinking ) {
319+
320+ // Track <think> tag transitions independently of visible text.
321+ // stripBlockTags updates partialBlockState as a side effect, so we must
322+ // call it even when `next` is empty (pure-thinking chunks).
323+ const wasThinking = ctx . state . partialBlockState . thinking ;
324+ if ( ! chunk ) {
325+ ( ctx . state . partialBlockState as { thinkingTransitioned ?: boolean } ) . thinkingTransitioned =
326+ false ;
327+ }
328+ const visibleDelta = chunk ? ctx . stripBlockTags ( chunk , ctx . state . partialBlockState ) : "" ;
329+
330+ // Handle single-chunk transitions: if the chunk contained both <think> and </think>,
331+ // partialBlockState.thinking ends as false. Detect this by checking whether
332+ // stripBlockTags consumed any thinking content even though final state is not-thinking.
333+ const nowThinking = ctx . state . partialBlockState . thinking ;
334+ const enteredThinking = ! wasThinking && nowThinking ;
335+ const exitedThinking = wasThinking && ! nowThinking ;
336+ // Single-chunk: was not thinking, is not thinking, but stripBlockTags found and consumed
337+ // a complete thinking block (open+close in one delta). Uses the thinkingTransitioned flag
338+ // set by stripBlockTags, which correctly respects code-span filtering.
339+ const singleChunkThinking =
340+ ! wasThinking &&
341+ ! nowThinking &&
342+ ! ! ( ctx . state . partialBlockState as { thinkingTransitioned ?: boolean } ) . thinkingTransitioned ;
343+
344+ // Skip tagged transitions entirely when native thinking events already manage the lifecycle.
345+ // This prevents duplicated thinking_start/thinking_end when providers mirror reasoning in
346+ // both native events and <think> text tags simultaneously.
347+ const nativeThinkingActive = ctx . state . nativeThinkingActive ?? false ;
348+
349+ if ( ( enteredThinking || singleChunkThinking ) && ! nativeThinkingActive ) {
350+ // Only fire tagged thinking_start if native thinking hasn't already opened it
351+ if ( ! ctx . state . reasoningStreamOpen ) {
272352 ctx . state . reasoningStreamOpen = true ;
353+ ctx . state . thinkingStartedAt = Date . now ( ) ;
354+ // Fire thinking_start hook for <think> tag flows
355+ const hookRunner = ctx . hookRunner ?? getGlobalHookRunner ( ) ;
356+ if ( hookRunner ?. hasHooks ( "thinking_start" ) ) {
357+ void hookRunner . runThinkingStart (
358+ { runId : ctx . params . runId } ,
359+ {
360+ agentId : ctx . params . agentId ,
361+ sessionKey : ctx . params . sessionKey ,
362+ } ,
363+ ) ;
364+ }
273365 }
274- // Detect when thinking block ends (</think> tag processed)
275- if ( wasThinking && ! ctx . state . partialBlockState . thinking ) {
276- emitReasoningEnd ( ctx ) ;
366+ }
367+ // Detect when thinking block ends (</think> tag processed)
368+ if ( ( exitedThinking || singleChunkThinking ) && ! nativeThinkingActive ) {
369+ emitReasoningEnd ( ctx ) ;
370+ // Fire thinking_end hook for <think> tag flows
371+ const hookRunner = ctx . hookRunner ?? getGlobalHookRunner ( ) ;
372+ if ( hookRunner ?. hasHooks ( "thinking_end" ) ) {
373+ const fullThinking = extractThinkingFromTaggedText ( ctx . state . deltaBuffer ) ;
374+ void hookRunner . runThinkingEnd (
375+ {
376+ runId : ctx . params . runId ,
377+ text : fullThinking || undefined ,
378+ durationMs : ctx . state . thinkingStartedAt
379+ ? Date . now ( ) - ctx . state . thinkingStartedAt
380+ : undefined ,
381+ } ,
382+ {
383+ agentId : ctx . params . agentId ,
384+ sessionKey : ctx . params . sessionKey ,
385+ } ,
386+ ) ;
277387 }
388+ }
389+
390+ if ( next ) {
278391 const parsedDelta = visibleDelta ? ctx . consumePartialReplyDirectives ( visibleDelta ) : null ;
279392 const parsedFull = parseReplyDirectives ( stripTrailingDirective ( next ) ) ;
280393 const cleanedText = parsedFull . text ;
@@ -339,6 +452,33 @@ export function handleMessageEnd(
339452 const assistantMessage = msg ;
340453 ctx . noteLastAssistant ( assistantMessage ) ;
341454 ctx . recordAssistantUsage ( ( assistantMessage as { usage ?: unknown } ) . usage ) ;
455+
456+ // Safety net: if thinking_start was fired but no thinking_end arrived (provider
457+ // skipped the explicit end event), close the thinking lifecycle now so plugins
458+ // don't get stuck with an unclosed thinking indicator.
459+ if ( ctx . state . reasoningStreamOpen ) {
460+ emitReasoningEnd ( ctx ) ;
461+ const hookRunner = ctx . hookRunner ?? getGlobalHookRunner ( ) ;
462+ if ( hookRunner ?. hasHooks ( "thinking_end" ) ) {
463+ const fullThinking =
464+ extractAssistantThinking ( msg ) ||
465+ extractThinkingFromTaggedText ( extractAssistantTextWithThinkingTags ( msg ) ) ;
466+ void hookRunner . runThinkingEnd (
467+ {
468+ runId : ctx . params . runId ,
469+ text : fullThinking || undefined ,
470+ durationMs : ctx . state . thinkingStartedAt
471+ ? Date . now ( ) - ctx . state . thinkingStartedAt
472+ : undefined ,
473+ } ,
474+ {
475+ agentId : ctx . params . agentId ,
476+ sessionKey : ctx . params . sessionKey ,
477+ } ,
478+ ) ;
479+ }
480+ }
481+
342482 if ( ctx . state . deterministicApprovalPromptSent ) {
343483 return ;
344484 }
@@ -359,7 +499,7 @@ export function handleMessageEnd(
359499 messagingToolSentTexts : ctx . state . messagingToolSentTexts ,
360500 } ) ;
361501 const rawThinking =
362- ctx . state . includeReasoning || ctx . state . streamReasoning
502+ ctx . state . includeReasoning || ctx . state . streamReasoning || Boolean ( ctx . params . onAgentEvent )
363503 ? extractAssistantThinking ( assistantMessage ) || extractThinkingFromTaggedText ( rawText )
364504 : "" ;
365505 const formattedReasoning = rawThinking ? formatReasoningMessage ( rawThinking ) : "" ;
@@ -490,7 +630,7 @@ export function handleMessageEnd(
490630 if ( ! shouldEmitReasoningBeforeAnswer ) {
491631 maybeEmitReasoning ( ) ;
492632 }
493- if ( ctx . state . streamReasoning && rawThinking ) {
633+ if ( ( ctx . state . streamReasoning || ctx . params . onAgentEvent ) && rawThinking ) {
494634 ctx . emitReasoningStream ( rawThinking ) ;
495635 }
496636
0 commit comments