fix(native-chat): preserve first-work rename across runtime and queued turns

This commit is contained in:
Merge Sim
2026-09-06 16:10:45 -07:00
parent a7cc810aa7
commit 91a100081f
11 changed files with 379 additions and 164 deletions
@@ -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<void>[] = []
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,
@@ -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)
}
}
}
@@ -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<void> | 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
)
}
@@ -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)
@@ -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<string, unknown>): boolean => {
@@ -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', () => {
@@ -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
}
@@ -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 }]]), () => {
@@ -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()
})
}
@@ -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<typeof firstWorkRenameDeps>
const WORKSPACE_ID = 'repo1::/repo/wt'
function summary(overrides: Partial<AgentSessionStatusSummary> = {}): 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)
})
})
+2 -147
View File
@@ -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)
}
}
}