package agent

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"

	"github.com/tidbcloud/AgentTeam/desktop/internal/consts"
	"github.com/tidbcloud/AgentTeam/desktop/internal/promptcompiler"
)

// handleSessionStatus translates session.status events.
//
// Wire format in properties:
//
//	{ "sessionID": "...", "status": { "type": "idle" | "busy" | "retry" } }
//
// When the session transitions to "idle", this is the authoritative turn-end
// signal, equivalent to "result" in claude or "threads.turn.completed" in
// codex-app-server.
func (d *OpenCodeDriver) handleSessionStatus(props json.RawMessage, currentSessionID string) {
	var p struct {
		SessionID string `json:"sessionID"`
		Status    struct {
			Type    string `json:"type"`
			Message string `json:"message"`
			Attempt int    `json:"attempt"`
			Next    int64  `json:"next"`
		} `json:"status"`
	}
	if err := json.Unmarshal(props, &p); err != nil {
		d.emitRuntimeDebug("opencode session.status parse failed", map[string]any{"error": err})
		return
	}

	if p.SessionID != currentSessionID {
		d.emitRuntimeDebug("opencode session.status ignored for another session", map[string]any{
			"status_session_id":  p.SessionID,
			"current_session_id": currentSessionID,
			"status":             p.Status.Type,
		})
		return
	}
	d.emitRuntimeDebug("opencode session.status", map[string]any{
		"session_id": p.SessionID,
		"status":     p.Status.Type,
		"attempt":    p.Status.Attempt,
		"next":       p.Status.Next,
		"message":    p.Status.Message,
	})

	switch p.Status.Type {
	case "busy":
		d.mu.Lock()
		if d.generationRetiring {
			d.mu.Unlock()
			return
		}
		d.contextTerminalDrain = false
		d.postBoundaryAwaitFreshBusy = false
		d.interruptCleanupDrain = false
		d.noteOpenCodeSummarizeBusyLocked(currentSessionID)
		d.noteSessionTurnActiveLocked(p.Status.Type, true)
		d.lastRetryStatusKey = ""
		d.mu.Unlock()
	case "retry":
		d.mu.Lock()
		if d.generationRetiring {
			d.mu.Unlock()
			return
		}
		d.contextTerminalDrain = false
		d.postBoundaryAwaitFreshBusy = false
		d.interruptCleanupDrain = false
		d.noteOpenCodeSummarizeBusyLocked(currentSessionID)
		d.noteSessionTurnActiveLocked(p.Status.Type, true)
		emitRetryError := d.shouldEmitRetryStatusLocked(currentSessionID, p.Status.Message, p.Status.Attempt, p.Status.Next)
		d.mu.Unlock()
		if emitRetryError {
			d.emitRetryStatusError(currentSessionID, p.Status.Message, p.Status.Attempt, p.Status.Next)
		}
	case "idle":
		if d.consumeInterruptIdleFence(currentSessionID) {
			return
		}
		ended, terminalError, retireGeneration, serverCancel := d.finishSessionTurnOnIdle()
		if !ended {
			d.emitRuntimeDebug("opencode idle status did not end an active turn", map[string]any{
				"session_id": currentSessionID,
			})
			return
		}
		if retireGeneration {
			// A session.error that survives until idle is terminal. Reusing this
			// server would be unsafe: an exact-parent ACK may never arrive, and
			// prompt_async's outer catchCause can still publish one more error
			// after Runner.finishRun has already emitted idle.
			d.emitRuntimeDebug("opencode terminal session error; retiring runtime generation", map[string]any{
				"session_id": currentSessionID,
			})
			if serverCancel != nil {
				serverCancel()
			}
			if terminalError != nil {
				if parsedBoolMetadata(terminalError.Metadata, contextCompactionFailedMetadataKey) {
					d.emitContextCompactionFailureBoundary(*terminalError)
				} else {
					data, _ := json.Marshal(*terminalError)
					d.translatedLines <- string(data)
				}
			}
			return
		}
		if terminalError != nil {
			data, _ := json.Marshal(*terminalError)
			d.translatedLines <- string(data)
		}
		d.emitTurnEnd(currentSessionID)
	}
}

// handleSessionError translates session.error events. OpenCode also publishes
// this event for recoverable preprocessing failures (for example a failed file
// read that is converted into synthetic text before runAgentTeam continues), so the
// event is diagnostic rather than a terminal/idle boundary.
func (d *OpenCodeDriver) handleSessionError(props json.RawMessage, currentSessionID string) {
	var p struct {
		SessionID string          `json:"sessionID"`
		Error     json.RawMessage `json:"error"`
	}
	if err := json.Unmarshal(props, &p); err != nil {
		d.emitRuntimeDebug("opencode session.error parse failed", map[string]any{"error": err})
		return
	}

	if p.SessionID != currentSessionID {
		d.emitRuntimeDebug("opencode session.error ignored for another session", map[string]any{
			"error_session_id":   p.SessionID,
			"current_session_id": currentSessionID,
		})
		return
	}
	msg := runtimeErrorMessageFromRaw(p.Error)
	errorDiagnostic := openCodeRuntimeErrorDiagnostic(p.Error, msg)
	errorDiagnostic["session_id"] = p.SessionID
	d.emitRuntimeDebug("opencode session.error", errorDiagnostic)
	if strings.TrimSpace(msg) != "" {
		transientError := classifyTransientRuntimeErrorRaw(p.Error)
		if !transientError.Retryable {
			transientError = classifyTransientRuntimeErrorText(msg)
		}
		metadata := mergeTransientRuntimeErrorMetadata(nil, transientError)
		if metadata == nil {
			metadata = make(map[string]any, 1)
		}
		if errorName := openCodeRuntimeErrorName(p.Error); strings.EqualFold(errorName, "ContextOverflowError") {
			metadata[openCodeErrorNameMetadataKey] = errorName
		}
		// session.error is not a terminal lifecycle signal in OpenCode. It is
		// also emitted for recoverable file/directory preprocessing failures,
		// after which createUserMessage inserts synthetic text and runAgentTeam
		// continues. The authoritative session.status boundary decides the turn.
		metadata[runtimeErrorNonterminalMetadataKey] = true
		ev := translatedEvent{
			Kind:      consts.ParsedEventKindError,
			SessionID: currentSessionID,
			Message:   msg,
			Metadata:  metadata,
		}
		terminalMetadata := make(map[string]any, len(metadata))
		for key, value := range metadata {
			if key != runtimeErrorNonterminalMetadataKey {
				terminalMetadata[key] = value
			}
		}
		d.mu.Lock()
		if d.generationRetiring {
			d.mu.Unlock()
			d.emitRuntimeDebug("opencode late session.error ignored for retiring generation", map[string]any{
				"session_id": currentSessionID,
			})
			return
		}
		if d.summarizeInProgress {
			// SessionHttpApi.summarize returns HTTP 200 whenever prompt.loop
			// returns, even when SessionProcessor.halt published an error and
			// idle. During summarize there are no recoverable user file parts,
			// so session.error invalidates the compaction immediately; HTTP
			// success must never turn it into runtime_idle. Before fresh
			// busy/retry, a delayed prompt_async wrapper still belongs to the
			// prior turn.
			terminalMetadata[contextCompactionFailedMetadataKey] = true
			if d.proactiveSummarizeInProgressLocked(currentSessionID) &&
				!d.postBoundaryAwaitFreshBusy {
				terminalMetadata[contextCompactionMaintenanceMetadataKey] = true
			}
			serverCancel := d.beginOpenCodeGenerationRetirementLocked()
			d.pendingSessionError = nil
			d.pendingSessionErrorFinalIdle = false
			d.sessionStatus = "error"
			d.turnActive = true
			d.mu.Unlock()
			ev.Metadata = terminalMetadata
			if serverCancel != nil {
				serverCancel()
			}
			d.emitContextCompactionFailureBoundary(ev)
			return
		}
		postBoundaryError := d.promptAwaitingRunner == nil &&
			(d.postBoundaryAwaitFreshBusy ||
				(!d.turnActive && strings.TrimSpace(d.sessionStatus) == "idle"))
		if postBoundaryError {
			// Runner.finishRun publishes idle before prompt_async's outer
			// catchCause publishes its wrapping session.error. A nonterminal
			// error after that boundary would make Process busy forever because
			// no later idle is guaranteed. Fail closed and retire this
			// generation instead.
			serverCancel := d.beginOpenCodeGenerationRetirementLocked()
			d.sessionStatus = "error"
			d.turnActive = true
			d.mu.Unlock()
			ev.Metadata = terminalMetadata
			if serverCancel != nil {
				serverCancel()
			}
			data, _ := json.Marshal(ev)
			d.translatedLines <- string(data)
			d.emitRuntimeDebug("opencode post-boundary session.error; retiring runtime generation", map[string]any{
				"session_id": currentSessionID,
			})
			return
		}
		d.pendingSessionError = &translatedEvent{
			Kind:      consts.ParsedEventKindError,
			SessionID: currentSessionID,
			Message:   msg,
			Metadata:  terminalMetadata,
		}
		d.pendingSessionErrorFinalIdle = false
		d.mu.Unlock()
		data, _ := json.Marshal(ev)
		d.translatedLines <- string(data)
	}
}

func (d *OpenCodeDriver) shouldEmitRetryStatusLocked(sessionID string, message string, attempt int, next int64) bool {
	trimmedMessage := strings.TrimSpace(message)
	if trimmedMessage == "" {
		return false
	}
	key := fmt.Sprintf("%s|%d|%d|%s", strings.TrimSpace(sessionID), attempt, next, trimmedMessage)
	if key == d.lastRetryStatusKey {
		return false
	}
	d.lastRetryStatusKey = key
	return true
}

func (d *OpenCodeDriver) emitRetryStatusError(sessionID string, message string, attempt int, next int64) {
	trimmedMessage := strings.TrimSpace(message)
	if trimmedMessage == "" {
		return
	}
	metadata := map[string]any{
		"will_retry": true,
	}
	if attempt > 0 {
		metadata["retry_attempt"] = attempt
	}
	if next > 0 {
		metadata["retry_next"] = next
	}
	ev := translatedEvent{
		Kind:      consts.ParsedEventKindError,
		SessionID: sessionID,
		Message:   trimmedMessage,
		Metadata:  metadata,
	}
	data, _ := json.Marshal(ev)
	d.translatedLines <- string(data)
}

func (d *OpenCodeDriver) emitTurnEnd(sessionID string) {
	d.emitTurnEndWithMetadata(sessionID, nil)
}

func (d *OpenCodeDriver) emitTurnEndWithMetadata(sessionID string, metadata map[string]any) {
	d.emitRuntimeDebug("opencode turn end emitted", map[string]any{"session_id": sessionID})
	d.flushTextBuffer()

	ev := translatedEvent{
		Kind:      consts.ParsedEventKindTurnEnd,
		SessionID: sessionID,
		Metadata:  metadata,
	}
	data, _ := json.Marshal(ev)
	d.translatedLines <- string(data)
}

func (d *OpenCodeDriver) emitContextCompactionCompleted(sessionID string) {
	ev := translatedEvent{
		Kind:      consts.ParsedEventKindRuntimeCompacted,
		SessionID: sessionID,
		Message:   contextCompactionCompletedDetail,
	}
	data, _ := json.Marshal(ev)
	d.translatedLines <- string(data)
}

// emitRuntimeIdle publishes a runtime_idle boundary. OpenCode uses it to signal
// that an out-of-band session summarize compaction completed successfully and
// the runtime is quiescent; the process layer promotes an in-progress
// compaction to a continuation injection at this boundary.
func (d *OpenCodeDriver) emitRuntimeIdle(sessionID string) {
	d.flushTextBuffer()

	ev := translatedEvent{
		Kind:      consts.ParsedEventKindRuntimeIdle,
		SessionID: sessionID,
	}
	data, _ := json.Marshal(ev)
	d.translatedLines <- string(data)
}

// flushTextBuffer emits any accumulated text delta content as a single
// consolidated "text" event and resets the buffer. This is called before
// turn_end and before tool_call events to ensure the process layer receives
// the buffered text.
func (d *OpenCodeDriver) flushTextBuffer() {
	d.mu.Lock()
	if d.generationRetiring {
		d.textBuf.Reset()
		d.mu.Unlock()
		return
	}
	text := d.textBuf.String()
	d.textBuf.Reset()
	d.mu.Unlock()

	if strings.TrimSpace(text) != "" {
		d.emitRuntimeDebug("opencode text buffer flushed", map[string]any{
			"bytes": len(text),
		})
		ev := translatedEvent{
			Kind: consts.ParsedEventKindText,
			Text: text,
		}
		data, _ := json.Marshal(ev)
		d.translatedLines <- string(data)
	}
}

func (d *OpenCodeDriver) emitRuntimeInputAcked(runtimeInputID string, sessionID string) {
	if d.translatedLines == nil {
		return
	}
	trimmedRuntimeInputID := strings.TrimSpace(runtimeInputID)
	if trimmedRuntimeInputID == "" {
		return
	}
	d.emitRuntimeDebug("opencode runtime input acknowledged", map[string]any{
		"runtime_input_id": trimmedRuntimeInputID,
		"session_id":       strings.TrimSpace(sessionID),
	})
	ev := translatedEvent{
		Kind:           consts.ParsedEventKindRuntimeInputAcked,
		RuntimeInputID: trimmedRuntimeInputID,
		SessionID:      strings.TrimSpace(sessionID),
	}
	data, _ := json.Marshal(ev)
	d.translatedLines <- string(data)
}

func (d *OpenCodeDriver) resetOpenCodeRunnerMessageAdmissionLocked() {
	d.runnerUserMessageIDs = nil
	d.runnerAssistantMessageIDs = nil
	d.runnerCompactionUserMessageIDs = nil
	d.runnerSummaryAssistantMessageIDs = nil
}

func (d *OpenCodeDriver) admitOpenCodeRunnerUserMessageLocked(messageID string) {
	trimmedMessageID := strings.TrimSpace(messageID)
	if trimmedMessageID == "" {
		return
	}
	if d.runnerUserMessageIDs == nil {
		d.runnerUserMessageIDs = make(map[string]struct{})
	}
	d.runnerUserMessageIDs[trimmedMessageID] = struct{}{}
}

// admitOpenCodeAutoContinuationUser recognizes the synthetic/replayed user row
// created after an automatic compaction summary. OpenCode publishes that row
// before the continuation assistant and before session.compacted.
func (d *OpenCodeDriver) admitOpenCodeAutoContinuationUser(messageID string) {
	trimmedMessageID := strings.TrimSpace(messageID)
	if trimmedMessageID == "" {
		return
	}
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.generationRetiring || !d.turnActive || d.promptAwaitingRunner != nil {
		return
	}
	if _, admitted := d.runnerUserMessageIDs[trimmedMessageID]; admitted {
		return
	}
	summaryAssistantID := strings.TrimSpace(d.autoCompactionAssistantMessageID)
	if summaryAssistantID == "" {
		return
	}
	d.admitOpenCodeRunnerUserMessageLocked(trimmedMessageID)
}

// admitOpenCodeSyntheticRunnerContinuationPart recognizes the two source-owned
// synthetic user rows that continue an existing Runner: the command-subtask
// summarization prompt and the automatic-compaction continuation marker.
// The source-owned marker supplies correlation; numeric ID ordering cannot,
// because OpenCode's generator resets its timestamp/counter on wall-clock
// rollback even inside one live Runner.
func (d *OpenCodeDriver) admitOpenCodeSyntheticRunnerContinuationPart(
	sessionID string,
	messageID string,
	text string,
	metadata map[string]any,
) bool {
	compactionContinuation, _ := metadata["compaction_continue"].(bool)
	if strings.TrimSpace(text) != promptcompiler.OpenCodeSubtaskSummaryPrompt() && !compactionContinuation {
		return false
	}
	trimmedSessionID := strings.TrimSpace(sessionID)
	trimmedMessageID := strings.TrimSpace(messageID)
	if trimmedSessionID == "" || trimmedMessageID == "" {
		return false
	}

	d.mu.Lock()
	defer d.mu.Unlock()
	if d.generationRetiring ||
		d.promptAwaitingRunner != nil ||
		!d.turnActive ||
		strings.TrimSpace(d.sessionID) != trimmedSessionID ||
		len(d.runnerAssistantMessageIDs) == 0 {
		return false
	}
	if _, admitted := d.runnerUserMessageIDs[trimmedMessageID]; admitted {
		return true
	}
	if strings.TrimSpace(d.activePromptMessageID) == "" {
		return false
	}
	d.admitOpenCodeRunnerUserMessageLocked(trimmedMessageID)
	return true
}

// admitOpenCodeAssistantEvent binds an assistant update to the current Runner
// before any of its parts can affect liveness or visible output. A same-session
// update is insufficient: background prune work can update old assistants
// after the next Runner has already published busy.
func (d *OpenCodeDriver) admitOpenCodeAssistantEvent(messageID string, parentID string, summary bool) bool {
	trimmedMessageID := strings.TrimSpace(messageID)
	trimmedParentID := strings.TrimSpace(parentID)
	if trimmedMessageID == "" || trimmedParentID == "" {
		return false
	}

	d.mu.Lock()
	defer d.mu.Unlock()
	if d.generationRetiring {
		return false
	}
	if _, admitted := d.runnerAssistantMessageIDs[trimmedMessageID]; admitted {
		if summary {
			if d.runnerSummaryAssistantMessageIDs == nil {
				d.runnerSummaryAssistantMessageIDs = make(map[string]struct{})
			}
			d.runnerSummaryAssistantMessageIDs[trimmedMessageID] = struct{}{}
		}
		return true
	}

	reservation := d.promptAwaitingRunner
	if reservation != nil {
		if trimmedParentID != strings.TrimSpace(reservation.MessageID) {
			return false
		}
		d.admitOpenCodeRunnerUserMessageLocked(trimmedParentID)
		d.promptAwaitingRunner = nil
		close(reservation.done)
		d.turnActive = true
		d.sessionStatus = "busy"
		d.postBoundaryAwaitFreshBusy = false
	} else {
		parentAdmitted := trimmedParentID == strings.TrimSpace(d.activePromptMessageID)
		if !parentAdmitted {
			_, parentAdmitted = d.runnerUserMessageIDs[trimmedParentID]
		}
		if summary {
			_, summaryParent := d.runnerCompactionUserMessageIDs[trimmedParentID]
			if !summaryParent {
				fence := d.summarizeFence
				summaryParent = d.summarizeInProgress &&
					fence != nil &&
					trimmedParentID == strings.TrimSpace(fence.compactionUserID)
			}
			parentAdmitted = parentAdmitted && summaryParent
		}
		if !parentAdmitted || (!d.turnActive && !d.contextTerminalDrain) {
			return false
		}
	}

	if d.runnerAssistantMessageIDs == nil {
		d.runnerAssistantMessageIDs = make(map[string]struct{})
	}
	d.runnerAssistantMessageIDs[trimmedMessageID] = struct{}{}
	if summary {
		if d.runnerSummaryAssistantMessageIDs == nil {
			d.runnerSummaryAssistantMessageIDs = make(map[string]struct{})
		}
		d.runnerSummaryAssistantMessageIDs[trimmedMessageID] = struct{}{}
	}
	return true
}

func (d *OpenCodeDriver) markSessionTurnActiveForAssistant(parentID string) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.generationRetiring {
		return
	}
	reservation := d.promptAwaitingRunner
	if reservation != nil &&
		strings.TrimSpace(parentID) != strings.TrimSpace(reservation.MessageID) {
		return
	}
	d.noteSessionTurnActiveLocked("", reservation != nil)
}

func (d *OpenCodeDriver) markSessionTurnActive() {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.generationRetiring || d.promptAwaitingRunner != nil {
		return
	}
	d.noteSessionTurnActiveLocked("", false)
}

func (d *OpenCodeDriver) noteSessionTurnActiveLocked(status string, admitReservation bool) {
	if d.generationRetiring {
		return
	}
	d.turnActive = true
	// Once the current reservation is admitted, any correlated/current-run
	// positive activity closes the ambiguity window for a previous
	// prompt_async catchCause wrapper. Callers suppress stale part/delta and
	// mismatched assistant events while a fresh reservation is still pending.
	d.postBoundaryAwaitFreshBusy = false
	// Only busy/retry or an exact-parent assistant may set admitReservation.
	// Tool/text activity is accepted only after that admission fence is gone.
	if reservation := d.promptAwaitingRunner; admitReservation && reservation != nil {
		currentSessionID := strings.TrimSpace(d.sessionID)
		reservationSessionID := strings.TrimSpace(reservation.SessionID)
		if reservationSessionID == "" || reservationSessionID == currentSessionID {
			d.admitOpenCodeRunnerUserMessageLocked(reservation.MessageID)
			d.promptAwaitingRunner = nil
			close(reservation.done)
		}
	}
	// Any positive activity after session.error proves the runtime recovered or
	// retried within the same prompt. Only an error followed directly by the
	// authoritative idle boundary is terminalized.
	d.pendingSessionError = nil
	d.pendingSessionErrorFinalIdle = false
	trimmedStatus := strings.TrimSpace(status)
	if trimmedStatus != "" {
		d.sessionStatus = trimmedStatus
		return
	}
	if d.sessionStatus == "" || d.sessionStatus == "idle" {
		d.sessionStatus = "busy"
	}
}

// noteOpenCodeSummarizeBusyLocked binds the next accepted idle to this
// summarize invocation. Process starts compaction after consuming the previous
// turn_end, while duplicate/stale idle frames may still be queued on the SSE
// transport; OpenCode's summarize runAgentTeam always publishes busy before its own
// idle, so an idle without this marker cannot complete the fence.
func (d *OpenCodeDriver) noteOpenCodeSummarizeBusyLocked(sessionID string) {
	if !d.summarizeInProgress {
		return
	}
	fence := d.summarizeFence
	if fence == nil ||
		fence.retired ||
		fence.sessionGeneration != d.sessionGeneration ||
		fence.summarizeGeneration != d.summarizeGeneration ||
		strings.TrimSpace(fence.sessionID) != strings.TrimSpace(sessionID) {
		return
	}
	fence.busyObserved = true
}

func (d *OpenCodeDriver) maybeCompleteOpenCodeSummarizeSSEFenceLocked(fence *openCodeSummarizeBoundaryFence) {
	if fence == nil ||
		fence.retired ||
		fence.idleClosed ||
		!fence.compactionObserved ||
		strings.TrimSpace(fence.summaryAssistantID) == "" ||
		!fence.busyObserved ||
		!fence.compactedObserved ||
		!fence.idleObserved {
		return
	}
	fence.idleClosed = true
	close(fence.idleCh)
}

func (d *OpenCodeDriver) finishSessionTurnOnIdle() (bool, *translatedEvent, bool, context.CancelFunc) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.generationRetiring {
		return false, nil, false, nil
	}
	if d.interruptCleanupDrain {
		// /abort can publish processor idle, cleanup, and Runner idle on
		// separate SSE frames. After the interrupt fence returns, consume every
		// remaining old idle until a fresh busy/retry proves a new run began.
		if !d.summarizeInProgress && d.promptAwaitingRunner == nil {
			d.sessionStatus = "idle"
			d.turnActive = false
			d.lastRetryStatusKey = ""
		}
		return false, nil, false, nil
	}
	if d.summarizeInProgress {
		// OpenCode's native summarize runs an internal prompt.loop that emits
		// its own session.status idle before the summarize HTTP call returns.
		// Join that status boundary with HTTP success before
		// runSessionSummarize emits the authoritative runtime_idle completion, so
		// do not
		// translate this in-flight idle into a turn_end: with turnActive set by
		// the compaction start, the process layer would mistake it for an
		// unconfirmed compaction failure while contextCompactionInProgress is still
		// set.
		if fence := d.summarizeFence; fence != nil &&
			fence.sessionGeneration == d.sessionGeneration &&
			fence.summarizeGeneration == d.summarizeGeneration &&
			strings.TrimSpace(fence.sessionID) == strings.TrimSpace(d.sessionID) &&
			fence.busyObserved &&
			!fence.idleObserved &&
			!fence.retired {
			fence.idleObserved = true
			d.maybeCompleteOpenCodeSummarizeSSEFenceLocked(fence)
		}
		return false, nil, false, nil
	}
	// prompt_async returns before its fork enters Runner. /abort reports idle
	// when no Runner exists, but that does not cancel the fork; treating this
	// early idle as terminal would briefly reopen delivery and overlap prompts.
	// Keep the logical session busy until positive Runner/activity evidence,
	// after which the next real idle is authoritative.
	if d.promptAwaitingRunner != nil {
		return false, nil, false, nil
	}
	active := d.turnActive
	terminalError := d.pendingSessionError
	if terminalError != nil {
		contextError := isOpenCodeContextErrorEvent(terminalError.Message, terminalError.Metadata)
		autoSummaryError := strings.TrimSpace(d.autoCompactionAssistantMessageID) != ""
		if parsedBoolMetadata(terminalError.Metadata, contextCompactionFailedMetadataKey) ||
			d.hasPendingRuntimeInputForSessionLocked(d.sessionID) ||
			(!contextError && !autoSummaryError) {
			d.pendingSessionError = nil
			d.pendingSessionErrorFinalIdle = false
			d.autoCompactionUserMessageID = ""
			d.autoCompactionAssistantMessageID = ""
			d.activePromptMessageID = ""
			serverCancel := d.beginOpenCodeGenerationRetirementLocked()
			d.sessionStatus = "error"
			// Keep logical liveness closed until the process exit boundary. A
			// correlated runtime input can have reached Runner without ever gaining
			// an assistant parent; for other fatal errors, prompt_async may still
			// publish a wrapping post-idle error. Reopening normal delivery in
			// either case would strand ownership or race a new prompt. The one
			// reusable terminal path is a proven context-window error with no
			// pending correlation, because Process must start native summarize at
			// this exact boundary.
			d.turnActive = true
			d.lastRetryStatusKey = ""
			return true, terminalError, true, serverCancel
		}
		if !d.pendingSessionErrorFinalIdle && !d.contextTerminalDrain {
			// With compaction.auto=false, SessionProcessor.halt publishes an
			// early idle before its assistant cleanup, while Runner is still
			// registered as Running. Starting /summarize at that boundary would
			// call ensureRunning and silently discard the summarize work. Hold
			// the terminal candidate until cleanup plus Runner's later idle.
			d.contextTerminalDrain = true
			d.sessionStatus = "busy"
			d.turnActive = true
			return false, nil, false, nil
		}
		d.pendingSessionError = nil
		d.pendingSessionErrorFinalIdle = false
		d.contextTerminalDrain = false
		d.autoCompactionUserMessageID = ""
		d.autoCompactionAssistantMessageID = ""
		d.activePromptMessageID = ""
		d.postBoundaryAwaitFreshBusy = true
		d.sessionStatus = "idle"
		d.turnActive = false
		d.resetOpenCodeRunnerMessageAdmissionLocked()
		d.lastRetryStatusKey = ""
		return active, terminalError, false, nil
	}
	d.sessionStatus = "idle"
	d.turnActive = false
	d.resetOpenCodeRunnerMessageAdmissionLocked()
	d.lastRetryStatusKey = ""
	if active {
		d.postBoundaryAwaitFreshBusy = true
		d.activePromptMessageID = ""
		d.autoCompactionUserMessageID = ""
		d.autoCompactionAssistantMessageID = ""
	}
	return active, nil, false, nil
}
