feat(ui): cascade undo and redo to subagents
This commit is contained in:
@@ -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`:
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user