mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
fix(native-chat): preserve first-work rename across runtime and queued turns
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user