From 91a100081fbe3097fcddd3f26fd2ce1f289e831f Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sun, 6 Sep 2026 16:10:45 -0700 Subject: [PATCH] fix(native-chat): preserve first-work rename across runtime and queued turns --- .../first-work-branch-rename.test.ts | 98 ++++++++++++ .../agent-hooks/first-work-rename-runtime.ts | 119 ++++++++++++++ .../first-work-structured-session-rename.ts | 28 ++++ .../first-work-workspace-title-rename.ts | 8 + .../claude-structured-journal-translation.ts | 5 +- ...red-journal-translation-settlement.test.ts | 8 +- ...ex-structured-journal-translation-turns.ts | 13 +- ...ructured-agent-session-status-feed.test.ts | 80 ++++++++++ .../runtime/orca-runtime-get-worktree-ps.ts | 11 +- ...nch-rename-hook-structured-session.test.ts | 24 ++- src/main/startup/branch-rename-hook.ts | 149 +----------------- 11 files changed, 379 insertions(+), 164 deletions(-) create mode 100644 src/main/agent-hooks/first-work-rename-runtime.ts create mode 100644 src/main/agent-hooks/first-work-structured-session-rename.ts diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index cb1107a8622..43ff9e78049 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from './first-work-structured-session-rename' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' const { @@ -83,6 +87,76 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { ) }) + it.each(['claude', 'codex'] as const)( + 'renames %s on live work without a subscriber, preserving replay, dedupe and retries', + async (agent) => { + const { deps, setDisplayName } = makeDeps() + const items: AgentJournalRenderItem[] = [] + const journal = { + snapshot: () => ({ items }), + isReadOnly: false + } as unknown as AgentSessionJournal + const pending: Promise[] = [] + const observe = vi.fn((summary, options) => { + const work = maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary, options, deps) + if (work) { + pending.push(work) + } + }) + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([ + [ + 'session', + { journal, params: { location: { workspaceId: WORKTREE_ID }, provider: agent } } + ] + ]), + getRecord: () => null, + now: () => 1, + onStatusChanged: observe + }) + const user = { + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] } + } as AgentJournalRenderItem + const turn = { + body: { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } + } as AgentJournalRenderItem + items.push(user, turn) + feed.publish('session', journal, { replay: true }) + await Promise.all(pending) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + + items.pop() + feed.publish('session', journal) + items.push(turn) + generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'temporary failure' }) + feed.publish('session', journal) + await Promise.all(pending) + expect(generateBranchNameMock).toHaveBeenCalledOnce() + expect(setDisplayName).not.toHaveBeenCalled() + const callsBeforeOutput = observe.mock.calls.length + for (let index = 0; index < 100; index++) { + feed.publish('session', journal) + } + expect(observe).toHaveBeenCalledTimes(callsBeforeOutput) + + items.pop() + feed.publish('session', journal) + items.push(turn) + feed.publish('session', journal) + await Promise.all(pending) + expect(generateBranchNameMock).toHaveBeenCalledTimes(2) + expect(setDisplayName).toHaveBeenCalledWith(WORKTREE_ID, 'Fix auth') + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['branch', '-m', 'you/fix-auth'], + expect.anything() + ) + } + ) + it('keeps incidental work-item markers from overriding the generated display name', async () => { const { deps, onRenamed, setDisplayName } = makeDeps() await maybeAutoRenameBranchOnFirstWork(workingEvent({ prompt: 'Fix auth from note #1' }), deps) @@ -233,6 +307,30 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect(onRenamed).toHaveBeenCalledWith(FOLDER_WORKTREE_ID) }) + it.each([true, false])( + 'preserves a manual folder name during generation (pending=%s)', + async (pendingAfterRename) => { + let name = 'Platform workspace' + let pending = true + const { deps, setDisplayName } = makeDeps({ + resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID, + getFolderWorkspacePath: () => '/workspace/platform', + isPendingFirstAgentMessageRename: () => pending, + getCurrentDisplayName: () => name + }) + generateBranchNameMock.mockImplementationOnce(async () => { + name = 'My manual title' + pending = pendingAfterRename + return { success: true, slug: 'fix-auth' } + }) + + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + + expect(generateBranchNameMock).toHaveBeenCalledOnce() + expect(setDisplayName).not.toHaveBeenCalled() + } + ) + it('does not rename folder workspace titles without the pending marker', async () => { const { deps, setDisplayName } = makeDeps({ resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID, diff --git a/src/main/agent-hooks/first-work-rename-runtime.ts b/src/main/agent-hooks/first-work-rename-runtime.ts new file mode 100644 index 00000000000..ebc9484071b --- /dev/null +++ b/src/main/agent-hooks/first-work-rename-runtime.ts @@ -0,0 +1,119 @@ +import { existsSync } from 'node:fs' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import type { FirstWorkBranchRenameDeps } from './first-work-branch-rename' +import { rememberBranchRenameFailureOutput } from './branch-rename-failure-output' +import { renameWorktreeFolderOnFirstWork } from './first-work-folder-rename' +import { moveWorktree } from '../git/worktree' +import type { Store } from '../persistence' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +const ENABLE_FIRST_WORK_FOLDER_RENAME = false + +export function firstWorkRenameDeps( + store: Store, + runtime: Pick< + OrcaRuntimeService, + | 'getCommitMessageAgentEnvironmentResolvers' + | 'notifyFolderWorkspaceChanged' + | 'notifyBranchRenamed' + | 'notifyWorktreeFolderRenamed' + > +): FirstWorkBranchRenameDeps { + return { + getSettings: () => store.getSettings(), + getRepo: (repoId) => store.getRepo(repoId), + getAgentEnvResolvers: () => runtime.getCommitMessageAgentEnvironmentResolvers(), + getCurrentDisplayName: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.name + : store.getWorktreeMeta(worktreeId)?.displayName + }, + getFolderWorkspacePath: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath + : undefined + }, + isPendingFirstAgentMessageRename: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.pendingFirstAgentMessageRename === true + : store.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true + }, + canRenameOrcaCreatedBranch: (worktreeId) => { + const meta = store.getWorktreeMeta(worktreeId) + // Why: a user branch could coincidentally match a creature name; only Orca-stamped worktrees are safe to auto-rename. + return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true + }, + setDisplayName: (worktreeId, displayName) => { + rememberBranchRenameFailureOutput(worktreeId, null) + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + store.updateFolderWorkspace(scope.folderWorkspaceId, { + name: displayName, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + }) + runtime.notifyFolderWorkspaceChanged() + return + } + store.setWorktreeMeta(worktreeId, { + displayName, + // The first-agent title is an intentional user-facing label; keep it stable after the + // generated branch is renamed and across subsequent catalog refreshes. + displayNameIsPinned: true, + pendingFirstAgentMessageRename: false, + // Success clears the failure badge (redundant with the explicit setRenameError(null)). + firstAgentMessageRenameError: null + }) + }, + renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME + ? (worktreeId, newLeaf) => + renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, { + getRepo: (repoId) => store.getRepo(repoId), + getSettings: () => store.getSettings(), + migrateWorktreeIdentity: (oldId, newId) => store.migrateWorktreeIdentity(oldId, newId), + notifyWorktreeRenamed: (repoId, oldId, newId) => + runtime.notifyWorktreeFolderRenamed(repoId, oldId, newId), + pathExists: async (candidate) => existsSync(candidate), + moveWorktree + }) + : undefined, + setRenameError: (worktreeId, error, failureOutput) => { + // Refresh the full-output capture before the dedupe below — a repeat error string is still a fresh run. + rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) + // Skip the write + push when unchanged — most settled worktrees never had an error to clear. + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const current = store.getFolderWorkspace( + scope.folderWorkspaceId + )?.firstAgentMessageRenameError + if ((current ?? null) === (error ?? null)) { + return + } + store.updateFolderWorkspace(scope.folderWorkspaceId, { + firstAgentMessageRenameError: error + }) + runtime.notifyFolderWorkspaceChanged() + return + } + const current = store.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError + if ((current ?? null) === (error ?? null)) { + return + } + store.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) + // Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects. + runtime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) + }, + resolveWorktreeIdForTab: (tabId) => store.getWorktreeIdForTab(tabId), + onRenamed: (repoIdOrWorktreeId) => { + if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') { + runtime.notifyFolderWorkspaceChanged() + return + } + runtime.notifyBranchRenamed(repoIdOrWorktreeId) + } + } +} diff --git a/src/main/agent-hooks/first-work-structured-session-rename.ts b/src/main/agent-hooks/first-work-structured-session-rename.ts new file mode 100644 index 00000000000..637dc1e9c3c --- /dev/null +++ b/src/main/agent-hooks/first-work-structured-session-rename.ts @@ -0,0 +1,28 @@ +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import { + maybeAutoRenameBranchOnFirstWork, + type FirstWorkBranchRenameDeps +} from './first-work-branch-rename' + +export function maybeAutoRenameWorkspaceOnFirstStructuredTurn( + summary: AgentSessionStatusSummary, + options: { replay: boolean }, + deps: FirstWorkBranchRenameDeps +): Promise | undefined { + if (summary.status !== 'working') { + return + } + return maybeAutoRenameBranchOnFirstWork( + { + // No pane: a structured session is resolved by its workspace id, not by a terminal tab. + paneKey: '', + tabId: undefined, + worktreeId: summary.workspaceId, + state: 'working', + prompt: summary.latestPrompt, + assistantMessage: undefined, + isReplay: options.replay + }, + deps + ) +} diff --git a/src/main/agent-hooks/first-work-workspace-title-rename.ts b/src/main/agent-hooks/first-work-workspace-title-rename.ts index fb682c86cf3..063c6f15aec 100644 --- a/src/main/agent-hooks/first-work-workspace-title-rename.ts +++ b/src/main/agent-hooks/first-work-workspace-title-rename.ts @@ -27,6 +27,7 @@ export async function runFolderWorkspaceTitleAutoRename( return stop('folder workspace path unavailable') } + const originalDisplayName = deps.getCurrentDisplayName(worktreeId) const settings = deps.getSettings() const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null) if (!resolvedParams.ok) { @@ -49,6 +50,13 @@ export async function runFolderWorkspaceTitleAutoRename( resolvedParams.params, target ) + // Generation may outlive a manual rename or workspace removal. + if ( + deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true || + deps.getCurrentDisplayName(worktreeId) !== originalDisplayName + ) { + return stop('folder workspace changed during generation', true) + } if (!generated.success) { if (!generated.canceled) { deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index ffaad4da570..812e86001ba 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -112,7 +112,10 @@ export function createClaudeJournalTranslator( } else { deps.sink.appendTombstone(identity) } - deps.sink.publish() + // Preserve first-work evidence when completion arrives before the journal drains. + deps.sink.publish({ + coalescingKey: running ? `turn-start:${sessionId}:${turnId}` : 'publish' + }) } const handleStream = (message: Record): boolean => { diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index 5773cf8d6fa..7613de05136 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -212,7 +212,7 @@ describe('codex journal translation', () => { expect(translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))).toEqual({ accepted: true }) - expect(deferred.state()).toMatchObject({ queuedOperations: 4, backpressured: true }) + expect(deferred.state()).toMatchObject({ queuedOperations: 5, backpressured: true }) deferred.bind(deferredTarget(bodies, publishes)) await expect(deferred.lifecycleBarrier()).resolves.toEqual({ ok: true }) @@ -225,7 +225,7 @@ describe('codex journal translation', () => { expect.objectContaining({ kind: 'tool-call', state: 'running' }), expect.objectContaining({ kind: 'tool-call', state: 'failed' }) ]) - expect(publishes).toHaveLength(1) + expect(publishes).toHaveLength(2) }) it('admits terminal session settlement publication across the hard watermark', async () => { @@ -257,7 +257,7 @@ describe('codex journal translation', () => { acquisitionGeneration: 'generation-1' }) ).toEqual({ accepted: true }) - expect(deferred.state()).toMatchObject({ queuedOperations: 4, backpressured: true }) + expect(deferred.state()).toMatchObject({ queuedOperations: 5, backpressured: true }) deferred.bind(deferredTarget(bodies, publishes)) await expect(deferred.lifecycleBarrier()).resolves.toEqual({ ok: true }) @@ -277,7 +277,7 @@ describe('codex journal translation', () => { }), { kind: 'status', text: 'Provider exited: lost child' } ]) - expect(publishes).toHaveLength(1) + expect(publishes).toHaveLength(2) }) it('retries a rejected terminal admission without losing tool, prompt, turn, or session truth', () => { diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index 06bb28f85f9..7313946a53e 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -56,9 +56,16 @@ export function publishCodexTurnLifecycle(input: { return admission } } - if (input.sink.tryPublish) { - return input.sink.tryPublish({ lifecycle: true }) + // Preserve first-work evidence when completion arrives before the journal drains. + const publishOptions = { + lifecycle: true, + ...(input.state === 'running' + ? { coalescingKey: `turn-start:${input.sessionId}:${input.turnId}` } + : {}) } - input.sink.publish({ lifecycle: true }) + if (input.sink.tryPublish) { + return input.sink.tryPublish(publishOptions) + } + input.sink.publish(publishOptions) return ADMITTED } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index c511fe72afa..1bd774298da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -3,6 +3,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' +import { publishCodexTurnLifecycle } from '../../codex/codex-structured-journal-translation-turns' +import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import { StructuredAgentSessionStatusFeed, @@ -277,6 +280,83 @@ describe('StructuredAgentSessionStatusFeed', () => { expect(seen.at(-1)).toEqual({ status: 'idle', prompt: 'fix the auth bug', replay: true }) }) + it.each(['claude', 'codex'] as const)( + 'observes a fast %s turn even when start and finish queue before persistence', + async (agent) => { + const journal = await openJournal() + await journal.appendItem( + USER_IDENTITY, + { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Fix auth' }] + }, + { fence: 1 } + ) + const seen: (string | null)[] = [] + const { feed } = feedFor(new Map([[SESSION, { journal }]]), (summary) => + seen.push(summary.status) + ) + const deferred = createDeferredStructuredAgentSessionEventSink() + if (agent === 'claude') { + const translator = createClaudeJournalTranslator({ sink: deferred.sink }) + translator.handle({ + type: 'message', + sessionId: SESSION, + startsTurn: true, + message: { + type: 'user', + uuid: 'prompt-1', + session_id: 'claude-session', + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: 'Fix auth' }] } + } + }) + translator.handle({ + type: 'message', + sessionId: SESSION, + message: { + type: 'result', + subtype: 'success', + session_id: 'claude-session', + uuid: 'result-1', + result: 'Done' + } + }) + translator.dispose() + } else { + for (const state of ['running', 'completed'] as const) { + publishCodexTurnLifecycle({ + sink: deferred.sink, + primaryThreadId: 'thread-1', + sessionId: SESSION, + threadId: 'thread-1', + turnId: 'turn-1', + state + }) + } + } + for (let index = 0; index < 100; index++) { + deferred.sink.publish() + } + // This queue is also reached while a previous asynchronous journal write is pending. + let publications = 0 + deferred.bind({ + journal, + fence: 1, + publish: () => { + publications += 1 + feed.publish(SESSION, journal) + } + }) + expect(await deferred.drained()).toEqual({ ok: true }) + expect(seen).toEqual(['idle', 'working', 'idle']) + expect(publications).toBe(2) + expect(deferred.state()).toMatchObject({ queuedBytes: 0, queuedOperations: 0 }) + deferred.close() + } + ) + it('keeps publishing to subscribers when the host observer throws', async () => { const journal = await openJournal() const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), () => { diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 73fbdb677e7..240156d93c6 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -14,7 +14,8 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { Repo } from '../../shared/repo-types' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' -import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from '../startup/branch-rename-hook' +import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from '../agent-hooks/first-work-structured-session-rename' +import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { buildWorktreeListingPage } from './worktree-listing-host-scope' @@ -159,7 +160,13 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), // Structured chat has no agent CLI hooks, so this projection is what the first-work // workspace rename listens to instead of `agentStatus:set`. - onSessionStatusChanged: maybeAutoRenameWorkspaceOnFirstStructuredTurn, + onSessionStatusChanged: (summary, options) => { + void maybeAutoRenameWorkspaceOnFirstStructuredTurn( + summary, + options, + firstWorkRenameDeps(this.requireStore(), this) + ) + }, handoffTransport: this.createStructuredAgentSessionHandoffTransport() }) } diff --git a/src/main/startup/branch-rename-hook-structured-session.test.ts b/src/main/startup/branch-rename-hook-structured-session.test.ts index a30ac352e1a..de85e824db6 100644 --- a/src/main/startup/branch-rename-hook-structured-session.test.ts +++ b/src/main/startup/branch-rename-hook-structured-session.test.ts @@ -19,9 +19,12 @@ vi.mock('../agent-hooks/first-work-folder-rename', () => ({ vi.mock('../git/worktree', () => ({ moveWorktree: vi.fn() })) vi.mock('electron', () => ({ app: { getPath: () => '', on: vi.fn(), isReady: () => true } })) -import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from './branch-rename-hook' +import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from '../agent-hooks/first-work-structured-session-rename' +import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { mainProcessState } from './main-process-state' +let renameDeps: ReturnType + const WORKSPACE_ID = 'repo1::/repo/wt' function summary(overrides: Partial = {}): AgentSessionStatusSummary { @@ -47,11 +50,12 @@ beforeEach(() => { mainProcessState.runtime = { getCommitMessageAgentEnvironmentResolvers: () => undefined } as unknown as typeof mainProcessState.runtime + renameDeps = firstWorkRenameDeps(mainProcessState.store!, mainProcessState.runtime!) }) describe('maybeAutoRenameWorkspaceOnFirstStructuredTurn', () => { it('drives the first-work rename from the session workspace, with no pane to resolve', () => { - maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary(), { replay: false }) + maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary(), { replay: false }, renameDeps) expect(renameCalls).toHaveLength(1) expect(renameCalls[0]?.[0]).toEqual({ @@ -66,23 +70,29 @@ describe('maybeAutoRenameWorkspaceOnFirstStructuredTurn', () => { }) it('marks a re-projected summary as a replay so restore cannot rename on old state', () => { - maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary(), { replay: true }) + maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary(), { replay: true }, renameDeps) expect(renameCalls[0]?.[0]).toMatchObject({ isReplay: true }) }) it('ignores every status that is not a running turn', () => { for (const status of ['idle', 'attention', null] as const) { - maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary({ status }), { replay: false }) + maybeAutoRenameWorkspaceOnFirstStructuredTurn( + summary({ status }), + { replay: false }, + renameDeps + ) } expect(renameCalls).toEqual([]) }) - it('does nothing before the store and runtime singletons exist', () => { + it('uses the owning runtime even when desktop singletons do not exist', () => { mainProcessState.store = null - maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary(), { replay: false }) + mainProcessState.runtime = null + maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary(), { replay: false }, renameDeps) - expect(renameCalls).toEqual([]) + expect(renameCalls).toHaveLength(1) + expect(renameCalls[0]?.[1]).toBe(renameDeps) }) }) diff --git a/src/main/startup/branch-rename-hook.ts b/src/main/startup/branch-rename-hook.ts index bc9f3e68d28..e2d5dcfcd19 100644 --- a/src/main/startup/branch-rename-hook.ts +++ b/src/main/startup/branch-rename-hook.ts @@ -1,21 +1,7 @@ -import { existsSync } from 'node:fs' -import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' -import { parseWorkspaceKey } from '../../shared/workspace-scope' -import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' -import { - maybeAutoRenameBranchOnFirstWork, - type FirstWorkBranchRenameDeps -} from '../agent-hooks/first-work-branch-rename' -import { rememberBranchRenameFailureOutput } from '../agent-hooks/branch-rename-failure-output' -import { renameWorktreeFolderOnFirstWork } from '../agent-hooks/first-work-folder-rename' -import { moveWorktree } from '../git/worktree' -import type { Store } from '../persistence' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { maybeAutoRenameBranchOnFirstWork } from '../agent-hooks/first-work-branch-rename' +import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { mainProcessState as state } from './main-process-state' -// Kill switch for the first-work on-disk folder rename; the renderer reconciles the id change (migrateWorktreeIdentity) so it isn't mistaken for a deletion. -const ENABLE_FIRST_WORK_FOLDER_RENAME = false - // Why: inject the index.ts store/runtime singletons so the rename orchestrator stays module-state-free and unit-testable. export function maybeAutoRenameBranchOnFirstWorkFromHook(event: { paneKey: string @@ -42,134 +28,3 @@ export function maybeAutoRenameBranchOnFirstWorkFromHook(event: { firstWorkRenameDeps(store, runtime) ) } - -/** - * Structured native chat (Claude and Codex) runs no agent CLI hooks, so the host's own status - * projection is its "work has begun" signal. The workspace id is the worktree id, and the projected - * prompt is normalized the same way the hook payload is, so this reaches the same orchestrator. - */ -export function maybeAutoRenameWorkspaceOnFirstStructuredTurn( - summary: AgentSessionStatusSummary, - options: { replay: boolean } -): void { - if (summary.status !== 'working') { - return - } - const store = state.store - const runtime = state.runtime - if (!store || !runtime) { - return - } - void maybeAutoRenameBranchOnFirstWork( - { - // No pane: a structured session is resolved by its workspace id, not by a terminal tab. - paneKey: '', - tabId: undefined, - worktreeId: summary.workspaceId, - state: 'working', - prompt: summary.latestPrompt, - assistantMessage: undefined, - isReplay: options.replay - }, - firstWorkRenameDeps(store, runtime) - ) -} - -function firstWorkRenameDeps(store: Store, runtime: OrcaRuntimeService): FirstWorkBranchRenameDeps { - return { - getSettings: () => store.getSettings(), - getRepo: (repoId) => store.getRepo(repoId), - getAgentEnvResolvers: () => runtime.getCommitMessageAgentEnvironmentResolvers(), - getCurrentDisplayName: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - return scope?.type === 'folder' - ? store.getFolderWorkspace(scope.folderWorkspaceId)?.name - : store.getWorktreeMeta(worktreeId)?.displayName - }, - getFolderWorkspacePath: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - return scope?.type === 'folder' - ? store.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath - : undefined - }, - isPendingFirstAgentMessageRename: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - return scope?.type === 'folder' - ? store.getFolderWorkspace(scope.folderWorkspaceId)?.pendingFirstAgentMessageRename === true - : store.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true - }, - canRenameOrcaCreatedBranch: (worktreeId) => { - const meta = store.getWorktreeMeta(worktreeId) - // Why: a user branch could coincidentally match a creature name; only Orca-stamped worktrees are safe to auto-rename. - return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true - }, - setDisplayName: (worktreeId, displayName) => { - rememberBranchRenameFailureOutput(worktreeId, null) - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - store.updateFolderWorkspace(scope.folderWorkspaceId, { - name: displayName, - pendingFirstAgentMessageRename: false, - firstAgentMessageRenameError: null - }) - runtime.notifyFolderWorkspaceChanged() - return - } - store.setWorktreeMeta(worktreeId, { - displayName, - // The first-agent title is an intentional user-facing label; keep it stable after the - // generated branch is renamed and across subsequent catalog refreshes. - displayNameIsPinned: true, - pendingFirstAgentMessageRename: false, - // Success clears the failure badge (redundant with the explicit setRenameError(null)). - firstAgentMessageRenameError: null - }) - }, - renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME - ? (worktreeId, newLeaf) => - renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, { - getRepo: (repoId) => store.getRepo(repoId), - getSettings: () => store.getSettings(), - migrateWorktreeIdentity: (oldId, newId) => store.migrateWorktreeIdentity(oldId, newId), - notifyWorktreeRenamed: (repoId, oldId, newId) => - runtime.notifyWorktreeFolderRenamed(repoId, oldId, newId), - pathExists: async (candidate) => existsSync(candidate), - moveWorktree - }) - : undefined, - setRenameError: (worktreeId, error, failureOutput) => { - // Refresh the full-output capture before the dedupe below — a repeat error string is still a fresh run. - rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) - // Skip the write + push when unchanged — most settled worktrees never had an error to clear. - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const current = store.getFolderWorkspace( - scope.folderWorkspaceId - )?.firstAgentMessageRenameError - if ((current ?? null) === (error ?? null)) { - return - } - store.updateFolderWorkspace(scope.folderWorkspaceId, { - firstAgentMessageRenameError: error - }) - runtime.notifyFolderWorkspaceChanged() - return - } - const current = store.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError - if ((current ?? null) === (error ?? null)) { - return - } - store.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) - // Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects. - runtime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) - }, - resolveWorktreeIdForTab: (tabId) => store.getWorktreeIdForTab(tabId), - onRenamed: (repoIdOrWorktreeId) => { - if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') { - runtime.notifyFolderWorkspaceChanged() - return - } - runtime.notifyBranchRenamed(repoIdOrWorktreeId) - } - } -}