mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 00:02:34 +00:00
fix(omp): preserve owner-rewritten terminal state markers
Keep state, identity and the opaque session label when native OMP titles are rewritten for the launch owner. Reuse the native marker parser for ASCII brands, stale-title clearing and idempotent owner normalization. Fixes #17690. Secondary comparison with #17723 corroborates the separator and identity repair; credit its author for that direction. Co-authored-by: shahidbeig-a11y <258701601+shahidbeig-a11y@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"capturedAt": "2026-09-14T11:25:01.730Z",
|
||||
"platform": "darwin",
|
||||
"command": [
|
||||
"bun",
|
||||
"tests/tools/omp-native-title-capture.mjs",
|
||||
"<read-only-omp-checkout>"
|
||||
],
|
||||
"cols": 100,
|
||||
"rows": 30,
|
||||
"note": "OMP source ne7546987ca526eac8f605fac19ef9805b8f01898 buildTerminalTitleWithState; explicit win32 argument on macOS PTY, synthetic state transitions, no model/account. Not a Windows runtime capture.",
|
||||
"exitCode": 0
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
]0;π : Run a long task]0;π : release | π : note | OMP ! action required ✦]0;π > Run a long task]0;π > release | π : note | OMP ! action required ✦]0;π ! Run a long task]0;π ! release | π : note | OMP ! action required ✦
|
||||
@@ -90,6 +90,26 @@ describe('buildTitleDerivedAgentRows', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[':', 'working'],
|
||||
['>', 'idle'],
|
||||
['!', 'waiting']
|
||||
])('retains hook-less OMP rows for owner marker %s', (marker, state) => {
|
||||
const title = `OMP ${marker} Run a long task`
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'omp' })],
|
||||
entries: [],
|
||||
retained: [],
|
||||
runtimePaneTitlesByTabId: { 'tab-1': { 1: title } },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-omp'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
|
||||
now: 2000
|
||||
})
|
||||
expect(rows.map((row) => [row.agentType, row.state, row.entry.terminalTitle])).toEqual([
|
||||
['omp', state, title]
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps Pi-compatible title-derived rows as Pi for launched Pi sessions', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'pi' })],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
// Why: Phase 3 slice 1 of terminal-side-effect-authority.md runs a per-PTY
|
||||
// title tracker in main alongside the renderer transport's byte parser. Both
|
||||
// must derive IDENTICAL ordered title/status facts from the same bytes, or
|
||||
@@ -103,6 +104,19 @@ describe('main title tracker parity with the renderer transport processor', () =
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('agrees on captured OMP native frames before and after owner rebranding', () => {
|
||||
const captured = readFileSync(
|
||||
new URL('../../../../main/runtime/__fixtures__/omp-native-title-win32.txt', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
feedBoth(paths, captured)
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events.some((event) => event.kind === 'became-working')).toBe(true)
|
||||
expect(paths.main.events.some((event) => event.kind === 'became-idle')).toBe(true)
|
||||
feedBoth(paths, captured.replaceAll(']0;π', ']0;OMP'))
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
})
|
||||
|
||||
it('derives identical facts from a coalesced spinner+idle chunk (issue #1083)', () => {
|
||||
// One realistic node-pty batch: Pi's 80ms spinner frames plus agent_end's
|
||||
// trailing idle title. A last-title reader sees only the idle title and
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getPiStateTitleBrand } from './pi-state-title-marker'
|
||||
import {
|
||||
AGY_AGENT_NAME_RE,
|
||||
CLAUDE_IDLE,
|
||||
@@ -67,6 +68,10 @@ function computeAgentLabel(title: string): string | null {
|
||||
) {
|
||||
return 'Claude Code'
|
||||
}
|
||||
const piStateBrand = getPiStateTitleBrand(title)
|
||||
if (piStateBrand) {
|
||||
return piStateBrand
|
||||
}
|
||||
if (isGeminiTerminalTitle(title)) {
|
||||
return 'Gemini CLI'
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rebrandPiStateTitle } from './pi-state-title-marker'
|
||||
import { detectAgentStatusFromTitle, getAgentLabel } from './agent-detection'
|
||||
import type { AgentStatusEntry, AgentType } from './agent-status-types'
|
||||
import {
|
||||
@@ -157,6 +158,10 @@ export function normalizeCompatibleAgentTitleForOwner(
|
||||
) {
|
||||
return title
|
||||
}
|
||||
const stateTitle = rebrandPiStateTitle(title, ownerProfile.workingLabel)
|
||||
if (stateTitle !== null) {
|
||||
return stateTitle
|
||||
}
|
||||
// Why: a π-branded title is the agent's own semantic session title (`π > <session> - <cwd>`;
|
||||
// Orca's injected extension writes the same shape). Swap only the BRAND for the owner's label
|
||||
// so the pane still reads as its launch owner (#6689, #7633, #9077) without discarding the
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getPiCompatibleTitleSeparatorStatus } from './pi-compatible-synthetic-title'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectAgentStatusFromTitle, getAgentLabel } from './agent-detection'
|
||||
import { normalizeCompatibleAgentTitleForOwner } from './agent-title-owner'
|
||||
import { clearPiStateWorkingMarker } from './pi-state-title-marker'
|
||||
|
||||
const transcript = readFileSync(
|
||||
new URL('../main/runtime/__fixtures__/omp-native-title-win32.txt', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
// oxlint-disable-next-line no-control-regex -- The fixture retains actual OSC control bytes.
|
||||
const titles = [...transcript.matchAll(/\x1b\]0;([^\x07]+)\x07/g)].map((match) => match[1])
|
||||
|
||||
describe('owner-rewritten OMP titles from captured upstream output', () => {
|
||||
it('contains the six upstream state frames', () => expect(titles).toHaveLength(6))
|
||||
it.each(
|
||||
titles.map((title, index) => ({
|
||||
title,
|
||||
state: index < 2 ? 'working' : index < 4 ? 'idle' : 'permission'
|
||||
}))
|
||||
)('preserves $state and label for $title', ({ title, state }) => {
|
||||
for (const prefix of ['', 'zsh | ', 'tmux: ']) {
|
||||
const wrapped = prefix + title
|
||||
expect(detectAgentStatusFromTitle(wrapped)).toBe(state)
|
||||
const owned = normalizeCompatibleAgentTitleForOwner(wrapped, 'omp', { ownerIsLaunch: true })
|
||||
expect(owned).toBe(prefix + title.replace('π', 'OMP'))
|
||||
expect(getAgentLabel(owned)).toBe('OMP')
|
||||
expect(detectAgentStatusFromTitle(owned)).toBe(state)
|
||||
expect(getPiCompatibleTitleSeparatorStatus(owned)).toBe(state)
|
||||
expect(normalizeCompatibleAgentTitleForOwner(owned, 'omp')).toBe(owned)
|
||||
expect(normalizeCompatibleAgentTitleForOwner(owned, 'pi')).toBe(
|
||||
prefix + title.replace('π', 'Pi')
|
||||
)
|
||||
if (state === 'working') {
|
||||
expect(detectAgentStatusFromTitle(clearPiStateWorkingMarker(owned) ?? '')).toBe('idle')
|
||||
}
|
||||
}
|
||||
})
|
||||
it.each([
|
||||
'omp-harness ready',
|
||||
'/tmp/OMP : file',
|
||||
'lowercase omp : note',
|
||||
'Pi: legacy',
|
||||
'OMP ready'
|
||||
])('does not rewrite neutral or legacy title %s as a working marker', (title) => {
|
||||
expect(clearPiStateWorkingMarker(title)).toBeNull()
|
||||
expect(detectAgentStatusFromTitle(title)).not.toBe('working')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getPiStateTitleStatus } from './pi-state-title-marker'
|
||||
|
||||
export type PiCompatibleSyntheticAgentLabel = 'Pi' | 'OMP'
|
||||
export type PiCompatibleSyntheticAgentStatus = 'working' | 'permission' | 'idle'
|
||||
|
||||
@@ -71,6 +73,10 @@ export function isLegacyPiCompatibleTitle(title: string): boolean {
|
||||
export function getPiCompatibleTitleSeparatorStatus(
|
||||
title: string
|
||||
): PiCompatibleSyntheticAgentStatus | null {
|
||||
const nativeState = getPiStateTitleStatus(title)
|
||||
if (nativeState) {
|
||||
return nativeState
|
||||
}
|
||||
// Why: a spinner anywhere means the agent is working, and that outranks the separator —
|
||||
// the frame is drawn over the idle separator position while a turn runs.
|
||||
if (containsBrailleSpinner(title)) {
|
||||
|
||||
@@ -27,15 +27,17 @@ function escapeForCharacterClass(marker: string): string {
|
||||
return marker.replace(/[\\\]^-]/g, '\\$&')
|
||||
}
|
||||
|
||||
// Why: `π` must sit at a token boundary so wrapper prefixes of any shape (`zsh | π : cwd`,
|
||||
// Why: the brand must sit at a token boundary so wrapper prefixes (`zsh | OMP : cwd`,
|
||||
// `tmux: π : cwd`) still expose the marker, and whitespace must separate the marker so the
|
||||
// legacy no-space `π: cwd` disabled title keeps its historical idle classification.
|
||||
const PI_STATE_TITLE_RE = new RegExp(
|
||||
`(?:^|[\\s|])π[ \\t]+([${PI_STATE_MARKERS.map(escapeForCharacterClass).join('')}])(?=\\s|$)`,
|
||||
`(?:^|[\\s|])(π|Pi|OMP)[ \\t]+([${PI_STATE_MARKERS.map(escapeForCharacterClass).join('')}])(?=\\s|$)`,
|
||||
'u'
|
||||
)
|
||||
|
||||
type PiStateTitleMatch = {
|
||||
brand: string
|
||||
brandIndex: number
|
||||
marker: PiStateMarker
|
||||
markerIndex: number
|
||||
}
|
||||
@@ -49,8 +51,14 @@ function matchPiStateTitle(title: string): PiStateTitleMatch | null {
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const marker = match[2]
|
||||
if (marker !== ':' && marker !== '!' && marker !== '>') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
marker: match[1] as PiStateMarker,
|
||||
brand: match[1],
|
||||
brandIndex: match.index + match[0].indexOf(match[1]),
|
||||
marker,
|
||||
markerIndex: match.index + match[0].length - 1
|
||||
}
|
||||
}
|
||||
@@ -73,3 +81,20 @@ export function clearPiStateWorkingMarker(title: string): string | null {
|
||||
}
|
||||
return `${title.slice(0, match.markerIndex)}${PI_IDLE_MARKER}${title.slice(match.markerIndex + 1)}`
|
||||
}
|
||||
|
||||
/** The state marker owns identity too; its label may mention another agent. */
|
||||
export function getPiStateTitleBrand(title: string): 'Pi' | 'OMP' | null {
|
||||
const match = matchPiStateTitle(title)
|
||||
return match ? (match.brand === 'OMP' ? 'OMP' : 'Pi') : null
|
||||
}
|
||||
|
||||
/** Rebrand only the protocol prefix, preserving wrappers and the opaque session label. */
|
||||
export function rebrandPiStateTitle(title: string, brand: string): string | null {
|
||||
const match = matchPiStateTitle(title)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
title.slice(0, match.brandIndex) + brand + title.slice(match.brandIndex + match.brand.length)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Run under Bun through capture-agent-pty-transcript.mjs; sourceRoot is read-only.
|
||||
import { resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const sourceRoot = process.argv[2]
|
||||
if (!sourceRoot) {
|
||||
throw new Error('Expected path to the read-only oh-my-pi checkout')
|
||||
}
|
||||
const { buildTerminalTitleWithState } = await import(
|
||||
pathToFileURL(resolve(sourceRoot, 'packages/coding-agent/src/utils/title-generator.ts')).href
|
||||
)
|
||||
for (const state of ['working', 'idle', 'attention']) {
|
||||
for (const label of ['Run a long task', 'release | π : note | OMP ! action required ✦']) {
|
||||
// Exercise upstream's explicit Windows argument, independently of the capture host OS.
|
||||
const title = buildTerminalTitleWithState(label, state, 0, true, 'win32')
|
||||
process.stdout.write(`\x1b]0;${title}\x07`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user