From 1c6787b72cbabee96c4f4829f914b31c6c0a7979 Mon Sep 17 00:00:00 2001 From: Ryuki Teraji Date: Thu, 8 Oct 2026 23:24:30 +0900 Subject: [PATCH 1/2] fix(tabs): keep known agent identity when title only mentions another agent A tab title that names another agent only in task text (free-text evidence) no longer replaces a known launch/completed-hook identity. Only anchored or vendor-marker titles can reclaim a reused pane. The tab bar now calls the tested resolveTabAgentFromSignals instead of a drifted local copy, which also brings the OMP/Pi (#15713) and stale sleeping-session (#21714) fixes to the tab icon. Part of #26513 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UBp6T3vnmFFtQXXSyqTYhK --- .../src/lib/tab-agent-from-signals.ts | 25 ++- ...tab-agent-title-free-text-override.test.ts | 50 ++++++ src/renderer/src/lib/use-tab-agent.ts | 164 +----------------- 3 files changed, 77 insertions(+), 162 deletions(-) create mode 100644 src/renderer/src/lib/tab-agent-title-free-text-override.test.ts diff --git a/src/renderer/src/lib/tab-agent-from-signals.ts b/src/renderer/src/lib/tab-agent-from-signals.ts index 383edba70070..2759086fc013 100644 --- a/src/renderer/src/lib/tab-agent-from-signals.ts +++ b/src/renderer/src/lib/tab-agent-from-signals.ts @@ -1,4 +1,5 @@ import { titleShowsNoAgent } from '../../../shared/agent-detection' +import { collectAgentTitleEvidence } from '../../../shared/agent-title-evidence' import { isClaudeIdentityFrameTitle, resolveExplicitTerminalTitleAgentType @@ -58,9 +59,26 @@ export function resolveLaunchedAgentExitEvidence(args: { return args.hasCompletedHook || (!args.isRemote && args.hasObservedAgentSignal) } +// Why: a name only in free text (task wording) is a mention, not identity, so it can't displace a known owner (#26513). +function titleEvidenceIsStrong(title: string): boolean { + const { reason } = collectAgentTitleEvidence(title) + return reason === 'anchored' || reason === 'vendor-marker' +} + /** - * Identity-first precedence: live hook > process > title > completed > sleeping - * > launch > sibling. Same-group titles (OMP wraps Pi) are not reuse evidence. + * Resolve which coding-harness agent a terminal tab is running, for its tab-bar + * icon: a pane's IDENTITY (separate from activity state), from already-computed + * state with no foreground probing. Identity-first precedence: + * + * 1. Live focused hook — ground truth while the agent works; never title-overridden. + * 2. Process identity — recognized foreground process (local only); re-owned within its title-identity group so OMP's nested `pi` (shell → omp → pi) can't flip the icon. + * 3. Title — a reuse override only with vendor-marker/anchored evidence (a name in task text never displaces a known owner), or a legacy standalone identity; native OpenCode titles cannot displace durable ownership. + * 4. Idle focused identity — the pane's completed hook or sidebar-retained completion; suppressed locally once OSC 133;D proves exit. + * 5. Sleeping session identity — current provider-session ownership. + * 6. launchAgent — bootstrap before any hook/process signal; cleared once exit evidence shows it left. + * 7. Sibling-pane identity (live, then completed/retained) — split-tab fallback. + * + * Same-group titles (OMP wraps Pi) are not reuse evidence. */ export function resolveTabAgentFromSignals(args: { hasObservedAgentSignal: boolean @@ -128,7 +146,8 @@ export function resolveTabAgentFromSignals(args: { explicitTitleAgent !== priorIdentity && !shareCompatibleTitleIdentityGroup(rawTitleAgent, priorIdentity) && titleClaimsIdentity && - (args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle) + (args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle) && + titleEvidenceIsStrong(args.title) // Why: native OpenCode titles lack a provider generation and cannot displace durable ownership. const titleAgent = processProvesShell || diff --git a/src/renderer/src/lib/tab-agent-title-free-text-override.test.ts b/src/renderer/src/lib/tab-agent-title-free-text-override.test.ts new file mode 100644 index 000000000000..5d434ca532a6 --- /dev/null +++ b/src/renderer/src/lib/tab-agent-title-free-text-override.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { collectAgentTitleEvidence } from '../../../shared/agent-title-evidence' +import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' +import { resolveTabAgentFromSignals } from './tab-agent-from-signals' + +// Names two agents in task text with no vendor marker (stablyai/orca#26513). +const MENTION_ONLY_TITLE = '◑ Tab title showing Codex instead of Claude Code' +const GENUINE_CODEX_TITLE = 'Task - codex' + +const BASE = { isRemote: false, hasObservedAgentSignal: true } as const + +describe('title that only mentions another agent', () => { + it('is free-text evidence yet still parses as a codex title', () => { + expect(collectAgentTitleEvidence(MENTION_ONLY_TITLE)).toMatchObject({ + agent: null, + reason: 'free-text-only' + }) + expect(resolveExplicitTerminalTitleAgentType(MENTION_ONLY_TITLE)).toBe('codex') + }) + + // Sibling-only identity is not covered: with an empty focused slot the title still fills it (empty-slot rule unchanged here). + it.each([ + ['live hook', { hookAgent: 'claude' }], + ['foreground process', { hookAgent: null, processAgent: 'claude' }], + ['completed hook', { hookAgent: null, focusedCompletedHookAgent: 'claude' }], + ['launch intent', { hookAgent: null, launchAgent: 'claude' }] + ] as const)('keeps claude from the %s', (_label, signals) => { + expect(resolveTabAgentFromSignals({ ...BASE, title: MENTION_ONLY_TITLE, ...signals })).toBe( + 'claude' + ) + }) +}) + +describe('title that genuinely names another agent', () => { + it('is anchored evidence for codex', () => { + expect(collectAgentTitleEvidence(GENUINE_CODEX_TITLE)).toMatchObject({ + agent: 'codex', + reason: 'anchored' + }) + }) + + it.each([ + ['completed hook', { hookAgent: null, focusedCompletedHookAgent: 'claude' }], + ['launch intent', { hookAgent: null, launchAgent: 'claude' }] + ] as const)('still replaces a stale %s identity', (_label, signals) => { + expect(resolveTabAgentFromSignals({ ...BASE, title: GENUINE_CODEX_TITLE, ...signals })).toBe( + 'codex' + ) + }) +}) diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 2eacce3e9312..75f9eefc5a05 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' -import { isShellProcess } from '../../../shared/agent-detection' import { worktreeUsesRemoteConnection } from '@/store/terminals/terminal-workspace-routing' import { hasRemoteRuntimePtyForTab } from './tab-agent-remote-pty-selector' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' @@ -12,168 +11,15 @@ import { resolveSiblingRetainedTabAgent, resolveSiblingTabAgent } from './tab-agent' +import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' import { - isClaudeIdentityFrameTitle, - resolveExplicitTerminalTitleAgentType -} from '../../../shared/terminal-title-agent-type' -import { resolveSignalAgentForLaunchOwner } from './tab-agent-from-signals' -import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' -import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner' + resolveLaunchedAgentExitEvidence, + resolveTabAgentFromSignals +} from './tab-agent-from-signals' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { TerminalAgent } from '../../../shared/terminal-agent' -import type { TuiAgent } from '../../../shared/tui-agent' -import { agentTypeToIconAgent } from './agent-status' -// A shell name or the tab's neutral default title (where inferred-interrupt reset parks it); blank titles are no evidence. -function titleShowsNoAgent(title: string, defaultTitle?: string): boolean { - const trimmed = title.trim() - return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim()) -} - -/** - * Probe-free evidence a launched agent exited: title shows no agent, no live - * hook remains, and either the hook completed or observed activity vanished. - * Vanished-activity is local-only — remote rows also drop on transport blips. - */ -export function resolveLaunchedAgentExitEvidence(args: { - title: string - defaultTitle?: string - isRemote: boolean - hasObservedAgentSignal: boolean - hookAgent: TerminalAgent | null - siblingHookAgent?: TerminalAgent | null - hasCompletedHook: boolean - processAgent?: TerminalAgent | null - processShellForeground?: boolean -}): boolean { - if (args.hookAgent || args.siblingHookAgent || args.processAgent) { - return false - } - // Why: OSC 133;D (foreground back at shell) is title-independent exit evidence; local-only — remote panes have no shell-foreground producer. - if (!args.isRemote && args.processShellForeground && args.hasObservedAgentSignal) { - return true - } - if (!titleShowsNoAgent(args.title, args.defaultTitle)) { - return false - } - return args.hasCompletedHook || (!args.isRemote && args.hasObservedAgentSignal) -} - -export function resolveTabAgentFromSignals(args: { - hasObservedAgentSignal: boolean - isRemote: boolean - title: string - defaultTitle?: string - hookAgent: TerminalAgent | null - siblingHookAgent?: TerminalAgent | null - focusedCompletedHookAgent?: TerminalAgent | null - siblingCompletedHookAgent?: TerminalAgent | null - processAgent?: TerminalAgent | null - processShellForeground?: boolean - sleepingSessionAgent?: TerminalAgent | null - launchAgent?: TuiAgent -}): TerminalAgent | null { - const launchAgent = args.launchAgent ?? null - // Durable focused-pane owner (launch intent → hook → session); focused-pane-scoped so a sibling can't re-own the focused title (would mislabel a Pi pane as OMP). - const owner = agentTypeToIconAgent( - resolvePaneAgentOwner({ - launchAgent, - hookAgent: args.hookAgent, - completedHookAgent: args.focusedCompletedHookAgent, - sleepingSessionAgent: args.sleepingSessionAgent - }) - ) - - // The live/idle split governs title override; siblings normalize against launch intent only. - const liveFocusedIdentity = resolveSignalAgentForLaunchOwner(args.hookAgent, owner) - const liveSiblingIdentity = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent) - // Why: OSC 133;D proves this local pane returned to shell, so the idle identity is stale; remote titles lag runtime, so keep it there. - const processProvesShell = !args.isRemote && args.processShellForeground === true - const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null - const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle) - const idleIdentitySuppressed = - !args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook - const idleFocusedIdentity = idleIdentitySuppressed - ? null - : resolveSignalAgentForLaunchOwner(args.focusedCompletedHookAgent, owner) - // Why: idleIdentitySuppressed is the FOCUSED pane's exit evidence, so it must not clear a sibling's idle identity. - const idleSiblingIdentity = resolveSignalAgentForLaunchOwner( - args.siblingCompletedHookAgent, - launchAgent - ) - const sleepingSessionAgent = args.sleepingSessionAgent ?? null - - // Title carries identity only as a reuse override (names a DIFFERENT-group agent) or a legacy standalone id when no hook — same-group titles say nothing (OMP wraps Pi), so the record wins. - const explicitTitleAgent = resolveSignalAgentForLaunchOwner( - resolveExplicitTerminalTitleAgentType(args.title), - owner - ) - const priorIdentity = idleFocusedIdentity ?? launchAgent - const nativeOpenCodeTitle = explicitTitleAgent === 'opencode' && isOpenCodeNativeTitle(args.title) - // Why: a "claude" token in another agent's task text is a mention, not identity, so it must - // not take a pane from its known owner — only a title that PRESENTS Claude may (#8940). - const titleClaimsIdentity = - explicitTitleAgent !== 'claude' || isClaudeIdentityFrameTitle(args.title) - // Why: native OpenCode titles can reclaim stale launch intent before any observed hook signal. - const titleReclaimsReusedPane = - priorIdentity !== null && - explicitTitleAgent !== null && - explicitTitleAgent !== priorIdentity && - titleClaimsIdentity && - (args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle) - // Why: native OpenCode titles lack a provider generation and cannot displace durable ownership. - const titleAgent = - processProvesShell || - sleepingSessionAgent || - (nativeOpenCodeTitle && idleFocusedIdentity !== null) - ? null - : titleReclaimsReusedPane - ? explicitTitleAgent - : priorIdentity - ? null - : explicitTitleAgent - - const launchedAgentExited = resolveLaunchedAgentExitEvidence({ - title: args.title, - defaultTitle: args.defaultTitle, - isRemote: args.isRemote, - hasObservedAgentSignal: args.hasObservedAgentSignal, - hookAgent: liveFocusedIdentity, - siblingHookAgent: liveSiblingIdentity, - hasCompletedHook, - processAgent: args.processAgent, - processShellForeground: args.processShellForeground - }) - const activeLaunchAgent = launchedAgentExited ? null : launchAgent - // Why: re-own the foreground process within its title-identity group so OMP's nested pi (shell → omp → pi) can't flip an OMP-owned tab's icon. - const processAgent = resolveSignalAgentForLaunchOwner(args.processAgent, owner) - // Identity-first precedence (see JSDoc): live hook > process > title > completed > sleeping > launch > sibling. - return ( - liveFocusedIdentity ?? - processAgent ?? - titleAgent ?? - idleFocusedIdentity ?? - sleepingSessionAgent ?? - activeLaunchAgent ?? - liveSiblingIdentity ?? - idleSiblingIdentity - ) -} - -/** - * Resolve which coding-harness agent a terminal tab is running, for its tab-bar - * icon. A pane's IDENTITY (separate from activity state), from the same - * already-computed state as the sidebar rows — no foreground probing. - * Identity-first precedence: - * - * 1. Live focused hook — ground truth while the agent works; never title-overridden. - * 2. Process identity — recognized foreground process (local only); re-owned within its title-identity group so OMP's nested `pi` (shell → omp → pi) can't flip the icon. - * 3. Title — only a reuse override or legacy standalone identity; native OpenCode titles cannot displace durable ownership. - * 4. Idle focused identity — the pane's completed hook or sidebar-retained completion; suppressed locally once OSC 133;D proves exit. - * 5. Sleeping session identity — current provider-session ownership. - * 6. launchAgent — bootstrap before any hook/process signal; cleared once exit evidence shows it left. - * 7. Sibling-pane identity (live, then completed/retained) — split-tab fallback. - */ +/** Resolve which coding-harness agent a terminal tab is running, for its tab-bar icon; precedence lives on resolveTabAgentFromSignals. */ export function useTabAgent(tab: TerminalTab): TerminalAgent | null { const focusedHookAgent = useAppStore((s) => resolveFocusedTabAgent(s.agentStatusByPaneKey, s.terminalLayoutsByTabId[tab.id], tab.id) From 7fefd18047585cdae7e5ae8b2f24e47603374fe3 Mon Sep 17 00:00:00 2001 From: Ryuki Teraji Date: Fri, 9 Oct 2026 01:24:04 +0900 Subject: [PATCH 2/2] test(tabs): expect a free-text Codex title not to replace a completed identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "⠋ Codex" title names Codex only in free text, so it no longer reclaims a completed claude identity. Update the pi-identity expectation to match, and drop comments that repeated the regression test's own fixture names. Co-Authored-By: Claude Haiku 5.5 --- src/renderer/src/lib/tab-agent-from-signals.ts | 15 ++------------- .../tab-agent-title-free-text-override.test.ts | 2 -- .../src/lib/use-tab-agent-pi-identity.test.ts | 12 +++++------- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/lib/tab-agent-from-signals.ts b/src/renderer/src/lib/tab-agent-from-signals.ts index 2759086fc013..91efeb4ecd77 100644 --- a/src/renderer/src/lib/tab-agent-from-signals.ts +++ b/src/renderer/src/lib/tab-agent-from-signals.ts @@ -66,19 +66,8 @@ function titleEvidenceIsStrong(title: string): boolean { } /** - * Resolve which coding-harness agent a terminal tab is running, for its tab-bar - * icon: a pane's IDENTITY (separate from activity state), from already-computed - * state with no foreground probing. Identity-first precedence: - * - * 1. Live focused hook — ground truth while the agent works; never title-overridden. - * 2. Process identity — recognized foreground process (local only); re-owned within its title-identity group so OMP's nested `pi` (shell → omp → pi) can't flip the icon. - * 3. Title — a reuse override only with vendor-marker/anchored evidence (a name in task text never displaces a known owner), or a legacy standalone identity; native OpenCode titles cannot displace durable ownership. - * 4. Idle focused identity — the pane's completed hook or sidebar-retained completion; suppressed locally once OSC 133;D proves exit. - * 5. Sleeping session identity — current provider-session ownership. - * 6. launchAgent — bootstrap before any hook/process signal; cleared once exit evidence shows it left. - * 7. Sibling-pane identity (live, then completed/retained) — split-tab fallback. - * - * Same-group titles (OMP wraps Pi) are not reuse evidence. + * Identity-first precedence: live hook > process > title > completed > sleeping + * > launch > sibling. Same-group titles (OMP wraps Pi) are not reuse evidence. */ export function resolveTabAgentFromSignals(args: { hasObservedAgentSignal: boolean diff --git a/src/renderer/src/lib/tab-agent-title-free-text-override.test.ts b/src/renderer/src/lib/tab-agent-title-free-text-override.test.ts index 5d434ca532a6..1a361683b794 100644 --- a/src/renderer/src/lib/tab-agent-title-free-text-override.test.ts +++ b/src/renderer/src/lib/tab-agent-title-free-text-override.test.ts @@ -3,7 +3,6 @@ import { collectAgentTitleEvidence } from '../../../shared/agent-title-evidence' import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' import { resolveTabAgentFromSignals } from './tab-agent-from-signals' -// Names two agents in task text with no vendor marker (stablyai/orca#26513). const MENTION_ONLY_TITLE = '◑ Tab title showing Codex instead of Claude Code' const GENUINE_CODEX_TITLE = 'Task - codex' @@ -18,7 +17,6 @@ describe('title that only mentions another agent', () => { expect(resolveExplicitTerminalTitleAgentType(MENTION_ONLY_TITLE)).toBe('codex') }) - // Sibling-only identity is not covered: with an empty focused slot the title still fills it (empty-slot rule unchanged here). it.each([ ['live hook', { hookAgent: 'claude' }], ['foreground process', { hookAgent: null, processAgent: 'claude' }], diff --git a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts index f1e186d6f1bc..92032d44fc9f 100644 --- a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts +++ b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts @@ -336,11 +336,9 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => { ).toBe('pi') }) - it('does not flash the exited agent before a hookless reuse title reclaims on mount', () => { - // hasObservedAgentSignal starts false for one mount commit; a completed hook - // is itself activity evidence, so the reuse title reclaims immediately - // instead of flashing the prior agent's idle identity. (claude ran+idled, - // then a hookless codex reused the pane and emits its own title.) + it('keeps the completed identity over a free-text Codex title on mount', () => { + // A "⠋ Codex" title names Codex only in free text, so it must not replace the + // completed claude identity, neither on mount nor once activity is observed. const onMount = resolveTabAgentFromSignals({ hasObservedAgentSignal: false, isRemote: false, @@ -357,7 +355,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => { focusedCompletedHookAgent: 'claude', launchAgent: undefined }) - expect(onMount).toBe('codex') - expect(afterObserved).toBe('codex') + expect(onMount).toBe('claude') + expect(afterObserved).toBe('claude') }) })