Merge pull request #3146 from alexandrereyes/feat/undo-subagent

feat(ui): cascade undo and redo to subagents
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 01:23:46 +03:00
committed by GitHub
4 changed files with 216 additions and 2 deletions
+1
View File
@@ -268,6 +268,7 @@ Rules:
6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation.
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. 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.
Examples of global-store updates performed in `session-actions.ts`:
+133 -2
View File
@@ -13,6 +13,9 @@ let permissionReplyError: unknown | null = null
let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] }
const sessionMessageRecords = new Map<string, Array<{ info: Message; parts: Part[] }>>()
const failingRevertSessionIds = new Set<string>()
const failingUnrevertSessionIds = new Set<string>()
let sessionDeleteError: unknown | null = null
let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null
let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null
@@ -68,6 +71,13 @@ const mockSdk = {
replyCalls.push({ method: "session.revert", params })
return Promise.resolve(sessionRevertResult)
}),
unrevert: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "session.unrevert", params })
if (failingUnrevertSessionIds.has(String(params.sessionID))) {
return Promise.resolve({ error: { message: "rejected" }, response: { status: 500 } })
}
return Promise.resolve({ data: { id: params.sessionID, time: { created: 1 } } })
}),
abort: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "session.abort", params })
return Promise.resolve({ data: true })
@@ -127,6 +137,10 @@ mock.module("@/lib/opencode/client", () => ({
getDirectory: () => "/test/project",
getFilesystemHome: mock(async () => "/home/test"),
getSdkClient: () => mockSdk,
getSessionMessages: mock((sessionId: string, _limit?: number, directory?: string | null) => {
replyCalls.push({ method: "session.messages", params: { sessionID: sessionId, directory } })
return Promise.resolve(sessionMessageRecords.get(sessionId) ?? [])
}),
replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => {
replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } })
return Promise.resolve(true)
@@ -140,11 +154,11 @@ mock.module("@/lib/opencode/client", () => ({
method: "session.revert",
params: { sessionID: sessionId, messageID: messageId, partID: partId, directory },
})
if (sessionRevertResult.error) {
if (sessionRevertResult.error || failingRevertSessionIds.has(sessionId)) {
const status = sessionRevertResult.response?.status
throw new Error(`session.revert failed${status ? ` (${status})` : ""}: rejected`)
}
return Promise.resolve(sessionRevertResult.data)
return Promise.resolve(sessionRevertResult.data ?? { id: sessionId, time: { created: 1 }, revert: { messageID: messageId } })
}),
updateSession: mock((sessionId: string, changes: Record<string, unknown>, directory?: string | null) => {
replyCalls.push({ method: "session.update", params: { sessionID: sessionId, ...changes, directory } })
@@ -1293,6 +1307,8 @@ describe("revertToMessage passes session directory", () => {
replyCalls.length = 0
scopedClientDirectories.length = 0
sessionRevertResult = {}
sessionMessageRecords.clear()
failingRevertSessionIds.clear()
Object.assign(inputState, {
pendingInputText: "previous draft",
pendingInputMode: "normal" as const,
@@ -1354,6 +1370,121 @@ describe("revertToMessage passes session directory", () => {
expect((sessionStore.getState().session[0] as Session & { revert?: { messageID?: string } }).revert).toBe(undefined)
expect(inputState.pendingInputText).toBe("previous draft")
})
test("reverts recursive descendants at their first user message on or after the parent cutoff", async () => {
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 } },
{ id: "child", parentID: "root", directory: "/tree", time: { created: 2 } },
{ id: "grandchild", parentID: "child", directory: "/tree", time: { created: 3 } },
{ id: "old-child", parentID: "root", directory: "/tree", time: { created: 4 } },
] as Session[]
const store = createStore({}, { session: sessions, message: { root: [rootMessage] } })
sessionMessageRecords.set("child", [
{ info: { id: "child-before", sessionID: "child", role: "user", time: { created: 10 } } as Message, parts: [] },
{ info: { id: "child-boundary", sessionID: "child", role: "user", time: { created: 20 } } as Message, parts: [] },
{ info: { id: "child-later", sessionID: "child", role: "user", time: { created: 30 } } as Message, parts: [] },
])
sessionMessageRecords.set("grandchild", [
{ info: { id: "grandchild-assistant", sessionID: "grandchild", role: "assistant", time: { created: 20 } } as Message, parts: [] },
{ info: { id: "grandchild-user", sessionID: "grandchild", role: "user", time: { created: 21 } } as Message, parts: [] },
])
sessionMessageRecords.set("old-child", [
{ info: { id: "old-child-user", sessionID: "old-child", role: "user", time: { created: 19 } } as Message, parts: [] },
])
const { setActionRefs, revertToMessage } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await revertToMessage("root", "root-cutoff")
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => [
call.params.sessionID,
call.params.messageID,
])).toEqual([
["child", "child-boundary"],
["grandchild", "grandchild-user"],
["root", "root-cutoff"],
])
})
test("continues reverting other descendants and the parent when one child fails", async () => {
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 } },
{ id: "failing-child", parentID: "root", directory: "/tree", time: { created: 2 } },
{ id: "healthy-child", parentID: "root", directory: "/tree", time: { created: 3 } },
] as Session[]
const store = createStore({}, { session: sessions, message: { root: [rootMessage] } })
for (const id of ["failing-child", "healthy-child"]) {
sessionMessageRecords.set(id, [{
info: { id: `${id}-target`, sessionID: id, role: "user", time: { created: 20 } } as Message,
parts: [],
}])
}
failingRevertSessionIds.add("failing-child")
const { setActionRefs, revertToMessage } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await revertToMessage("root", "root-cutoff")
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => call.params.sessionID)).toEqual([
"failing-child",
"healthy-child",
"root",
])
})
})
describe("unrevertSession descendant cascade", () => {
beforeEach(() => {
replyCalls.length = 0
sessionMessagesResult = { data: [] }
failingUnrevertSessionIds.clear()
})
test("unreverts only marked descendants before the parent", async () => {
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
{ id: "marked-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "child-target" } },
{ id: "plain-child", parentID: "root", directory: "/tree", time: { created: 3 } },
{ id: "marked-grandchild", parentID: "plain-child", directory: "/tree", time: { created: 4 }, revert: { messageID: "grandchild-target" } },
] as Session[]
const store = createStore({}, { session: sessions })
const { setActionRefs, unrevertSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await unrevertSession("root")
expect(replyCalls.filter((call) => call.method === "session.unrevert").map((call) => call.params.sessionID)).toEqual([
"marked-child",
"marked-grandchild",
"root",
])
})
test("continues after a descendant unrevert fails", async () => {
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
{ id: "failing-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "first-target" } },
{ id: "healthy-child", parentID: "root", directory: "/tree", time: { created: 3 }, revert: { messageID: "second-target" } },
] as Session[]
const store = createStore({}, { session: sessions })
failingUnrevertSessionIds.add("failing-child")
const { setActionRefs, unrevertSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await unrevertSession("root")
expect(replyCalls.filter((call) => call.method === "session.unrevert").map((call) => call.params.sessionID)).toEqual([
"failing-child",
"healthy-child",
"root",
])
})
})
describe("dismissPermission passes directory", () => {
+81
View File
@@ -438,6 +438,76 @@ type SessionListSnapshot = {
type DirectoryStoreApi = ReturnType<ChildStoreManager["ensureChild"]>
type DescendantSession = {
session: Session
directory: string
}
function getDescendantSessions(rootId: string): DescendantSession[] {
const stores = _childStores
if (!stores) return []
const sessionsById = new Map<string, DescendantSession>()
for (const [storeDirectory, store] of stores.children) {
for (const session of store.getState().session) {
const directory = session.directory || storeDirectory
const current = sessionsById.get(session.id)
if (!current || session.directory) sessionsById.set(session.id, { session, directory })
}
}
const subtreeIds = computeSubtreeIds(
[...sessionsById.values()].map(({ session }) => session),
rootId,
)
subtreeIds.delete(rootId)
return [...subtreeIds]
.map((id) => sessionsById.get(id))
.filter((entry): entry is DescendantSession => !!entry)
}
function firstUserMessageAtOrAfter(messages: Message[], cutoff: number): Message | null {
let target: Message | null = null
for (const message of messages) {
if (message.role !== "user" || message.time.created < cutoff) continue
if (!target || message.time.created < target.time.created) target = message
}
return target
}
async function fetchSessionMessages(sessionId: string, directory?: string | null): Promise<Message[]> {
const records = await opencodeClient.getSessionMessages(sessionId, undefined, directory)
return records.map(({ info }) => info)
}
async function cascadeRevertToDescendants(rootId: string, cutoff: number): Promise<void> {
for (const { session, directory } of getDescendantSessions(rootId)) {
try {
const messages = await fetchSessionMessages(session.id, directory)
// Equal timestamps belong to the reverted side of the boundary. Keeping
// them would rely on unrelated message IDs to decide chronology.
const target = firstUserMessageAtOrAfter(messages, cutoff)
if (!target) continue
const reverted = await opencodeClient.revertSession(session.id, target.id, undefined, directory)
mirrorSessionIntoLiveStores(reverted, directory)
} catch (error) {
console.error(`[session-actions] Failed to cascade revert to descendant ${session.id}:`, error)
}
}
}
async function cascadeUnrevertToDescendants(rootId: string): Promise<void> {
for (const { session, directory } of getDescendantSessions(rootId)) {
if (!session.revert) continue
try {
const result = await sdk().session.unrevert({ sessionID: session.id, directory })
mirrorSessionIntoLiveStores(assertSdkData(result, "session.unrevert"), directory)
} catch (error) {
console.error(`[session-actions] Failed to cascade unrevert to descendant ${session.id}:`, error)
}
}
}
function getGlobalSessionSnapshot(sessionId: string): Session | null {
const global = useGlobalSessionsStore.getState()
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null
@@ -1842,6 +1912,11 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
const { store, directory } = dirStoreForSession(sessionId)
const state = store.getState()
const localTarget = state.message[sessionId]?.find((message) => message.id === messageId)
const targetMessage = localTarget
?? (await fetchSessionMessages(sessionId, directory)).find((message) => message.id === messageId)
if (!targetMessage) throw new Error(`Cannot revert session: message ${messageId} was not found`)
// Abort if busy before mutating session state
const status = state.session_status[sessionId]
if (status && status.type !== "idle") {
@@ -1912,6 +1987,9 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
// Call SDK and merge authoritative result into store
try {
// Descendants go first because OpenCode also restores file snapshots during
// revert. All sessions share a directory, so the parent's snapshot must win.
await cascadeRevertToDescendants(sessionId, targetMessage.time.created)
const revertedSession = await opencodeClient.revertSession(sessionId, messageId, undefined, directory)
const current = store.getState()
const updated = [...current.session]
@@ -1994,6 +2072,9 @@ export async function unrevertSession(sessionId: string): Promise<void> {
}
}
// Descendants go first because unrevert can also restore shared file state.
// Applying the parent last leaves the working tree at the parent's snapshot.
await cascadeUnrevertToDescendants(sessionId)
const result = await sdk().session.unrevert({ sessionID: sessionId, directory })
const unrevertedSession = assertSdkData(result, "session.unrevert")
const current = store.getState()