fix(ui): route answer worktrees from source session

This commit is contained in:
Bohdan Triapitsyn
2026-09-04 19:46:21 +03:00
parent f160f3aac4
commit b4a38061bc
7 changed files with 234 additions and 61 deletions
+1
View File
@@ -290,6 +290,7 @@ Rules:
8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
9. `SessionLiveActivity` has three answers and `unknown` is never `idle`. `getSessionLiveActivity` reports `active` when any child store or the global session-status index holds a non-idle status, `idle` only when a child store actually covers the session's directory, and `unknown` otherwise — child stores are evicted for background directories, and the global index keeps only non-idle entries, so absence of a status is not proof of idleness. Callers that gate a destructive action (worktree moves) must refuse on `unknown`.
10. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo.
11. Starting a session from an assistant answer carries the source session ID, rendered directory, and answer text into the action. It must not rediscover that context from the globally active child store or the OpenCode client's fallback directory: the visible session may belong to an existing worktree while the active provider directory points elsewhere. New isolated worktrees resolve their registered parent project from that captured directory, preferring recorded worktree metadata when available. The dialog offers creation only after the project root is confirmed as a Git repository, and the creation boundary repeats that check so stale or bypassed UI state cannot run Git commands against a non-repository directory; failures leave the dialog open and visible.
Examples of global-store updates performed in `session-actions.ts`:
@@ -6,6 +6,8 @@ const createSessionCalls: Array<{ title?: string; directory: string | null; pare
const permissionAutoAcceptCalls: Array<[string, boolean]> = []
const savedVariantCalls: Array<string | undefined> = []
let configVariantOverride: string | null | undefined
let projects: Array<{ id: string; path: string; label: string }> = []
const createdWorktreeProjects: Array<{ id: string; path: string }> = []
// Sync's session→directory index. `createSession` writes it, and directory
// resolution reads it as the authoritative source, so the mock has to keep one.
const sessionDirectoryRegistry = new Map<string, string>()
@@ -111,7 +113,7 @@ mock.module("@/stores/useConfigStore", () => ({
mock.module("@/stores/useProjectsStore", () => ({
useProjectsStore: {
getState: () => ({
projects: [],
projects,
activeProjectId: null,
getActiveProject: () => null,
}),
@@ -127,6 +129,12 @@ mock.module("@/stores/useDirectoryStore", () => ({
},
}))
mock.module("@/stores/useSessionGoalArmStore", () => ({
useSessionGoalArmStore: {
getState: () => ({ setArmed: () => undefined }),
},
}))
mock.module("@/stores/useGlobalSessionsStore", () => ({
useGlobalSessionsStore: {
getState: () => ({
@@ -304,6 +312,33 @@ mock.module("../session-actions", () => ({
abortCurrentOperation: mock(async () => undefined),
}))
mock.module("@/lib/git/branchNameGenerator", () => ({
generateBranchName: () => "generated-branch",
}))
mock.module("@/lib/openchamberConfig", () => ({
getWorktreeSetupCommands: async () => [],
getWorktreeSetupWaitEnabled: async () => false,
}))
mock.module("@/lib/worktrees/worktreeBootstrap", () => ({
waitForWorktreeBootstrap: async () => undefined,
}))
mock.module("@/lib/worktrees/worktreeCreate", () => ({
createWorktreeWithDefaults: async (project: { id: string; path: string }) => {
createdWorktreeProjects.push(project)
return {
source: "sdk",
name: "generated-branch",
path: "/worktrees/generated-branch",
projectDirectory: project.path,
branch: "generated-branch",
label: "generated-branch",
}
},
}))
const { materializeOpenDraftSession, useSessionUIStore } = await import("../session-ui-store")
describe("issue 2039 draft auto-accept", () => {
@@ -500,3 +535,94 @@ describe("issue 2039 draft auto-accept", () => {
expect(useSessionUIStore.getState().getDirectoryForSession(sessionId)).toBe("/canonical/worktree")
})
})
describe("assistant answer worktree routing", () => {
test("reports session creation failure instead of completing silently", async () => {
const state = useSessionUIStore.getState()
const createFromAssistantMessage = state.createSessionFromAssistantMessage
const originalCreateSession = state.createSession
useSessionUIStore.setState({
createSession: async () => null,
})
try {
await expect(createFromAssistantMessage({
sessionId: "source-session",
directory: "/repo",
text: "Implement the plan",
}, {
providerID: "provider",
modelID: "model",
variant: "",
agent: "build",
instructions: "Follow the answer",
})).rejects.toThrow("Failed to create session")
} finally {
useSessionUIStore.setState({ createSession: originalCreateSession })
}
})
test("creates a sibling worktree from the captured source worktree directory", async () => {
projects = [
{ id: "project", path: "/repo", label: "Repo" },
{ id: "source-worktree", path: "/worktrees/source", label: "Source worktree" },
]
createdWorktreeProjects.length = 0
const sourceWorktree = {
path: "/worktrees/source",
projectDirectory: "/repo",
branch: "source",
label: "source",
}
const state = useSessionUIStore.getState()
const createFromAssistantMessage = state.createSessionFromAssistantMessage
const originalCreateSession = state.createSession
const originalSendMessage = state.sendMessage
const originalWorktreeMetadata = state.worktreeMetadata
let createdDirectory: string | null | undefined
useSessionUIStore.setState({
availableWorktreesByProject: new Map([["/repo", [sourceWorktree]]]),
worktreeMetadata: new Map([["source-session", sourceWorktree]]),
createSession: async (_title, directory) => {
createdDirectory = directory
return {
id: "created-session",
slug: "created-session",
projectID: "project",
directory: directory ?? "",
title: "Created session",
version: "1",
time: { created: 1, updated: 1 },
}
},
sendMessage: async () => undefined,
})
try {
await createFromAssistantMessage({
sessionId: "source-session",
directory: "/worktrees/source",
text: "Implement the plan",
}, {
providerID: "provider",
modelID: "model",
variant: "",
agent: "build",
instructions: "Follow the answer",
createWorktree: true,
})
} finally {
useSessionUIStore.setState({
createSession: originalCreateSession,
sendMessage: originalSendMessage,
worktreeMetadata: originalWorktreeMetadata,
})
projects = []
}
expect(createdWorktreeProjects).toEqual([{ id: "project", path: "/repo" }])
expect(createdDirectory).toBe("/worktrees/generated-branch")
})
})
+27 -43
View File
@@ -14,7 +14,7 @@
import type { ContextPartMetadata } from "@/lib/messages/contextParts"
import { create } from "zustand"
import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/client"
import type { Session, Part, TextPart } from "@opencode-ai/sdk/v2/client"
import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes"
import type { WorktreeMetadata } from "@/types/worktree"
import { opencodeClient } from "@/lib/opencode/client"
@@ -33,7 +33,6 @@ import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
import { normalizePath } from "@/lib/pathNormalization"
import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryPath, warmChatsRootDirectory } from "@/lib/chatDirectories"
import { isVSCodeRuntime } from "@/lib/desktop"
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice"
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
@@ -252,6 +251,12 @@ type AssistantMessageSessionExecution = {
runAsGoal?: boolean
}
type AssistantMessageSessionSource = {
sessionId: string
directory: string
text: string
}
function notifyMessageSent(sessionId: string): void {
runtimeFetch(`/api/sessions/${sessionId}/message-sent`, { method: "POST" })
.catch(() => { /* ignore */ })
@@ -385,7 +390,7 @@ export type SessionUIState = {
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>
handleSlashUndo: (sessionId: string) => Promise<void>
handleSlashRedo: (sessionId: string, options?: { fullUnrevert?: boolean }) => Promise<void>
createSessionFromAssistantMessage: (sourceMessageId: string, execution: AssistantMessageSessionExecution) => Promise<void>
createSessionFromAssistantMessage: (source: AssistantMessageSessionSource, execution: AssistantMessageSessionExecution) => Promise<void>
// Data access helpers (read from sync)
getSessionsByDirectory: (directory: string) => Session[]
@@ -1977,47 +1982,26 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
},
// ---------------------------------------------------------------------------
// createSessionFromAssistantMessage — reads from sync
// createSessionFromAssistantMessage — uses the rendered source context
// ---------------------------------------------------------------------------
createSessionFromAssistantMessage: async (sourceMessageId, execution) => {
if (!sourceMessageId) return
createSessionFromAssistantMessage: async (source, execution) => {
if (!source.sessionId) return
if (!execution?.instructions?.trim()) return
// Find which session this message belongs to by scanning sync state
const state = getDirectoryState()
if (!state) return
let sourceSessionId: string | undefined
let sourceMessage: Message | undefined
for (const [sid, msgs] of Object.entries(state.message ?? {})) {
const found = msgs.find((m) => m.id === sourceMessageId)
if (found) {
sourceSessionId = sid
sourceMessage = found
break
}
}
if (!sourceMessage || sourceMessage.role !== "assistant") return
const sourceParts = getSyncParts(sourceMessageId)
const assistantPlanText = flattenAssistantTextParts(sourceParts)
const assistantPlanText = source.text
if (!assistantPlanText.trim()) return
const directory = resolveSessionDirectory(
sourceSessionId ?? null,
(sid) => get().worktreeMetadata.get(sid),
)
const sourceWorktreeMetadata = sourceSessionId ? get().worktreeMetadata.get(sourceSessionId) : undefined
const sourceDirectory = normalizePath(source.directory)
if (!sourceDirectory) {
throw new Error("Source session directory is unavailable")
}
const sourceWorktreeMetadata = get().worktreeMetadata.get(source.sessionId)
const pID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID
const mID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID
const providerID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID
const modelID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID
if (!pID || !mID) return
if (!providerID || !modelID) return
const sourceDirectory = normalizePath(directory ?? opencodeClient.getDirectory() ?? null)
let sessionDirectory = sourceDirectory
let sessionDirectory: string | null = sourceDirectory
let createdWorktree: WorktreeMetadata | null = null
let createdWorktreeProject: { id: string; path: string } | null = null
@@ -2026,11 +2010,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const project = resolveProjectForSessionDirectory(
projects,
get().availableWorktreesByProject,
sourceDirectory,
sourceWorktreeMetadata?.projectDirectory ?? null,
) ?? resolveProjectForSessionDirectory(
projects,
get().availableWorktreesByProject,
sourceWorktreeMetadata?.projectDirectory ?? null,
sourceDirectory,
)
if (!project?.path) {
throw new Error("Project is not registered in OpenChamber")
@@ -2061,13 +2045,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
}
}
const session = await get().createSession(undefined, sessionDirectory || null, null)
const session = await get().createSession(undefined, sessionDirectory, null)
if (!session) {
if (createdWorktree && createdWorktreeProject) {
const { removeProjectWorktree } = await import("@/lib/worktrees/worktreeManager")
await removeProjectWorktree(createdWorktreeProject, createdWorktree, { deleteLocalBranch: true }).catch(() => undefined)
}
return
throw new Error("Failed to create session")
}
if (createdWorktree) {
@@ -2087,8 +2071,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
await get().sendMessage(
composeForkSessionMessage(execution.instructions, assistantPlanText),
pID,
mID,
providerID,
modelID,
execution.agent || undefined,
undefined,
undefined,