diff --git a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx
index 7dff45cb33..d9c5b63421 100644
--- a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx
+++ b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx
@@ -28,6 +28,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
data: {
experiments: {
claudeCodeMockCliTraffic: false,
+ editMessages: false,
newOnboarding: false,
toolsHub: true,
},
diff --git a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx
index b2f91fa6c8..e0f56f87db 100644
--- a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx
+++ b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx
@@ -24,6 +24,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
data: {
experiments: {
claudeCodeMockCliTraffic: false,
+ editMessages: false,
newOnboarding: false,
toolsHub: true,
},
diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx
index da5a3b06d9..b26e61b4ea 100644
--- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx
+++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx
@@ -168,6 +168,8 @@ export interface FollowUpComposerProps {
onChangeMessage: (value: string, mentionRanges: PromptTextMention[]) => void;
onModifierSubmit: () => void;
onSubmit: () => void;
+ /** Accessible label and tooltip for the primary submit action. */
+ submitTitle?: string;
compactPromptPlaceholder: string;
promptPlaceholder: string;
canModifierSubmit: boolean;
@@ -716,21 +718,25 @@ function FollowUpPromptBoxWithComposer({
composer.isFollowUpSubmitting ||
(steerOnPrimarySubmit && !composer.canModifierSubmit),
onModifierSubmit,
- title: canQueueFollowUp
- ? steerOnPrimarySubmit
- ? "Steer current run (Enter)"
- : "Queue follow-up (Enter)"
- : isStopping
- ? "Stopping run..."
- : isLoadingExecutionOptions
- ? "Loading models..."
- : isLoadingPendingInteractions
- ? "Checking pending interactions..."
- : isProvisioning
- ? "Provisioning..."
- : isUnavailable
- ? "Unavailable"
- : "Submit (Enter)",
+ title: composer.isFollowUpSubmitting
+ ? "Submitting..."
+ : canSubmit && composer.submitTitle !== undefined
+ ? composer.submitTitle
+ : canQueueFollowUp
+ ? steerOnPrimarySubmit
+ ? "Steer current run (Enter)"
+ : "Queue follow-up (Enter)"
+ : isStopping
+ ? "Stopping run..."
+ : isLoadingExecutionOptions
+ ? "Loading models..."
+ : isLoadingPendingInteractions
+ ? "Checking pending interactions..."
+ : isProvisioning
+ ? "Provisioning..."
+ : isUnavailable
+ ? "Unavailable"
+ : "Submit (Enter)",
isRunning: canStopRuntime,
}}
typeahead={typeahead}
diff --git a/apps/app/src/components/promptbox/InlineMessageEditorFrame.tsx b/apps/app/src/components/promptbox/InlineMessageEditorFrame.tsx
new file mode 100644
index 0000000000..d95f22b4d7
--- /dev/null
+++ b/apps/app/src/components/promptbox/InlineMessageEditorFrame.tsx
@@ -0,0 +1,71 @@
+import type { ReactNode } from "react";
+import { Button } from "@bb/shared-ui/button";
+import { Icon } from "@bb/shared-ui/icon";
+import { cn } from "@bb/shared-ui/lib/utils";
+
+import { PromptStackCard } from "@/components/promptbox/banner/PromptStackCard";
+
+interface InlineMessageEditorFrameProps {
+ cancelLabel: string;
+ children: ReactNode;
+ label: string;
+ onCancel: () => void;
+ variant?: "embedded" | "cap";
+}
+
+/** Shared chrome for compact editors shown inline with a message. */
+export function InlineMessageEditorFrame({
+ cancelLabel,
+ children,
+ label,
+ onCancel,
+ variant = "embedded",
+}: InlineMessageEditorFrameProps) {
+ const header = (
+
+
+
+ {label}
+
+
+
+ );
+
+ if (variant === "cap") {
+ return (
+
+
+ {header}
+
+
{children}
+
+ );
+ }
+
+ return (
+
+ {header}
+ {children}
+
+ );
+}
diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx
index 39cc1b10ba..889d633fa7 100644
--- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx
+++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx
@@ -387,6 +387,9 @@ describe("QueuedMessagesList", () => {
),
).toBe(true);
const editingLabel = getByText(/Editing queued message/u);
+ expect(
+ editingLabel.closest('[data-inline-message-editor-frame="embedded"]'),
+ ).not.toBeNull();
const dismissButton = getByRole("button", {
name: "Stop editing queued message",
});
diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx
index 5e88c382d4..e63e6f0538 100644
--- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx
+++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx
@@ -54,6 +54,7 @@ import { Icon } from "@bb/shared-ui/icon";
import { PromptStackCard } from "@/components/promptbox/banner/PromptStackCard";
import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState";
import { OverflowFade } from "@/components/ui/overflow-fade";
+import { InlineMessageEditorFrame } from "@/components/promptbox/InlineMessageEditorFrame";
import { useBottomAnchoredScroll } from "@/components/ui/bottom-anchored-scroll-body";
import {
Tooltip,
@@ -936,23 +937,13 @@ function QueuedMessageInlineEditorSlot({
className="relative z-10 border-b border-border/35 px-2.5 py-1 last:border-b-0"
>
-
-
-
- Editing queued message {editor.queuedMessageIndex + 1}
-
-
+
{editor.content}
-
+
);
diff --git a/apps/app/src/components/thread/embedded-chat/index.ts b/apps/app/src/components/thread/embedded-chat/index.ts
index 862a3141a5..4a4cca8000 100644
--- a/apps/app/src/components/thread/embedded-chat/index.ts
+++ b/apps/app/src/components/thread/embedded-chat/index.ts
@@ -11,6 +11,10 @@ export {
type InlineQueuedMessageEditState,
} from "./useInlineQueuedMessageEditing";
export { useActiveComposerDraft } from "./useActiveComposerDraft";
-export { useComposerAttachmentUploads } from "./useComposerAttachmentUploads";
+export {
+ useComposerAttachmentUploads,
+ useDraftAttachmentUploads,
+ type DraftAttachmentUploadTarget,
+} from "./useComposerAttachmentUploads";
export { useComposerTypeahead } from "./useComposerTypeahead";
export { useQueuedMessageActions } from "./useQueuedMessageActions";
diff --git a/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.test.tsx b/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.test.tsx
index c3cf62cae4..d1f846740e 100644
--- a/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.test.tsx
+++ b/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.test.tsx
@@ -3,7 +3,11 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { InlineQueuedMessageEditState } from "./useInlineQueuedMessageEditing";
-import { useComposerAttachmentUploads } from "./useComposerAttachmentUploads";
+import type { PromptDraftAttachment } from "@/lib/prompt-draft";
+import {
+ useComposerAttachmentUploads,
+ useDraftAttachmentUploads,
+} from "./useComposerAttachmentUploads";
const mocks = vi.hoisted(() => ({
upload: vi.fn(),
@@ -150,4 +154,56 @@ describe("useComposerAttachmentUploads", () => {
expect(result.current.inlineAttachmentError).toBeNull();
expect(commitInlineQueuedMessage).not.toHaveBeenCalled();
});
+
+ it("does not leak a dismissed upload into a later independent draft", async () => {
+ const oldUpload = deferred();
+ mocks.upload.mockReturnValueOnce(oldUpload.promise);
+ const addFirstAttachment = vi.fn();
+ const addSecondAttachment = vi.fn();
+ const { result, rerender } = renderHook(
+ ({ target }) =>
+ useDraftAttachmentUploads({
+ projectId: "proj_1",
+ target,
+ }),
+ {
+ initialProps: {
+ target: {
+ key: "edit-1",
+ addAttachment: addFirstAttachment,
+ } as {
+ key: string;
+ addAttachment: (attachment: PromptDraftAttachment) => void;
+ } | null,
+ },
+ },
+ );
+
+ let uploadPromise!: Promise;
+ act(() => {
+ uploadPromise = result.current.handleAttachFiles([
+ new File(["old"], "old.txt"),
+ ]);
+ });
+ expect(result.current.isAttachingFiles).toBe(true);
+
+ rerender({ target: null });
+ rerender({
+ target: { key: "edit-2", addAttachment: addSecondAttachment },
+ });
+ await act(async () => {
+ oldUpload.resolve({
+ type: "localFile",
+ path: "uploads/old.txt",
+ name: "old.txt",
+ sizeBytes: 3,
+ });
+ await uploadPromise;
+ });
+
+ expect(addFirstAttachment).not.toHaveBeenCalled();
+ expect(addSecondAttachment).not.toHaveBeenCalled();
+ expect(result.current.attachmentError).toBeNull();
+ expect(result.current.isAttachingFiles).toBe(false);
+ });
});
diff --git a/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.ts b/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.ts
index 0ea642e4bf..174e0a70d3 100644
--- a/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.ts
+++ b/apps/app/src/components/thread/embedded-chat/useComposerAttachmentUploads.ts
@@ -1,4 +1,4 @@
-import { useCallback, useState } from "react";
+import { useCallback, useRef, useState } from "react";
import { useUploadPromptAttachment } from "@/hooks/mutations/project-mutations";
import type { PromptDraftAttachment } from "@/lib/prompt-draft";
import type { InlineQueuedMessageEditState } from "./useInlineQueuedMessageEditing";
@@ -25,97 +25,69 @@ export interface UseComposerAttachmentUploadsResult {
isAttachingInlineFiles: boolean;
}
-interface AttachmentOperationState {
- error: string | null;
- pendingCount: number;
+export interface DraftAttachmentUploadTarget {
+ /** Changes whenever a newly mounted draft must not receive older uploads. */
+ key: string;
+ addAttachment: (attachment: PromptDraftAttachment) => void;
}
-interface InlineAttachmentOperationState extends AttachmentOperationState {
- editSessionId: number | null;
+interface UseDraftAttachmentUploadsArgs {
+ projectId: string;
+ target: DraftAttachmentUploadTarget | null;
}
-/**
- * Uploads dropped/picked files for either independently mounted composer. The
- * inline owner is captured per invocation so a dismissed edit session cannot
- * receive a late upload.
- */
-export function useComposerAttachmentUploads({
+export interface UseDraftAttachmentUploadsResult {
+ attachmentError: string | null;
+ setAttachmentError: (error: string | null) => void;
+ handleAttachFiles: (files: File[]) => Promise;
+ isAttachingFiles: boolean;
+}
+
+interface DraftAttachmentOperationState {
+ error: string | null;
+ pendingCount: number;
+ targetKey: string | null;
+}
+
+/** Upload state for one independently mounted composer draft. */
+export function useDraftAttachmentUploads({
projectId,
- addDraftAttachment,
- inlineEditingQueuedMessage,
- inlineEditingQueuedMessageRef,
- commitInlineQueuedMessage,
-}: UseComposerAttachmentUploadsArgs): UseComposerAttachmentUploadsResult {
+ target,
+}: UseDraftAttachmentUploadsArgs): UseDraftAttachmentUploadsResult {
const uploadPromptAttachment = useUploadPromptAttachment();
- const [bottomOperation, setBottomOperation] =
- useState({ error: null, pendingCount: 0 });
- const [inlineOperation, setInlineOperation] =
- useState({
- editSessionId: null,
- error: null,
- pendingCount: 0,
- });
+ const targetRef = useRef(target);
+ targetRef.current = target;
+ const [operation, setOperation] = useState({
+ error: null,
+ pendingCount: 0,
+ targetKey: null,
+ });
+ const targetKey = target?.key ?? null;
+ const isCurrentOperation = operation.targetKey === targetKey;
- const setBottomAttachmentError = useCallback((error: string | null) => {
- setBottomOperation((current) => ({ ...current, error }));
- }, []);
- const setInlineAttachmentError = useCallback(
+ const setAttachmentError = useCallback(
(error: string | null) => {
- const editSessionId = inlineEditingQueuedMessage?.editSessionId ?? null;
- setInlineOperation((current) => ({
- editSessionId,
+ setOperation((current) => ({
error,
pendingCount:
- current.editSessionId === editSessionId ? current.pendingCount : 0,
+ current.targetKey === targetKey ? current.pendingCount : 0,
+ targetKey,
}));
},
- [inlineEditingQueuedMessage?.editSessionId],
- );
-
- const handleAttachBottomFiles = useCallback(
- async (files: File[]) => {
- if (files.length === 0) return;
- setBottomOperation((current) => ({
- error: null,
- pendingCount: current.pendingCount + 1,
- }));
- const failedFiles: string[] = [];
- try {
- for (const file of files) {
- try {
- const uploaded = await uploadPromptAttachment.mutateAsync({
- projectId,
- file,
- });
- addDraftAttachment(uploaded);
- } catch {
- failedFiles.push(file.name);
- }
- }
- } finally {
- setBottomOperation((current) => ({
- error:
- failedFiles.length > 0
- ? `Failed to attach: ${failedFiles.join(", ")}`
- : current.error,
- pendingCount: Math.max(0, current.pendingCount - 1),
- }));
- }
- },
- [addDraftAttachment, projectId, uploadPromptAttachment],
+ [targetKey],
);
- const handleAttachInlineFiles = useCallback(
+ const handleAttachFiles = useCallback(
async (files: File[]) => {
- if (!inlineEditingQueuedMessage || files.length === 0) return;
- const { editSessionId, ownerThreadId, queuedMessageId } =
- inlineEditingQueuedMessage;
- setInlineOperation((current) => ({
- editSessionId,
+ const activeTarget = targetRef.current;
+ if (!activeTarget || files.length === 0) return;
+ const capturedTargetKey = activeTarget.key;
+ setOperation((current) => ({
error: null,
pendingCount:
- current.editSessionId === editSessionId
+ current.targetKey === capturedTargetKey
? current.pendingCount + 1
: 1,
+ targetKey: capturedTargetKey,
}));
const failedFiles: string[] = [];
try {
@@ -125,69 +97,115 @@ export function useComposerAttachmentUploads({
projectId,
file,
});
- const current = inlineEditingQueuedMessageRef.current;
- if (
- current?.editSessionId === editSessionId &&
- current.ownerThreadId === ownerThreadId &&
- current.queuedMessageId === queuedMessageId &&
- !current.draft.attachments.some(
- (existing) => existing.path === uploaded.path,
- )
- ) {
- commitInlineQueuedMessage({
- ...current,
- draft: {
- ...current.draft,
- attachments: [...current.draft.attachments, uploaded],
- },
- });
+ const currentTarget = targetRef.current;
+ if (currentTarget?.key === capturedTargetKey) {
+ currentTarget.addAttachment(uploaded);
}
} catch {
failedFiles.push(file.name);
}
}
} finally {
- setInlineOperation((current) =>
- current.editSessionId === editSessionId
+ setOperation((current) =>
+ current.targetKey === capturedTargetKey
? {
- editSessionId,
error:
failedFiles.length > 0 &&
- inlineEditingQueuedMessageRef.current?.editSessionId ===
- editSessionId
+ targetRef.current?.key === capturedTargetKey
? `Failed to attach: ${failedFiles.join(", ")}`
: current.error,
pendingCount: Math.max(0, current.pendingCount - 1),
+ targetKey: capturedTargetKey,
}
: current,
);
}
},
+ [projectId, uploadPromptAttachment],
+ );
+
+ return {
+ attachmentError: isCurrentOperation ? operation.error : null,
+ setAttachmentError,
+ handleAttachFiles,
+ isAttachingFiles: isCurrentOperation && operation.pendingCount > 0,
+ };
+}
+
+/**
+ * Uploads dropped/picked files for either independently mounted composer. The
+ * inline owner is captured per invocation so a dismissed edit session cannot
+ * receive a late upload.
+ */
+export function useComposerAttachmentUploads({
+ projectId,
+ addDraftAttachment,
+ inlineEditingQueuedMessage,
+ inlineEditingQueuedMessageRef,
+ commitInlineQueuedMessage,
+}: UseComposerAttachmentUploadsArgs): UseComposerAttachmentUploadsResult {
+ const {
+ attachmentError: bottomAttachmentError,
+ setAttachmentError: setBottomAttachmentError,
+ handleAttachFiles: handleAttachBottomFiles,
+ isAttachingFiles: isAttachingBottomFiles,
+ } = useDraftAttachmentUploads({
+ projectId,
+ target: { key: "bottom", addAttachment: addDraftAttachment },
+ });
+ const inlineEditSessionId = inlineEditingQueuedMessage?.editSessionId ?? null;
+ const addInlineAttachment = useCallback(
+ (uploaded: PromptDraftAttachment) => {
+ const current = inlineEditingQueuedMessageRef.current;
+ if (
+ current === null ||
+ current.editSessionId !== inlineEditSessionId ||
+ current.draft.attachments.some(
+ (existing) => existing.path === uploaded.path,
+ )
+ ) {
+ return;
+ }
+ commitInlineQueuedMessage({
+ ...current,
+ draft: {
+ ...current.draft,
+ attachments: [...current.draft.attachments, uploaded],
+ },
+ });
+ },
[
commitInlineQueuedMessage,
- inlineEditingQueuedMessage,
+ inlineEditSessionId,
inlineEditingQueuedMessageRef,
- projectId,
- uploadPromptAttachment,
],
);
-
- const currentInlineEditSessionId =
- inlineEditingQueuedMessage?.editSessionId ?? null;
- const isCurrentInlineOperation =
- inlineOperation.editSessionId === currentInlineEditSessionId;
+ const {
+ attachmentError: inlineAttachmentError,
+ setAttachmentError: setInlineAttachmentError,
+ handleAttachFiles: handleAttachInlineFiles,
+ isAttachingFiles: isAttachingInlineFiles,
+ } = useDraftAttachmentUploads({
+ projectId,
+ // `editSessionId` is monotonically unique per edit session, so a key match
+ // is a session match.
+ target:
+ inlineEditSessionId !== null
+ ? {
+ key: String(inlineEditSessionId),
+ addAttachment: addInlineAttachment,
+ }
+ : null,
+ });
return {
- bottomAttachmentError: bottomOperation.error,
+ bottomAttachmentError,
setBottomAttachmentError,
handleAttachBottomFiles,
- isAttachingBottomFiles: bottomOperation.pendingCount > 0,
- inlineAttachmentError: isCurrentInlineOperation
- ? inlineOperation.error
- : null,
+ isAttachingBottomFiles,
+ inlineAttachmentError,
setInlineAttachmentError,
handleAttachInlineFiles,
- isAttachingInlineFiles:
- isCurrentInlineOperation && inlineOperation.pendingCount > 0,
+ isAttachingInlineFiles,
};
}
diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx
index 0adec02cf7..2a46af3783 100644
--- a/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx
+++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx
@@ -165,7 +165,11 @@ describe("ConversationMessageContent long user messages", () => {
systemMessageKind="unlabeled"
systemMessageSubject={null}
text={text}
- turnRequest={{ kind: "message", status: "accepted" }}
+ turnRequest={{
+ isGrouped: false,
+ kind: "message",
+ status: "accepted",
+ }}
/>
,
@@ -212,7 +216,11 @@ describe("ConversationMessageContent user thread mentions", () => {
systemMessageKind="unlabeled"
systemMessageSubject={null}
text="Why was @thread:thr_ti4st72wgs not a pill?"
- turnRequest={{ kind: "message", status: "accepted" }}
+ turnRequest={{
+ isGrouped: false,
+ kind: "message",
+ status: "accepted",
+ }}
/>
@@ -258,7 +266,11 @@ describe("ConversationMessageContent user thread mentions", () => {
systemMessageKind="unlabeled"
systemMessageSubject={null}
text="See @thread:thr_cross_project for the result."
- turnRequest={{ kind: "message", status: "accepted" }}
+ turnRequest={{
+ isGrouped: false,
+ kind: "message",
+ status: "accepted",
+ }}
/>
diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx
index 6ae276e3bb..d6ec524358 100644
--- a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx
+++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx
@@ -86,6 +86,7 @@ export interface ConversationMessageContentUserProps extends ConversationMessage
initiator: TimelineUserConversationRow["initiator"];
mentions: readonly PromptTextMention[];
onAddToChat?: ThreadTimelineAddToChatHandler;
+ onEdit?: () => void;
resolveMentionLink?: PromptMentionLinkResolver;
resolveSegmentLinkHref?: TimelineTitleLinkResolver;
onOpenLink?: ThreadTimelineLinkHandler;
@@ -187,6 +188,7 @@ interface UserConversationMessageProps {
mentions: readonly PromptTextMention[];
mobileActionDisplay: "inline" | "overflow";
onAddToChat?: ThreadTimelineAddToChatHandler;
+ onEdit?: () => void;
onOpenLink?: ThreadTimelineLinkHandler;
onOpenLocalFileLink?: ThreadTimelineLocalFileLinkHandler;
projectId?: string;
@@ -376,6 +378,7 @@ function UserConversationMessage({
mentions,
mobileActionDisplay,
onAddToChat,
+ onEdit,
onOpenLink,
onOpenLocalFileLink,
pluginActions = [],
@@ -491,6 +494,7 @@ function UserConversationMessage({
{messageText ||
addToChatAttachments.length > 0 ||
+ onEdit !== undefined ||
pluginActions.length > 0 ? (
@@ -711,6 +716,7 @@ export function ConversationMessageContent(
mentions={props.mentions}
mobileActionDisplay={props.mobileActionDisplay ?? "overflow"}
onAddToChat={props.onAddToChat}
+ onEdit={props.onEdit}
onOpenLink={props.onOpenLink}
onOpenLocalFileLink={onOpenLocalFileLink}
projectId={projectId}
diff --git a/apps/app/src/components/thread/timeline/GeneratedConversationMessage.stories.tsx b/apps/app/src/components/thread/timeline/GeneratedConversationMessage.stories.tsx
index e9183f105f..3c1b593d65 100644
--- a/apps/app/src/components/thread/timeline/GeneratedConversationMessage.stories.tsx
+++ b/apps/app/src/components/thread/timeline/GeneratedConversationMessage.stories.tsx
@@ -31,6 +31,7 @@ function resolveThreadLink(link: TimelineTitleLink): string | null {
}
const acceptedMessage = {
+ isGrouped: false,
kind: "message" as const,
status: "accepted" as const,
};
diff --git a/apps/app/src/components/thread/timeline/MessageActionBar.tsx b/apps/app/src/components/thread/timeline/MessageActionBar.tsx
index 5385989a31..68e087848e 100644
--- a/apps/app/src/components/thread/timeline/MessageActionBar.tsx
+++ b/apps/app/src/components/thread/timeline/MessageActionBar.tsx
@@ -55,6 +55,7 @@ interface MessageActionBarProps {
text: string,
attachments?: readonly PromptDraftAttachment[],
) => void;
+ onEdit?: () => void;
onFork?: () => void;
/**
* Hand this message back to the main thread. Supplied only inside a side chat
@@ -68,7 +69,7 @@ interface MessageActionBarProps {
}
interface MessageOverflowAction {
- icon: "Copy" | "MessageSquarePlus" | "Fork" | "ArrowTurnBackward";
+ icon: "Copy" | "Edit" | "MessageSquarePlus" | "Fork" | "ArrowTurnBackward";
/** Set on plugin-contributed actions; renders PluginActionIcon over `icon`. */
plugin?: { pluginId: string | null; icon: string | null };
/** Render key when `label` may not be unique (plugin actions). */
@@ -210,6 +211,7 @@ export function MessageActionBar({
mobileActionDisplay,
addToChatAttachments = [],
onAddToChat,
+ onEdit,
onFork,
onSendToMain,
disabled,
@@ -254,6 +256,15 @@ export function MessageActionBar({
},
]
: []),
+ ...(onEdit
+ ? [
+ {
+ icon: "Edit" as const,
+ label: "Edit message",
+ onSelect: onEdit,
+ },
+ ]
+ : []),
...(hasAddToChat
? [
{
@@ -295,6 +306,7 @@ export function MessageActionBar({
if (
!hasCopy &&
+ !onEdit &&
!hasAddToChat &&
!onFork &&
!onSendToMain &&
@@ -329,6 +341,30 @@ export function MessageActionBar({
) : null}
+ {onEdit ? (
+
+
+
+
+
+ Edit message
+
+
+ ) : null}
{hasAddToChat ? (
diff --git a/apps/app/src/components/thread/timeline/StreamingRows.stories.tsx b/apps/app/src/components/thread/timeline/StreamingRows.stories.tsx
index 98bde8088c..3191c3ab18 100644
--- a/apps/app/src/components/thread/timeline/StreamingRows.stories.tsx
+++ b/apps/app/src/components/thread/timeline/StreamingRows.stories.tsx
@@ -190,7 +190,7 @@ function buildOptimisticUserRow(id: string): TimelineRow {
text: OPTIMISTIC_USER_PROMPT_TEXT,
mentions: [],
attachments: null,
- turnRequest: { kind: "message", status: "accepted" },
+ turnRequest: { isGrouped: false, kind: "message", status: "accepted" },
};
}
@@ -310,7 +310,7 @@ function conversationRowFromStep(
systemMessageKind: "unlabeled",
systemMessageSubject: null,
mentions: [],
- turnRequest: { kind: "message", status: "accepted" },
+ turnRequest: { isGrouped: false, kind: "message", status: "accepted" },
};
}
return {
@@ -455,7 +455,7 @@ function AssistantContentStreaming({
systemMessageSubject: null,
text: ASSISTANT_STREAMING_USER_PROMPT.text,
attachments: null,
- turnRequest: { kind: "message", status: "accepted" },
+ turnRequest: { isGrouped: false, kind: "message", status: "accepted" },
};
const assistantText = ASSISTANT_STREAMING_CHUNKS.slice(0, step).join("");
const assistantRow: TimelineRow = {
diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx
index 74ea54d44b..e94d2285b6 100644
--- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx
+++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx
@@ -94,6 +94,43 @@ function SameThreadSearchNavigationHarness() {
);
}
+function EditActionAvailabilityHarness({
+ onEditMessage,
+}: {
+ onEditMessage: NonNullable<
+ ComponentProps["onEditMessage"]
+ >;
+}) {
+ const [isIdle, setIsIdle] = useState(false);
+ const [rows] = useState(() => [
+ conversationRow({
+ id: "earlier_user_message",
+ role: "user",
+ text: "An earlier request.",
+ sourceSeqStart: 3,
+ }),
+ conversationRow({
+ id: "latest_user_message",
+ role: "user",
+ text: "The latest request.",
+ sourceSeqStart: 7,
+ }),
+ ]);
+ return (
+ <>
+
+
+ >
+ );
+}
+
function SearchOlderRowsHarness({
onLoadOlderRows,
}: {
@@ -322,6 +359,106 @@ describe("ThreadTimelineRows actions", () => {
).toContain("max-md:pointer-coarse:size-7");
});
+ it("restores edit actions on every cached message after an active turn becomes idle", () => {
+ const onEditMessage = vi.fn();
+ renderWithRouter(
+ ,
+ );
+ expect(
+ screen.queryAllByRole("button", { name: "Edit message" }),
+ ).toHaveLength(0);
+
+ fireEvent.click(screen.getByRole("button", { name: "Complete turn" }));
+
+ const editButtons = screen.getAllByRole("button", {
+ name: "Edit message",
+ });
+ expect(editButtons).toHaveLength(2);
+ fireEvent.click(editButtons[0]!);
+ expect(onEditMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ messageId: "earlier_user_message" }),
+ );
+ });
+
+ it("does not offer edit for a steer request", () => {
+ const markup = toMarkup(
+ ,
+ );
+
+ expect(markup).not.toContain('aria-label="Edit message"');
+ });
+
+ it("does not offer edit for one bubble in a grouped request", () => {
+ const markup = toMarkup(
+ ,
+ );
+
+ expect(markup).not.toContain('aria-label="Edit message"');
+ });
+
+ it("offers edit for an attachment-only request without requiring add-to-chat", () => {
+ const onEditMessage = vi.fn();
+ renderWithRouter(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Edit message" }));
+ expect(onEditMessage).toHaveBeenCalledWith({
+ messageId: expect.any(String),
+ expectedRequestSequence: 11,
+ input: [{ type: "localImage", path: "uploads/screenshot.png" }],
+ });
+ });
+
it("keeps the last real user action footer inline when a remote-image-only row follows", () => {
const { container } = renderWithRouter(
+
+
+ );
+ }
// The narrow, stable message reference plugin actions receive — sourced
// from row fields, never the row object itself.
const messageReference: ThreadChatMessageReference = {
@@ -951,6 +979,36 @@ function ConversationRow({
// anchor (thread-start) row — pass null for every other generated row so a
// later cross-thread agent message in a forked thread keeps its own icon.
const childOrigin = isForkSeedAnchorRow(row) ? threadChildOrigin : null;
+ const canEditMessage =
+ onEditMessage !== undefined &&
+ row.initiator === "user" &&
+ !row.turnRequest.isGrouped &&
+ row.turnRequest.kind === "message" &&
+ row.turnRequest.status === "accepted" &&
+ (row.attachments?.imageUrls.length ?? 0) === 0;
+ const onEdit = canEditMessage
+ ? () => {
+ const input: PromptInput[] = [];
+ if (row.text.trim().length > 0) {
+ input.push({
+ type: "text",
+ text: row.text,
+ mentions: [...row.mentions],
+ });
+ }
+ for (const path of row.attachments?.localImagePaths ?? []) {
+ input.push({ type: "localImage", path });
+ }
+ for (const path of row.attachments?.localFilePaths ?? []) {
+ input.push({ type: "localFile", path });
+ }
+ onEditMessage({
+ messageId: row.id,
+ expectedRequestSequence: row.sourceSeqStart,
+ input,
+ });
+ }
+ : undefined;
return (
findLastActionableUserMessageId(
rows,
- props.onSelectionAddToChat !== undefined,
+ props.onSelectionAddToChat !== undefined ||
+ props.onEditMessage !== undefined,
),
- [props.onSelectionAddToChat, rows],
+ [props.onEditMessage, props.onSelectionAddToChat, rows],
);
const scopeActive = isRunningThreadRuntimeDisplayStatus(
props.threadRuntimeDisplayStatus,
@@ -2018,6 +2078,8 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
canSpawnChild: props.canSpawnChild ?? false,
getViewRows,
onForkMessage: props.onForkMessage,
+ onEditMessage: props.onEditMessage,
+ inlineMessageEditor: props.inlineMessageEditor,
onMessageAddToChat: props.onMessageAddToChat,
onSendToMainMessage: props.onSendToMainMessage,
onSelectionAddToChat: selectionAddToChatHandler,
@@ -2047,6 +2109,8 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
props.canSpawnChild,
getViewRows,
props.onForkMessage,
+ props.onEditMessage,
+ props.inlineMessageEditor,
props.onMessageAddToChat,
props.onSendToMainMessage,
selectionAddToChatHandler,
diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx
index c7f2bf4d81..63a2230eae 100644
--- a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx
+++ b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx
@@ -20,6 +20,8 @@ import type { TimelineTitleActionResolver } from "./TimelineTitleView.js";
import { TimelineWorkingIndicator } from "./TimelineWorkingIndicator.js";
import type {
ThreadTimelineForkMessageHandler,
+ ThreadTimelineEditMessageHandler,
+ ThreadTimelineInlineMessageEditor,
ThreadTimelineAddToChatHandler,
ThreadTimelineSendToMainMessageHandler,
ThreadTimelineConsumerMessageAction,
@@ -46,6 +48,8 @@ export interface ThreadTimelineSurfaceProps {
loadingContent?: ReactNode;
leadingContent?: ReactNode;
onForkMessage?: ThreadTimelineForkMessageHandler;
+ onEditMessage?: ThreadTimelineEditMessageHandler;
+ inlineMessageEditor?: ThreadTimelineInlineMessageEditor;
onMessageAddToChat?: ThreadTimelineAddToChatHandler;
onSendToMainMessage?: ThreadTimelineSendToMainMessageHandler;
onSelectionAddToChat?: ThreadTimelineAddToChatHandler;
@@ -147,6 +151,8 @@ export function ThreadTimelineSurface({
loadingContent,
leadingContent,
onForkMessage,
+ onEditMessage,
+ inlineMessageEditor,
onMessageAddToChat,
onSendToMainMessage,
onSelectionAddToChat,
@@ -218,6 +224,8 @@ export function ThreadTimelineSurface({
canSpawnChild={canSpawnChild}
threadChildOrigin={threadChildOrigin}
onForkMessage={onForkMessage}
+ onEditMessage={onEditMessage}
+ inlineMessageEditor={inlineMessageEditor}
onMessageAddToChat={onMessageAddToChat}
onSendToMainMessage={onSendToMainMessage}
onSelectionAddToChat={onSelectionAddToChat}
diff --git a/apps/app/src/components/thread/timeline/UserMessageMarkdown.stories.tsx b/apps/app/src/components/thread/timeline/UserMessageMarkdown.stories.tsx
index 8df0140087..17e9e00e4c 100644
--- a/apps/app/src/components/thread/timeline/UserMessageMarkdown.stories.tsx
+++ b/apps/app/src/components/thread/timeline/UserMessageMarkdown.stories.tsx
@@ -44,6 +44,7 @@ function resolveThreadLink(link: TimelineTitleLink): string | null {
const resolveMentionLink = () => () => {};
const acceptedMessage: TimelineConversationTurnRequest = {
+ isGrouped: false,
kind: "message",
status: "accepted",
};
diff --git a/apps/app/src/components/thread/timeline/index.ts b/apps/app/src/components/thread/timeline/index.ts
index d5837c6e1d..383c6ae29b 100644
--- a/apps/app/src/components/thread/timeline/index.ts
+++ b/apps/app/src/components/thread/timeline/index.ts
@@ -30,6 +30,9 @@ export {
type ThreadContextWindowIndicatorProps,
} from "./ThreadContextWindowIndicator.js";
export type {
+ ThreadTimelineEditMessageHandler,
+ ThreadTimelineEditMessageTarget,
+ ThreadTimelineInlineMessageEditor,
ThreadTimelineForkMessageHandler,
ThreadTimelineAddToChatHandler,
ThreadTimelineSendToMainMessageHandler,
diff --git a/apps/app/src/components/thread/timeline/rows/UnreadDivider.stories.tsx b/apps/app/src/components/thread/timeline/rows/UnreadDivider.stories.tsx
index 12288bdf57..1f9c24988d 100644
--- a/apps/app/src/components/thread/timeline/rows/UnreadDivider.stories.tsx
+++ b/apps/app/src/components/thread/timeline/rows/UnreadDivider.stories.tsx
@@ -50,7 +50,7 @@ function userRow(args: {
text: args.text,
mentions: [],
attachments: null,
- turnRequest: { kind: "message", status: "accepted" },
+ turnRequest: { isGrouped: false, kind: "message", status: "accepted" },
};
}
diff --git a/apps/app/src/components/thread/timeline/rows/UserMessage.stories.tsx b/apps/app/src/components/thread/timeline/rows/UserMessage.stories.tsx
index c7ce382f47..37a4ba9b2f 100644
--- a/apps/app/src/components/thread/timeline/rows/UserMessage.stories.tsx
+++ b/apps/app/src/components/thread/timeline/rows/UserMessage.stories.tsx
@@ -49,11 +49,20 @@ function resolveThreadLink(link: TimelineTitleLink): string | null {
}
const acceptedMessage = {
+ isGrouped: false,
kind: "message" as const,
status: "accepted" as const,
};
-const pendingSteer = { kind: "steer" as const, status: "pending" as const };
-const acceptedSteer = { kind: "steer" as const, status: "accepted" as const };
+const pendingSteer = {
+ isGrouped: false,
+ kind: "steer" as const,
+ status: "pending" as const,
+};
+const acceptedSteer = {
+ isGrouped: false,
+ kind: "steer" as const,
+ status: "accepted" as const,
+};
interface StoryMentionArgs {
resource: PromptMentionResource;
diff --git a/apps/app/src/components/thread/timeline/timeline-auto-expand.test.ts b/apps/app/src/components/thread/timeline/timeline-auto-expand.test.ts
index 1cc35fccd6..3bb84dc6c7 100644
--- a/apps/app/src/components/thread/timeline/timeline-auto-expand.test.ts
+++ b/apps/app/src/components/thread/timeline/timeline-auto-expand.test.ts
@@ -159,7 +159,7 @@ describe("collectTimelineAutoExpansionRowIds", () => {
role: "user",
text: "please keep going",
sourceSeqStart: 2,
- turnRequest: { kind: "message", status: "accepted" },
+ turnRequest: { isGrouped: false, kind: "message", status: "accepted" },
}),
]);
@@ -397,7 +397,7 @@ describe("collectTimelineAutoExpansionRowIds", () => {
id: "pending-steer-1",
role: "user",
text: "Keep this in mind",
- turnRequest: { kind: "steer", status: "pending" },
+ turnRequest: { isGrouped: false, kind: "steer", status: "pending" },
}),
]);
diff --git a/apps/app/src/components/thread/timeline/types.ts b/apps/app/src/components/thread/timeline/types.ts
index a46487b64d..3ca756e652 100644
--- a/apps/app/src/components/thread/timeline/types.ts
+++ b/apps/app/src/components/thread/timeline/types.ts
@@ -1,4 +1,5 @@
import type { ThreadChatMessageReference } from "@bb/plugin-sdk";
+import type { PromptInput } from "@bb/domain";
import type {
MarkdownPreviewLocalFileLink,
MarkdownPreviewLocalFileLinkHandler,
@@ -33,6 +34,30 @@ export type ThreadTimelineForkMessageHandler = (
target: ThreadTimelineForkMessageTarget,
) => void;
+export interface ThreadTimelineEditMessageTarget {
+ /** Stable id of the specific rendered user bubble being edited. */
+ messageId: string;
+ /** Event sequence of the user request the edit would replace. */
+ expectedRequestSequence: number;
+ /** User-visible input reconstructed from the unchanged timeline row. */
+ input: PromptInput[];
+}
+
+/**
+ * Start a client-local edit session for an eligible user request.
+ * Supplying this handler only enables the affordance; the row never mutates
+ * thread or provider state itself.
+ */
+export type ThreadTimelineEditMessageHandler = (
+ target: ThreadTimelineEditMessageTarget,
+) => void;
+
+/** Client-local editor mounted in place of one user conversation row. */
+export interface ThreadTimelineInlineMessageEditor {
+ messageId: string;
+ onHostElementChange: (element: HTMLDivElement | null) => void;
+}
+
export interface ThreadTimelineSendToMainMessageTarget {
/** Visible text of the side-chat agent message to hand back to the main thread. */
messageText: string;
diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx
index 437c0c3c08..fee5218556 100644
--- a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx
+++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx
@@ -78,7 +78,7 @@ function makeUserRow(
senderThreadId: null,
systemMessageKind: "unlabeled",
systemMessageSubject: null,
- turnRequest: { kind: "message", status: "accepted" },
+ turnRequest: { isGrouped: false, kind: "message", status: "accepted" },
};
}
diff --git a/apps/app/src/components/thread/toc/ThreadTableOfContents.stories.tsx b/apps/app/src/components/thread/toc/ThreadTableOfContents.stories.tsx
index 11b00a9091..e0a2a209b2 100644
--- a/apps/app/src/components/thread/toc/ThreadTableOfContents.stories.tsx
+++ b/apps/app/src/components/thread/toc/ThreadTableOfContents.stories.tsx
@@ -46,6 +46,7 @@ function conversationRow({
systemMessageKind: "unlabeled",
systemMessageSubject: null,
turnRequest: {
+ isGrouped: false,
kind: "message",
status: "accepted",
},
diff --git a/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx b/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx
index 9e9c6e0711..269a2f2435 100644
--- a/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx
+++ b/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx
@@ -67,6 +67,7 @@ function userConversationRow(index = 1): TimelineRow {
systemMessageKind: "unlabeled",
systemMessageSubject: null,
turnRequest: {
+ isGrouped: false,
kind: "message",
status: "accepted",
},
diff --git a/apps/app/src/components/ui/markdown-thread-mentions.test.tsx b/apps/app/src/components/ui/markdown-thread-mentions.test.tsx
index a5ee745ad1..753562a256 100644
--- a/apps/app/src/components/ui/markdown-thread-mentions.test.tsx
+++ b/apps/app/src/components/ui/markdown-thread-mentions.test.tsx
@@ -66,6 +66,7 @@ function threadResponse(
displayStatus: "idle",
hostReconnectGraceExpiresAt: null,
},
+ activeBackgroundAgentCount: 0,
canSpawnChild: true,
...overrides,
};
diff --git a/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts b/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts
index 832d5192d4..37b113f98e 100644
--- a/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts
+++ b/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts
@@ -212,6 +212,23 @@ export function invalidateThreadAcceptedMessageQueriesWithoutRealtime({
});
}
+export function invalidateThreadHistoryRewriteQueries({
+ queryClient,
+ threadId,
+}: ThreadArg): void {
+ invalidateThreadAcceptedMessageQueriesWithoutRealtime({
+ queryClient,
+ threadId,
+ });
+ queryClient.invalidateQueries({ queryKey: threadSearchQueryKeyPrefix() });
+ invalidateQueryKeys({
+ queryClient,
+ queryKeys: getProjectPromptHistoryInvalidationQueryKeys({
+ projectId: undefined,
+ }),
+ });
+}
+
export function invalidateThreadStopQueries({
queryClient,
threadId,
diff --git a/apps/app/src/hooks/cache-owners/query-cache.ts b/apps/app/src/hooks/cache-owners/query-cache.ts
index a946b5c8a9..487b583abd 100644
--- a/apps/app/src/hooks/cache-owners/query-cache.ts
+++ b/apps/app/src/hooks/cache-owners/query-cache.ts
@@ -462,7 +462,7 @@ export function getEnvironmentActionInvalidationQueryKeys({
export function getCachedThreadListPlaceholder(
queryClient: QueryClient,
threadId: string,
-): ThreadWithRuntime | undefined {
+): ThreadListEntry | undefined {
if (!threadId) {
return undefined;
}
diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
index 3759688ea5..8613294601 100644
--- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
+++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
@@ -269,6 +269,18 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = {
dirtyThreadPromptHistoryQueriesForTurnRequests, // Follow-up recall is built from client turn requests.
],
},
+ "history-rewritten": {
+ flush: "immediate",
+ dirty: [
+ dirtyThreadListQueries,
+ dirtyThreadDetailQueries,
+ dirtyThreadSearchQueries,
+ dirtyThreadTimelineRewriteQueries,
+ dirtyThreadQueueContentQueries,
+ dirtyProjectPromptHistoryQueries,
+ dirtyThreadPendingInteractionQueries,
+ ],
+ },
"interactions-changed": {
flush: "debounced",
dirty: [
@@ -673,6 +685,12 @@ function dirtyThreadTimelineQueries({
});
}
+function dirtyThreadTimelineRewriteQueries({
+ threadId,
+}: ThreadRealtimeDirtyContext): QueryKey[] {
+ return getThreadTimelineInvalidationQueryKeys({ threadId });
+}
+
function dirtyThreadQueueContentQueries({
threadId,
}: ThreadRealtimeDirtyContext): QueryKey[] {
diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts
index 7c45a5bb71..536a7cb6cb 100644
--- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts
+++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts
@@ -618,6 +618,7 @@ function buildOptimisticUserMessageRow({
systemMessageKind: "unlabeled",
systemMessageSubject: null,
turnRequest: {
+ isGrouped: false,
kind: optimisticTurnRequestKind({ mode, threadStatus }),
status: "pending",
},
diff --git a/apps/app/src/hooks/mutations/mutation-request-types.ts b/apps/app/src/hooks/mutations/mutation-request-types.ts
index 8fe79a4eea..733687338a 100644
--- a/apps/app/src/hooks/mutations/mutation-request-types.ts
+++ b/apps/app/src/hooks/mutations/mutation-request-types.ts
@@ -1,5 +1,6 @@
import type {
EnvironmentActionRequest,
+ EditMessageRequest,
SendMessageRequest,
} from "@bb/server-contract";
@@ -10,3 +11,7 @@ export type RequestEnvironmentActionMutationRequest = {
export interface SendThreadMessageMutationRequest extends SendMessageRequest {
id: string;
}
+
+export interface EditMessageMutationRequest extends EditMessageRequest {
+ id: string;
+}
diff --git a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx
index 2935f16d54..a92f196569 100644
--- a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx
+++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx
@@ -8,6 +8,7 @@ import type {
} from "@bb/server-contract";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BbHttpError, sdk } from "@/lib/sdk";
+import { wsManager } from "@/lib/ws";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import {
threadQueuedMessagesQueryKey,
@@ -18,6 +19,7 @@ import {
useClearThreadGoal,
useCreateThreadQueuedMessage,
useDeleteThreadQueuedMessage,
+ useEditThreadMessage,
useSetThreadQueuedMessageGroupBoundary,
useSendThreadMessage,
} from "./thread-runtime-mutations";
@@ -30,6 +32,7 @@ vi.mock("@/lib/sdk", async (importOriginal) => {
threads: {
cancelPlan: vi.fn(),
clearGoal: vi.fn(),
+ editMessage: vi.fn(),
queuedMessages: {
create: vi.fn(),
delete: vi.fn(),
@@ -113,8 +116,14 @@ const executionInputSources = {
} satisfies ExistingThreadExecutionInputSources;
beforeEach(() => {
+ vi.mocked(wsManager.getConnectionState).mockReturnValue("connected");
vi.mocked(sdk.threads.cancelPlan).mockResolvedValue({ ok: true });
vi.mocked(sdk.threads.clearGoal).mockResolvedValue({ ok: true });
+ vi.mocked(sdk.threads.editMessage).mockResolvedValue({
+ ok: true,
+ operationId: "edit-op-1",
+ requestSequence: 42,
+ });
vi.mocked(sdk.threads.send).mockResolvedValue({ ok: true });
vi.mocked(sdk.threads.queuedMessages.create).mockResolvedValue(
makeQueuedMessage(),
@@ -129,6 +138,67 @@ afterEach(() => {
});
describe("thread runtime mutations", () => {
+ it("keeps the existing timeline while an edit is pending and lets connected realtime own success", async () => {
+ const { queryClient, wrapper } = createQueryClientTestHarness();
+ const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
+ const edit = deferred<{
+ ok: true;
+ operationId: string;
+ requestSequence: number;
+ }>();
+ vi.mocked(sdk.threads.editMessage).mockReturnValueOnce(edit.promise);
+ const timeline = makeBannerTimeline();
+ queryClient.setQueryData(threadTimelineQueryKey("thread-1"), timeline);
+ const { result } = renderHook(() => useEditThreadMessage(), {
+ wrapper,
+ });
+
+ let editPromise!: Promise;
+ act(() => {
+ editPromise = result.current.mutateAsync({
+ id: "thread-1",
+ operationId: "edit-op-1",
+ expectedRequestSequence: 41,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ });
+ });
+ await waitFor(() => expect(sdk.threads.editMessage).toHaveBeenCalledOnce());
+ expect(queryClient.getQueryData(threadTimelineQueryKey("thread-1"))).toBe(
+ timeline,
+ );
+ expect(invalidateQueries).not.toHaveBeenCalled();
+
+ edit.resolve({
+ ok: true,
+ operationId: "edit-op-1",
+ requestSequence: 42,
+ });
+ await act(async () => {
+ await editPromise;
+ });
+ expect(invalidateQueries).not.toHaveBeenCalled();
+ });
+
+ it("invalidates rewritten history after edit success when realtime is disconnected", async () => {
+ vi.mocked(wsManager.getConnectionState).mockReturnValue("reconnecting");
+ const { queryClient, wrapper } = createQueryClientTestHarness();
+ const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
+ const { result } = renderHook(() => useEditThreadMessage(), { wrapper });
+
+ await act(async () => {
+ await result.current.mutateAsync({
+ id: "thread-1",
+ operationId: "edit-op-disconnected",
+ expectedRequestSequence: 41,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ });
+ });
+
+ expect(invalidateQueries).toHaveBeenCalledWith(
+ expect.objectContaining({ queryKey: threadTimelineQueryKey("thread-1") }),
+ );
+ });
+
it.each([
["Plan", useCancelThreadPlan, () => sdk.threads.cancelPlan],
["Goal", useClearThreadGoal, () => sdk.threads.clearGoal],
diff --git a/apps/app/src/hooks/mutations/thread-runtime-mutations.ts b/apps/app/src/hooks/mutations/thread-runtime-mutations.ts
index 947dbad54d..f25991b35d 100644
--- a/apps/app/src/hooks/mutations/thread-runtime-mutations.ts
+++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.ts
@@ -11,7 +11,10 @@ import type {
import { BbHttpError, sdk } from "@/lib/sdk";
import { wsManager } from "@/lib/ws";
import type { QueuedMessageReorderRequest } from "@/lib/queued-message-reorder";
-import type { SendThreadMessageMutationRequest } from "./mutation-request-types";
+import type {
+ EditMessageMutationRequest,
+ SendThreadMessageMutationRequest,
+} from "./mutation-request-types";
import {
applyCreateThreadResult,
applyQueuedMessageCreateResult,
@@ -46,7 +49,10 @@ import {
type StopThreadTransaction,
type UpdateQueuedMessageTransaction,
} from "../cache-owners/thread-runtime-cache-owner";
-import { invalidateThreadBannerQueries } from "../cache-owners/mutation-cache-effects";
+import {
+ invalidateThreadBannerQueries,
+ invalidateThreadHistoryRewriteQueries,
+} from "../cache-owners/mutation-cache-effects";
interface CreateThreadQueuedMessageMutationRequest extends CreateQueuedMessageRequest {
id: string;
@@ -209,6 +215,28 @@ export function useSendThreadMessage() {
});
}
+export function useEditThreadMessage() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ meta: {
+ errorMessage: "Failed to edit the message.",
+ lifecycleOperation: "edit_message",
+ showErrorToast: false,
+ },
+ mutationFn: ({ id, ...request }: EditMessageMutationRequest) =>
+ sdk.threads.editMessage({ threadId: id, ...request }),
+ onSuccess: (_result, variables) => {
+ if (wsManager.getConnectionState() === "connected") {
+ return;
+ }
+ invalidateThreadHistoryRewriteQueries({
+ queryClient,
+ threadId: variables.id,
+ });
+ },
+ });
+}
+
export function useCreateThreadQueuedMessage() {
const queryClient = useQueryClient();
diff --git a/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx
index 2be5cd704b..ab40eb264c 100644
--- a/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx
+++ b/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx
@@ -61,6 +61,7 @@ function makeThreadResponse(
): ThreadResponse {
return {
...makeThreadWithRuntime(thread),
+ activeBackgroundAgentCount: 0,
canSpawnChild: true,
...thread,
};
diff --git a/apps/app/src/hooks/queries/query-helpers.test.ts b/apps/app/src/hooks/queries/query-helpers.test.ts
index 0521a43330..d4dfbbea3b 100644
--- a/apps/app/src/hooks/queries/query-helpers.test.ts
+++ b/apps/app/src/hooks/queries/query-helpers.test.ts
@@ -246,6 +246,7 @@ describe("resolveThreadPlaceholder", () => {
it("reuses previous thread data only for the same thread query", () => {
const previousThread: ThreadResponse = {
...makeThreadWithRuntime({ id: "thread-1" }),
+ activeBackgroundAgentCount: 0,
canSpawnChild: false,
};
diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx
index 85b1b7673c..615f2b5db0 100644
--- a/apps/app/src/hooks/queries/thread-queries.test.tsx
+++ b/apps/app/src/hooks/queries/thread-queries.test.tsx
@@ -76,6 +76,7 @@ const THREAD_WITH_INCLUDES = {
displayStatus: "idle",
hostReconnectGraceExpiresAt: null,
},
+ activeBackgroundAgentCount: 0,
canSpawnChild: true,
environment: null,
host: null,
diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts
index 84e60bdbad..183397b37f 100644
--- a/apps/app/src/hooks/queries/thread-queries.ts
+++ b/apps/app/src/hooks/queries/thread-queries.ts
@@ -6,7 +6,7 @@ import {
} from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useDebounceValue } from "usehooks-ts";
-import type { PendingInteraction, ThreadWithRuntime } from "@bb/domain";
+import type { PendingInteraction, ThreadListEntry } from "@bb/domain";
import type {
PromptHistoryResponse,
ThreadQueuedMessageListResponse,
@@ -542,12 +542,16 @@ export function useThread(id: string, options?: QueryOptions) {
// placeholder; the real single-thread response, which carries the server-
// computed value, resolves moments later.
function liftThreadListPlaceholder(
- thread: ThreadWithRuntime | undefined,
+ thread: ThreadListEntry | undefined,
): ThreadResponse | undefined {
if (thread === undefined) {
return undefined;
}
- return { ...thread, canSpawnChild: false };
+ return {
+ ...thread,
+ activeBackgroundAgentCount: thread.activity.activeBackgroundAgentCount,
+ canSpawnChild: false,
+ };
}
export function useThreadDetailBootstrap(
diff --git a/apps/app/src/lib/lifecycle-errors.ts b/apps/app/src/lib/lifecycle-errors.ts
index 23e8911ce6..f843a3a2cc 100644
--- a/apps/app/src/lib/lifecycle-errors.ts
+++ b/apps/app/src/lib/lifecycle-errors.ts
@@ -11,6 +11,7 @@ export type LifecycleErrorOperation =
| "archive_thread"
| "commit"
| "create_thread"
+ | "edit_message"
| "load_diff"
| "load_git_status"
| "load_thread_storage"
@@ -88,6 +89,8 @@ function operationTitle(operation: LifecycleErrorOperation): string {
return "Commit failed";
case "create_thread":
return "Failed to create thread";
+ case "edit_message":
+ return "Failed to edit message";
case "load_diff":
return "Failed to load diff";
case "load_git_status":
diff --git a/apps/app/src/lib/mutation-errors.ts b/apps/app/src/lib/mutation-errors.ts
index 256e156e95..9f8a135f4b 100644
--- a/apps/app/src/lib/mutation-errors.ts
+++ b/apps/app/src/lib/mutation-errors.ts
@@ -82,6 +82,7 @@ function toLifecycleErrorOperation(
case "archive_thread":
case "commit":
case "create_thread":
+ case "edit_message":
case "load_diff":
case "load_git_status":
case "load_thread_storage":
diff --git a/apps/app/src/lib/system-config-atoms.ts b/apps/app/src/lib/system-config-atoms.ts
index b62795a708..2d3f59910b 100644
--- a/apps/app/src/lib/system-config-atoms.ts
+++ b/apps/app/src/lib/system-config-atoms.ts
@@ -15,6 +15,7 @@ const unavailableSystemConfig: SystemConfigResponse = {
keybindingOverrides: [],
experiments: {
claudeCodeMockCliTraffic: false,
+ editMessages: false,
newOnboarding: false,
toolsHub: false,
},
diff --git a/apps/app/src/test/fixtures/thread-timeline-rows.ts b/apps/app/src/test/fixtures/thread-timeline-rows.ts
index aba0176f6e..f97e13b982 100644
--- a/apps/app/src/test/fixtures/thread-timeline-rows.ts
+++ b/apps/app/src/test/fixtures/thread-timeline-rows.ts
@@ -446,7 +446,11 @@ export function conversationRow({
: null,
systemMessageKind,
systemMessageSubject,
- turnRequest: turnRequest ?? { kind: "message", status: "accepted" },
+ turnRequest: turnRequest ?? {
+ isGrouped: false,
+ kind: "message",
+ status: "accepted",
+ },
};
}
return {
diff --git a/apps/app/src/views/SettingsView.experiments.test.tsx b/apps/app/src/views/SettingsView.experiments.test.tsx
index 75abe83d7c..ff2d895508 100644
--- a/apps/app/src/views/SettingsView.experiments.test.tsx
+++ b/apps/app/src/views/SettingsView.experiments.test.tsx
@@ -13,8 +13,10 @@ function renderSection(overrides?: {
state.setExperiments((current) => ({
@@ -348,6 +349,12 @@ function ExperimentsStory() {
claudeCodeMockCliTraffic: enabled,
}))
}
+ onEditMessagesEnabledChange={(enabled) =>
+ state.setExperiments((current) => ({
+ ...current,
+ editMessages: enabled,
+ }))
+ }
onNewOnboardingEnabledChange={(enabled) =>
state.setExperiments((current) => ({
...current,
diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx
index 76691dc212..a3704fb25f 100644
--- a/apps/app/src/views/SettingsView.tsx
+++ b/apps/app/src/views/SettingsView.tsx
@@ -211,8 +211,10 @@ export interface ExperimentsSettingsSectionProps {
/** True while the config query hasn't loaded or a toggle write is in flight. */
disabled: boolean;
claudeCodeMockCliTrafficEnabled: boolean;
+ editMessagesEnabled: boolean;
newOnboardingEnabled: boolean;
onClaudeCodeMockCliTrafficEnabledChange: (enabled: boolean) => void;
+ onEditMessagesEnabledChange: (enabled: boolean) => void;
onNewOnboardingEnabledChange: (enabled: boolean) => void;
onToolsHubEnabledChange: (enabled: boolean) => void;
toolsHubEnabled: boolean;
@@ -953,13 +955,16 @@ export function ProviderSettingsSection({
}
const CLAUDE_CODE_MOCK_CLI_TRAFFIC_EXPERIMENT_LABEL = "Mock CLI Traffic";
+const EDIT_MESSAGES_EXPERIMENT_LABEL = "Edit messages";
const NEW_ONBOARDING_EXPERIMENT_LABEL = "New onboarding";
const EXTENSIONS_EXPERIMENT_LABEL = "Extensions";
export function ExperimentsSettingsSection({
claudeCodeMockCliTrafficEnabled,
disabled,
+ editMessagesEnabled,
newOnboardingEnabled,
onClaudeCodeMockCliTrafficEnabledChange,
+ onEditMessagesEnabledChange,
onNewOnboardingEnabledChange,
onToolsHubEnabledChange,
toolsHubEnabled,
@@ -983,6 +988,18 @@ export function ExperimentsSettingsSection({
/>
+
+
+
+
+ updateExperimentsMutation.mutate({
+ ...experiments,
+ editMessages: enabled,
+ })
+ }
newOnboardingEnabled={experiments.newOnboarding}
onNewOnboardingEnabledChange={(enabled) =>
updateExperimentsMutation.mutate({
diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx
index 99baed17d9..7b55bdd18e 100644
--- a/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx
+++ b/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx
@@ -44,6 +44,7 @@ function storyThread(id: string, title: string): ThreadResponse {
displayStatus: "idle",
hostReconnectGraceExpiresAt: null,
},
+ activeBackgroundAgentCount: 0,
canSpawnChild: false,
};
}
diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx
index 22dbdfc904..c75f4ca381 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx
@@ -30,7 +30,10 @@ import {
resetPluginSlotStoreForTest,
setPluginSlotRegistrations,
} from "@/lib/plugin-slots";
-import { ThreadDetailPromptArea } from "./ThreadDetailPromptArea";
+import {
+ ThreadDetailPromptArea,
+ type ThreadDetailSentMessageEdit,
+} from "./ThreadDetailPromptArea";
const mocks = vi.hoisted(() => ({
cancelThreadPlanMutate: vi.fn(),
@@ -87,6 +90,7 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({
pluginComposerHost,
showScrollToBottomButton,
stack,
+ suppressPluginComposerCustomizations,
textEffects,
}: {
attachments: {
@@ -97,6 +101,7 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({
message: string;
onChangeMessage: (message: string, mentions: []) => void;
onSubmit: () => void;
+ submitTitle?: string;
submitMode: { kind: string; reason?: string };
} | null;
execution: {
@@ -116,6 +121,7 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({
pluginComposerHost?: PluginComposerHost | null;
showScrollToBottomButton?: boolean;
stack: ReactNode;
+ suppressPluginComposerCustomizations?: boolean;
textEffects?: readonly {
effect: { className: string };
}[];
@@ -126,6 +132,10 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", () => ({
{composer?.submitMode.kind}:{composer?.submitMode.reason ?? ""}
+ {composer?.submitTitle ?? "Submit"}
+
+ {suppressPluginComposerCustomizations ? "true" : "false"}
+
{execution.model.active?.model}
{execution.reasoning.value}
@@ -626,6 +636,7 @@ interface RenderPromptAreaOptions {
modelFallback?: ThreadTimelineModelFallback | null;
pendingInteractions?: readonly PendingInteraction[];
pendingInteractionsInitialLoading?: boolean;
+ sentMessageEdit?: ThreadDetailSentMessageEdit;
thread?: ThreadWithRuntime;
}
@@ -636,10 +647,12 @@ function buildPromptAreaElement({
modelFallback = null,
pendingInteractions = [],
pendingInteractionsInitialLoading = false,
+ sentMessageEdit,
thread = makeThread(),
}: RenderPromptAreaOptions = {}) {
return (
{
afterEach(() => {
cleanup();
+ document
+ .querySelectorAll("[data-sent-message-editor-test-host]")
+ .forEach((element) => element.remove());
resetPluginSlotStoreForTest();
vi.clearAllMocks();
});
describe("ThreadDetailPromptArea", () => {
+ it("keeps sent-message edit submission out of the normal send path", () => {
+ mocks.defaultExecutionOptions = {
+ model: "gpt-5",
+ permissionMode: "auto",
+ reasoningLevel: "medium",
+ serviceTier: "default",
+ source: "client/turn/requested",
+ };
+ mocks.promptDraft.text = "Untouched follow-up draft";
+ const onSubmit = vi.fn();
+ const onCancel = vi.fn();
+ const updateDraft = vi.fn();
+ const hostElement = document.createElement("div");
+ hostElement.dataset.sentMessageEditorTestHost = "";
+ document.body.append(hostElement);
+
+ renderPromptArea({
+ sentMessageEdit: {
+ draft: {
+ text: "Edited request",
+ mentions: [],
+ attachments: [],
+ },
+ hostElement,
+ isSubmitting: false,
+ operationId: "edit-operation-1",
+ onCancel,
+ onSubmit,
+ updateDraft,
+ },
+ });
+
+ const inlineEditor = within(hostElement);
+ const editingLabel = inlineEditor.getByText("Editing message");
+ const editingFrame = editingLabel.closest(
+ '[data-inline-message-editor-frame="cap"]',
+ );
+ expect(editingFrame).not.toBeNull();
+ expect(editingLabel.closest("section")?.className).toContain("-mb-5");
+ expect(editingLabel.closest("section")?.className).toContain(
+ "rounded-b-none",
+ );
+ expect(inlineEditor.getByTestId("submit-title").textContent).toBe(
+ "Submit edit (Enter)",
+ );
+ expect(
+ inlineEditor.getByTestId("plugin-customizations-suppressed").textContent,
+ ).toBe("true");
+ expect(
+ (
+ inlineEditor.getByRole("textbox", {
+ name: "Composer message",
+ }) as HTMLInputElement
+ ).value,
+ ).toBe("Edited request");
+ const bottomComposer = screen
+ .getAllByTestId("follow-up-prompt-box")
+ .find((element) => !hostElement.contains(element));
+ expect(bottomComposer).toBeDefined();
+ expect(
+ (
+ within(bottomComposer!).getByRole("textbox", {
+ name: "Composer message",
+ }) as HTMLInputElement
+ ).value,
+ ).toBe("Untouched follow-up draft");
+ fireEvent.click(
+ inlineEditor.getByRole("button", {
+ name: "Simulate plugin replacement",
+ }),
+ );
+ expect(updateDraft).toHaveBeenCalledTimes(1);
+ expect(mocks.promptDraft.setDraft).not.toHaveBeenCalled();
+
+ fireEvent.click(
+ inlineEditor.getByRole("button", { name: "Submit composer" }),
+ );
+
+ expect(onSubmit).toHaveBeenCalledWith({
+ execution: {
+ model: "gpt-5",
+ permissionMode: "auto",
+ reasoningLevel: "medium",
+ serviceTier: undefined,
+ supportsServiceTier: false,
+ executionInputSources: {},
+ },
+ input: [{ type: "text", text: "Edited request", mentions: [] }],
+ });
+ expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled();
+ expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled();
+
+ fireEvent.click(
+ inlineEditor.getByRole("button", {
+ name: "Stop editing sent message",
+ }),
+ );
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it("blocks a staged sent-message edit when the thread becomes ineligible", () => {
+ mocks.defaultExecutionOptions = {
+ model: "gpt-5",
+ permissionMode: "auto",
+ reasoningLevel: "medium",
+ serviceTier: "default",
+ source: "client/turn/requested",
+ };
+ mocks.queuedMessages = [makeQueuedMessage()];
+ const hostElement = document.createElement("div");
+ hostElement.dataset.sentMessageEditorTestHost = "";
+ document.body.append(hostElement);
+ const onSubmit = vi.fn();
+
+ renderPromptArea({
+ sentMessageEdit: {
+ draft: { text: "Edited request", mentions: [], attachments: [] },
+ hostElement,
+ isSubmitting: false,
+ operationId: "edit-operation-1",
+ onCancel: vi.fn(),
+ onSubmit,
+ updateDraft: vi.fn(),
+ },
+ });
+
+ const inlineEditor = within(hostElement);
+ expect(inlineEditor.getByTestId("submit-mode").textContent).toBe(
+ "blocked:unavailable",
+ );
+ fireEvent.click(
+ inlineEditor.getByRole("button", { name: "Submit composer" }),
+ );
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
it("keeps the queued drawer adjacent to the bottom composer", () => {
mocks.queuedMessages = [makeQueuedMessage()];
diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
index d531629ecb..173ccc3f3d 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
import { useNavigate } from "react-router-dom";
import type { IconName } from "@bb/shared-ui/icon";
import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link";
@@ -10,6 +11,7 @@ import { isPluginPendingInteraction, PERSONAL_PROJECT_ID } from "@bb/domain";
import type {
EnvironmentStatus,
PendingInteraction,
+ PromptInput,
ThreadQueuedMessage,
ThreadPullRequest,
ThreadTimelineActivePromptMode,
@@ -45,6 +47,7 @@ import { ThreadPromptModeCard } from "@/components/promptbox/banner/ThreadPrompt
import { ThreadWorkflowCard } from "@/components/promptbox/banner/ThreadWorkflowCard";
import { ThreadBackgroundCommandsCard } from "@/components/promptbox/banner/ThreadBackgroundCommandsCard";
import { ThreadModelFallbackCard } from "@/components/promptbox/banner/ThreadModelFallbackCard";
+import { InlineMessageEditorFrame } from "@/components/promptbox/InlineMessageEditorFrame";
import type {
WorkspaceChangedFileSelection,
WorkspaceChangedFilesSection,
@@ -62,6 +65,7 @@ import { useProjectDisplayName } from "@/hooks/queries/sidebar-navigation-query"
import {
useActiveComposerDraft,
useComposerAttachmentUploads,
+ useDraftAttachmentUploads,
useComposerTypeahead,
useInlineQueuedMessageEditing,
useQueuedMessageActions,
@@ -86,9 +90,15 @@ import { getProjectComposeRoutePath } from "@/lib/route-paths";
import { getThreadDisplayTitle } from "@/lib/thread-title";
import { buildThreadHandoffLocationState } from "@/lib/thread-handoff-request";
import { appToast } from "@/components/ui/app-toast";
+import {
+ promptDraftToInput,
+ type PromptDraftAttachment,
+ type PromptDraftState,
+} from "@/lib/prompt-draft";
import {
FollowUpPromptBox,
type FollowUpComposerProps,
+ type FollowUpPromptBoxProps,
type FollowUpSubmitMode,
} from "@/components/promptbox/FollowUpPromptBox";
import type { SendMessageMutationLike } from "./threadDetailMutationTypes";
@@ -105,10 +115,26 @@ import {
const ignorePromptBannerFileClick = () => {};
+export interface ThreadDetailSentMessageEdit {
+ draft: PromptDraftState;
+ hostElement: HTMLDivElement | null;
+ isSubmitting: boolean;
+ operationId: string;
+ onCancel: () => void;
+ onSubmit: (target: {
+ execution: FollowUpExecutionSelection;
+ input: PromptInput[];
+ }) => void;
+ updateDraft: (
+ update: (current: PromptDraftState) => PromptDraftState,
+ ) => void;
+}
+
export const THREAD_DETAIL_COMPOSER_TEXTAREA_ID =
"thread-detail-follow-up-composer";
interface ThreadDetailPromptAreaProps {
+ activeBackgroundAgentCount: number;
canUseGitUi: boolean;
contextWindowUsage?: ThreadTimelineResponse["contextWindowUsage"];
environmentCheckout?: WorkspaceCheckoutDisplay;
@@ -179,6 +205,8 @@ interface ThreadDetailPromptAreaProps {
/** Pull request summary for the active thread branch. Null when there is no PR. */
pullRequest: ThreadPullRequest | null;
sendMessage: SendMessageMutationLike;
+ /** Present only while a sent-message editor is mounted in the timeline. */
+ sentMessageEdit?: ThreadDetailSentMessageEdit;
steerActiveThreadOnEnter: boolean;
/**
* Bumped by the timeline host each time a quote is appended to the shared
@@ -189,7 +217,93 @@ interface ThreadDetailPromptAreaProps {
thread: ThreadWithRuntime;
}
+interface InlineDraftComposerOptions {
+ attachments: FollowUpPromptBoxProps["attachments"];
+ canModifierSubmit: boolean;
+ compactPromptPlaceholder: string;
+ composerId: string;
+ /** Live draft under edit; supplies the message text, mentions, and history draft. */
+ draft: PromptDraftState;
+ editFocusNonce: number;
+ execution: FollowUpPromptBoxProps["execution"];
+ /** Combined with editFocusNonce to focus the caret at the end per edit session. */
+ focusSessionKey: string | number;
+ historyResetKey: string;
+ isSubmitting: boolean;
+ onChangeMessage: FollowUpComposerProps["onChangeMessage"];
+ onSelectHistoryEntry: (draft: PromptDraftState) => void;
+ permission: FollowUpPromptBoxProps["permission"];
+ pluginComposerHost: PluginComposerHost;
+ promptActions: FollowUpPromptBoxProps["promptActions"];
+ promptPlaceholder: string;
+ submit: () => void;
+ submitMode: FollowUpSubmitMode;
+ submitTitle?: string;
+ suppressPluginComposerCustomizations?: boolean;
+ textEffects: FollowUpPromptBoxProps["textEffects"];
+ threadRuntimeDisplayStatus: FollowUpComposerProps["threadRuntimeDisplayStatus"];
+ typeahead: FollowUpPromptBoxProps["typeahead"];
+ zenModeResetKey: string;
+}
+
+/**
+ * The queued-message and sent-message inline editors render the same
+ * FollowUpPromptBox shape: read-only execution/permission controls, no stack,
+ * no environment summary, and a plugin-composer host bound to the draft under
+ * edit. Only the draft accessors, submit wiring, and session keys differ, so
+ * both call sites pass those in here and share the rest.
+ */
+function buildInlineDraftComposer(options: InlineDraftComposerOptions) {
+ return (
+
+ );
+}
+
export function ThreadDetailPromptArea({
+ activeBackgroundAgentCount,
canUseGitUi,
contextWindowUsage,
environmentCheckout,
@@ -223,6 +337,7 @@ export function ThreadDetailPromptArea({
childThreadsSection,
pullRequest,
sendMessage,
+ sentMessageEdit,
steerActiveThreadOnEnter,
composerFocusRequestNonce,
thread,
@@ -256,9 +371,11 @@ export function ThreadDetailPromptArea({
const focusBottomPluginComposer = useCallback(() => {
setBottomPluginFocusNonce((nonce) => nonce + 1);
}, []);
- const focusQueuedPluginComposer = useCallback(() => {
+ const focusInlinePluginComposer = useCallback(() => {
setEditFocusNonce((nonce) => nonce + 1);
}, []);
+ const sentMessageEditRef = useRef(sentMessageEdit);
+ sentMessageEditRef.current = sentMessageEdit;
const clearInlineAttachmentErrorRef = useRef<() => void>(() => {});
const {
inlineEditingQueuedMessage,
@@ -310,6 +427,22 @@ export function ThreadDetailPromptArea({
inlineEditingQueuedMessageRef,
commitInlineQueuedMessage,
});
+ const updateSentMessageEditDraft = sentMessageEdit?.updateDraft;
+ const addSentMessageEditAttachment = useCallback(
+ (attachment: PromptDraftAttachment) => {
+ updateSentMessageEditDraft?.((current) =>
+ current.attachments.some(
+ (existing) => existing.path === attachment.path,
+ )
+ ? current
+ : {
+ ...current,
+ attachments: [...current.attachments, attachment],
+ },
+ );
+ },
+ [updateSentMessageEditDraft],
+ );
const {
bottomAttachmentError,
setBottomAttachmentError,
@@ -326,13 +459,30 @@ export function ThreadDetailPromptArea({
inlineEditingQueuedMessageRef,
commitInlineQueuedMessage,
});
+ const {
+ attachmentError: sentMessageAttachmentError,
+ handleAttachFiles: handleAttachSentMessageFiles,
+ isAttachingFiles: isAttachingSentMessageFiles,
+ } = useDraftAttachmentUploads({
+ projectId,
+ target: sentMessageEdit
+ ? {
+ key: sentMessageEdit.operationId,
+ addAttachment: addSentMessageEditAttachment,
+ }
+ : null,
+ });
clearInlineAttachmentErrorRef.current = () => setInlineAttachmentError(null);
const promptTextEffects = useComposerTextEffects(promptDraft.storageKey);
- const queuedComposerTextEffectKey = inlineEditingQueuedMessage
- ? `queued-message:${thread.id}:${inlineEditingQueuedMessage.queuedMessageId}:${inlineEditingQueuedMessage.editSessionId}`
- : null;
const queuedComposerTextEffects = useComposerTextEffects(
- queuedComposerTextEffectKey,
+ inlineEditingQueuedMessage
+ ? `queued-message:${thread.id}:${inlineEditingQueuedMessage.queuedMessageId}:${inlineEditingQueuedMessage.editSessionId}`
+ : null,
+ );
+ const sentMessageComposerTextEffects = useComposerTextEffects(
+ sentMessageEdit
+ ? `sent-message:${thread.id}:${sentMessageEdit.operationId}`
+ : null,
);
const [expandedBannerSection, setExpandedBannerSection] =
useState(null);
@@ -553,49 +703,6 @@ export function ThreadDetailPromptArea({
thread.id,
],
);
- const queuedPluginComposerHostBinding = useMemo | null>(() => {
- if (!inlineEditingQueuedMessage) return null;
-
- const {
- draft: initialDraft,
- editSessionId,
- queuedMessageId,
- } = inlineEditingQueuedMessage;
- const isCurrentSession = (
- current: InlineQueuedMessageEditState | null,
- ): current is InlineQueuedMessageEditState =>
- current?.editSessionId === editSessionId &&
- current.queuedMessageId === queuedMessageId;
-
- return {
- scope: {
- kind: "queued-message",
- threadId: thread.id,
- queuedMessageId,
- },
- textEffectKey: `queued-message:${thread.id}:${queuedMessageId}:${editSessionId}`,
- getCurrent: () => {
- const current = inlineEditingQueuedMessageRef.current;
- return isCurrentSession(current) ? current.draft : initialDraft;
- },
- setDraft: (draft) => {
- const current = inlineEditingQueuedMessageRef.current;
- if (isCurrentSession(current)) {
- commitInlineQueuedMessage({ ...current, draft });
- }
- },
- focus: focusQueuedPluginComposer,
- };
- }, [
- inlineEditingQueuedMessage,
- inlineEditingQueuedMessageRef,
- commitInlineQueuedMessage,
- focusQueuedPluginComposer,
- thread.id,
- ]);
const normalPluginComposerHost = useMemo(
() => ({
...normalPluginComposerHostBinding,
@@ -603,19 +710,6 @@ export function ThreadDetailPromptArea({
}),
[currentPromptDraft, normalPluginComposerHostBinding],
);
- const queuedPluginComposerHost = useMemo(
- () =>
- queuedPluginComposerHostBinding
- ? {
- ...queuedPluginComposerHostBinding,
- draft: activeComposerDraft,
- }
- : null,
- [activeComposerDraft, queuedPluginComposerHostBinding],
- );
- usePublishPluginComposerHost(
- queuedPluginComposerHost ?? normalPluginComposerHost,
- );
const hasPromptDraftInput = currentPromptDraftInput.length > 0;
const isPromptEmpty = useCallback(
() => !hasPromptDraftInput,
@@ -854,25 +948,6 @@ export function ThreadDetailPromptArea({
promptDraft.removeAttachment,
],
);
- const inlineAttachmentsConfig = useMemo(
- () => ({
- items: activeComposerDraft.attachments,
- projectId,
- isAttaching: isAttachingInlineFiles,
- error: inlineAttachmentError,
- onAttachFiles: handleAttachInlineFiles,
- onRemove: removeActiveComposerAttachment,
- }),
- [
- activeComposerDraft.attachments,
- inlineAttachmentError,
- handleAttachInlineFiles,
- isAttachingInlineFiles,
- projectId,
- removeActiveComposerAttachment,
- ],
- );
-
const handleBottomComposerSubmit = useCallback(() => {
void handleSend();
}, [handleSend]);
@@ -921,47 +996,49 @@ export function ThreadDetailPromptArea({
thread.id,
],
);
- const inlineComposerConfig = useMemo(
+ const sentMessageEditInput = useMemo(
+ () => (sentMessageEdit ? promptDraftToInput(sentMessageEdit.draft) : []),
+ [sentMessageEdit],
+ );
+ const canSubmitSentMessageEdit =
+ sentMessageEdit !== undefined &&
+ sentMessageEditInput.length > 0 &&
+ submitMode.kind === "ready" &&
+ !shouldHideComposer &&
+ !isDefaultExecutionOptionsLoading &&
+ !isAttachingSentMessageFiles &&
+ !isFollowUpSubmitting &&
+ !isQueueMutationPending &&
+ !sentMessageEdit.isSubmitting &&
+ queuedMessages.length === 0 &&
+ activeBackgroundAgentCount === 0 &&
+ activeWorkflows.length === 0 &&
+ activeBackgroundCommands.length === 0;
+ // Empty input renders as "ready" with a no-op submit, matching the queued
+ // inline editor; handleSentMessageEditSubmit guards on canSubmitSentMessageEdit.
+ const sentMessageEditSubmitMode = useMemo(
() =>
- inlineEditingQueuedMessage
- ? {
- history: {
- currentDraft: activeComposerDraft,
- entries: [],
- onSelectEntry: setActiveComposerDraft,
- resetKey: `${thread.id}:${inlineEditingQueuedMessage.editSessionId}`,
- },
- isFollowUpSubmitting: isUpdateQueuedMessagePending,
- message: activeComposerDraft.text,
- mentionRanges: activeComposerDraft.mentions,
- onChangeMessage: handleComposerMessageChange,
- onModifierSubmit: handleInlineComposerSubmit,
- onSubmit: handleInlineComposerSubmit,
- compactPromptPlaceholder,
- promptPlaceholder,
- canModifierSubmit:
- activeComposerDraftInput.length > 0 &&
- !isUpdateQueuedMessagePending,
- steerActiveThreadOnEnter: false,
- submitMode: { kind: "ready" },
- threadRuntimeDisplayStatus: runtimeDisplayStatus,
- }
- : null,
- [
- activeComposerDraft,
- activeComposerDraftInput.length,
- compactPromptPlaceholder,
- handleComposerMessageChange,
- handleInlineComposerSubmit,
- inlineEditingQueuedMessage,
- isUpdateQueuedMessagePending,
- promptPlaceholder,
- runtimeDisplayStatus,
- setActiveComposerDraft,
- thread.id,
- ],
+ canSubmitSentMessageEdit || sentMessageEditInput.length === 0
+ ? { kind: "ready" }
+ : submitMode.kind === "blocked"
+ ? submitMode
+ : { kind: "blocked", reason: "unavailable" },
+ [canSubmitSentMessageEdit, sentMessageEditInput.length, submitMode],
);
-
+ const handleSentMessageEditSubmit = useCallback(() => {
+ if (!sentMessageEdit || !canSubmitSentMessageEdit) {
+ return;
+ }
+ sentMessageEdit.onSubmit({
+ execution: followUpExecutionSelection,
+ input: sentMessageEditInput,
+ });
+ }, [
+ canSubmitSentMessageEdit,
+ followUpExecutionSelection,
+ sentMessageEdit,
+ sentMessageEditInput,
+ ]);
const bottomExecutionConfig = useMemo(
() => ({
providerRouting: executionOptionsRouting,
@@ -1023,27 +1100,30 @@ export function ThreadDetailPromptArea({
supportsServiceTier,
],
);
- const inlineExecutionConfig = useMemo(() => {
- if (!inlineEditingQueuedMessage) return null;
+ const compactExecutionConfig = useMemo(() => {
const { footerAction: _footerAction, ...executionWithoutFooterAction } =
bottomExecutionConfig;
+ return executionWithoutFooterAction;
+ }, [bottomExecutionConfig]);
+ const inlineExecutionConfig = useMemo(() => {
+ if (!inlineEditingQueuedMessage) return null;
return {
- ...executionWithoutFooterAction,
+ ...compactExecutionConfig,
model: {
- ...bottomExecutionConfig.model,
+ ...compactExecutionConfig.model,
active: { model: inlineEditingQueuedMessage.model },
selected: inlineEditingQueuedMessage.model,
},
serviceTier: {
- ...bottomExecutionConfig.serviceTier,
+ ...compactExecutionConfig.serviceTier,
value: inlineEditingQueuedMessage.serviceTier,
},
reasoning: {
- ...bottomExecutionConfig.reasoning,
+ ...compactExecutionConfig.reasoning,
value: inlineEditingQueuedMessage.reasoningLevel,
},
};
- }, [bottomExecutionConfig, inlineEditingQueuedMessage]);
+ }, [compactExecutionConfig, inlineEditingQueuedMessage]);
const bottomPermissionConfig = useMemo(
() => ({
@@ -1122,55 +1202,208 @@ export function ThreadDetailPromptArea({
),
[clearThreadGoal.isPending, goal, handleClearGoal, isGoalExpanded],
);
- const inlineEditor = useMemo(() => {
+ const queuedMessageEditor = useMemo(() => {
if (
!inlineEditingQueuedMessage ||
- !inlineComposerConfig ||
!inlineExecutionConfig ||
- !inlinePermissionConfig ||
- !queuedPluginComposerHost
+ !inlinePermissionConfig
) {
- return undefined;
+ return null;
}
- return {
- queuedMessageId: inlineEditingQueuedMessage.queuedMessageId,
+ const {
+ draft: initialDraft,
+ editSessionId,
+ queuedMessageId,
+ } = inlineEditingQueuedMessage;
+ const isCurrentSession = (
+ current: InlineQueuedMessageEditState | null,
+ ): current is InlineQueuedMessageEditState =>
+ current?.editSessionId === editSessionId &&
+ current.queuedMessageId === queuedMessageId;
+ const pluginComposerHost: PluginComposerHost = {
+ scope: {
+ kind: "queued-message",
+ threadId: thread.id,
+ queuedMessageId,
+ },
+ textEffectKey: `queued-message:${thread.id}:${queuedMessageId}:${editSessionId}`,
+ draft: activeComposerDraft,
+ getCurrent: () => {
+ const current = inlineEditingQueuedMessageRef.current;
+ return isCurrentSession(current) ? current.draft : initialDraft;
+ },
+ setDraft: (draft) => {
+ const current = inlineEditingQueuedMessageRef.current;
+ if (isCurrentSession(current)) {
+ commitInlineQueuedMessage({ ...current, draft });
+ }
+ },
+ focus: focusInlinePluginComposer,
+ };
+ const inlineEditor: QueuedMessageInlineEditor = {
+ queuedMessageId,
queuedMessageIndex: inlineEditingQueuedMessage.queuedMessageIndex,
onDismiss: dismissInlineQueuedMessageEditor,
- content: (
-
- ),
+ content: buildInlineDraftComposer({
+ attachments: {
+ items: activeComposerDraft.attachments,
+ projectId,
+ isAttaching: isAttachingInlineFiles,
+ error: inlineAttachmentError,
+ onAttachFiles: handleAttachInlineFiles,
+ onRemove: removeActiveComposerAttachment,
+ },
+ canModifierSubmit:
+ activeComposerDraftInput.length > 0 && !isUpdateQueuedMessagePending,
+ compactPromptPlaceholder,
+ composerId: `${THREAD_DETAIL_COMPOSER_TEXTAREA_ID}-queued-${queuedMessageId}`,
+ draft: activeComposerDraft,
+ editFocusNonce,
+ execution: inlineExecutionConfig,
+ focusSessionKey: editSessionId,
+ historyResetKey: `${thread.id}:${editSessionId}`,
+ isSubmitting: isUpdateQueuedMessagePending,
+ onChangeMessage: handleComposerMessageChange,
+ onSelectHistoryEntry: setActiveComposerDraft,
+ permission: inlinePermissionConfig,
+ pluginComposerHost,
+ promptActions,
+ promptPlaceholder,
+ submit: handleInlineComposerSubmit,
+ submitMode: { kind: "ready" },
+ textEffects: queuedComposerTextEffects,
+ threadRuntimeDisplayStatus: runtimeDisplayStatus,
+ typeahead: typeaheadConfig,
+ zenModeResetKey: `queued-message:${queuedMessageId}`,
+ }),
};
+ return { inlineEditor, pluginComposerHost };
}, [
+ activeComposerDraft,
+ activeComposerDraftInput.length,
+ commitInlineQueuedMessage,
+ compactPromptPlaceholder,
dismissInlineQueuedMessageEditor,
editFocusNonce,
- inlineAttachmentsConfig,
- inlineComposerConfig,
+ focusInlinePluginComposer,
+ handleAttachInlineFiles,
+ handleComposerMessageChange,
+ handleInlineComposerSubmit,
+ inlineAttachmentError,
inlineEditingQueuedMessage,
+ inlineEditingQueuedMessageRef,
inlineExecutionConfig,
inlinePermissionConfig,
+ isAttachingInlineFiles,
+ isUpdateQueuedMessagePending,
+ projectId,
promptActions,
+ promptPlaceholder,
queuedComposerTextEffects,
- queuedPluginComposerHost,
+ removeActiveComposerAttachment,
+ runtimeDisplayStatus,
+ setActiveComposerDraft,
+ thread.id,
+ typeaheadConfig,
+ ]);
+ usePublishPluginComposerHost(
+ queuedMessageEditor?.pluginComposerHost ?? normalPluginComposerHost,
+ );
+ const sentMessageEditorPortal = useMemo(() => {
+ if (!sentMessageEdit?.hostElement) {
+ return null;
+ }
+ const { draft, hostElement, operationId } = sentMessageEdit;
+ return createPortal(
+
+ {buildInlineDraftComposer({
+ attachments: {
+ items: draft.attachments,
+ projectId,
+ isAttaching: isAttachingSentMessageFiles,
+ error: sentMessageAttachmentError,
+ onAttachFiles: handleAttachSentMessageFiles,
+ onRemove: (path) => {
+ sentMessageEdit.updateDraft((current) => ({
+ ...current,
+ attachments: current.attachments.filter(
+ (attachment) => attachment.path !== path,
+ ),
+ }));
+ },
+ },
+ canModifierSubmit: canSubmitSentMessageEdit,
+ compactPromptPlaceholder: "Edit message",
+ composerId: `${THREAD_DETAIL_COMPOSER_TEXTAREA_ID}-sent-${operationId}`,
+ draft,
+ editFocusNonce,
+ execution: compactExecutionConfig,
+ focusSessionKey: operationId,
+ historyResetKey: `${thread.id}:${operationId}`,
+ isSubmitting: sentMessageEdit.isSubmitting,
+ onChangeMessage: (text, mentions) =>
+ sentMessageEdit.updateDraft((current) => ({
+ ...current,
+ text,
+ mentions,
+ })),
+ onSelectHistoryEntry: (nextDraft) =>
+ sentMessageEdit.updateDraft(() => nextDraft),
+ permission: bottomPermissionConfig,
+ pluginComposerHost: {
+ scope: { kind: "thread", threadId: thread.id },
+ textEffectKey: `sent-message:${thread.id}:${operationId}`,
+ draft,
+ getCurrent: () => {
+ const current = sentMessageEditRef.current;
+ return current?.operationId === operationId
+ ? current.draft
+ : draft;
+ },
+ setDraft: (nextDraft) => {
+ const current = sentMessageEditRef.current;
+ if (current?.operationId === operationId) {
+ current.updateDraft(() => nextDraft);
+ }
+ },
+ focus: focusInlinePluginComposer,
+ },
+ promptActions,
+ promptPlaceholder: "Edit message",
+ submit: handleSentMessageEditSubmit,
+ submitMode: sentMessageEditSubmitMode,
+ submitTitle: "Submit edit (Enter)",
+ suppressPluginComposerCustomizations: true,
+ textEffects: sentMessageComposerTextEffects,
+ threadRuntimeDisplayStatus: runtimeDisplayStatus,
+ typeahead: typeaheadConfig,
+ zenModeResetKey: `sent-message:${operationId}`,
+ })}
+ ,
+ hostElement,
+ );
+ }, [
+ bottomPermissionConfig,
+ canSubmitSentMessageEdit,
+ compactExecutionConfig,
+ editFocusNonce,
+ focusInlinePluginComposer,
+ handleAttachSentMessageFiles,
+ handleSentMessageEditSubmit,
+ isAttachingSentMessageFiles,
+ projectId,
+ promptActions,
+ runtimeDisplayStatus,
+ sentMessageAttachmentError,
+ sentMessageComposerTextEffects,
+ sentMessageEdit,
+ sentMessageEditSubmitMode,
+ thread.id,
typeaheadConfig,
]);
const promptStack = useMemo(
@@ -1244,7 +1477,7 @@ export function ThreadDetailPromptArea({
- ) : (
-
- );
- return (
+ const bottomContent =
+ activePendingInteraction && !shouldHideComposer ? (
{activePromptMode ? activePromptModeCard : null}
{goal ? activeGoalCard : null}
@@ -1342,29 +1563,42 @@ export function ThreadDetailPromptArea({
}}
/>
- {pendingInteractionComposer}
+ {isPluginPendingInteraction(activePendingInteraction) ? (
+
+ ) : (
+
+ )}
+ ) : (
+
);
- }
return (
-
+ <>
+ {sentMessageEditorPortal}
+ {bottomContent}
+ >
);
}
diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
index 20c78e0670..1294685dc1 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
@@ -1,9 +1,12 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import {
isRunningThreadRuntimeDisplayStatus,
+ type ThreadTimelineEditMessageHandler,
+ type ThreadTimelineEditMessageTarget,
+ type ThreadTimelineInlineMessageEditor,
type ThreadTimelineForkMessageHandler,
type ThreadTimelineSendToMainMessageHandler,
type ThreadTimelineLinkHandler,
@@ -23,6 +26,7 @@ import {
import type {
PullRequestMergeMethod,
TerminalSession,
+ TimelineRow,
} from "@bb/server-contract";
import type { WorkspaceOpenTarget } from "@bb/host-daemon-contract";
import { appToast } from "@/components/ui/app-toast";
@@ -37,6 +41,7 @@ import {
} from "../../hooks/mutations/thread-state-mutations";
import {
useCreateThreadQueuedMessage,
+ useEditThreadMessage,
useSendThreadMessage,
} from "../../hooks/mutations/thread-runtime-mutations";
import { useUpdateEnvironment } from "../../hooks/mutations/environment-mutations";
@@ -53,6 +58,7 @@ import {
useThread,
useThreadDetailBootstrap,
useThreadPendingInteractions,
+ useThreadQueuedMessages,
type ProjectThreadSubsetFilters,
} from "../../hooks/queries/thread-queries";
import { isTransientReadError } from "@/hooks/queries/query-helpers";
@@ -97,7 +103,12 @@ import {
} from "@/components/workspace/workspace-change-summary";
import { getThreadDisplayTitle } from "@/lib/thread-title";
import { getMutationErrorMessage } from "@/lib/mutation-errors";
-import type { PromptDraftAttachment } from "@/lib/prompt-draft";
+import {
+ arePromptDraftStatesEqual,
+ promptInputToDraft,
+ type PromptDraftAttachment,
+ type PromptDraftState,
+} from "@/lib/prompt-draft";
import { createLocalStorageEnumStorage } from "@/lib/browser-storage";
import {
getProjectComposeRoutePath,
@@ -107,7 +118,10 @@ import {
} from "@/lib/route-paths";
import { useGitDiffPanel } from "@/components/secondary-panel/git-diff/useGitDiffPanel";
import { ThreadDetailHeader } from "./ThreadDetailHeader";
-import { ThreadDetailPromptArea } from "./ThreadDetailPromptArea";
+import {
+ ThreadDetailPromptArea,
+ type ThreadDetailSentMessageEdit,
+} from "./ThreadDetailPromptArea";
import {
type ContextBannerMergeBaseConfig,
isThreadDisplayStatusBannerActive,
@@ -267,6 +281,27 @@ type OpenInEditorHandler = NonNullable<
>;
type OpenFilePreviewHandler = (relativePath: string) => void;
+interface SentMessageEditSession {
+ draft: PromptDraftState;
+ originalDraft: PromptDraftState;
+ operationId: string;
+ target: ThreadTimelineEditMessageTarget;
+ threadId: string;
+}
+
+function hasTimelineRowId(
+ rows: readonly TimelineRow[],
+ rowId: string,
+): boolean {
+ return rows.some(
+ (row) =>
+ row.id === rowId ||
+ (row.kind === "turn" &&
+ row.children !== null &&
+ hasTimelineRowId(row.children, rowId)),
+ );
+}
+
function getPullRequestMergeLoadingTitle(
method: PullRequestMergeMethod,
): string {
@@ -572,6 +607,10 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) {
(pendingInteractionsQuery.isLoading || pendingInteractionsQuery.isFetching);
const hasPendingInteraction =
getLatestPendingInteraction(pendingInteractions) !== null;
+ const { data: queuedMessagesForEditEligibility = [] } =
+ useThreadQueuedMessages(thread?.id ?? "", {
+ enabled: threadQueryState.status === "ready" && Boolean(thread?.id),
+ });
const unreadDividerState = useThreadUnreadDividerState({
routeThreadId: threadId,
thread,
@@ -793,6 +832,7 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) {
threadId: threadId ?? "",
});
const sendMessage = useSendThreadMessage();
+ const editMessage = useEditThreadMessage();
const createQueuedMessage = useCreateThreadQueuedMessage();
const requestEnvironmentAction = useRequestEnvironmentAction();
const [pullRequestMergeMethod, setPullRequestMergeMethod] = useAtom(
@@ -911,6 +951,191 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) {
// Desktop quote actions keep their existing focus handoff. Mobile web does
// not focus inputs programmatically; see PromptBoxInternal.
const [composerFocusRequestNonce, setComposerFocusRequestNonce] = useState(0);
+ const [sentMessageEditSession, setSentMessageEditSession] =
+ useState(null);
+ const [sentMessageEditHostElement, setSentMessageEditHostElement] =
+ useState(null);
+ const activeSentMessageEditSession =
+ sentMessageEditSession?.threadId === thread?.id
+ ? sentMessageEditSession
+ : null;
+ // Client-side affordance policy for the UX prototype. The eventual mutation
+ // must repeat the full eligibility check on the server before changing state.
+ const canEditSentMessages =
+ thread !== undefined &&
+ (systemConfigQuery.data?.experiments.editMessages ?? false) &&
+ (thread.providerId === "claude-code" ||
+ thread.providerId === "codex" ||
+ thread.providerId === "pi") &&
+ thread.runtime.displayStatus === "idle" &&
+ thread.archivedAt === null &&
+ thread.deletedAt === null &&
+ !hasPendingInteraction &&
+ sentMessageEditSession === null &&
+ !sendMessage.isPending &&
+ !createQueuedMessage.isPending &&
+ !editMessage.isPending &&
+ !(timelineLoading && timelineRows.length === 0) &&
+ queuedMessagesForEditEligibility.length === 0 &&
+ activeWorkflows.length === 0 &&
+ thread.activeBackgroundAgentCount === 0 &&
+ activeBackgroundCommands.length === 0;
+ const sentMessageEditEntryRef = useRef({ canEditSentMessages, thread });
+ sentMessageEditEntryRef.current = { canEditSentMessages, thread };
+ const handleEditSentMessage = useCallback(
+ (target: ThreadTimelineEditMessageTarget) => {
+ const current = sentMessageEditEntryRef.current;
+ if (!current.thread || !current.canEditSentMessages) {
+ return;
+ }
+ const editDraft = promptInputToDraft(target.input);
+ setSentMessageEditHostElement(null);
+ setSentMessageEditSession({
+ draft: editDraft,
+ originalDraft: editDraft,
+ operationId: crypto.randomUUID(),
+ target,
+ threadId: current.thread.id,
+ });
+ },
+ [],
+ );
+ const sentMessageEditThreadId = sentMessageEditSession?.threadId ?? null;
+ const sentMessageEditTargetMessageId =
+ sentMessageEditSession?.target.messageId ?? null;
+ const currentThreadId = thread?.id ?? null;
+ const sentMessageEditTargetStillPresent =
+ sentMessageEditThreadId === currentThreadId &&
+ sentMessageEditTargetMessageId !== null
+ ? hasTimelineRowId(timelineRows, sentMessageEditTargetMessageId)
+ : true;
+ const shouldDiscardMissingSentMessageEdit =
+ sentMessageEditThreadId !== null &&
+ sentMessageEditThreadId === currentThreadId &&
+ !timelineLoading &&
+ !sentMessageEditTargetStillPresent;
+ useEffect(() => {
+ if (!shouldDiscardMissingSentMessageEdit) {
+ return;
+ }
+ setSentMessageEditHostElement(null);
+ setSentMessageEditSession(null);
+ appToast.warning("The message being edited is no longer available.");
+ }, [shouldDiscardMissingSentMessageEdit]);
+ const activeSentMessageEditOperationId =
+ activeSentMessageEditSession?.operationId ?? null;
+ const updateSentMessageEditDraft = useCallback(
+ (update: (current: PromptDraftState) => PromptDraftState) => {
+ setSentMessageEditSession((current) =>
+ current?.operationId === activeSentMessageEditOperationId
+ ? { ...current, draft: update(current.draft) }
+ : current,
+ );
+ },
+ [activeSentMessageEditOperationId],
+ );
+ const finishCancelSentMessageEdit = useCallback((operationId: string) => {
+ setSentMessageEditSession((current) =>
+ current?.operationId === operationId ? null : current,
+ );
+ }, []);
+ const cancelSentMessageEdit = useCallback(() => {
+ const current = activeSentMessageEditSession;
+ if (!current) {
+ return;
+ }
+ if (arePromptDraftStatesEqual(current.draft, current.originalDraft)) {
+ finishCancelSentMessageEdit(current.operationId);
+ return;
+ }
+ appToast.warning("Discard this message edit?", {
+ description:
+ "The sent message and conversation are still unchanged. Your follow-up draft is unaffected.",
+ action: {
+ label: "Discard edit",
+ onClick: () => finishCancelSentMessageEdit(current.operationId),
+ },
+ cancel: { label: "Keep editing", onClick: () => {} },
+ });
+ }, [activeSentMessageEditSession, finishCancelSentMessageEdit]);
+ const submitSentMessageEdit = useCallback<
+ ThreadDetailSentMessageEdit["onSubmit"]
+ >(
+ (target) => {
+ if (!activeSentMessageEditSession) {
+ return;
+ }
+ const session = activeSentMessageEditSession;
+ const execution = target.execution;
+ void editMessage
+ .mutateAsync({
+ id: session.threadId,
+ operationId: session.operationId,
+ expectedRequestSequence: session.target.expectedRequestSequence,
+ input: target.input,
+ ...(execution
+ ? {
+ model: execution.model,
+ permissionMode: execution.permissionMode,
+ reasoningLevel: execution.reasoningLevel,
+ executionInputSources: execution.executionInputSources,
+ ...(execution.supportsServiceTier && execution.serviceTier
+ ? { serviceTier: execution.serviceTier }
+ : {}),
+ }
+ : {}),
+ })
+ .then(() => {
+ finishCancelSentMessageEdit(session.operationId);
+ })
+ .catch((error) => {
+ appToast.error(
+ getMutationErrorMessage({
+ error,
+ fallbackMessage: "Failed to edit the message",
+ lifecycleOperation: "edit_message",
+ }),
+ );
+ });
+ },
+ [activeSentMessageEditSession, editMessage, finishCancelSentMessageEdit],
+ );
+ const activeSentMessageEditTargetMessageId =
+ activeSentMessageEditSession?.target.messageId ?? null;
+ const inlineMessageEditor = useMemo<
+ ThreadTimelineInlineMessageEditor | undefined
+ >(
+ () =>
+ activeSentMessageEditTargetMessageId !== null
+ ? {
+ messageId: activeSentMessageEditTargetMessageId,
+ onHostElementChange: setSentMessageEditHostElement,
+ }
+ : undefined,
+ [activeSentMessageEditTargetMessageId],
+ );
+ const sentMessageEdit = useMemo(
+ () =>
+ activeSentMessageEditSession
+ ? {
+ draft: activeSentMessageEditSession.draft,
+ hostElement: sentMessageEditHostElement,
+ isSubmitting: editMessage.isPending,
+ operationId: activeSentMessageEditSession.operationId,
+ onCancel: cancelSentMessageEdit,
+ onSubmit: submitSentMessageEdit,
+ updateDraft: updateSentMessageEditDraft,
+ }
+ : undefined,
+ [
+ activeSentMessageEditSession,
+ cancelSentMessageEdit,
+ editMessage.isPending,
+ sentMessageEditHostElement,
+ submitSentMessageEdit,
+ updateSentMessageEditDraft,
+ ],
+ );
// Plugin useComposer() writes ride the focus bus (they can't reach this
// view's local nonce); same storage key = same draft the composer shows.
useEffect(
@@ -2349,6 +2574,7 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) {
);
const composerFooter = (
{
);
});
+ it("bb thread edit-message targets the latest editable message by default", async () => {
+ const submitEdit = vi.fn(async () => ({
+ ok: true,
+ operationId: "edit-op-server",
+ requestSequence: 43,
+ }));
+ stubServerApi({
+ "v1.threads.:id.edit-message.$post": submitEdit,
+ });
+
+ await runCommand(
+ ["thread", "edit-message", "thread-edit-1", "--message", "Replacement"],
+ register,
+ );
+
+ expect(submitEdit).toHaveBeenCalledWith({
+ param: { id: "thread-edit-1" },
+ json: {
+ operationId: expect.any(String),
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ },
+ });
+ expect(collectLogLines(vi.mocked(console.log))).toContain(
+ "Thread thread-edit-1 message replaced; workspace changes were kept",
+ );
+ });
+
+ it("bb thread edit-message preserves an agent caller when targeting another thread", async () => {
+ vi.stubEnv("BB_THREAD_ID", "thread-agent-caller");
+ const submitEdit = vi.fn(async () => ({
+ ok: true,
+ operationId: "edit-op-server",
+ requestSequence: 43,
+ }));
+ stubServerApi({
+ "v1.threads.:id.edit-message.$post": submitEdit,
+ });
+
+ await runCommand(
+ [
+ "thread",
+ "edit-message",
+ "thread-edit-target",
+ "--message",
+ "Replacement",
+ "--expected-request-sequence",
+ "41",
+ ],
+ register,
+ );
+
+ expect(submitEdit).toHaveBeenCalledWith({
+ param: { id: "thread-edit-target" },
+ json: expect.objectContaining({
+ senderThreadId: "thread-agent-caller",
+ }),
+ });
+ });
+
+ it("bb thread edit-message accepts an explicit stale-edit guard", async () => {
+ vi.stubEnv("BB_THREAD_ID", "thread-edit-self");
+ const submitEdit = vi.fn(async () => ({
+ ok: true,
+ operationId: "edit-op-server",
+ requestSequence: 43,
+ }));
+ stubServerApi({
+ "v1.threads.:id.edit-message.$post": submitEdit,
+ });
+
+ await runCommand(
+ [
+ "thread",
+ "edit-message",
+ "--self",
+ "--message",
+ "Replacement",
+ "--expected-request-sequence",
+ "41",
+ "--json",
+ ],
+ register,
+ );
+
+ expect(submitEdit).toHaveBeenCalledWith({
+ param: { id: "thread-edit-self" },
+ json: expect.objectContaining({ expectedRequestSequence: 41 }),
+ });
+ expect(
+ JSON.parse(collectLogLines(vi.mocked(console.log)).join("\n")),
+ ).toMatchObject({
+ threadId: "thread-edit-self",
+ ok: true,
+ requestSequence: 43,
+ });
+ });
+
+ it("bb thread edit-message rejects a partially numeric request sequence", async () => {
+ const submitEdit = vi.fn();
+ stubServerApi({ "v1.threads.:id.edit-message.$post": submitEdit });
+
+ await expect(
+ runCommand(
+ [
+ "thread",
+ "edit-message",
+ "thread-edit-1",
+ "--message",
+ "Replacement",
+ "--expected-request-sequence",
+ "41abc",
+ ],
+ register,
+ ),
+ ).rejects.toThrow("process.exit:1");
+
+ expect(submitEdit).not.toHaveBeenCalled();
+ expect(collectLogLines(vi.mocked(console.error))).toContain(
+ "Error: --expected-request-sequence must be a non-negative integer.",
+ );
+ });
+
it("bb thread pin sends the thread id from args", async () => {
const pinnedThread = fixtures.makeThread({
id: "thread-pin-1",
diff --git a/apps/cli/src/__tests__/command-output/thread-log.test.ts b/apps/cli/src/__tests__/command-output/thread-log.test.ts
index 00494a462a..70890e7af2 100644
--- a/apps/cli/src/__tests__/command-output/thread-log.test.ts
+++ b/apps/cli/src/__tests__/command-output/thread-log.test.ts
@@ -75,7 +75,11 @@ describe("bb thread log command output", () => {
senderThreadId: null,
systemMessageKind: "unlabeled",
systemMessageSubject: null,
- turnRequest: { kind: "message", status: "accepted" },
+ turnRequest: {
+ isGrouped: false,
+ kind: "message",
+ status: "accepted",
+ },
},
{
...fixtures.makeTimelineBase({
diff --git a/apps/cli/src/__tests__/helpers/command-output-fixtures.ts b/apps/cli/src/__tests__/helpers/command-output-fixtures.ts
index 735d94afbd..d7c23b2105 100644
--- a/apps/cli/src/__tests__/helpers/command-output-fixtures.ts
+++ b/apps/cli/src/__tests__/helpers/command-output-fixtures.ts
@@ -98,7 +98,7 @@ export function makePendingSteerTimelineRow(): TimelineUserConversationRow {
senderThreadId: null,
systemMessageKind: "unlabeled",
systemMessageSubject: null,
- turnRequest: { kind: "steer", status: "pending" },
+ turnRequest: { isGrouped: false, kind: "steer", status: "pending" },
};
}
diff --git a/apps/cli/src/commands/thread/actions.ts b/apps/cli/src/commands/thread/actions.ts
index effd06b23d..b6e7345826 100644
--- a/apps/cli/src/commands/thread/actions.ts
+++ b/apps/cli/src/commands/thread/actions.ts
@@ -1,4 +1,5 @@
import { Command } from "commander";
+import { randomUUID } from "node:crypto";
import {
threadVisibilitySchema,
type PermissionMode,
@@ -81,6 +82,13 @@ interface ThreadRetryCommandOptions extends ThreadActionOptions {
requestId?: string;
}
+interface ThreadEditMessageCommandOptions {
+ expectedRequestSequence?: string;
+ json?: boolean;
+ message: string;
+ self?: boolean;
+}
+
type ThreadTellDeliveryMode = "auto" | "queue" | "steer";
interface PostThreadMessageArgs {
@@ -356,6 +364,57 @@ export function registerActionsCommands(
}),
);
+ parent
+ .command("edit-message [id]")
+ .description("Replace a completed user message and rerun from that point")
+ .requiredOption("--message ", "Replacement message text")
+ .option("--self", "Target the current thread (from BB_THREAD_ID)")
+ .option(
+ "--expected-request-sequence ",
+ "Edit the message at this event sequence (default: the latest editable message)",
+ )
+ .option("--json", "Print machine-readable JSON output")
+ .action(
+ action(
+ async (
+ id: string | undefined,
+ opts: ThreadEditMessageCommandOptions,
+ ) => {
+ const threadId = requireThreadIdOrSelf(id, opts);
+ const sdk = createCliBbSdk(getUrl());
+ const expectedRequestSequence =
+ opts.expectedRequestSequence === undefined
+ ? undefined
+ : Number(opts.expectedRequestSequence);
+ if (
+ expectedRequestSequence !== undefined &&
+ (!Number.isInteger(expectedRequestSequence) ||
+ expectedRequestSequence < 0)
+ ) {
+ throw new Error(
+ "--expected-request-sequence must be a non-negative integer.",
+ );
+ }
+ const senderThreadId = resolveSenderThreadId(threadId);
+ const result = await sdk.threads.editMessage({
+ threadId,
+ operationId: randomUUID(),
+ ...(expectedRequestSequence !== undefined
+ ? { expectedRequestSequence }
+ : {}),
+ input: buildPromptInputs({ message: opts.message }),
+ ...(senderThreadId !== undefined ? { senderThreadId } : {}),
+ });
+ if (outputJson(opts, { threadId, ...result })) {
+ return;
+ }
+ console.log(
+ `Thread ${threadId} message replaced; workspace changes were kept`,
+ );
+ },
+ ),
+ );
+
parent
.command("tell ")
.description("Send a follow-up message to a thread")
diff --git a/apps/desktop/test/preload-build.test.ts b/apps/desktop/test/preload-build.test.ts
index a0ef170ea1..4f4425b1d9 100644
--- a/apps/desktop/test/preload-build.test.ts
+++ b/apps/desktop/test/preload-build.test.ts
@@ -123,6 +123,7 @@ async function startDesktopSmokeServer(
dataDir: args.dataDir,
experiments: {
claudeCodeMockCliTraffic: false,
+ editMessages: false,
newOnboarding: false,
toolsHub: false,
},
diff --git a/apps/host-daemon/src/app.test.ts b/apps/host-daemon/src/app.test.ts
index 9ecd5fd36c..b8cd4cf4cb 100644
--- a/apps/host-daemon/src/app.test.ts
+++ b/apps/host-daemon/src/app.test.ts
@@ -240,6 +240,10 @@ function createFakeRuntime(): AgentRuntime {
async startThread() {
return { providerThreadId: "provider-thread-app-test" };
},
+ async prepareThreadRewind() {
+ return { providerThreadId: "provider-thread-rewind-app-test" };
+ },
+ async discardThreadRewind() {},
async resumeThread() {
return { providerThreadId: "provider-thread-app-test" };
},
diff --git a/apps/host-daemon/src/command-dispatch-support.test.ts b/apps/host-daemon/src/command-dispatch-support.test.ts
index 99cb0e6a1b..2432bd0d69 100644
--- a/apps/host-daemon/src/command-dispatch-support.test.ts
+++ b/apps/host-daemon/src/command-dispatch-support.test.ts
@@ -48,6 +48,10 @@ function makeRuntime(args: MakeRuntimeArgs): AgentRuntime {
async startThread() {
return { providerThreadId: "provider-thread-test" };
},
+ async prepareThreadRewind() {
+ return { providerThreadId: "provider-thread-rewind-test" };
+ },
+ async discardThreadRewind() {},
async resumeThread() {
return { providerThreadId: "provider-thread-test" };
},
diff --git a/apps/host-daemon/src/command-dispatch.test.ts b/apps/host-daemon/src/command-dispatch.test.ts
index 6a8490dc3c..446320a49d 100644
--- a/apps/host-daemon/src/command-dispatch.test.ts
+++ b/apps/host-daemon/src/command-dispatch.test.ts
@@ -177,6 +177,10 @@ function createRuntime(): FakeDispatchRuntime {
hostedThreadIds.add(args.threadId);
return { providerThreadId: "provider-thread-1" };
}),
+ prepareThreadRewind: vi.fn(async () => ({
+ providerThreadId: "provider-thread-rewind-1",
+ })),
+ discardThreadRewind: vi.fn(async () => undefined),
resumeThread: vi.fn(async (args: { threadId: string }) => {
hostedThreadIds.add(args.threadId);
return { providerThreadId: "provider-thread-1" };
@@ -941,6 +945,130 @@ describe("dispatchCommand", () => {
expect(runtime.startThread).toHaveBeenCalledOnce();
});
+ it("prepares a Codex rewind through the requested retained turn", async () => {
+ const runtime = createRuntime();
+ const manager = new RuntimeManager({
+ createRuntime: () => runtime,
+ provisionWorkspace: async () => createWorkspace(),
+ });
+ const command: CommandOf<"thread.rewind.prepare"> = {
+ type: "thread.rewind.prepare",
+ environmentId: "env-1",
+ threadId: "thread-1",
+ workspaceContext: {
+ workspacePath: WORKSPACE_PATH,
+ workspaceProvisionType: "unmanaged",
+ },
+ projectId: "proj_1",
+ providerId: "codex",
+ leaseId: "lease-1",
+ sourceProviderThreadId: "provider-source-1",
+ retainThroughProviderCheckpoint: "turn-before-edit",
+ options: {
+ model: "gpt-5",
+ serviceTier: "default",
+ reasoningLevel: "medium",
+ workflowsEnabled: false,
+ permissionMode: "full",
+ permissionScope: "full",
+ approvalReviewer: null,
+ permissionEscalation: null,
+ },
+ instructions: "Be concise.",
+ dynamicTools: [],
+ injectedSkillSources: [],
+ instructionMode: "append",
+ };
+ const supportedCodexStatus: ProviderCliStatus = {
+ displayName: "Codex",
+ executableName: "codex",
+ executablePath: "/usr/local/bin/codex",
+ installed: true,
+ installSource: "npmGlobal",
+ currentVersion: "0.146.0",
+ latestVersion: null,
+ minimumSupportedVersion: "0.136.0",
+ npmPackageName: "@openai/codex",
+ npmGlobalPackageVersion: "0.146.0",
+ installAction: null,
+ needsUpdate: false,
+ versionUnsupported: false,
+ };
+
+ await expect(
+ dispatchCommand(command, {
+ dataDir: "/tmp/bb-data",
+ eventSink: {
+ emit: vi.fn(),
+ flush: vi.fn(async () => undefined),
+ },
+ fetchProjectAttachment: async () => {
+ throw new Error("Unexpected project attachment fetch");
+ },
+ getProviderCliStatusForProvider: async () => supportedCodexStatus,
+ runtimeManager: manager,
+ threadStorageRootPath: "/tmp/bb-thread-storage",
+ }),
+ ).resolves.toEqual({ providerThreadId: "provider-thread-rewind-1" });
+ expect(runtime.prepareThreadRewind).toHaveBeenCalledWith(
+ expect.objectContaining({
+ leaseId: "lease-1",
+ sourceProviderThreadId: "provider-source-1",
+ retainThroughProviderCheckpoint: "turn-before-edit",
+ threadId: "thread-1",
+ }),
+ );
+ await expect(
+ dispatchCommand(
+ { ...command, leaseId: "lease-old-codex" },
+ {
+ dataDir: "/tmp/bb-data",
+ eventSink: {
+ emit: vi.fn(),
+ flush: vi.fn(async () => undefined),
+ },
+ fetchProjectAttachment: async () => {
+ throw new Error("Unexpected project attachment fetch");
+ },
+ getProviderCliStatusForProvider: async () => ({
+ ...supportedCodexStatus,
+ currentVersion: "0.140.0",
+ npmGlobalPackageVersion: "0.140.0",
+ }),
+ runtimeManager: manager,
+ threadStorageRootPath: "/tmp/bb-thread-storage",
+ },
+ ),
+ ).rejects.toMatchObject({ code: "provider_cli_unsupported_version" });
+ expect(runtime.prepareThreadRewind).toHaveBeenCalledOnce();
+
+ await expect(
+ dispatchCommand(
+ {
+ type: "thread.rewind.discard",
+ environmentId: "env-1",
+ threadId: "thread-1",
+ leaseId: "lease-1",
+ },
+ {
+ dataDir: "/tmp/bb-data",
+ eventSink: {
+ emit: vi.fn(),
+ flush: vi.fn(async () => undefined),
+ },
+ fetchProjectAttachment: async () => {
+ throw new Error("Unexpected project attachment fetch");
+ },
+ runtimeManager: manager,
+ threadStorageRootPath: "/tmp/bb-thread-storage",
+ },
+ ),
+ ).resolves.toEqual({});
+ expect(runtime.discardThreadRewind).toHaveBeenCalledWith({
+ leaseId: "lease-1",
+ });
+ });
+
it("invalidates the provider maintenance runtime after a successful Codex CLI update", async () => {
const dataDir = await makeTempDir("bb-command-dispatch-provider-cli-");
const staleRuntime = createRuntime();
diff --git a/apps/host-daemon/src/command-dispatch.ts b/apps/host-daemon/src/command-dispatch.ts
index 01cd0a5552..75e8db5f51 100644
--- a/apps/host-daemon/src/command-dispatch.ts
+++ b/apps/host-daemon/src/command-dispatch.ts
@@ -64,7 +64,9 @@ import {
streamProviderCliInstall,
} from "./provider-cli-health.js";
import {
+ discardThreadRewind,
ensureThreadRuntime,
+ prepareThreadRewind,
startThread,
submitTurn,
} from "./command-handlers/thread.js";
@@ -229,6 +231,30 @@ function shouldInvalidateProviderMaintenanceRuntimeAfterProviderCliInstall(args:
}
const commandHandlers: CommandHandlerMap = {
+ "thread.rewind.discard": async (command, options) => {
+ const release =
+ await options.runtimeManager.retainEnvironmentForThreadCommand(
+ command.environmentId,
+ command.threadId,
+ );
+ try {
+ return await discardThreadRewind(command, options);
+ } finally {
+ release();
+ }
+ },
+ "thread.rewind.prepare": async (command, options) => {
+ const release =
+ await options.runtimeManager.retainEnvironmentForThreadCommand(
+ command.environmentId,
+ command.threadId,
+ );
+ try {
+ return await prepareThreadRewind(command, options);
+ } finally {
+ release();
+ }
+ },
"thread.start": async (command, options) => {
const release =
await options.runtimeManager.retainEnvironmentForThreadCommand(
diff --git a/apps/host-daemon/src/command-handlers/thread.ts b/apps/host-daemon/src/command-handlers/thread.ts
index 6fe52eb6bb..9dbb174e48 100644
--- a/apps/host-daemon/src/command-handlers/thread.ts
+++ b/apps/host-daemon/src/command-handlers/thread.ts
@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
+import semver from "semver";
import type { PromptInput } from "@bb/domain";
import type { HostDaemonCommandResult } from "@bb/host-daemon-contract";
import { resolveContainedPath } from "@bb/process-utils";
@@ -43,10 +44,12 @@ interface StagedThreadCommandInput {
}
interface RequireSupportedProviderCliArgs {
- command: CommandOf<"thread.start">;
+ command: CommandOf<"thread.start"> | CommandOf<"thread.rewind.prepare">;
options: CommandDispatchOptions;
}
+const CODEX_REWIND_MINIMUM_SUPPORTED_VERSION = "0.143.0";
+
function requireConfinedPath(rootPath: string, candidatePath: string): string {
const resolved = resolveContainedPath({
rootPath,
@@ -100,17 +103,29 @@ async function requireSupportedProviderCliForThreadStart({
(await getProviderCliStatusForProvider("codex", {
env: options.runtimeManager.getShellEnv(),
}));
- if (!status.versionUnsupported) {
+ const minimumVersion =
+ command.type === "thread.rewind.prepare"
+ ? CODEX_REWIND_MINIMUM_SUPPORTED_VERSION
+ : status.minimumSupportedVersion;
+ const versionUnsupported =
+ command.type === "thread.rewind.prepare"
+ ? status.currentVersion === null ||
+ !semver.gte(
+ status.currentVersion,
+ CODEX_REWIND_MINIMUM_SUPPORTED_VERSION,
+ )
+ : status.versionUnsupported;
+ if (!versionUnsupported) {
return;
}
const currentVersion = status.currentVersion
? ` ${status.currentVersion}`
: "";
- const minimumVersion = status.minimumSupportedVersion ?? "a newer version";
+ const requiredVersion = minimumVersion ?? "a newer version";
throw new ExpectedCommandDispatchError(
"provider_cli_unsupported_version",
- `Codex${currentVersion} is too old for this bb version. Update Codex to ${minimumVersion} or newer.`,
+ `Codex${currentVersion} is too old for this operation. Update Codex to ${requiredVersion} or newer.`,
);
}
@@ -237,6 +252,50 @@ export async function startThread(
}
}
+export async function prepareThreadRewind(
+ command: CommandOf<"thread.rewind.prepare">,
+ options: CommandDispatchOptions,
+): Promise> {
+ await requireSupportedProviderCliForThreadStart({ command, options });
+ const entry = await requireResolvedWorkspaceForCommand({
+ dataDir: options.dataDir,
+ environmentId: command.environmentId,
+ injectedSkillSources: command.injectedSkillSources,
+ runtimeManager: options.runtimeManager,
+ targetThreadId: command.threadId,
+ workspaceContext: command.workspaceContext,
+ });
+ return entry.runtime.prepareThreadRewind({
+ ...(command.acpLaunchSpec !== undefined
+ ? { acpLaunchSpec: command.acpLaunchSpec }
+ : {}),
+ environmentId: command.environmentId,
+ threadId: command.threadId,
+ leaseId: command.leaseId,
+ projectId: command.projectId,
+ providerId: command.providerId,
+ sourceProviderThreadId: command.sourceProviderThreadId,
+ retainThroughProviderCheckpoint: command.retainThroughProviderCheckpoint,
+ options: command.options,
+ instructions: command.instructions,
+ dynamicTools: command.dynamicTools,
+ disallowedTools: command.disallowedTools,
+ instructionMode: command.instructionMode,
+ });
+}
+
+export async function discardThreadRewind(
+ command: CommandOf<"thread.rewind.discard">,
+ options: CommandDispatchOptions,
+): Promise> {
+ const entry = await options.runtimeManager.getOrAwait(command.environmentId);
+ if (!entry) {
+ return {};
+ }
+ await entry.runtime.discardThreadRewind({ leaseId: command.leaseId });
+ return {};
+}
+
export async function ensureThreadRuntime(
command: ExistingThreadRuntimeCommand,
options: CommandDispatchOptions,
diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts
index 466d960dd4..4b28ed401b 100644
--- a/apps/host-daemon/src/runtime-manager.test.ts
+++ b/apps/host-daemon/src/runtime-manager.test.ts
@@ -257,6 +257,10 @@ function createFakeRuntime() {
startThread: vi.fn(async (_args: StartThreadArgs) => ({
providerThreadId: "provider-1",
})),
+ prepareThreadRewind: vi.fn(async () => ({
+ providerThreadId: "provider-rewind-1",
+ })),
+ discardThreadRewind: vi.fn(async () => undefined),
resumeThread: vi.fn(async (_args: ResumeThreadArgs) => ({
providerThreadId: "provider-1",
})),
diff --git a/apps/host-daemon/src/terminals/terminal-manager.test.ts b/apps/host-daemon/src/terminals/terminal-manager.test.ts
index 5d93d4e642..a576e20889 100644
--- a/apps/host-daemon/src/terminals/terminal-manager.test.ts
+++ b/apps/host-daemon/src/terminals/terminal-manager.test.ts
@@ -212,6 +212,10 @@ function createFakeRuntime(): AgentRuntime {
startThread: vi.fn(async () => ({
providerThreadId: "provider-thread",
})),
+ prepareThreadRewind: vi.fn(async () => ({
+ providerThreadId: "provider-thread-rewind",
+ })),
+ discardThreadRewind: vi.fn(async () => undefined),
resumeThread: vi.fn(async () => ({
providerThreadId: "provider-thread",
})),
diff --git a/apps/host-daemon/test/command/dispatch-helpers.ts b/apps/host-daemon/test/command/dispatch-helpers.ts
index 7aa4fcb99c..fdb2c93d5c 100644
--- a/apps/host-daemon/test/command/dispatch-helpers.ts
+++ b/apps/host-daemon/test/command/dispatch-helpers.ts
@@ -349,6 +349,12 @@ export function createFakeRuntime() {
}
return { providerThreadId: `provider-${args.threadId}` };
},
+ async prepareThreadRewind(args) {
+ return {
+ providerThreadId: `provider-rewind-${args.threadId}-${args.leaseId}`,
+ };
+ },
+ async discardThreadRewind() {},
async resumeThread(args) {
state.resumedAcpLaunchSpec = args.acpLaunchSpec;
state.resumedEnvironmentId = args.environmentId;
diff --git a/apps/server/src/routes/threads/actions.ts b/apps/server/src/routes/threads/actions.ts
index fd404a0583..237f36928f 100644
--- a/apps/server/src/routes/threads/actions.ts
+++ b/apps/server/src/routes/threads/actions.ts
@@ -53,6 +53,7 @@ import {
resolveMessageSenderThreadId,
sendThreadMessage,
} from "../../services/threads/thread-send.js";
+import { editThreadMessage } from "../../services/threads/thread-edit-message.js";
import {
buildExecutionOptions,
buildThreadStopCommand,
@@ -367,6 +368,19 @@ export function registerThreadActionRoutes(app: Hono, deps: AppDeps): void {
);
});
+ post(routes.editMessage, async (context, payload) => {
+ const thread = requirePublicThread(deps.db, context.req.param("id"));
+ const environment = await requireThreadCommandEnvironment(deps, {
+ thread,
+ });
+ const result = await editThreadMessage(deps, {
+ environment,
+ payload,
+ thread,
+ });
+ return context.json(result);
+ });
+
post(routes.createQueuedMessage, async (context, payload) => {
const thread = requirePublicThread(deps.db, context.req.param("id"));
const queuedMessage = await createQueuedMessageForThread(deps, {
diff --git a/apps/server/src/services/hosts/live-command.ts b/apps/server/src/services/hosts/live-command.ts
index e23c24ed8f..6446eaa6e1 100644
--- a/apps/server/src/services/hosts/live-command.ts
+++ b/apps/server/src/services/hosts/live-command.ts
@@ -47,6 +47,7 @@ export interface StartLiveHostCommandArgs<
> extends RunLiveHostCommandArgs {
onError?: LiveHostCommandErrorHandler;
onExpectedError?: LiveHostCommandErrorHandler;
+ onSettled?: () => void | Promise;
}
type LiveHostCommandResultReportForType<
@@ -267,28 +268,39 @@ export function startLiveHostCommand<
): void {
const execution =
args.execution ?? createLiveHostCommandExecution(args.hostId);
- void runLiveHostCommand(deps, { ...args, execution }).catch((error) => {
- const normalized =
- error instanceof Error ? error : new Error(String(error));
- const handlerArgs: LiveHostCommandErrorHandlerArgs = {
- command: args.command,
- error: normalized,
- execution,
- hostId: args.hostId,
- };
- const expectedErrorFields =
- expectedLiveHostCommandErrorLogFields(normalized);
- if (expectedErrorFields !== null) {
- deps.logger.debug(
- {
- ...liveHostCommandBaseLogFields(handlerArgs),
- ...expectedErrorFields,
- },
- "Expected live host command failure",
- );
- args.onExpectedError?.(handlerArgs);
- return;
- }
- args.onError?.(handlerArgs);
- });
+ void runLiveHostCommand(deps, { ...args, execution })
+ .catch((error) => {
+ const normalized =
+ error instanceof Error ? error : new Error(String(error));
+ const handlerArgs: LiveHostCommandErrorHandlerArgs = {
+ command: args.command,
+ error: normalized,
+ execution,
+ hostId: args.hostId,
+ };
+ const expectedErrorFields =
+ expectedLiveHostCommandErrorLogFields(normalized);
+ if (expectedErrorFields !== null) {
+ deps.logger.debug(
+ {
+ ...liveHostCommandBaseLogFields(handlerArgs),
+ ...expectedErrorFields,
+ },
+ "Expected live host command failure",
+ );
+ args.onExpectedError?.(handlerArgs);
+ return;
+ }
+ args.onError?.(handlerArgs);
+ })
+ .finally(async () => {
+ try {
+ await args.onSettled?.();
+ } catch (error) {
+ deps.logger.error(
+ { err: error, commandType: args.command.type },
+ "Live command settled callback failed",
+ );
+ }
+ });
}
diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md
index 6de97af7cc..a3385c7d11 100644
--- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md
+++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md
@@ -105,6 +105,9 @@ message agents, or inspect projects, providers, and environments.
project setup guide. Change it with
`bb settings experiment newOnboarding `. Use
`bb settings replay-onboarding` to enable it and show the guide again.
+- The default-off `editMessages` experiment allows completed user messages in
+ Codex, Claude Code, and Pi threads to be replaced and rerun. Change it with
+ `bb settings experiment editMessages `.
- Thread timeline windows are capped by event count as well as by user-message
count (`BB_FF_TIMELINE_WINDOW_EVENT_BUDGET`, default 1500), because a thread
with few user messages but many events would otherwise reproject its whole
@@ -340,6 +343,13 @@ or artifacts, validation performed, and blockers.
` when you need a shorter or longer budget.
- Use `bb thread tell "..."` when requirements change, a blocker
needs clarification, or follow-up work is needed.
+- Use `bb thread edit-message --message "..."` to replace and rerun
+ the latest completed user message in an idle Codex, Claude Code, or Pi thread.
+ Pass `--expected-request-sequence ` to select an earlier message.
+ Opening edit mode in the app is non-destructive; history changes only when the
+ edit is submitted successfully, and workspace changes remain. When an agent
+ edits another thread, the CLI carries its `BB_THREAD_ID` so the replacement
+ runs under agent permission policy.
- `bb thread tell` steers by default, delivering the message immediately into
the active turn. Use `--mode queue` when the message is non-urgent and the
agent can finish its current work first. Steer is especially important for a
diff --git a/apps/server/src/services/threads/thread-edit-message.ts b/apps/server/src/services/threads/thread-edit-message.ts
new file mode 100644
index 0000000000..c797c6e5e8
--- /dev/null
+++ b/apps/server/src/services/threads/thread-edit-message.ts
@@ -0,0 +1,554 @@
+import { createHash, randomUUID } from "node:crypto";
+import { and, desc, eq, lt, sql } from "drizzle-orm";
+import {
+ deleteThreadEventSuffixInTransaction,
+ events,
+ getActivePendingInteractionForThread,
+ getExperiments,
+ getHighWaterMarks,
+ getThread,
+ hasQueuedThreadMessages,
+ hasRootStoredTurnStarted,
+ listActiveBackgroundTaskCountsByThreadIds,
+ type DbQueryConnection,
+} from "@bb/db";
+import { threadScope, type Thread, type ThreadEvent } from "@bb/domain";
+import type {
+ EditMessageRequest,
+ EditMessageResponse,
+} from "@bb/server-contract";
+import type {
+ HostDaemonCommand,
+ HostDaemonCommandResult,
+} from "@bb/host-daemon-contract";
+import type { LoggedPendingInteractionWorkSessionDeps } from "../../types.js";
+import { ApiError } from "../../errors.js";
+import {
+ appendThreadEventInTransaction,
+ createClientTurnRequestId,
+ parseStoredTurnRequestEvent,
+} from "./thread-events.js";
+import { parseStoredEvent } from "./thread-data.js";
+import {
+ buildExecutionOptions,
+ buildThreadStartCommand,
+} from "./thread-commands.js";
+import { resolvePermissionEscalation } from "./thread-runtime-config.js";
+import { requireReadyThreadEnvironment } from "./thread-turn-dispatch.js";
+import { ensureHostSessionReadyForWork } from "../hosts/host-lifecycle.js";
+import {
+ LIVE_DAEMON_COMMAND_TIMEOUT_MS,
+ runLiveHostCommand,
+} from "../hosts/live-command.js";
+import {
+ resolveMessageSenderThreadId,
+ sendThreadMessage,
+} from "./thread-send.js";
+
+type ThreadRewindPrepareCommand = Extract<
+ HostDaemonCommand,
+ { type: "thread.rewind.prepare" }
+>;
+
+interface EditableTurn {
+ currentTurnId: string;
+ oldMaxSequence: number;
+ precedingProviderCheckpoint: string | null;
+ requestSequence: number;
+ sourceProviderThreadId: string | null;
+}
+
+function conflict(message: string): never {
+ throw new ApiError(409, "invalid_request", message);
+}
+
+function requestFingerprint(payload: EditMessageRequest): string {
+ return createHash("sha256")
+ .update(
+ JSON.stringify({
+ expectedRequestSequence: payload.expectedRequestSequence,
+ executionInputSources: payload.executionInputSources,
+ input: payload.input,
+ model: payload.model,
+ permissionMode: payload.permissionMode,
+ reasoningLevel: payload.reasoningLevel,
+ senderThreadId: payload.senderThreadId,
+ serviceTier: payload.serviceTier,
+ }),
+ )
+ .digest("hex");
+}
+
+function findCommittedOperation(
+ db: DbQueryConnection,
+ args: { operationId: string; threadId: string },
+): { fingerprint: string; requestSequence: number } | null {
+ const row = db
+ .select()
+ .from(events)
+ .where(
+ and(
+ eq(events.threadId, args.threadId),
+ eq(events.type, "system/operation"),
+ sql`json_extract(${events.data}, '$.operation') = 'edit_message'`,
+ sql`json_extract(${events.data}, '$.operationId') = ${args.operationId}`,
+ ),
+ )
+ .limit(1)
+ .get();
+ if (!row) return null;
+ const event = parseStoredEvent(row);
+ if (event.type !== "system/operation") return null;
+ const fingerprint = event.metadata?.fingerprint;
+ const requestSequence = event.metadata?.requestSequence;
+ return typeof fingerprint === "string" && typeof requestSequence === "number"
+ ? { fingerprint, requestSequence }
+ : null;
+}
+
+const EDIT_MESSAGE_PROVIDER_IDS = new Set(["claude-code", "codex", "pi"]);
+
+function getTurnCompletion(
+ db: DbQueryConnection,
+ threadId: string,
+ turnId: string,
+): Extract | null {
+ const row = db
+ .select()
+ .from(events)
+ .where(
+ and(
+ eq(events.threadId, threadId),
+ eq(events.type, "turn/completed"),
+ eq(events.turnId, turnId),
+ ),
+ )
+ .orderBy(desc(events.sequence))
+ .limit(1)
+ .get();
+ if (!row) return null;
+ const event = parseStoredEvent(row);
+ return event.type === "turn/completed" ? event : null;
+}
+
+function resolveEditableTurnCandidate(
+ db: DbQueryConnection,
+ thread: Thread,
+ requestRow: Parameters[0],
+): EditableTurn {
+ const request = parseStoredTurnRequestEvent(requestRow);
+ if (
+ request.initiator !== "user" ||
+ request.senderThreadId !== null ||
+ (request.target.kind !== "new-turn" &&
+ request.target.kind !== "thread-start")
+ ) {
+ conflict("The selected request is not an editable user turn");
+ }
+ if (request.inputGroups !== undefined) {
+ conflict("Grouped messages cannot be edited yet");
+ }
+
+ const acceptedRows = db
+ .select({ sequence: events.sequence, turnId: events.turnId })
+ .from(events)
+ .where(
+ and(
+ eq(events.threadId, thread.id),
+ eq(events.type, "turn/input/accepted"),
+ sql`json_extract(${events.data}, '$.clientRequestId') = ${request.requestId}`,
+ ),
+ )
+ .orderBy(events.sequence)
+ .limit(2)
+ .all();
+ const accepted = acceptedRows.length === 1 ? acceptedRows[0] : undefined;
+ if (!accepted?.turnId) {
+ conflict("The selected request was not accepted into exactly one turn");
+ }
+
+ if (
+ !hasRootStoredTurnStarted(db, {
+ threadId: thread.id,
+ turnId: accepted.turnId,
+ })
+ ) {
+ conflict("The selected message does not belong to a root turn");
+ }
+ const turnAcceptedCount = db
+ .select({ count: sql`COUNT(*)` })
+ .from(events)
+ .where(
+ and(
+ eq(events.threadId, thread.id),
+ eq(events.type, "turn/input/accepted"),
+ eq(events.turnId, accepted.turnId),
+ ),
+ )
+ .get()?.count;
+ if (turnAcceptedCount !== 1) {
+ conflict(
+ "A turn containing steers or multiple accepted messages cannot be edited",
+ );
+ }
+ const completion = getTurnCompletion(db, thread.id, accepted.turnId);
+ if (completion?.status !== "completed") {
+ conflict("The selected turn did not complete successfully");
+ }
+
+ const precedingTurn = db
+ .select({ turnId: events.turnId })
+ .from(events)
+ .where(
+ and(
+ eq(events.threadId, thread.id),
+ eq(events.type, "turn/started"),
+ lt(events.sequence, requestRow.sequence),
+ sql`COALESCE(json_extract(${events.data}, '$.parentToolCallId'), '') = ''`,
+ ),
+ )
+ .orderBy(desc(events.sequence))
+ .limit(1)
+ .get();
+ const precedingTurnId = precedingTurn?.turnId ?? null;
+ const precedingCompletion =
+ precedingTurnId === null
+ ? null
+ : getTurnCompletion(db, thread.id, precedingTurnId);
+ if (
+ precedingTurnId !== null &&
+ (precedingCompletion?.status !== "completed" ||
+ precedingCompletion.providerThreadId === null)
+ ) {
+ conflict("This earlier turn has no completed provider history");
+ }
+ const precedingProviderCheckpoint =
+ precedingTurnId === null
+ ? null
+ : thread.providerId === "codex"
+ ? precedingTurnId
+ : (precedingCompletion?.providerCheckpointId ?? null);
+ if (precedingTurnId !== null && precedingProviderCheckpoint === null) {
+ conflict("This earlier provider turn has no editable history checkpoint");
+ }
+ return {
+ currentTurnId: accepted.turnId,
+ oldMaxSequence: getHighWaterMarks(db, [thread.id])[thread.id] ?? 0,
+ precedingProviderCheckpoint,
+ requestSequence: requestRow.sequence,
+ sourceProviderThreadId:
+ precedingTurnId === null
+ ? null
+ : (precedingCompletion?.providerThreadId ?? null),
+ };
+}
+
+function resolveEditableTurn(
+ db: DbQueryConnection,
+ thread: Thread,
+ requestSequence?: number,
+): EditableTurn {
+ if (!EDIT_MESSAGE_PROVIDER_IDS.has(thread.providerId)) {
+ conflict(`Editing messages is not supported for ${thread.providerId}`);
+ }
+ if (thread.archivedAt !== null || thread.deletedAt !== null) {
+ conflict("The thread is not writable");
+ }
+ if (thread.status !== "idle") {
+ conflict("The thread must be idle before a message can be edited");
+ }
+ const backgroundActivity = listActiveBackgroundTaskCountsByThreadIds(db, {
+ threadIds: [thread.id],
+ })[0];
+ if (
+ backgroundActivity &&
+ (backgroundActivity.activeBackgroundAgentCount > 0 ||
+ backgroundActivity.activeBackgroundCommandCount > 0 ||
+ backgroundActivity.activeWorkflowCount > 0)
+ ) {
+ conflict("Wait for background work to finish before editing the message");
+ }
+
+ if (requestSequence !== undefined) {
+ const requestRow = db
+ .select({
+ data: events.data,
+ sequence: events.sequence,
+ threadId: events.threadId,
+ type: events.type,
+ })
+ .from(events)
+ .where(
+ and(
+ eq(events.threadId, thread.id),
+ eq(events.type, "client/turn/requested"),
+ eq(events.sequence, requestSequence),
+ ),
+ )
+ .limit(1)
+ .get();
+ if (requestRow !== undefined) {
+ return resolveEditableTurnCandidate(db, thread, requestRow);
+ }
+ conflict("The thread has no editable user message");
+ }
+
+ const requestRows = db
+ .select({
+ data: events.data,
+ sequence: events.sequence,
+ threadId: events.threadId,
+ type: events.type,
+ })
+ .from(events)
+ .where(
+ and(
+ eq(events.threadId, thread.id),
+ eq(events.type, "client/turn/requested"),
+ sql`json_extract(${events.data}, '$.initiator') = 'user'`,
+ sql`json_extract(${events.data}, '$.senderThreadId') IS NULL`,
+ sql`json_extract(${events.data}, '$.target.kind') IN ('new-turn', 'thread-start')`,
+ ),
+ )
+ .orderBy(desc(events.sequence))
+ .all();
+ for (const requestRow of requestRows) {
+ try {
+ return resolveEditableTurnCandidate(db, thread, requestRow);
+ } catch (error) {
+ if (
+ !(error instanceof ApiError) ||
+ error.status !== 409 ||
+ error.body.code !== "invalid_request"
+ ) {
+ throw error;
+ }
+ }
+ }
+ conflict("The thread has no editable user message");
+}
+
+function rewindPrepareCommandFromStart(
+ start: Extract,
+ args: {
+ leaseId: string;
+ retainThroughProviderCheckpoint: string;
+ sourceProviderThreadId: string;
+ },
+): ThreadRewindPrepareCommand {
+ // The daemon parses commands strictly, so every start-only field must stay
+ // in this destructure — the rest is exactly the shared runtime context.
+ const {
+ type: _type,
+ requestId: _requestId,
+ input: _input,
+ inputGroups: _inputGroups,
+ threadStoragePath: _threadStoragePath,
+ fork: _fork,
+ ...context
+ } = start;
+ return { type: "thread.rewind.prepare", ...context, ...args };
+}
+
+export async function editThreadMessage(
+ deps: LoggedPendingInteractionWorkSessionDeps,
+ args: {
+ environment: Parameters[0];
+ payload: EditMessageRequest;
+ thread: Thread;
+ },
+): Promise {
+ if (!getExperiments(deps.db).editMessages) {
+ conflict("Enable the Edit messages experiment before editing a message");
+ }
+ const fingerprint = requestFingerprint(args.payload);
+ const committed = findCommittedOperation(deps.db, {
+ operationId: args.payload.operationId,
+ threadId: args.thread.id,
+ });
+ if (committed) {
+ if (committed.fingerprint !== fingerprint) {
+ conflict("The edit operation id was already used for different input");
+ }
+ return {
+ ok: true,
+ operationId: args.payload.operationId,
+ requestSequence: committed.requestSequence,
+ };
+ }
+ if (deps.pendingInteractions.hasPendingThreadInteraction(args.thread.id)) {
+ conflict("Resolve the pending interaction before editing the message");
+ }
+ if (hasQueuedThreadMessages(deps.db, args.thread.id)) {
+ conflict("Send or remove queued messages before editing a message");
+ }
+ const senderThreadId = resolveMessageSenderThreadId(deps, {
+ senderThreadId: args.payload.senderThreadId,
+ targetThread: args.thread,
+ });
+ const initiator = senderThreadId === null ? "user" : "agent";
+
+ const target = resolveEditableTurn(
+ deps.db,
+ args.thread,
+ args.payload.expectedRequestSequence,
+ );
+ const readyEnvironment = requireReadyThreadEnvironment(args.environment);
+ await ensureHostSessionReadyForWork(deps, {
+ hostId: readyEnvironment.hostId,
+ });
+ const execution = await buildExecutionOptions(
+ deps,
+ args.payload,
+ { threadId: args.thread.id },
+ "client/turn/requested",
+ );
+
+ let stagedProviderThreadId: string | null = null;
+ let rewindLeaseId: string | null = null;
+ if (target.precedingProviderCheckpoint !== null) {
+ if (target.sourceProviderThreadId === null) {
+ conflict("This earlier turn has no provider session");
+ }
+ rewindLeaseId = randomUUID();
+ const startCommand = await buildThreadStartCommand(deps, {
+ thread: args.thread,
+ fork: null,
+ input: [],
+ requestId: createClientTurnRequestId(),
+ execution,
+ permissionEscalation: resolvePermissionEscalation({
+ thread: args.thread,
+ initiator,
+ }),
+ environment: readyEnvironment,
+ projectId: args.thread.projectId,
+ providerId: args.thread.providerId,
+ syncGeneratedTitle: false,
+ });
+ const prepared: HostDaemonCommandResult<"thread.rewind.prepare"> =
+ await runLiveHostCommand(deps, {
+ command: rewindPrepareCommandFromStart(startCommand, {
+ leaseId: rewindLeaseId,
+ retainThroughProviderCheckpoint: target.precedingProviderCheckpoint,
+ sourceProviderThreadId: target.sourceProviderThreadId,
+ }),
+ hostId: readyEnvironment.hostId,
+ timeoutMs: LIVE_DAEMON_COMMAND_TIMEOUT_MS,
+ });
+ stagedProviderThreadId = prepared.providerThreadId;
+ }
+
+ const requestSequence = target.oldMaxSequence + 2;
+ const discardStagedRewind =
+ stagedProviderThreadId === null || rewindLeaseId === null
+ ? undefined
+ : async () => {
+ try {
+ await runLiveHostCommand(deps, {
+ command: {
+ type: "thread.rewind.discard",
+ environmentId: readyEnvironment.id,
+ threadId: args.thread.id,
+ leaseId: rewindLeaseId,
+ },
+ hostId: readyEnvironment.hostId,
+ timeoutMs: LIVE_DAEMON_COMMAND_TIMEOUT_MS,
+ });
+ } catch (error) {
+ deps.logger.warn(
+ { err: error, threadId: args.thread.id },
+ "Failed to discard staged message-edit rewind",
+ );
+ }
+ };
+ const {
+ operationId: _operationId,
+ expectedRequestSequence: _expectedRequestSequence,
+ ...sendPayload
+ } = args.payload;
+ try {
+ await sendThreadMessage(deps, {
+ beforeAppendInTransaction: ({ tx }) => {
+ if (getActivePendingInteractionForThread(tx, args.thread.id)) {
+ conflict(
+ "Resolve the pending interaction before editing the message",
+ );
+ }
+ if (hasQueuedThreadMessages(tx, args.thread.id)) {
+ conflict("Send or remove queued messages before editing a message");
+ }
+ const currentThread = getThread(tx, args.thread.id);
+ if (!currentThread) conflict("Thread not found");
+ const currentTarget = resolveEditableTurn(
+ tx,
+ currentThread,
+ target.requestSequence,
+ );
+ if (
+ currentTarget.oldMaxSequence !== target.oldMaxSequence ||
+ currentTarget.currentTurnId !== target.currentTurnId
+ ) {
+ conflict("The thread changed while the edit was being prepared");
+ }
+ appendThreadEventInTransaction(tx, {
+ threadId: args.thread.id,
+ environmentId: args.thread.environmentId,
+ type: "system/operation",
+ scope: threadScope(),
+ data: {
+ operation: "edit_message",
+ status: "completed",
+ message: "Message edited",
+ operationId: args.payload.operationId,
+ metadata: {
+ cutoffSequence: target.requestSequence,
+ oldMaxSequence: target.oldMaxSequence,
+ removedTurnId: target.currentTurnId,
+ replacementProviderThreadId: stagedProviderThreadId,
+ fingerprint,
+ requestSequence,
+ },
+ },
+ });
+ deleteThreadEventSuffixInTransaction(tx, {
+ cutoffSequence: target.requestSequence,
+ oldMaxSequence: target.oldMaxSequence,
+ threadId: args.thread.id,
+ });
+ },
+ environment: args.environment,
+ historyReplacement: {
+ forkSourceProviderThreadId: stagedProviderThreadId,
+ ...(discardStagedRewind !== undefined
+ ? { onCommandSettled: discardStagedRewind }
+ : {}),
+ },
+ payload: { ...sendPayload, mode: "start" },
+ thread: args.thread,
+ trigger: "user",
+ });
+ deps.hub.notifyThread(args.thread.id, ["history-rewritten"], {
+ projectId: args.thread.projectId,
+ });
+ } catch (error) {
+ await discardStagedRewind?.();
+ const concurrentCommit = findCommittedOperation(deps.db, {
+ operationId: args.payload.operationId,
+ threadId: args.thread.id,
+ });
+ if (!concurrentCommit || concurrentCommit.fingerprint !== fingerprint) {
+ throw error;
+ }
+ return {
+ ok: true,
+ operationId: args.payload.operationId,
+ requestSequence: concurrentCommit.requestSequence,
+ };
+ }
+ return {
+ ok: true,
+ operationId: args.payload.operationId,
+ requestSequence,
+ };
+}
diff --git a/apps/server/src/services/threads/thread-runtime-display.ts b/apps/server/src/services/threads/thread-runtime-display.ts
index 4e5532d5ab..4e7caa9ec9 100644
--- a/apps/server/src/services/threads/thread-runtime-display.ts
+++ b/apps/server/src/services/threads/thread-runtime-display.ts
@@ -256,6 +256,10 @@ export function toThreadResponseFromThread(
});
return {
...threadWithRuntime,
+ activeBackgroundAgentCount:
+ listActiveBackgroundTaskCountsByThreadIds(deps.db, {
+ threadIds: [args.thread.id],
+ })[0]?.activeBackgroundAgentCount ?? 0,
canSpawnChild: canThreadSpawnChild(deps, { thread: args.thread }),
};
}
diff --git a/apps/server/src/services/threads/thread-send.ts b/apps/server/src/services/threads/thread-send.ts
index f26220d55d..c97ddfb44a 100644
--- a/apps/server/src/services/threads/thread-send.ts
+++ b/apps/server/src/services/threads/thread-send.ts
@@ -23,6 +23,7 @@ import { ApiError } from "../../errors.js";
import {
addRequestIdToTurnSubmitCommandPayload,
buildExecutionOptions,
+ buildThreadStartCommand,
prepareTurnSubmitCommandPayload,
} from "./thread-commands.js";
import {
@@ -71,6 +72,15 @@ type SendThreadMessagePayload = SendMessageRequest & {
export interface SendThreadMessageArgs {
beforeAppendInTransaction?: SendThreadMessageTransactionPreflight;
environment: Environment;
+ /**
+ * Internal edit-message path. Presence forces a new provider session;
+ * a string forks from a staged provider session and null starts fresh
+ * (which also makes the request a thread-start rather than a new turn).
+ */
+ historyReplacement?: {
+ forkSourceProviderThreadId: string | null;
+ onCommandSettled?: () => void | Promise;
+ };
payload: SendThreadMessagePayload;
thread: Thread;
trigger: SendThreadMessageTrigger;
@@ -489,7 +499,13 @@ export async function sendThreadMessage(
);
let target: TurnRequestTarget;
if (mode === "start") {
- target = { kind: "new-turn" };
+ target = {
+ kind:
+ args.historyReplacement !== undefined &&
+ args.historyReplacement.forkSourceProviderThreadId === null
+ ? "thread-start"
+ : "new-turn",
+ };
} else {
target = {
kind: mode,
@@ -500,10 +516,10 @@ export async function sendThreadMessage(
const requestId = createClientTurnRequestId();
if (mode === "start") {
- const command = await prepareReadyThreadTurnCommand(deps, {
+ const commandArgs = {
thread,
- // A send/steer always targets an already-started thread; forking only
- // happens at create time.
+ // Normal sends target the existing provider session. A history
+ // replacement deliberately starts from a staged provider fork instead.
fork: null,
input,
...(inputGroups !== undefined ? { inputGroups } : {}),
@@ -520,7 +536,23 @@ export async function sendThreadMessage(
projectId: thread.projectId,
providerId: thread.providerId,
syncGeneratedTitle: false,
- });
+ };
+ const command = args.historyReplacement
+ ? {
+ command: await buildThreadStartCommand(deps, {
+ ...commandArgs,
+ fork:
+ args.historyReplacement.forkSourceProviderThreadId === null
+ ? null
+ : {
+ sourceProviderThreadId:
+ args.historyReplacement.forkSourceProviderThreadId,
+ },
+ }),
+ mode: "thread.start" as const,
+ sessionId: "history-replacement",
+ }
+ : await prepareReadyThreadTurnCommand(deps, commandArgs);
const queuedRequest = appendAndQueueSendThreadMessageInTransaction({
beforeAppendInTransaction: ({ tx }) => {
args.beforeAppendInTransaction?.({ tx });
@@ -574,6 +606,9 @@ export async function sendThreadMessage(
command: command.command,
hostId: readyEnvironment.hostId,
timeoutMs: LIVE_DAEMON_COMMAND_TIMEOUT_MS,
+ ...(args.historyReplacement?.onCommandSettled !== undefined
+ ? { onSettled: args.historyReplacement.onCommandSettled }
+ : {}),
onError: ({ error }) => {
deps.logger.warn(
{ err: error, threadId: thread.id },
diff --git a/apps/server/test/services/threads/timeline-pagination.test.ts b/apps/server/test/services/threads/timeline-pagination.test.ts
index 0a9a625829..98b0d3787b 100644
--- a/apps/server/test/services/threads/timeline-pagination.test.ts
+++ b/apps/server/test/services/threads/timeline-pagination.test.ts
@@ -27,7 +27,7 @@ function userRow(args: {
senderThreadId: null,
systemMessageKind: "unlabeled",
systemMessageSubject: null,
- turnRequest: { kind: "message", status: "accepted" },
+ turnRequest: { isGrouped: false, kind: "message", status: "accepted" },
};
}
diff --git a/apps/server/test/system/experiments.test.ts b/apps/server/test/system/experiments.test.ts
index f6806306cf..04948d0441 100644
--- a/apps/server/test/system/experiments.test.ts
+++ b/apps/server/test/system/experiments.test.ts
@@ -13,6 +13,7 @@ describe("experiments settings", () => {
const body = systemConfigResponseSchema.parse(await readJson(response));
expect(body.experiments).toEqual({
claudeCodeMockCliTraffic: false,
+ editMessages: false,
newOnboarding: false,
toolsHub: false,
});
@@ -26,6 +27,7 @@ describe("experiments settings", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({
claudeCodeMockCliTraffic: true,
+ editMessages: true,
newOnboarding: true,
toolsHub: true,
}),
@@ -33,11 +35,13 @@ describe("experiments settings", () => {
expect(put.status).toBe(200);
expect(experimentsSchema.parse(await readJson(put))).toEqual({
claudeCodeMockCliTraffic: true,
+ editMessages: true,
newOnboarding: true,
toolsHub: true,
});
expect(getExperiments(harness.db)).toEqual({
claudeCodeMockCliTraffic: true,
+ editMessages: true,
newOnboarding: true,
toolsHub: true,
});
@@ -47,6 +51,7 @@ describe("experiments settings", () => {
systemConfigResponseSchema.parse(await readJson(config)).experiments,
).toEqual({
claudeCodeMockCliTraffic: true,
+ editMessages: true,
newOnboarding: true,
toolsHub: true,
});
@@ -63,6 +68,7 @@ describe("experiments settings", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({
claudeCodeMockCliTraffic: false,
+ editMessages: false,
newOnboarding: false,
toolsHub: false,
}),
diff --git a/apps/server/test/threads/thread-edit-message.test.ts b/apps/server/test/threads/thread-edit-message.test.ts
new file mode 100644
index 0000000000..a5b7ef117b
--- /dev/null
+++ b/apps/server/test/threads/thread-edit-message.test.ts
@@ -0,0 +1,1042 @@
+import {
+ claimQueuedThreadMessage,
+ createPendingInteraction,
+ createPromptHistoryEntry,
+ getThread,
+ listEvents,
+ listStoredProjectPromptHistoryRows,
+ listStoredThreadPromptHistoryRows,
+ setExperiments,
+} from "@bb/db";
+import {
+ defaultExperiments,
+ encodeClientTurnRequestIdNumber,
+ threadScope,
+ turnScope,
+ type PromptInput,
+} from "@bb/domain";
+import { describe, expect, it, vi } from "vitest";
+import { editThreadMessage } from "../../src/services/threads/thread-edit-message.js";
+import {
+ listQueuedThreadCommands,
+ reportQueuedCommandError,
+ reportQueuedCommandSuccess,
+ waitForQueuedCommand,
+ waitForQueuedCommandAfter,
+} from "../helpers/commands.js";
+import {
+ seedEnvironment,
+ seedHostSession,
+ seedProjectWithSource,
+ seedQueuedMessage,
+ seedStoredEvent,
+ seedThread,
+} from "../helpers/seed.js";
+import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js";
+
+function seedCompletedTurn(
+ harness: TestAppHarness,
+ args: {
+ providerThreadId: string;
+ providerCheckpointId?: string;
+ completionStatus?: "completed" | "failed";
+ inputGroups?: PromptInput[][];
+ initiator?: "agent" | "user";
+ requestSequence: number;
+ senderThreadId?: string;
+ text: string;
+ threadId: string;
+ turnId: string;
+ },
+): void {
+ const requestId = encodeClientTurnRequestIdNumber({
+ value: args.requestSequence,
+ });
+ seedStoredEvent(harness.deps, {
+ threadId: args.threadId,
+ providerThreadId: args.providerThreadId,
+ sequence: args.requestSequence,
+ type: "client/turn/requested",
+ scope: threadScope(),
+ data: {
+ direction: "outbound",
+ requestId,
+ input: args.inputGroups?.flatMap((group, index) =>
+ index === 0
+ ? group
+ : [{ type: "text" as const, text: "\n\n", mentions: [] }, ...group],
+ ) ?? [{ type: "text", text: args.text, mentions: [] }],
+ ...(args.inputGroups !== undefined
+ ? { inputGroups: args.inputGroups }
+ : {}),
+ target:
+ args.requestSequence === 2
+ ? { kind: "thread-start" }
+ : { kind: "new-turn" },
+ execution: {
+ model: "gpt-5",
+ serviceTier: "default",
+ reasoningLevel: "medium" as const,
+ permissionMode: "full",
+ source: "client/turn/requested",
+ },
+ initiator: args.initiator ?? "user",
+ senderThreadId: args.senderThreadId ?? null,
+ request: { method: "turn/start", params: {} },
+ source: "tell",
+ },
+ });
+ seedStoredEvent(harness.deps, {
+ threadId: args.threadId,
+ providerThreadId: args.providerThreadId,
+ sequence: args.requestSequence + 1,
+ type: "turn/started",
+ scope: turnScope(args.turnId),
+ data: { providerThreadId: args.providerThreadId },
+ });
+ seedStoredEvent(harness.deps, {
+ threadId: args.threadId,
+ providerThreadId: args.providerThreadId,
+ sequence: args.requestSequence + 2,
+ type: "turn/input/accepted",
+ scope: turnScope(args.turnId),
+ data: {
+ providerThreadId: args.providerThreadId,
+ clientRequestId: requestId,
+ },
+ });
+ seedStoredEvent(harness.deps, {
+ threadId: args.threadId,
+ providerThreadId: args.providerThreadId,
+ sequence: args.requestSequence + 3,
+ type: "item/completed",
+ scope: turnScope(args.turnId),
+ itemId: `message-${args.turnId}`,
+ itemKind: "agentMessage",
+ data: {
+ item: {
+ id: `message-${args.turnId}`,
+ type: "agentMessage",
+ text: `Reply to ${args.text}`,
+ },
+ },
+ });
+ seedStoredEvent(harness.deps, {
+ threadId: args.threadId,
+ providerThreadId: args.providerThreadId,
+ sequence: args.requestSequence + 4,
+ type: "turn/completed",
+ scope: turnScope(args.turnId),
+ data: {
+ providerThreadId: args.providerThreadId,
+ status: args.completionStatus ?? "completed",
+ ...(args.providerCheckpointId !== undefined
+ ? { providerCheckpointId: args.providerCheckpointId }
+ : {}),
+ },
+ });
+}
+
+function seedEditableThread(
+ harness: TestAppHarness,
+ args: {
+ editMessagesExperiment?: boolean;
+ firstProviderCheckpoint?: string | null;
+ firstProviderThreadId?: string;
+ includeIdentity?: boolean;
+ includeSecondTurn?: boolean;
+ providerId?: "claude-code" | "codex" | "pi";
+ } = {},
+) {
+ setExperiments(harness.db, {
+ ...defaultExperiments,
+ editMessages: args.editMessagesExperiment ?? true,
+ });
+ const { host } = seedHostSession(harness.deps, {
+ id: "host-edit-message",
+ });
+ const { project } = seedProjectWithSource(harness.deps, {
+ hostId: host.id,
+ path: "/tmp/edit-message",
+ });
+ const environment = seedEnvironment(harness.deps, {
+ hostId: host.id,
+ projectId: project.id,
+ path: "/tmp/edit-message",
+ status: "ready",
+ });
+ const thread = seedThread(harness.deps, {
+ projectId: project.id,
+ environmentId: environment.id,
+ providerId: args.providerId ?? "codex",
+ status: "idle",
+ });
+ if (args.includeIdentity !== false) {
+ seedStoredEvent(harness.deps, {
+ threadId: thread.id,
+ environmentId: environment.id,
+ providerThreadId: "provider-original",
+ sequence: 1,
+ type: "thread/identity",
+ scope: threadScope(),
+ data: {},
+ });
+ }
+ seedCompletedTurn(harness, {
+ providerThreadId: args.firstProviderThreadId ?? "provider-original",
+ requestSequence: 2,
+ text: "First message",
+ threadId: thread.id,
+ turnId: "turn-first",
+ ...(args.firstProviderCheckpoint !== null
+ ? {
+ providerCheckpointId:
+ args.firstProviderCheckpoint ?? "checkpoint-first",
+ }
+ : {}),
+ });
+ createPromptHistoryEntry(harness.db, {
+ input: [{ type: "text", text: "First message", mentions: [] }],
+ projectId: project.id,
+ requestSequence: 2,
+ scope: "project",
+ threadId: thread.id,
+ });
+ if (args.includeSecondTurn !== false) {
+ seedCompletedTurn(harness, {
+ providerThreadId: "provider-original",
+ requestSequence: 7,
+ text: "Original last message",
+ threadId: thread.id,
+ turnId: "turn-last",
+ providerCheckpointId: "checkpoint-last",
+ });
+ createPromptHistoryEntry(harness.db, {
+ input: [{ type: "text", text: "Original last message", mentions: [] }],
+ projectId: project.id,
+ requestSequence: 7,
+ scope: "thread",
+ threadId: thread.id,
+ });
+ }
+ return { environment, thread };
+}
+
+describe("editThreadMessage", () => {
+ it("preserves an agent caller and applies agent permission policy", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness, {
+ includeSecondTurn: false,
+ });
+ const senderThread = seedThread(harness.deps, {
+ environmentId: environment.id,
+ projectId: thread.projectId,
+ providerId: "codex",
+ status: "active",
+ });
+
+ await expect(
+ editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: "edit-op-agent-caller",
+ expectedRequestSequence: 2,
+ input: [
+ { type: "text", text: "Replacement by agent", mentions: [] },
+ ],
+ permissionMode: "accept-edits",
+ senderThreadId: senderThread.id,
+ },
+ }),
+ ).resolves.toMatchObject({ ok: true, requestSequence: 8 });
+
+ const replacement = listEvents(harness.db, { threadId: thread.id }).find(
+ (event) => event.sequence === 8,
+ );
+ expect(JSON.parse(replacement?.data ?? "null")).toMatchObject({
+ initiator: "agent",
+ senderThreadId: senderThread.id,
+ execution: {
+ permissionMode: "accept-edits",
+ },
+ input: [
+ expect.objectContaining({
+ type: "text",
+ text: expect.stringContaining("Replacement by agent"),
+ }),
+ ],
+ });
+ const start = await waitForQueuedCommand(
+ harness,
+ (queued) =>
+ queued.command.type === "thread.start" &&
+ queued.command.threadId === thread.id,
+ );
+ expect(start.command).toMatchObject({
+ options: { permissionEscalation: "deny" },
+ });
+ });
+ });
+
+ it("rejects an unknown agent caller before rewriting history", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness, {
+ includeSecondTurn: false,
+ });
+
+ await expect(
+ editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: "edit-op-unknown-agent",
+ expectedRequestSequence: 2,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ senderThreadId: "thread-does-not-exist",
+ },
+ }),
+ ).rejects.toThrow("Sender thread is invalid");
+ expect(listEvents(harness.db, { threadId: thread.id })).toHaveLength(6);
+ });
+ });
+
+ it("targets the latest user message when expectedRequestSequence is omitted, skipping agent turns", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness);
+ seedCompletedTurn(harness, {
+ initiator: "agent",
+ providerCheckpointId: "checkpoint-agent",
+ providerThreadId: "provider-original",
+ requestSequence: 12,
+ senderThreadId: "sender-thread",
+ text: "Agent follow-up",
+ threadId: thread.id,
+ turnId: "turn-agent",
+ });
+
+ const editPromise = editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: "edit-op-latest",
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ },
+ });
+ const rewind = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.prepare",
+ );
+ expect(rewind.command).toMatchObject({
+ retainThroughProviderCheckpoint: "turn-first",
+ });
+ if (rewind.command.type !== "thread.rewind.prepare") {
+ throw new Error("Expected a thread.rewind.prepare command");
+ }
+ await reportQueuedCommandSuccess(harness, rewind, {
+ providerThreadId: "provider-staged-rewind",
+ });
+ await expect(editPromise).resolves.toMatchObject({ ok: true });
+
+ const marker = listEvents(harness.db, { threadId: thread.id }).find(
+ (event) => event.type === "system/operation",
+ );
+ expect(JSON.parse(marker?.data ?? "null")).toMatchObject({
+ metadata: { cutoffSequence: 7 },
+ });
+ });
+ });
+
+ it("replaces an earlier Codex turn and removes every later conversation turn", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness);
+
+ await expect(
+ editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: "edit-op-earlier-turn",
+ expectedRequestSequence: 2,
+ input: [
+ { type: "text", text: "Replacement first message", mentions: [] },
+ ],
+ },
+ }),
+ ).resolves.toEqual({
+ ok: true,
+ operationId: "edit-op-earlier-turn",
+ requestSequence: 13,
+ });
+
+ expect(
+ listQueuedThreadCommands(harness, "thread.rewind.prepare", thread.id),
+ ).toHaveLength(0);
+ await waitForQueuedCommand(
+ harness,
+ (queued) =>
+ queued.command.type === "thread.start" &&
+ queued.command.threadId === thread.id,
+ );
+ expect(
+ listEvents(harness.db, { threadId: thread.id }).map(
+ (event) => event.sequence,
+ ),
+ ).toEqual([1, 12, 13]);
+ });
+ });
+
+ it.each(["codex", "claude-code", "pi"] as const)(
+ "starts a fresh %s session when editing the first turn",
+ async (providerId) => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness, {
+ includeIdentity: false,
+ includeSecondTurn: false,
+ providerId,
+ });
+ const operationId = `edit-op-first-turn-${providerId}`;
+
+ await expect(
+ editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId,
+ expectedRequestSequence: 2,
+ input: [
+ {
+ type: "text",
+ text: "Replacement first message",
+ mentions: [],
+ },
+ ],
+ },
+ }),
+ ).resolves.toEqual({
+ ok: true,
+ operationId,
+ requestSequence: 8,
+ });
+
+ expect(
+ listQueuedThreadCommands(harness, "thread.rewind.prepare", thread.id),
+ ).toHaveLength(0);
+ await waitForQueuedCommand(
+ harness,
+ (queued) =>
+ queued.command.type === "thread.start" &&
+ queued.command.threadId === thread.id,
+ );
+ expect(
+ listQueuedThreadCommands(harness, "thread.start", thread.id),
+ ).toEqual([expect.not.objectContaining({ fork: expect.anything() })]);
+ expect(
+ listEvents(harness.db, { threadId: thread.id }).map(
+ (event) => event.sequence,
+ ),
+ ).toEqual([7, 8]);
+ expect(
+ listStoredProjectPromptHistoryRows(harness.db, {
+ projectId: thread.projectId,
+ limit: 10,
+ }).map((entry) => entry.requestSequence),
+ ).toEqual([8]);
+ expect(
+ listStoredThreadPromptHistoryRows(harness.db, {
+ threadId: thread.id,
+ limit: 10,
+ }),
+ ).toEqual([]);
+ });
+ },
+ );
+
+ it("keeps history intact until the staged Codex rewind succeeds, then replaces the suffix", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness);
+ const payload = {
+ operationId: "edit-op-success",
+ expectedRequestSequence: 7,
+ input: [
+ { type: "text" as const, text: "Replacement message", mentions: [] },
+ ],
+ model: "gpt-5",
+ serviceTier: "default" as const,
+ reasoningLevel: "medium" as const,
+ permissionMode: "full" as const,
+ };
+ const editPromise = editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload,
+ });
+ const rewind = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.prepare",
+ );
+ expect(rewind.command).toMatchObject({
+ sourceProviderThreadId: "provider-original",
+ retainThroughProviderCheckpoint: "turn-first",
+ threadId: thread.id,
+ });
+ expect(listEvents(harness.db, { threadId: thread.id })).toHaveLength(11);
+
+ if (rewind.command.type !== "thread.rewind.prepare") {
+ throw new Error("Expected a thread.rewind.prepare command");
+ }
+ await reportQueuedCommandSuccess(harness, rewind, {
+ providerThreadId: "provider-staged-rewind",
+ });
+ await expect(editPromise).resolves.toEqual({
+ ok: true,
+ operationId: "edit-op-success",
+ requestSequence: 13,
+ });
+
+ const stored = listEvents(harness.db, { threadId: thread.id });
+ expect(stored.map((event) => event.sequence)).toEqual([
+ 1, 2, 3, 4, 5, 6, 12, 13,
+ ]);
+ const replacement = stored.find((event) => event.sequence === 13);
+ expect(replacement).toMatchObject({ type: "client/turn/requested" });
+ expect(JSON.parse(replacement?.data ?? "null")).toMatchObject({
+ input: [{ type: "text", text: "Replacement message", mentions: [] }],
+ });
+ expect(getThread(harness.db, thread.id)).toMatchObject({
+ status: "active",
+ });
+ const replacementStart = await waitForQueuedCommand(
+ harness,
+ (queued) =>
+ queued.command.type === "thread.start" &&
+ queued.command.threadId === thread.id,
+ );
+ expect(
+ listQueuedThreadCommands(harness, "thread.start", thread.id),
+ ).toEqual([
+ expect.objectContaining({
+ fork: { sourceProviderThreadId: "provider-staged-rewind" },
+ input: [{ type: "text", text: "Replacement message", mentions: [] }],
+ }),
+ ]);
+ await reportQueuedCommandSuccess(harness, replacementStart, {
+ providerThreadId: "provider-replacement",
+ });
+ const discard = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.discard",
+ );
+ expect(discard.command).toMatchObject({
+ leaseId:
+ rewind.command.type === "thread.rewind.prepare"
+ ? rewind.command.leaseId
+ : undefined,
+ threadId: thread.id,
+ });
+ await reportQueuedCommandSuccess(harness, discard, {});
+ await expect(
+ editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload,
+ }),
+ ).resolves.toEqual({
+ ok: true,
+ operationId: "edit-op-success",
+ requestSequence: 13,
+ });
+ expect(
+ listQueuedThreadCommands(harness, "thread.start", thread.id),
+ ).toHaveLength(0);
+ expect(
+ listStoredProjectPromptHistoryRows(harness.db, {
+ projectId: thread.projectId,
+ limit: 10,
+ }).map((entry) => entry.requestSequence),
+ ).toEqual([2]);
+ expect(
+ listStoredThreadPromptHistoryRows(harness.db, {
+ threadId: thread.id,
+ limit: 10,
+ }).map((entry) => entry.requestSequence),
+ ).toEqual([13]);
+ });
+ });
+
+ it("returns the committed result to an overlapping retry of the same operation", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness);
+ const payload = {
+ operationId: "edit-op-overlapping-retry",
+ expectedRequestSequence: 7,
+ input: [
+ { type: "text" as const, text: "Replacement message", mentions: [] },
+ ],
+ };
+
+ const firstEdit = editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload,
+ });
+ const secondEdit = editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload,
+ });
+ const editsResult = expect(
+ Promise.all([firstEdit, secondEdit]),
+ ).resolves.toEqual([
+ {
+ ok: true,
+ operationId: payload.operationId,
+ requestSequence: 13,
+ },
+ {
+ ok: true,
+ operationId: payload.operationId,
+ requestSequence: 13,
+ },
+ ]);
+ const firstRewind = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.prepare",
+ );
+ const secondRewind = await waitForQueuedCommandAfter(
+ harness,
+ firstRewind.row.cursor,
+ (queued) => queued.command.type === "thread.rewind.prepare",
+ );
+ await reportQueuedCommandSuccess(harness, firstRewind, {
+ providerThreadId: "provider-staged-overlap",
+ });
+ await reportQueuedCommandSuccess(harness, secondRewind, {
+ providerThreadId: "provider-staged-overlap",
+ });
+
+ const losingLeaseDiscard = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.discard",
+ );
+ await reportQueuedCommandSuccess(harness, losingLeaseDiscard, {});
+
+ await editsResult;
+ await waitForQueuedCommand(
+ harness,
+ (queued) =>
+ queued.command.type === "thread.start" &&
+ queued.command.threadId === thread.id,
+ );
+ expect(
+ listQueuedThreadCommands(harness, "thread.start", thread.id),
+ ).toHaveLength(1);
+ });
+ });
+
+ it.each(["claude-code", "pi"] as const)(
+ "stages %s history through the preceding provider checkpoint",
+ async (providerId) => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness, {
+ providerId,
+ });
+ const editPromise = editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: `edit-op-${providerId}`,
+ expectedRequestSequence: 7,
+ input: [
+ { type: "text", text: "Replacement message", mentions: [] },
+ ],
+ },
+ });
+
+ const rewind = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.prepare",
+ );
+ expect(rewind.command).toMatchObject({
+ providerId,
+ retainThroughProviderCheckpoint: "checkpoint-first",
+ sourceProviderThreadId: "provider-original",
+ });
+ if (rewind.command.type !== "thread.rewind.prepare") {
+ throw new Error("Expected a thread.rewind.prepare command");
+ }
+ await reportQueuedCommandSuccess(harness, rewind, {
+ providerThreadId: `provider-staged-${providerId}`,
+ });
+ await expect(editPromise).resolves.toMatchObject({
+ ok: true,
+ operationId: `edit-op-${providerId}`,
+ });
+ });
+ },
+ );
+
+ it("uses the provider lineage that produced the preceding checkpoint", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness, {
+ firstProviderThreadId: "provider-before-restart",
+ });
+ const editPromise = editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: "edit-op-provider-lineage",
+ expectedRequestSequence: 7,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ },
+ });
+ const rewind = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.prepare",
+ );
+ expect(rewind.command).toMatchObject({
+ sourceProviderThreadId: "provider-before-restart",
+ });
+ const rejectedEdit = expect(editPromise).rejects.toThrow(
+ "Stop after inspecting command",
+ );
+ await reportQueuedCommandError(harness, rewind, {
+ errorCode: "test_complete",
+ errorMessage: "Stop after inspecting command",
+ });
+ await rejectedEdit;
+ });
+ });
+
+ it("rejects grouped requests", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness);
+ seedCompletedTurn(harness, {
+ inputGroups: [
+ [{ type: "text", text: "Grouped first", mentions: [] }],
+ [{ type: "text", text: "Grouped second", mentions: [] }],
+ ],
+ providerCheckpointId: "checkpoint-grouped",
+ providerThreadId: "provider-original",
+ requestSequence: 12,
+ text: "Grouped request",
+ threadId: thread.id,
+ turnId: "turn-grouped",
+ });
+
+ await expect(
+ editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: "edit-op-grouped",
+ expectedRequestSequence: 12,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ },
+ }),
+ ).rejects.toThrow("Grouped messages cannot be edited yet");
+ });
+ });
+
+ it("resolves the latest edit past ineligible candidates", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness);
+ seedCompletedTurn(harness, {
+ inputGroups: [
+ [{ type: "text", text: "Grouped a", mentions: [] }],
+ [{ type: "text", text: "Grouped b", mentions: [] }],
+ ],
+ providerCheckpointId: "checkpoint-grouped",
+ providerThreadId: "provider-original",
+ requestSequence: 12,
+ text: "Grouped request",
+ threadId: thread.id,
+ turnId: "turn-grouped",
+ });
+
+ const editPromise = editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: "edit-op-paginated",
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ },
+ });
+ const rejectedEdit =
+ expect(editPromise).rejects.toThrow("Codex fork failed");
+ const rewind = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.prepare",
+ );
+ // Resolving skips the ineligible grouped candidate and lands on
+ // sequence 7, whose preceding root turn is turn-first.
+ expect(rewind.command).toMatchObject({
+ retainThroughProviderCheckpoint: "turn-first",
+ });
+ await reportQueuedCommandError(harness, rewind, {
+ errorCode: "provider_error",
+ errorMessage: "Codex fork failed",
+ });
+ await rejectedEdit;
+ });
+ });
+
+ it("rejects editing a turn that did not complete successfully", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness);
+ seedCompletedTurn(harness, {
+ completionStatus: "failed",
+ providerCheckpointId: "checkpoint-failed",
+ providerThreadId: "provider-original",
+ requestSequence: 12,
+ text: "Failed request",
+ threadId: thread.id,
+ turnId: "turn-failed",
+ });
+
+ await expect(
+ editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: "edit-op-failed-turn",
+ expectedRequestSequence: 12,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ },
+ }),
+ ).rejects.toThrow("did not complete successfully");
+ });
+ });
+
+ it("rechecks claimed queued messages after rewind preparation", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness);
+ const editPromise = editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: "edit-op-queue-race",
+ expectedRequestSequence: 7,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ },
+ });
+ const rewind = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.prepare",
+ );
+ const queuedMessage = seedQueuedMessage(harness.deps, {
+ content: [
+ { type: "text", text: "Queued while preparing", mentions: [] },
+ ],
+ threadId: thread.id,
+ });
+ expect(
+ claimQueuedThreadMessage(
+ harness.db,
+ harness.deps.hub,
+ queuedMessage.id,
+ ),
+ ).not.toBeNull();
+ const rejectedEdit = expect(editPromise).rejects.toThrow(
+ "Send or remove queued messages before editing a message",
+ );
+ await reportQueuedCommandSuccess(harness, rewind, {
+ providerThreadId: "provider-staged-queue-race",
+ });
+ const discard = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.discard",
+ );
+ expect(discard.command).toMatchObject({
+ leaseId:
+ rewind.command.type === "thread.rewind.prepare"
+ ? rewind.command.leaseId
+ : undefined,
+ });
+ await reportQueuedCommandSuccess(harness, discard, {});
+ await rejectedEdit;
+ expect(listEvents(harness.db, { threadId: thread.id })).toHaveLength(11);
+ });
+ });
+
+ it("rechecks pending interactions in the edit commit transaction", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness);
+ let pendingInteractionChecks = 0;
+ const originalHasPendingInteraction =
+ harness.deps.pendingInteractions.hasPendingThreadInteraction.bind(
+ harness.deps.pendingInteractions,
+ );
+ const pendingInteractionSpy = vi
+ .spyOn(harness.deps.pendingInteractions, "hasPendingThreadInteraction")
+ .mockImplementation((threadId) => {
+ pendingInteractionChecks += 1;
+ if (pendingInteractionChecks === 2) {
+ createPendingInteraction(harness.db, {
+ originKind: "plugin",
+ pluginId: "plugin-edit-race",
+ rendererId: "renderer-edit-race",
+ threadId,
+ turnId: null,
+ payload: JSON.stringify({ kind: "plugin", title: "Confirm" }),
+ });
+ return false;
+ }
+ return originalHasPendingInteraction(threadId);
+ });
+ try {
+ const editPromise = editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: "edit-op-interaction-race",
+ expectedRequestSequence: 7,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ },
+ });
+ const rewind = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.prepare",
+ );
+ const rejectedEdit = expect(editPromise).rejects.toThrow(
+ "Resolve the pending interaction before editing the message",
+ );
+ await reportQueuedCommandSuccess(harness, rewind, {
+ providerThreadId: "provider-staged-interaction-race",
+ });
+ const discard = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.discard",
+ );
+ await reportQueuedCommandSuccess(harness, discard, {});
+ await rejectedEdit;
+ expect(listEvents(harness.db, { threadId: thread.id })).toHaveLength(
+ 11,
+ );
+ } finally {
+ pendingInteractionSpy.mockRestore();
+ }
+ });
+ });
+
+ it("discards the staged rewind when the thread changes before commit", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness);
+ const editPromise = editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: "edit-op-high-water-race",
+ expectedRequestSequence: 7,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ },
+ });
+ const rewind = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.prepare",
+ );
+ seedStoredEvent(harness.deps, {
+ data: { operation: "concurrent-change" },
+ sequence: 12,
+ scope: threadScope(),
+ threadId: thread.id,
+ type: "system/operation",
+ });
+ const rejectedEdit = expect(editPromise).rejects.toThrow(
+ "The thread changed while the edit was being prepared",
+ );
+ await reportQueuedCommandSuccess(harness, rewind, {
+ providerThreadId: "provider-staged-high-water-race",
+ });
+ const discard = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.discard",
+ );
+ await reportQueuedCommandSuccess(harness, discard, {});
+ await rejectedEdit;
+ expect(
+ listEvents(harness.db, { threadId: thread.id }).map(
+ (event) => event.sequence,
+ ),
+ ).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
+ });
+ });
+
+ it.each(["claude-code", "pi"] as const)(
+ "rejects a %s rewind through legacy history without a checkpoint",
+ async (providerId) => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness, {
+ firstProviderCheckpoint: null,
+ providerId,
+ });
+
+ await expect(
+ editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: `edit-op-legacy-${providerId}`,
+ expectedRequestSequence: 7,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ },
+ }),
+ ).rejects.toThrow("no editable history checkpoint");
+ expect(
+ listQueuedThreadCommands(harness, "thread.rewind.prepare", thread.id),
+ ).toHaveLength(0);
+ });
+ },
+ );
+
+ it("leaves the original suffix untouched when Codex cannot stage the rewind", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness);
+ const editPromise = editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: "edit-op-failure",
+ expectedRequestSequence: 7,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ },
+ });
+ const rejectedEdit =
+ expect(editPromise).rejects.toThrow("Codex fork failed");
+ const rewind = await waitForQueuedCommand(
+ harness,
+ (queued) => queued.command.type === "thread.rewind.prepare",
+ );
+ await reportQueuedCommandError(harness, rewind, {
+ errorCode: "provider_error",
+ errorMessage: "Codex fork failed",
+ });
+
+ await rejectedEdit;
+ expect(listEvents(harness.db, { threadId: thread.id })).toHaveLength(11);
+ expect(
+ listQueuedThreadCommands(harness, "thread.start", thread.id),
+ ).toHaveLength(0);
+ });
+ });
+
+ it("rejects mutations while the experiment is disabled", async () => {
+ await withTestHarness(async (harness) => {
+ const { environment, thread } = seedEditableThread(harness, {
+ editMessagesExperiment: false,
+ });
+
+ await expect(
+ editThreadMessage(harness.deps, {
+ environment,
+ thread,
+ payload: {
+ operationId: "edit-op-disabled",
+ expectedRequestSequence: 7,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ },
+ }),
+ ).rejects.toThrow("Enable the Edit messages experiment");
+ });
+ });
+});
diff --git a/apps/server/test/threads/thread-runtime-config.test.ts b/apps/server/test/threads/thread-runtime-config.test.ts
index a9d676e853..429d89dba8 100644
--- a/apps/server/test/threads/thread-runtime-config.test.ts
+++ b/apps/server/test/threads/thread-runtime-config.test.ts
@@ -857,6 +857,7 @@ describe("thread runtime config", () => {
setExperiments(harness.db, {
claudeCodeMockCliTraffic: true,
+ editMessages: false,
newOnboarding: false,
toolsHub: false,
});
diff --git a/docs/configuration.md b/docs/configuration.md
index 9cabbd8245..9033c56a54 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -585,6 +585,11 @@ The `toolsHub` experiment exposes Extensions for managing skills and plugins,
while Automations stays in the Plugins section beside threads. The `toolsHub`
gate only controls the UI. Installed skills, automation execution, plugin
runtimes, CLI commands, and backend APIs keep working while it is off.
+The `editMessages` experiment enables replacing an eligible, successfully
+completed root user message in an idle Codex, Claude Code, or Pi thread.
+Grouped multi-message requests are not yet editable. Opening the editor does
+not change history; submission atomically replaces that message and every later
+turn while keeping workspace changes.
## Thread Timeline Window
diff --git a/packages/agent-runtime/src/acp/adapter.ts b/packages/agent-runtime/src/acp/adapter.ts
index 6d833d1262..53284ba468 100644
--- a/packages/agent-runtime/src/acp/adapter.ts
+++ b/packages/agent-runtime/src/acp/adapter.ts
@@ -1541,6 +1541,8 @@ export function createAcpProviderAdapter(
method: "thread/stop",
params: { threadId: command.providerThreadId },
};
+ case "thread/discard":
+ return { kind: "noop", reason: "discard unsupported" };
case "thread/goal/clear":
return { kind: "noop", reason: "goals unsupported" };
case "thread/name/set":
diff --git a/packages/agent-runtime/src/claude-code/adapter.test.ts b/packages/agent-runtime/src/claude-code/adapter.test.ts
index cc31e368b4..4e2c8c2f30 100644
--- a/packages/agent-runtime/src/claude-code/adapter.test.ts
+++ b/packages/agent-runtime/src/claude-code/adapter.test.ts
@@ -308,6 +308,27 @@ describe("claude-code provider adapter", () => {
});
});
+ it("buildCommand thread/fork forwards the provider checkpoint", () => {
+ const adapter = createClaudeCodeProviderAdapter();
+ const cmd = adapter.buildCommandPlan({
+ type: "thread/fork",
+ cwd: "/tmp/worktree",
+ threadId: "bb-thread-1",
+ sourceProviderThreadId: "claude-session-1",
+ sourceProviderCheckpointId: "assistant-message-42",
+ instructionMode: "append",
+ options: fullProviderExecutionContext,
+ });
+
+ expect(cmd).toMatchObject({
+ method: "thread/fork",
+ params: {
+ sourceProviderCheckpointId: "assistant-message-42",
+ sourceProviderThreadId: "claude-session-1",
+ },
+ });
+ });
+
it("buildCommand passes workflowsEnabled through explicitly on thread/start and thread/resume", () => {
const adapter = createClaudeCodeProviderAdapter();
const start = adapter.buildCommandPlan({
@@ -929,6 +950,21 @@ describe("claude-code provider adapter", () => {
});
});
+ it("buildCommand thread/discard closes the staged bridge session", () => {
+ const adapter = createClaudeCodeProviderAdapter();
+ expect(
+ adapter.buildCommandPlan({
+ type: "thread/discard",
+ threadId: "bb-staging",
+ providerThreadId: "claude-staging",
+ }),
+ ).toEqual({
+ kind: "request",
+ method: "thread/stop",
+ params: { threadId: "bb-staging" },
+ });
+ });
+
it("decodeToolCallRequest preserves string request ids", () => {
const adapter = createClaudeCodeProviderAdapter();
expect(
@@ -2006,6 +2042,80 @@ describe("claude-code provider adapter", () => {
);
});
+ it("records the latest Claude assistant message as the turn checkpoint", () => {
+ const adapter = createClaudeCodeProviderAdapter();
+ adapter.translateEvent({
+ type: "assistant",
+ uuid: "assistant-message-42",
+ message: {
+ id: "msg-1",
+ role: "assistant",
+ content: [{ type: "text", text: "Done" }],
+ },
+ session_id: "sess-1",
+ });
+
+ const events = adapter.translateEvent({
+ type: "result",
+ subtype: "success",
+ session_id: "sess-1",
+ });
+
+ expect(events).toContainEqual(
+ expect.objectContaining({
+ type: "turn/completed",
+ status: "completed",
+ providerCheckpointId: "assistant-message-42",
+ }),
+ );
+ });
+
+ it("does not replace the root checkpoint with a sidechain assistant UUID", () => {
+ const adapter = createClaudeCodeProviderAdapter();
+ adapter.translateEvent(
+ {
+ type: "assistant",
+ uuid: "root-assistant-message",
+ message: {
+ id: "root-message",
+ role: "assistant",
+ content: [{ type: "text", text: "Root response" }],
+ },
+ session_id: "sess-1",
+ },
+ { threadId: "bb-thread-1" },
+ );
+ adapter.translateEvent(
+ {
+ type: "assistant",
+ uuid: "sidechain-assistant-message",
+ message: {
+ id: "sidechain-message",
+ role: "assistant",
+ content: [{ type: "text", text: "Subagent response" }],
+ },
+ session_id: "sess-1",
+ },
+ { threadId: "bb-thread-1", parentToolCallId: "tool-subagent" },
+ );
+
+ const events = adapter.translateEvent(
+ {
+ type: "result",
+ subtype: "success",
+ session_id: "sess-1",
+ },
+ { threadId: "bb-thread-1" },
+ );
+
+ expect(events).toContainEqual(
+ expect.objectContaining({
+ type: "turn/completed",
+ providerCheckpointId: "root-assistant-message",
+ }),
+ );
+ });
+
it("translateEvent completes a pending turn for Claude synthetic no-response messages", () => {
const adapter = createClaudeCodeProviderAdapter();
diff --git a/packages/agent-runtime/src/claude-code/adapter.ts b/packages/agent-runtime/src/claude-code/adapter.ts
index 70b5c113ff..04f0d1c5f9 100644
--- a/packages/agent-runtime/src/claude-code/adapter.ts
+++ b/packages/agent-runtime/src/claude-code/adapter.ts
@@ -875,6 +875,7 @@ export function createClaudeCodeProviderAdapter(
reasoningOutputTokens: 0,
},
latestRequestContextTokens: undefined,
+ latestProviderCheckpointId: undefined,
lastModelFallback: undefined,
openAssistantMessageIdsByScope: new Map(),
openCompaction: undefined,
@@ -906,6 +907,7 @@ export function createClaudeCodeProviderAdapter(
const hadOpenTurn = args.state.currentTurnId !== undefined;
if (!hadOpenTurn) {
args.state.latestRequestContextTokens = undefined;
+ args.state.latestProviderCheckpointId = undefined;
}
const turnId = turnState.ensureTurnStarted(args);
if (!hadOpenTurn) {
@@ -1331,6 +1333,12 @@ export function createClaudeCodeProviderAdapter(
threadId: command.threadId,
cwd: command.cwd,
sourceProviderThreadId: command.sourceProviderThreadId,
+ ...(command.sourceProviderCheckpointId !== undefined
+ ? {
+ sourceProviderCheckpointId:
+ command.sourceProviderCheckpointId,
+ }
+ : {}),
instructionMode: command.instructionMode,
claudeCodeMockCliTraffic:
command.options.claudeCodeMockCliTraffic,
@@ -1377,6 +1385,12 @@ export function createClaudeCodeProviderAdapter(
threadId: command.threadId,
},
};
+ case "thread/discard":
+ return {
+ kind: "request",
+ method: "thread/stop",
+ params: { threadId: command.threadId },
+ };
case "thread/goal/clear":
return { kind: "noop", reason: "goals unsupported" };
case "thread/name/set":
diff --git a/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts b/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts
index 096607abbe..ae21fb6b67 100644
--- a/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts
+++ b/packages/agent-runtime/src/claude-code/bridge/__tests__/bridge.test.ts
@@ -19,12 +19,14 @@ import {
type PermissionEscalation,
} from "@bb/domain";
-const { queryMock } = vi.hoisted(() => ({
+const { forkSessionMock, queryMock } = vi.hoisted(() => ({
+ forkSessionMock: vi.fn(),
queryMock: vi.fn(),
}));
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: queryMock,
+ forkSession: forkSessionMock,
createSdkMcpServer: vi.fn(() => ({})),
tool: vi.fn((_name, _desc, _schema, handler) => handler),
}));
@@ -578,6 +580,7 @@ async function forwardAskUserQuestion({
describe("bridge", () => {
beforeEach(() => {
vi.clearAllMocks();
+ forkSessionMock.mockResolvedValue({ sessionId: "forked-session-1" });
queryMock.mockReturnValue({
initializationResult: vi.fn().mockResolvedValue({
account: {},
@@ -621,6 +624,51 @@ describe("bridge", () => {
}
});
+ it("forks a Claude session through the requested provider checkpoint", async () => {
+ const bridge = createBridgeJsonRpcTestHarness(handleLine);
+ const queries: ControlledClaudeQuery[] = [];
+ queryMock.mockImplementation(() => {
+ const query = createControlledClaudeQuery();
+ queries.push(query);
+ return query;
+ });
+
+ try {
+ bridge.sendRequest(1, "thread/fork", {
+ approvedPlanPermissionMode: "default",
+ workflowsEnabled: false,
+ claudeCodeMockCliTraffic: DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_CONFIG,
+ baseInstructions: "test",
+ cwd: "/tmp/worktree",
+ instructionMode: "append",
+ permissionEscalation: "ask",
+ permissionMode: "default",
+ permissionScope: "workspace",
+ sourceProviderCheckpointId: "assistant-message-42",
+ sourceProviderThreadId: "source-session-1",
+ threadId: "forked-thread-1",
+ });
+
+ await expect(bridge.waitForResponse(1)).resolves.toMatchObject({
+ result: {
+ providerThreadId: "forked-session-1",
+ threadId: "forked-thread-1",
+ },
+ });
+ expect(forkSessionMock).toHaveBeenCalledWith("source-session-1", {
+ dir: "/tmp/worktree",
+ upToMessageId: "assistant-message-42",
+ });
+ } finally {
+ await stopBridgeThread({
+ bridge,
+ queries,
+ threadId: "forked-thread-1",
+ });
+ bridge.restore();
+ }
+ });
+
it("keeps manager sessions on a plain string system prompt", () => {
const options = buildSessionOptions(
{
diff --git a/packages/agent-runtime/src/claude-code/bridge/bridge.ts b/packages/agent-runtime/src/claude-code/bridge/bridge.ts
index b399e7bb2d..015de60ea2 100644
--- a/packages/agent-runtime/src/claude-code/bridge/bridge.ts
+++ b/packages/agent-runtime/src/claude-code/bridge/bridge.ts
@@ -1881,6 +1881,9 @@ async function handleThreadFork(
try {
const forkResult = await forkSession(params.sourceProviderThreadId, {
dir: params.cwd,
+ ...(params.sourceProviderCheckpointId !== undefined
+ ? { upToMessageId: params.sourceProviderCheckpointId }
+ : {}),
});
forkedProviderThreadId = forkResult.sessionId;
} catch (error) {
diff --git a/packages/agent-runtime/src/claude-code/bridge/commands.ts b/packages/agent-runtime/src/claude-code/bridge/commands.ts
index ef8fb88cdb..11cd65cf74 100644
--- a/packages/agent-runtime/src/claude-code/bridge/commands.ts
+++ b/packages/agent-runtime/src/claude-code/bridge/commands.ts
@@ -111,6 +111,7 @@ const claudeCodeCommandSchema = z.discriminatedUnion("method", [
threadId: z.string(),
cwd: z.string(),
sourceProviderThreadId: z.string(),
+ sourceProviderCheckpointId: z.string().min(1).optional(),
baseInstructions: z.string().optional(),
additionalWorkspaceWriteRoots: bridgeAdditionalWorkspaceWriteRootsSchema,
plugins: bridgeClaudePluginsSchema,
diff --git a/packages/agent-runtime/src/claude-code/schemas.ts b/packages/agent-runtime/src/claude-code/schemas.ts
index 1009bf180f..135ddba768 100644
--- a/packages/agent-runtime/src/claude-code/schemas.ts
+++ b/packages/agent-runtime/src/claude-code/schemas.ts
@@ -349,6 +349,7 @@ export const claudeAssistantMessageSchema = z
.object({
type: z.literal("assistant"),
message: z.unknown(),
+ uuid: z.string().min(1).optional(),
})
.passthrough();
export type ClaudeAssistantMessage = z.infer<
diff --git a/packages/agent-runtime/src/claude-code/translate-message.ts b/packages/agent-runtime/src/claude-code/translate-message.ts
index f5f19aa321..fab2db37c2 100644
--- a/packages/agent-runtime/src/claude-code/translate-message.ts
+++ b/packages/agent-runtime/src/claude-code/translate-message.ts
@@ -67,6 +67,7 @@ export interface ClaudeTurnState {
currentTurnId: string | undefined;
cumulativeTokens: ThreadEventTokenUsageBreakdown;
latestRequestContextTokens: number | undefined;
+ latestProviderCheckpointId: string | undefined;
lastModelFallback:
| {
fallbackModel: string;
@@ -677,6 +678,10 @@ export function translateClaudeSdkMessage(
});
}
const message = parsedMessage.data;
+ // Sidechain assistant messages belong to subagents/tools, not the root
+ // conversation lineage that thread/fork can retain through.
+ const providerCheckpointId =
+ parentToolCallId === undefined ? message.uuid : undefined;
// Claude sends this model transition before it begins streaming from the
// fallback model. Its richer system/model_* duplicate arrives only after
// the response, so emit now and deduplicate that later event.
@@ -684,6 +689,9 @@ export function translateClaudeSdkMessage(
extractClaudeFallbackOnlyAssistantMessage(message);
if (fallbackTransition !== null) {
const turnId = args.ensureTurnStarted({ events, state, threadId });
+ if (providerCheckpointId !== undefined) {
+ state.latestProviderCheckpointId = providerCheckpointId;
+ }
if (
!isDuplicateClaudeModelFallback(state, fallbackTransition, turnId)
) {
@@ -714,6 +722,9 @@ export function translateClaudeSdkMessage(
if (!turnId) {
return [];
}
+ if (providerCheckpointId !== undefined) {
+ state.latestProviderCheckpointId = providerCheckpointId;
+ }
if (hasCompletionBlockingClaudeTasks(state.tasksById)) {
return events;
}
@@ -723,6 +734,11 @@ export function translateClaudeSdkMessage(
providerThreadId: "",
scope: turnScope(turnId),
status: "completed",
+ ...(state.latestProviderCheckpointId !== undefined
+ ? {
+ providerCheckpointId: state.latestProviderCheckpointId,
+ }
+ : {}),
});
args.turnState.finishTurn({ state, threadId: stateKey });
return events;
@@ -732,6 +748,9 @@ export function translateClaudeSdkMessage(
state,
threadId,
});
+ if (providerCheckpointId !== undefined) {
+ state.latestProviderCheckpointId = providerCheckpointId;
+ }
const requestContextTokens = extractClaudeRequestContextTokens(message);
if (requestContextTokens !== null) {
state.latestRequestContextTokens = requestContextTokens;
@@ -979,6 +998,11 @@ export function translateClaudeSdkMessage(
providerThreadId: "",
scope: turnScope(state.currentTurnId),
status: failed ? "failed" : "completed",
+ ...(state.latestProviderCheckpointId !== undefined
+ ? {
+ providerCheckpointId: state.latestProviderCheckpointId,
+ }
+ : {}),
});
args.turnState.finishTurn({ state, threadId: stateKey });
}
diff --git a/packages/agent-runtime/src/codex/adapter.test.ts b/packages/agent-runtime/src/codex/adapter.test.ts
index 422a7de8ee..4582400899 100644
--- a/packages/agent-runtime/src/codex/adapter.test.ts
+++ b/packages/agent-runtime/src/codex/adapter.test.ts
@@ -1761,6 +1761,27 @@ describe("codex provider adapter", () => {
expect(JSON.stringify(cmd)).not.toContain("persistExtendedHistory");
});
+ it("buildCommand thread/fork can stop at a specific source turn", () => {
+ const adapter = createCodexProviderAdapter();
+ const cmd = adapter.buildCommandPlan({
+ type: "thread/fork",
+ cwd: "/tmp/worktree",
+ threadId: "bb-thread-edited",
+ sourceProviderThreadId: "codex-source-thread",
+ sourceProviderCheckpointId: "turn-before-edited-message",
+ instructionMode: "append",
+ options: fullProviderExecutionContext,
+ });
+
+ expect(cmd).toMatchObject({
+ method: "thread/fork",
+ params: {
+ threadId: "codex-source-thread",
+ lastTurnId: "turn-before-edited-message",
+ },
+ });
+ });
+
it("buildCommand maps max reasoning level through to Codex", () => {
const adapter = createCodexProviderAdapter();
const cmd = adapter.buildCommandPlan({
@@ -2235,6 +2256,21 @@ describe("codex provider adapter", () => {
});
});
+ it("buildCommand thread/discard archives the staged provider thread", () => {
+ const adapter = createCodexProviderAdapter();
+ expect(
+ adapter.buildCommandPlan({
+ type: "thread/discard",
+ threadId: "bb-staging",
+ providerThreadId: "codex-staging",
+ }),
+ ).toEqual({
+ kind: "request",
+ method: "thread/archive",
+ params: { threadId: "codex-staging" },
+ });
+ });
+
it("buildCommand thread/unarchive routes to provider thread id", () => {
const adapter = createCodexProviderAdapter();
const cmd = adapter.buildCommandPlan({
diff --git a/packages/agent-runtime/src/codex/adapter.ts b/packages/agent-runtime/src/codex/adapter.ts
index 59686c0aeb..83411a8669 100644
--- a/packages/agent-runtime/src/codex/adapter.ts
+++ b/packages/agent-runtime/src/codex/adapter.ts
@@ -104,6 +104,7 @@ type BbThreadStartParams = ThreadStartParams & {
type BbThreadForkParams = {
threadId: string;
+ lastTurnId?: string | null;
model?: string | null;
serviceTier?: string | null;
cwd?: string | null;
@@ -1910,6 +1911,9 @@ export function createCodexProviderAdapter(
const preparedGitRoots = prepareWorkspaceWriteGitRoots({ command });
const params: BbThreadForkParams = {
threadId: command.sourceProviderThreadId,
+ ...(command.sourceProviderCheckpointId !== undefined
+ ? { lastTurnId: command.sourceProviderCheckpointId }
+ : {}),
approvalPolicy: preparedGitRoots.permissionSettings.approvalPolicy,
approvalsReviewer:
preparedGitRoots.permissionSettings.approvalsReviewer,
@@ -2024,6 +2028,15 @@ export function createCodexProviderAdapter(
turnId: command.activeTurnId,
},
};
+ case "thread/discard":
+ if (!capabilities.supportsArchive) {
+ return { kind: "noop", reason: "archive unsupported" };
+ }
+ return {
+ kind: "request",
+ method: "thread/archive",
+ params: { threadId: command.providerThreadId },
+ };
case "thread/goal/clear":
return {
kind: "request",
diff --git a/packages/agent-runtime/src/pi/adapter.test.ts b/packages/agent-runtime/src/pi/adapter.test.ts
index b8314fefae..5c3565678a 100644
--- a/packages/agent-runtime/src/pi/adapter.test.ts
+++ b/packages/agent-runtime/src/pi/adapter.test.ts
@@ -332,6 +332,27 @@ describe("pi provider adapter", () => {
});
});
+ it("buildCommand thread/fork forwards the provider checkpoint", () => {
+ const adapter = createPiProviderAdapter();
+ const cmd = adapter.buildCommandPlan({
+ type: "thread/fork",
+ cwd: "/tmp/worktree",
+ threadId: "t1",
+ sourceProviderThreadId: "source-session",
+ sourceProviderCheckpointId: "pi-entry-42",
+ instructionMode: "append",
+ options: fullProviderExecutionContext,
+ });
+
+ expect(cmd).toMatchObject({
+ method: "thread/fork",
+ params: {
+ providerCheckpointId: "pi-entry-42",
+ sourceProviderThreadId: "source-session",
+ },
+ });
+ });
+
it("buildCommand thread/start maps skill roots to Pi additional skill paths", () => {
const adapter = createPiProviderAdapter();
const cmd = adapter.buildCommandPlan({
@@ -549,6 +570,21 @@ describe("pi provider adapter", () => {
});
});
+ it("buildCommand thread/discard maps to destructive bridge cleanup", () => {
+ const adapter = createPiProviderAdapter();
+ expect(
+ adapter.buildCommandPlan({
+ type: "thread/discard",
+ threadId: "bb-staging",
+ providerThreadId: "pi-staging",
+ }),
+ ).toEqual({
+ kind: "request",
+ method: "thread/discard",
+ params: { threadId: "pi-staging" },
+ });
+ });
+
it("buildCommand turn/start includes input", () => {
const adapter = createPiProviderAdapter();
const cmd = adapter.buildCommandPlan({
@@ -665,9 +701,10 @@ describe("pi provider adapter", () => {
// Start a turn first
adapter.translateEvent(loadFixture("agent-start.json"));
- const events = adapter.translateEvent(
- loadFixture("agent-end-with-message.json"),
- );
+ const events = adapter.translateEvent({
+ ...loadFixture("agent-end-with-message.json"),
+ providerCheckpointId: "pi-entry-42",
+ });
expect(events).toContainEqual(
expect.objectContaining({
@@ -683,6 +720,7 @@ describe("pi provider adapter", () => {
type: "turn/completed",
scope: turnScope("turn-1"),
status: "completed",
+ providerCheckpointId: "pi-entry-42",
}),
);
expect(events.some((event) => event.type === "provider/error")).toBe(false);
diff --git a/packages/agent-runtime/src/pi/adapter.ts b/packages/agent-runtime/src/pi/adapter.ts
index 35c482f880..bfcab6779c 100644
--- a/packages/agent-runtime/src/pi/adapter.ts
+++ b/packages/agent-runtime/src/pi/adapter.ts
@@ -280,6 +280,7 @@ const piAgentEndEventSchema = z
.object({
type: z.literal("agent_end"),
messages: z.array(piConversationMessageSchema),
+ providerCheckpointId: z.string().min(1).optional(),
willRetry: z.boolean().default(false),
})
.passthrough();
@@ -1086,6 +1087,11 @@ export function createPiProviderAdapter(
providerThreadId: "",
scope: turnScope(currentTurnId),
status: "completed",
+ ...(piEvent.data.providerCheckpointId !== undefined
+ ? {
+ providerCheckpointId: piEvent.data.providerCheckpointId,
+ }
+ : {}),
});
resetPiCommandOutputSnapshots(state);
turnState.finishTurn({ state, threadId: stateKey });
@@ -1477,6 +1483,11 @@ export function createPiProviderAdapter(
threadId: command.threadId,
sourceProviderThreadId: command.sourceProviderThreadId,
cwd: command.cwd,
+ ...(command.sourceProviderCheckpointId !== undefined
+ ? {
+ providerCheckpointId: command.sourceProviderCheckpointId,
+ }
+ : {}),
...resolvePiInstructionOverrides(command),
...(additionalSkillPathsParams ? additionalSkillPathsParams : {}),
...(config ? { config } : {}),
@@ -1507,6 +1518,12 @@ export function createPiProviderAdapter(
threadId: command.providerThreadId,
},
};
+ case "thread/discard":
+ return {
+ kind: "request",
+ method: "thread/discard",
+ params: { threadId: command.providerThreadId },
+ };
case "thread/goal/clear":
return { kind: "noop", reason: "goals unsupported" };
case "thread/name/set":
diff --git a/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts b/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts
index e219d155bb..b9351530c2 100644
--- a/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts
+++ b/packages/agent-runtime/src/pi/bridge/__tests__/bridge.test.ts
@@ -1,5 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import {
+ existsSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
@@ -74,18 +80,21 @@ const {
mockCreateAgentSession: vi.fn(),
mockCreateAgentSessionServices,
mockInMemory: vi.fn((cwd?: string) => ({ kind: "in-memory", cwd })),
- mockOpen: vi.fn((path: string) => ({ kind: "open", path })),
+ mockOpen: vi.fn(),
mockResourceLoaders,
mockGetPiModelRuntime: vi.fn(async () => mockModelRuntime),
};
});
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
- // Keep the real SessionManager.forkFrom so the fork test exercises genuine
- // session-file materialization on disk; only the agent-session and resume/open
- // entry points are mocked away from the real SDK runtime.
+ // Keep the real SessionManager file operations so fork tests exercise
+ // genuine full-history and checkpointed materialization on disk.
const actual =
await importOriginal();
+ mockOpen.mockImplementation(
+ (path: string, sessionDir?: string, cwdOverride?: string) =>
+ actual.SessionManager.open(path, sessionDir, cwdOverride),
+ );
return {
createAgentSessionFromServices: mockCreateAgentSession,
createAgentSessionServices: mockCreateAgentSessionServices,
@@ -126,6 +135,7 @@ interface ControlledPiAgentSession {
getContextUsage: ReturnType;
isStreaming: boolean;
prompt: ReturnType;
+ sessionManager: { getLeafId: ReturnType };
setActiveToolsByName: ReturnType;
subscribe: ReturnType;
}
@@ -159,6 +169,7 @@ function createControlledPiAgentSession(): ControlledPiAgentSession {
getContextUsage: vi.fn(() => undefined),
isStreaming: false,
prompt: vi.fn(async () => {}),
+ sessionManager: { getLeafId: vi.fn(() => "pi-entry-checkpoint") },
setActiveToolsByName: vi.fn(),
subscribe: vi.fn((listener: ControlledPiAgentSessionListener) => {
listeners.push(listener);
@@ -237,7 +248,10 @@ describe("pi bridge", () => {
method: "sdk/message",
params: {
threadId: "thread-extension-stdout",
- message: createAgentEndEvent(),
+ message: {
+ ...createAgentEndEvent(),
+ providerCheckpointId: "pi-entry-checkpoint",
+ },
},
}),
);
@@ -439,10 +453,11 @@ describe("pi bridge", () => {
}
});
- it("forks the source session history into the new thread's deterministic file", async () => {
+ it("forks source history through a checkpoint into the deterministic file", async () => {
const bridge = createBridgeJsonRpcTestHarness(handleLine);
+ const forkedSession = createControlledPiAgentSession();
mockCreateAgentSession.mockImplementation(async () => ({
- session: createControlledPiAgentSession(),
+ session: forkedSession,
}));
const sessionDir = mkdtempSync(join(tmpdir(), "pi-fork-test-"));
@@ -477,6 +492,23 @@ describe("pi bridge", () => {
content: [{ type: "text", text: "noted: 42" }],
},
}),
+ JSON.stringify({
+ type: "message",
+ id: "e3",
+ parentId: "e2",
+ timestamp: "2026-06-15T00:00:03.000Z",
+ message: { role: "user", content: "forget 42" },
+ }),
+ JSON.stringify({
+ type: "message",
+ id: "e4",
+ parentId: "e3",
+ timestamp: "2026-06-15T00:00:04.000Z",
+ message: {
+ role: "assistant",
+ content: [{ type: "text", text: "forgotten" }],
+ },
+ }),
].join("\n")}\n`;
writeFileSync(sourceFile, sourceContent);
@@ -486,19 +518,29 @@ describe("pi bridge", () => {
try {
bridge.sendRequest(40, "thread/fork", {
cwd: "/tmp/worktree",
+ providerCheckpointId: "e2",
threadId: targetThreadId,
sourceProviderThreadId: sourceThreadId,
});
- await expect(bridge.waitForResponse(40)).resolves.toMatchObject({
+ const response = await bridge.waitForResponse(40);
+ if (response.error !== undefined) {
+ throw new Error(JSON.stringify(response.error));
+ }
+ expect(response).toMatchObject({
id: 40,
- result: { threadId: targetThreadId },
+ result: {
+ providerThreadId: targetThreadId,
+ threadId: targetThreadId,
+ },
});
// The forked session is materialized at the NEW thread's deterministic
- // path, carrying the source history plus parentSession lineage.
+ // path, carrying the retained source path plus parentSession lineage.
const forkedContent = readFileSync(targetFile, "utf8");
expect(forkedContent).toContain("remember 42");
expect(forkedContent).toContain("noted: 42");
+ expect(forkedContent).not.toContain("forget 42");
+ expect(forkedContent).not.toContain("forgotten");
expect(forkedContent).toContain(`"parentSession":"${sourceFile}"`);
// Source file is left untouched by the fork.
expect(readFileSync(sourceFile, "utf8")).toBe(sourceContent);
@@ -515,6 +557,15 @@ describe("pi bridge", () => {
},
}),
);
+ bridge.sendRequest(42, "thread/discard", { threadId: targetThreadId });
+ await bridge.flushWork();
+ forkedSession.finishAbort();
+ await expect(bridge.waitForResponse(42)).resolves.toMatchObject({
+ id: 42,
+ result: { ok: true },
+ });
+ expect(existsSync(targetFile)).toBe(false);
+ expect(readFileSync(sourceFile, "utf8")).toBe(sourceContent);
} finally {
bridge.restore();
rmSync(sessionDir, { recursive: true, force: true });
diff --git a/packages/agent-runtime/src/pi/bridge/bridge.ts b/packages/agent-runtime/src/pi/bridge/bridge.ts
index e79ec8467f..011d40fc2f 100644
--- a/packages/agent-runtime/src/pi/bridge/bridge.ts
+++ b/packages/agent-runtime/src/pi/bridge/bridge.ts
@@ -152,6 +152,7 @@ const piThreadForkParamsSchema = z
threadId: z.string(),
sourceProviderThreadId: z.string(),
cwd: z.string(),
+ providerCheckpointId: z.string().min(1).optional(),
additionalSkillPaths: piAdditionalSkillPathsSchema,
baseInstructions: z.string().optional(),
appendSystemPrompt: z.string().optional(),
@@ -224,6 +225,12 @@ const piCommandSchema = z.discriminatedUnion("method", [
method: z.literal("thread/compact"),
params: piThreadIdParamsSchema,
}),
+ z.object({
+ method: z.literal("thread/discard"),
+ params: z.object({
+ threadId: z.string(),
+ }),
+ }),
]);
export type PiCommand = z.infer;
@@ -400,10 +407,20 @@ function createOnPiEvent(
threadId: args.threadId,
});
if (!threadSession) return;
+ const providerCheckpointId =
+ event.type === "agent_end"
+ ? threadSession.session.getProviderCheckpointId()
+ : undefined;
send({
jsonrpc: "2.0",
method: "sdk/message",
- params: { threadId: args.threadId, message: event },
+ params: {
+ threadId: args.threadId,
+ message:
+ providerCheckpointId === undefined
+ ? event
+ : { ...event, providerCheckpointId },
+ },
});
if (event.type === "agent_end" || event.type === "compaction_end") {
emitContextWindowUsage(args.threadId);
@@ -616,6 +633,9 @@ async function handleRequest(
case "thread/compact":
handleThreadCompact(request.id, request.params);
break;
+ case "thread/discard":
+ sendResult(request.id, await handleThreadDiscard(request.params));
+ break;
}
}
@@ -631,6 +651,10 @@ type ThreadForkParams = Extract["params"];
type TurnStartParams = Extract["params"];
type TurnSteerParams = Extract["params"];
type ThreadIdParams = Extract["params"];
+type ThreadDiscardParams = Extract<
+ PiCommand,
+ { method: "thread/discard" }
+>["params"];
type PiSessionParams =
| ThreadStartParams
| ThreadResumeParams
@@ -717,7 +741,10 @@ async function startPiThreadSession({
throw error;
}
- sendResult(id, { threadId });
+ // Pi has no separately minted session id: its provider identity is the BB
+ // thread id. Return that identity synchronously so callers do not have to
+ // race the thread/identity notification emitted after start/fork.
+ sendResult(id, { threadId, providerThreadId: threadId });
}
async function handleThreadStart(
@@ -773,12 +800,18 @@ async function handleThreadFork(
});
const bridgeSessionDir = resolvePiBridgeSessionDir({ env: process.env });
- const forked = SessionManager.forkFrom(
- sourceSessionFile,
- params.cwd,
- bridgeSessionDir,
- );
- const forkedFile = forked.getSessionFile();
+ const forkedFile =
+ params.providerCheckpointId === undefined
+ ? SessionManager.forkFrom(
+ sourceSessionFile,
+ params.cwd,
+ bridgeSessionDir,
+ ).getSessionFile()
+ : SessionManager.open(
+ sourceSessionFile,
+ bridgeSessionDir,
+ params.cwd,
+ ).createBranchedSession(params.providerCheckpointId);
if (!forkedFile) {
sendError(id, -32000, "Cannot fork: forked pi session was not persisted");
return;
@@ -900,6 +933,20 @@ function handleThreadCompact(
sendResult(id, { threadId: params.threadId });
}
+async function handleThreadDiscard(
+ params: ThreadDiscardParams,
+): Promise {
+ await closeThreadSession({
+ message: "Pi staged thread discarded while tool call was pending",
+ threadId: params.threadId,
+ });
+ rmSync(
+ resolvePiSessionFilePath({ env: process.env, threadId: params.threadId }),
+ { force: true },
+ );
+ return { ok: true };
+}
+
interface ExtractedInput {
text?: string;
images: ImageContent[];
diff --git a/packages/agent-runtime/src/pi/bridge/sdk-session.ts b/packages/agent-runtime/src/pi/bridge/sdk-session.ts
index c9cab2469f..c99fc0f27b 100644
--- a/packages/agent-runtime/src/pi/bridge/sdk-session.ts
+++ b/packages/agent-runtime/src/pi/bridge/sdk-session.ts
@@ -212,6 +212,10 @@ export class PiSdkSession {
return this.session?.getContextUsage();
}
+ getProviderCheckpointId(): string | undefined {
+ return this.session?.sessionManager.getLeafId() ?? undefined;
+ }
+
async start(): Promise {
assertExclusivePiPromptOverrides(this.options);
diff --git a/packages/agent-runtime/src/provider-adapter.ts b/packages/agent-runtime/src/provider-adapter.ts
index 1ea16ba43b..a7d4521f5a 100644
--- a/packages/agent-runtime/src/provider-adapter.ts
+++ b/packages/agent-runtime/src/provider-adapter.ts
@@ -160,6 +160,7 @@ export type AdapterCommand =
threadId: string;
cwd: string;
sourceProviderThreadId: string;
+ sourceProviderCheckpointId?: string;
options: ProviderExecutionContext;
dynamicTools?: DynamicTool[];
disallowedTools?: readonly string[];
@@ -195,6 +196,11 @@ export type AdapterCommand =
*/
activeTurnId: string | null;
}
+ | {
+ type: "thread/discard";
+ threadId: string;
+ providerThreadId: string;
+ }
| {
type: "thread/goal/clear";
threadId: string;
diff --git a/packages/agent-runtime/src/runtime-thread-rewind.test.ts b/packages/agent-runtime/src/runtime-thread-rewind.test.ts
new file mode 100644
index 0000000000..161b43d6fc
--- /dev/null
+++ b/packages/agent-runtime/src/runtime-thread-rewind.test.ts
@@ -0,0 +1,214 @@
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import type { ThreadEvent } from "@bb/domain";
+import type { AdapterCommand } from "./provider-adapter.js";
+import { createAgentRuntimeWithAdapters } from "./runtime.js";
+import {
+ createRecordingAdapter,
+ fullRuntimeOptions,
+} from "./test/runtime-test-harness.js";
+import { fakeProviderScriptPath } from "./test/index.js";
+
+const temporaryDirectories: string[] = [];
+
+afterEach(() => {
+ for (const directory of temporaryDirectories.splice(0)) {
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+describe("prepareThreadRewind", () => {
+ it("stages one independently discardable fork per lease and suppresses staging events", async () => {
+ const workspacePath = mkdtempSync(join(tmpdir(), "bb-runtime-rewind-"));
+ temporaryDirectories.push(workspacePath);
+ const commands: AdapterCommand[] = [];
+ const events: ThreadEvent[] = [];
+ const runtime = createAgentRuntimeWithAdapters({
+ workspacePath,
+ onEvent: (event) => events.push(event),
+ onToolCall: async () => ({
+ contentItems: [{ type: "inputText", text: "ok" }],
+ success: true,
+ }),
+ adapterFactory: () =>
+ createRecordingAdapter({
+ recordedCommands: commands,
+ scriptPath: fakeProviderScriptPath,
+ }),
+ });
+ const request = {
+ environmentId: "env-1",
+ threadId: "thread-1",
+ leaseId: "lease-1",
+ projectId: "project-1",
+ providerId: "codex",
+ sourceProviderThreadId: "provider-source-1",
+ retainThroughProviderCheckpoint: "turn-before-edit",
+ options: fullRuntimeOptions,
+ instructionMode: "append" as const,
+ };
+
+ try {
+ const first = await runtime.prepareThreadRewind(request);
+ // A replay of the same lease returns the staged fork without forking
+ // again; a different lease stages its own independent fork.
+ const replay = await runtime.prepareThreadRewind(request);
+ expect(replay).toEqual(first);
+ await runtime.prepareThreadRewind({ ...request, leaseId: "lease-2" });
+
+ const forkCommands = commands.filter(
+ (command) => command.type === "thread/fork",
+ );
+ expect(forkCommands).toEqual([
+ expect.objectContaining({
+ sourceProviderCheckpointId: "turn-before-edit",
+ sourceProviderThreadId: "provider-source-1",
+ threadId: "thread-1:rewind:lease-1",
+ }),
+ expect.objectContaining({
+ sourceProviderCheckpointId: "turn-before-edit",
+ sourceProviderThreadId: "provider-source-1",
+ threadId: "thread-1:rewind:lease-2",
+ }),
+ ]);
+ expect(events).toEqual([]);
+ expect(runtime.hasThread("thread-1:rewind:lease-1")).toBe(true);
+ expect(runtime.hasThread("thread-1:rewind:lease-2")).toBe(true);
+
+ // Discarding one lease leaves the other attempt's fork untouched.
+ await runtime.discardThreadRewind({ leaseId: "lease-1" });
+ expect(
+ commands.filter((command) => command.type === "thread/discard"),
+ ).toEqual([
+ expect.objectContaining({
+ providerThreadId: first.providerThreadId,
+ threadId: "thread-1:rewind:lease-1",
+ }),
+ ]);
+ expect(runtime.hasThread("thread-1:rewind:lease-1")).toBe(false);
+ expect(runtime.hasThread("thread-1:rewind:lease-2")).toBe(true);
+
+ await runtime.discardThreadRewind({ leaseId: "lease-2" });
+ expect(runtime.hasThread("thread-1:rewind:lease-2")).toBe(false);
+ } finally {
+ await runtime.shutdown();
+ }
+ });
+
+ it("retains a staged rewind when provider cleanup fails so cleanup can retry", async () => {
+ const workspacePath = mkdtempSync(join(tmpdir(), "bb-runtime-rewind-"));
+ temporaryDirectories.push(workspacePath);
+ const stderr: string[] = [];
+ const commands: AdapterCommand[] = [];
+ const runtime = createAgentRuntimeWithAdapters({
+ workspacePath,
+ onEvent: () => undefined,
+ onStderr: (line) => stderr.push(line),
+ onToolCall: async () => ({
+ contentItems: [{ type: "inputText", text: "ok" }],
+ success: true,
+ }),
+ adapterFactory: () => {
+ const adapter = createRecordingAdapter({
+ recordedCommands: commands,
+ scriptPath: fakeProviderScriptPath,
+ });
+ return {
+ ...adapter,
+ process: {
+ ...adapter.process,
+ args: [...adapter.process.args, "--discard-fails-once"],
+ },
+ };
+ },
+ });
+ const request = {
+ environmentId: "env-1",
+ threadId: "thread-1",
+ leaseId: "lease-retry-cleanup",
+ projectId: "project-1",
+ providerId: "codex",
+ sourceProviderThreadId: "provider-source-1",
+ retainThroughProviderCheckpoint: "turn-before-edit",
+ options: fullRuntimeOptions,
+ instructionMode: "append" as const,
+ };
+
+ try {
+ await runtime.prepareThreadRewind(request);
+ const stagingThreadId = "thread-1:rewind:lease-retry-cleanup";
+ await runtime.discardThreadRewind({ leaseId: request.leaseId });
+ expect(runtime.hasThread(stagingThreadId)).toBe(true);
+ expect(stderr).toEqual([
+ expect.stringContaining("discard is temporarily unavailable"),
+ ]);
+
+ await runtime.discardThreadRewind({ leaseId: request.leaseId });
+ expect(runtime.hasThread(stagingThreadId)).toBe(false);
+ } finally {
+ await runtime.shutdown();
+ }
+ });
+
+ it("discards a staged fork when its response does not identify an adoptable provider thread", async () => {
+ const workspacePath = mkdtempSync(join(tmpdir(), "bb-runtime-rewind-"));
+ temporaryDirectories.push(workspacePath);
+ const commands: AdapterCommand[] = [];
+ const runtime = createAgentRuntimeWithAdapters({
+ workspacePath,
+ onEvent: () => undefined,
+ onToolCall: async () => ({
+ contentItems: [{ type: "inputText", text: "ok" }],
+ success: true,
+ }),
+ adapterFactory: () => {
+ const adapter = createRecordingAdapter({
+ recordedCommands: commands,
+ scriptPath: fakeProviderScriptPath,
+ });
+ return {
+ ...adapter,
+ process: {
+ ...adapter.process,
+ args: [...adapter.process.args, "--thread-id-provider-identity"],
+ },
+ };
+ },
+ });
+ const request = {
+ environmentId: "env-1",
+ threadId: "thread-1",
+ leaseId: "lease-ambiguous-identity",
+ projectId: "project-1",
+ providerId: "pi",
+ sourceProviderThreadId: "provider-source-1",
+ retainThroughProviderCheckpoint: "turn-before-edit",
+ options: fullRuntimeOptions,
+ instructionMode: "append" as const,
+ };
+ try {
+ await expect(runtime.prepareThreadRewind(request)).rejects.toThrow(
+ "pi did not return a provider thread for rewind lease lease-ambiguous-identity",
+ );
+ const stagingThreadId = "thread-1:rewind:lease-ambiguous-identity";
+ expect(commands).toContainEqual(
+ expect.objectContaining({
+ type: "thread/fork",
+ threadId: stagingThreadId,
+ }),
+ );
+ expect(commands).toContainEqual(
+ expect.objectContaining({
+ type: "thread/discard",
+ providerThreadId: stagingThreadId,
+ threadId: stagingThreadId,
+ }),
+ );
+ expect(runtime.hasThread(stagingThreadId)).toBe(false);
+ } finally {
+ await runtime.shutdown();
+ }
+ });
+});
diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts
index 3ef9ffa5f1..7a54454b98 100644
--- a/packages/agent-runtime/src/runtime.ts
+++ b/packages/agent-runtime/src/runtime.ts
@@ -90,6 +90,30 @@ function normalizeExecutionOptions(args: {
return args.adapter.normalizeExecutionOptions?.(args.options) ?? args.options;
}
+interface PreparedThreadRewind {
+ state: "prepared";
+ cleanupPromise: Promise | null;
+ cleanupTimer: ReturnType | null;
+ processKey: string;
+ providerId: string;
+ providerState: RuntimeProviderProcess["identity"];
+ providerThreadId: string;
+ stagingThreadId: string;
+ threadId: string;
+}
+
+interface PreparingThreadRewind {
+ state: "preparing";
+ promise: Promise<{ providerThreadId: string }>;
+}
+
+/**
+ * A staged rewind fork, keyed by the server-minted per-attempt lease id.
+ * Each attempt owns exactly one staged fork; there is no cross-attempt
+ * sharing, so discarding a lease can never affect another attempt.
+ */
+type StagedThreadRewind = PreparingThreadRewind | PreparedThreadRewind;
+
interface ReapIdleProviderSessionCandidate {
idleSinceMs: number;
providerThreadId: string;
@@ -155,6 +179,8 @@ type ProviderProcess = RuntimeProviderProcess;
const threadGoalClearResultSchema = z.object({ cleared: z.boolean() }).strict();
const THREAD_GOAL_CLEAR_EVENT_TIMEOUT_MS = 5_000;
+const PREPARED_THREAD_REWIND_TTL_MS = 5 * 60_000;
+const PREPARED_THREAD_REWIND_RETRY_MS = 30_000;
interface ThreadRuntimeConfig {
dynamicTools?: DynamicTool[];
@@ -252,6 +278,8 @@ function createAgentRuntimeInternal(
const idleProviderSessionSinceMsByThreadId = new Map();
const pendingTurnStartThreadIds = new Set();
const threadOperationCounts = new Map();
+ const stagedThreadRewinds = new Map();
+ const suppressedThreadEventIds = new Set();
const threadGoalState = new RuntimeThreadGoalState();
const turnState = new RuntimeTurnState();
const backgroundWorkState = new RuntimeBackgroundWorkState();
@@ -555,9 +583,16 @@ function createAgentRuntimeInternal(
function forgetThreadRuntimeState(
proc: ProviderProcess,
threadId: string,
+ ): void {
+ forgetThreadRuntimeStateForProviderState(proc.identity, threadId);
+ }
+
+ function forgetThreadRuntimeStateForProviderState(
+ providerState: RuntimeProviderProcess["identity"],
+ threadId: string,
): void {
threadIdentityRegistry.forgetThread({
- providerState: proc.identity,
+ providerState,
threadId,
});
clearThreadRuntimeConfig(threadId);
@@ -958,6 +993,9 @@ function createAgentRuntimeInternal(
}
for (const targetThreadId of targetThreadIds) {
+ if (suppressedThreadEventIds.has(targetThreadId)) {
+ continue;
+ }
const stampedEvent = stampThreadEventScope({
event,
providerThreadId:
@@ -1009,6 +1047,12 @@ function createAgentRuntimeInternal(
function handleProviderNotification(args: RuntimeParsedMessageArgs): void {
const sourceThreadId = getJsonRpcStringParam(args.parsed, "threadId");
+ if (
+ sourceThreadId !== undefined &&
+ suppressedThreadEventIds.has(sourceThreadId)
+ ) {
+ return;
+ }
emitTranslatedEvents({
events: args.proc.adapter.translateEvent(args.parsed, {
threadId: sourceThreadId,
@@ -1071,6 +1115,128 @@ function createAgentRuntimeInternal(
// Public API
// -------------------------------------------------------------------------
+ function schedulePreparedThreadRewindCleanup(
+ leaseId: string,
+ prepared: PreparedThreadRewind,
+ delayMs: number,
+ ): void {
+ if (prepared.cleanupTimer !== null) {
+ clearTimeout(prepared.cleanupTimer);
+ }
+ prepared.cleanupTimer = setTimeout(() => {
+ void discardStagedThreadRewind(leaseId);
+ }, delayMs);
+ prepared.cleanupTimer.unref?.();
+ }
+
+ function finishPreparedThreadRewindCleanup(
+ leaseId: string,
+ prepared: PreparedThreadRewind,
+ ): void {
+ if (prepared.cleanupTimer !== null) {
+ clearTimeout(prepared.cleanupTimer);
+ prepared.cleanupTimer = null;
+ }
+ if (stagedThreadRewinds.get(leaseId) === prepared) {
+ stagedThreadRewinds.delete(leaseId);
+ }
+ suppressedThreadEventIds.delete(prepared.stagingThreadId);
+ }
+
+ async function sendStagedThreadDiscard(
+ proc: ProviderProcess,
+ stagingThreadId: string,
+ providerThreadId: string,
+ ): Promise {
+ const command = proc.adapter.buildCommandPlan({
+ type: "thread/discard",
+ threadId: stagingThreadId,
+ providerThreadId,
+ });
+ if (command.kind === "request") {
+ await sendCommand({
+ proc,
+ message: command,
+ resultSchema: ignoredJsonRpcResultSchema,
+ });
+ }
+ }
+
+ async function discardStagedThreadRewind(leaseId: string): Promise {
+ const staged = stagedThreadRewinds.get(leaseId);
+ if (staged?.state === "preparing") {
+ try {
+ await staged.promise;
+ } catch {
+ return;
+ }
+ }
+ const prepared = stagedThreadRewinds.get(leaseId);
+ if (prepared === undefined || prepared.state !== "prepared") {
+ return;
+ }
+ if (prepared.cleanupPromise !== null) {
+ await prepared.cleanupPromise;
+ return;
+ }
+
+ const cleanup = (async () => {
+ let proc: ProviderProcess;
+ try {
+ proc = requireProviderProcess({
+ processKey: prepared.processKey,
+ providerId: prepared.providerId,
+ });
+ } catch {
+ forgetThreadRuntimeStateForProviderState(
+ prepared.providerState,
+ prepared.stagingThreadId,
+ );
+ finishPreparedThreadRewindCleanup(leaseId, prepared);
+ return;
+ }
+
+ try {
+ await sendStagedThreadDiscard(
+ proc,
+ prepared.stagingThreadId,
+ prepared.providerThreadId,
+ );
+ } catch (error) {
+ schedulePreparedThreadRewindCleanup(
+ leaseId,
+ prepared,
+ PREPARED_THREAD_REWIND_RETRY_MS,
+ );
+ options.onStderr?.(
+ `Failed to discard staged rewind ${leaseId}; retrying: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ return;
+ }
+
+ forgetThreadRuntimeState(proc, prepared.stagingThreadId);
+ finishPreparedThreadRewindCleanup(leaseId, prepared);
+ try {
+ await shutdownThreadScopedCodexProcessIfIdle(proc);
+ } catch (error) {
+ options.onStderr?.(
+ `Failed to stop the idle provider after discarding staged rewind ${leaseId}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ })();
+ prepared.cleanupPromise = cleanup;
+ try {
+ await cleanup;
+ } finally {
+ if (
+ stagedThreadRewinds.get(leaseId) === prepared &&
+ prepared.cleanupPromise === cleanup
+ ) {
+ prepared.cleanupPromise = null;
+ }
+ }
+ }
+
const runtime: AgentRuntime = {
async ensureProvider({ providerId, forThreadId, acpLaunchSpec }) {
await providerProcesses.ensureProvider({
@@ -1254,6 +1420,190 @@ function createAgentRuntimeInternal(
});
},
+ async prepareThreadRewind({
+ environmentId,
+ threadId,
+ leaseId,
+ projectId,
+ providerId,
+ sourceProviderThreadId,
+ retainThroughProviderCheckpoint,
+ acpLaunchSpec,
+ options: execOpts,
+ instructions,
+ dynamicTools,
+ disallowedTools,
+ instructionMode = "append",
+ }) {
+ const existing = stagedThreadRewinds.get(leaseId);
+ if (existing !== undefined) {
+ // The server mints a fresh lease per attempt, so a duplicate can only
+ // be a replay of this exact request; return the same staged fork.
+ return existing.state === "preparing"
+ ? existing.promise
+ : { providerThreadId: existing.providerThreadId };
+ }
+
+ const preparation = runThreadOperation({
+ threadId,
+ work: async () => {
+ const processKey = resolveProviderProcessKey({
+ ...(acpLaunchSpec !== undefined ? { acpLaunchSpec } : {}),
+ providerId,
+ threadId,
+ });
+ await runtime.ensureProvider({
+ providerId,
+ forThreadId: threadId,
+ ...(acpLaunchSpec !== undefined ? { acpLaunchSpec } : {}),
+ });
+ const proc = requireProviderProcess({ processKey, providerId });
+ if (!proc.adapter.capabilities.supportsFork) {
+ throw new Error(
+ `Preparing a thread rewind is not supported by ${providerId}`,
+ );
+ }
+ const providerSkillRoots = skillRootsForProvider(providerId);
+ assertProviderSupportsExecutionOptions({
+ adapter: proc.adapter,
+ options: execOpts,
+ providerId,
+ });
+
+ // The lease id is a server-minted UUID, so it is safe inside
+ // identities that provider adapters may turn into filesystem keys.
+ const stagingThreadId = `${threadId}:rewind:${leaseId}`;
+ suppressedThreadEventIds.add(stagingThreadId);
+ threadIdentityRegistry.registerThreadProvider({
+ providerId,
+ providerState: proc.identity,
+ shouldWaitForProviderIdentity: true,
+ threadId: stagingThreadId,
+ });
+ let retainedForDiscard = false;
+ let providerThreadIdForCleanup: string | undefined;
+ try {
+ const envVars = buildThreadShellEnvironment({
+ baseShellEnv: options.shellEnv,
+ environmentId,
+ projectId,
+ threadStoragePath: resolveThreadStoragePath({
+ options,
+ threadId,
+ }),
+ threadId,
+ });
+ const adapterCommand: AdapterCommand = {
+ type: "thread/fork",
+ threadId: stagingThreadId,
+ cwd: options.workspacePath,
+ sourceProviderThreadId,
+ sourceProviderCheckpointId: retainThroughProviderCheckpoint,
+ options: toProviderExecutionContext({
+ envVars,
+ execOpts,
+ instructions,
+ skillRoots: providerSkillRoots,
+ }),
+ dynamicTools,
+ disallowedTools,
+ instructionMode,
+ };
+ const command = requireProviderRequestPlan({
+ commandType: adapterCommand.type,
+ plan: proc.adapter.buildCommandPlan(adapterCommand),
+ providerId,
+ });
+ const result = await sendCommand({
+ proc,
+ message: command,
+ resultSchema: threadIdentityResultSchema,
+ timeoutMs: THREAD_CREATION_REQUEST_TIMEOUT_MS,
+ });
+ // An ambiguous threadId is not sufficient to adopt a provider
+ // thread, but it is safe to use for best-effort cleanup because
+ // the BB staging id is unique to this rewind operation.
+ providerThreadIdForCleanup =
+ result.providerThreadId ??
+ result.thread?.id ??
+ result.threadId ??
+ undefined;
+ const providerThreadId = resolveThreadIdentityResult({
+ result,
+ threadId: stagingThreadId,
+ });
+ if (!providerThreadId) {
+ throw new Error(
+ `${providerId} did not return a provider thread for rewind lease ${leaseId}`,
+ );
+ }
+ recordProviderThreadIdentity(
+ proc,
+ stagingThreadId,
+ providerThreadId,
+ );
+ const prepared: PreparedThreadRewind = {
+ state: "prepared",
+ cleanupPromise: null,
+ cleanupTimer: null,
+ processKey,
+ providerId,
+ providerState: proc.identity,
+ providerThreadId,
+ stagingThreadId,
+ threadId,
+ };
+ stagedThreadRewinds.set(leaseId, prepared);
+ schedulePreparedThreadRewindCleanup(
+ leaseId,
+ prepared,
+ PREPARED_THREAD_REWIND_TTL_MS,
+ );
+ retainedForDiscard = true;
+ return { providerThreadId };
+ } finally {
+ if (!retainedForDiscard) {
+ if (providerThreadIdForCleanup !== undefined) {
+ try {
+ await sendStagedThreadDiscard(
+ proc,
+ stagingThreadId,
+ providerThreadIdForCleanup,
+ );
+ } catch (error) {
+ options.onStderr?.(
+ `Failed to discard unretained staged rewind ${leaseId}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+ suppressedThreadEventIds.delete(stagingThreadId);
+ threadIdentityRegistry.forgetThread({
+ providerState: proc.identity,
+ threadId: stagingThreadId,
+ });
+ }
+ }
+ },
+ });
+ stagedThreadRewinds.set(leaseId, {
+ state: "preparing",
+ promise: preparation,
+ });
+ try {
+ return await preparation;
+ } catch (error) {
+ const current = stagedThreadRewinds.get(leaseId);
+ if (current?.state === "preparing" && current.promise === preparation) {
+ stagedThreadRewinds.delete(leaseId);
+ }
+ throw error;
+ }
+ },
+
+ async discardThreadRewind({ leaseId }) {
+ await discardStagedThreadRewind(leaseId);
+ },
+
async resumeThread({
environmentId,
threadId,
@@ -1809,6 +2159,11 @@ function createAgentRuntimeInternal(
},
async shutdown() {
+ await Promise.all(
+ [...stagedThreadRewinds.keys()].map((leaseId) =>
+ discardStagedThreadRewind(leaseId),
+ ),
+ );
idleProviderSessionSinceMsByThreadId.clear();
pendingTurnStartThreadIds.clear();
threadOperationCounts.clear();
diff --git a/packages/agent-runtime/src/test/fake-adapter.ts b/packages/agent-runtime/src/test/fake-adapter.ts
index d2ed295b3d..91e74f8833 100644
--- a/packages/agent-runtime/src/test/fake-adapter.ts
+++ b/packages/agent-runtime/src/test/fake-adapter.ts
@@ -163,6 +163,15 @@ function buildCommandPlan(command: AdapterCommand): ProviderCommandPlan {
threadId: command.threadId,
},
};
+ case "thread/discard":
+ return {
+ kind: "request",
+ method: "thread/discard",
+ params: {
+ providerThreadId: command.providerThreadId,
+ threadId: command.threadId,
+ },
+ };
case "thread/goal/clear":
return {
kind: "request",
diff --git a/packages/agent-runtime/src/test/fake-provider-script.ts b/packages/agent-runtime/src/test/fake-provider-script.ts
index adbce8b793..967fa43d12 100644
--- a/packages/agent-runtime/src/test/fake-provider-script.ts
+++ b/packages/agent-runtime/src/test/fake-provider-script.ts
@@ -69,6 +69,10 @@ const defaultModelList = {
// Test-only mode used by runtime command-contract coverage.
const simulateArchivedSession = process.argv.includes("--archived-session");
const failUnarchive = process.argv.includes("--unarchive-fails");
+let failNextDiscard = process.argv.includes("--discard-fails-once");
+const returnThreadIdAsProviderIdentity = process.argv.includes(
+ "--thread-id-provider-identity",
+);
// Exit right after reporting the archived error, so recovery has to work
// against a replacement process.
const exitAfterArchivedError = process.argv.includes("--exit-after-archived");
@@ -438,8 +442,9 @@ function startOrResumeThread(
): void {
const params = getParams(message);
const threadId = getString(params.threadId, "unknown");
- const providerThreadId =
- mode === "resume"
+ const providerThreadId = returnThreadIdAsProviderIdentity
+ ? threadId
+ : mode === "resume"
? getString(params.providerThreadId) ||
`resumed-${nextProviderThreadId++}`
: `prov-${nextProviderThreadId++}`;
@@ -454,7 +459,9 @@ function startOrResumeThread(
send({
jsonrpc: "2.0",
id: getJsonRpcId(message.id) ?? 0,
- result: { providerThreadId },
+ result: returnThreadIdAsProviderIdentity
+ ? { threadId }
+ : { providerThreadId },
});
if (mode === "start" || mode === "fork") {
@@ -643,6 +650,24 @@ function handleMessage(message: JsonRecord): void {
return;
}
+ if (method === "thread/discard") {
+ if (failNextDiscard) {
+ failNextDiscard = false;
+ send({
+ jsonrpc: "2.0",
+ id: getJsonRpcId(message.id) ?? 0,
+ error: { code: -32000, message: "discard is temporarily unavailable" },
+ });
+ return;
+ }
+ send({
+ jsonrpc: "2.0",
+ id: getJsonRpcId(message.id) ?? 0,
+ result: { ok: true },
+ });
+ return;
+ }
+
if (method === "thread/goal/clear") {
const params = getParams(message);
const providerThreadId = getString(
diff --git a/packages/agent-runtime/src/test/runtime-test-harness.ts b/packages/agent-runtime/src/test/runtime-test-harness.ts
index b83df193d8..54c8c52c38 100644
--- a/packages/agent-runtime/src/test/runtime-test-harness.ts
+++ b/packages/agent-runtime/src/test/runtime-test-harness.ts
@@ -254,6 +254,7 @@ export function createWarningEventAdapter(scriptPath: string): ProviderAdapter {
case "skills/configure":
case "turn/steer":
case "thread/stop":
+ case "thread/discard":
case "thread/goal/clear":
case "thread/name/set":
case "thread/archive":
@@ -361,6 +362,7 @@ export function createStartedEventAdapter(scriptPath: string): ProviderAdapter {
case "turn/start":
case "turn/steer":
case "thread/stop":
+ case "thread/discard":
case "thread/goal/clear":
case "thread/name/set":
case "thread/archive":
diff --git a/packages/agent-runtime/src/types.ts b/packages/agent-runtime/src/types.ts
index 4674ba0e1e..738a309c47 100644
--- a/packages/agent-runtime/src/types.ts
+++ b/packages/agent-runtime/src/types.ts
@@ -172,6 +172,30 @@ export interface StartThreadResult {
providerThreadId: string;
}
+export interface PrepareThreadRewindArgs {
+ acpLaunchSpec?: HostDaemonAcpLaunchSpec;
+ environmentId: string;
+ threadId: string;
+ leaseId: string;
+ projectId: string;
+ providerId: string;
+ sourceProviderThreadId: string;
+ retainThroughProviderCheckpoint: string;
+ options: AgentRuntimeExecutionOptions;
+ instructions?: string;
+ dynamicTools?: DynamicTool[];
+ disallowedTools?: readonly string[];
+ instructionMode?: InstructionMode;
+}
+
+export interface PrepareThreadRewindResult {
+ providerThreadId: string;
+}
+
+export interface DiscardThreadRewindArgs {
+ leaseId: string;
+}
+
export interface ResumeThreadArgs {
acpLaunchSpec?: HostDaemonAcpLaunchSpec;
environmentId: string;
@@ -281,6 +305,12 @@ export interface AgentRuntime {
startThread(args: StartThreadArgs): Promise;
+ prepareThreadRewind(
+ args: PrepareThreadRewindArgs,
+ ): Promise;
+
+ discardThreadRewind(args: DiscardThreadRewindArgs): Promise;
+
resumeThread(args: ResumeThreadArgs): Promise;
runTurn(args: RunTurnArgs): Promise;
diff --git a/packages/db/drizzle/0092_windy_doctor_faustus.sql b/packages/db/drizzle/0092_windy_doctor_faustus.sql
new file mode 100644
index 0000000000..c1009b2b01
--- /dev/null
+++ b/packages/db/drizzle/0092_windy_doctor_faustus.sql
@@ -0,0 +1,2 @@
+DROP INDEX IF EXISTS `thread_search_segments_thread_idx`;--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS `thread_search_segments_thread_source_seq_idx` ON `thread_search_segments` (`thread_id`,`source_seq`);
diff --git a/packages/db/drizzle/meta/0092_snapshot.json b/packages/db/drizzle/meta/0092_snapshot.json
new file mode 100644
index 0000000000..fedda2c448
--- /dev/null
+++ b/packages/db/drizzle/meta/0092_snapshot.json
@@ -0,0 +1,3440 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "844ac91a-7746-44d4-b60f-dd235fa51627",
+ "prevId": "5b18174f-ab74-455c-ade6-d10d8677baf2",
+ "tables": {
+ "app_settings": {
+ "name": "app_settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "caffeinate": {
+ "name": "caffeinate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "show_keyboard_hints": {
+ "name": "show_keyboard_hints",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "steer_active_thread_on_enter": {
+ "name": "steer_active_thread_on_enter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "show_unhandled_provider_events": {
+ "name": "show_unhandled_provider_events",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "codex_memory_enabled": {
+ "name": "codex_memory_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "claude_code_memory_enabled": {
+ "name": "claude_code_memory_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "codex_subagents_disabled": {
+ "name": "codex_subagents_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "claude_code_subagents_disabled": {
+ "name": "claude_code_subagents_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "claude_code_workflows_disabled": {
+ "name": "claude_code_workflows_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "keybinding_overrides": {
+ "name": "keybinding_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "app_theme": {
+ "name": "app_theme",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "theme_id": {
+ "name": "theme_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "favicon_color": {
+ "name": "favicon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'default'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apikey": {
+ "name": "apikey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "start": {
+ "name": "start",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prefix": {
+ "name": "prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "referenceId": {
+ "name": "referenceId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refillInterval": {
+ "name": "refillInterval",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refillAmount": {
+ "name": "refillAmount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastRefillAt": {
+ "name": "lastRefillAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rateLimitEnabled": {
+ "name": "rateLimitEnabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rateLimitTimeWindow": {
+ "name": "rateLimitTimeWindow",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rateLimitMax": {
+ "name": "rateLimitMax",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requestCount": {
+ "name": "requestCount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "remaining": {
+ "name": "remaining",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastRequest": {
+ "name": "lastRequest",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "configId": {
+ "name": "configId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "apikey_key_unique": {
+ "name": "apikey_key_unique",
+ "columns": [
+ "key"
+ ],
+ "isUnique": true
+ },
+ "apikey_reference_id_idx": {
+ "name": "apikey_reference_id_idx",
+ "columns": [
+ "referenceId"
+ ],
+ "isUnique": false
+ },
+ "apikey_config_id_idx": {
+ "name": "apikey_config_id_idx",
+ "columns": [
+ "configId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "apikey_referenceId_user_id_fk": {
+ "name": "apikey_referenceId_user_id_fk",
+ "tableFrom": "apikey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "referenceId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "environments": {
+ "name": "environments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "managed": {
+ "name": "managed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_git_repo": {
+ "name": "is_git_repo",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_worktree": {
+ "name": "is_worktree",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "branch_name": {
+ "name": "branch_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "base_branch": {
+ "name": "base_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_branch": {
+ "name": "default_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "merge_base_branch": {
+ "name": "merge_base_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "destroy_attempt_id": {
+ "name": "destroy_attempt_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "retire_requested_at": {
+ "name": "retire_requested_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "workspace_provision_type": {
+ "name": "workspace_provision_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'provisioning'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "environments_project_host_path_idx": {
+ "name": "environments_project_host_path_idx",
+ "columns": [
+ "project_id",
+ "host_id",
+ "path"
+ ],
+ "isUnique": true
+ },
+ "environments_host_path_lookup_idx": {
+ "name": "environments_host_path_lookup_idx",
+ "columns": [
+ "host_id",
+ "path"
+ ],
+ "isUnique": false
+ },
+ "environments_project_idx": {
+ "name": "environments_project_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "environments_status_idx": {
+ "name": "environments_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "environments_project_id_projects_id_fk": {
+ "name": "environments_project_id_projects_id_fk",
+ "tableFrom": "environments",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "environments_host_id_hosts_id_fk": {
+ "name": "environments_host_id_hosts_id_fk",
+ "tableFrom": "environments",
+ "tableTo": "hosts",
+ "columnsFrom": [
+ "host_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "events": {
+ "name": "events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "environment_id": {
+ "name": "environment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope_kind": {
+ "name": "scope_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turn_id": {
+ "name": "turn_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_thread_id": {
+ "name": "provider_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sequence": {
+ "name": "sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_kind": {
+ "name": "item_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "events_thread_sequence_idx": {
+ "name": "events_thread_sequence_idx",
+ "columns": [
+ "thread_id",
+ "sequence"
+ ],
+ "isUnique": true
+ },
+ "events_thread_type_item_kind_sequence_idx": {
+ "name": "events_thread_type_item_kind_sequence_idx",
+ "columns": [
+ "thread_id",
+ "type",
+ "item_kind",
+ "sequence"
+ ],
+ "isUnique": false
+ },
+ "events_background_task_thread_type_item_sequence_idx": {
+ "name": "events_background_task_thread_type_item_sequence_idx",
+ "columns": [
+ "thread_id",
+ "type",
+ "item_id",
+ "sequence"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"item_kind\" = 'backgroundTask'"
+ },
+ "events_thread_type_sequence_idx": {
+ "name": "events_thread_type_sequence_idx",
+ "columns": [
+ "thread_id",
+ "type",
+ "sequence"
+ ],
+ "isUnique": false
+ },
+ "events_thread_turn_type_item_sequence_idx": {
+ "name": "events_thread_turn_type_item_sequence_idx",
+ "columns": [
+ "thread_id",
+ "turn_id",
+ "type",
+ "item_id",
+ "sequence"
+ ],
+ "isUnique": false
+ },
+ "events_environment_idx": {
+ "name": "events_environment_idx",
+ "columns": [
+ "environment_id"
+ ],
+ "isUnique": false
+ },
+ "events_completed_item_truncation_idx": {
+ "name": "events_completed_item_truncation_idx",
+ "columns": [
+ "item_kind",
+ "created_at",
+ "id"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"type\" = 'item/completed'"
+ },
+ "events_goal_thread_sequence_idx": {
+ "name": "events_goal_thread_sequence_idx",
+ "columns": [
+ "thread_id",
+ "sequence"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared')"
+ }
+ },
+ "foreignKeys": {
+ "events_thread_id_threads_id_fk": {
+ "name": "events_thread_id_threads_id_fk",
+ "tableFrom": "events",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "events_environment_id_environments_id_fk": {
+ "name": "events_environment_id_environments_id_fk",
+ "tableFrom": "events",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "events_scope_shape_check": {
+ "name": "events_scope_shape_check",
+ "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )"
+ }
+ }
+ },
+ "host_daemon_sessions": {
+ "name": "host_daemon_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "instance_id": {
+ "name": "instance_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_name": {
+ "name": "host_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_type": {
+ "name": "host_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data_dir": {
+ "name": "data_dir",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "protocol_version": {
+ "name": "protocol_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "heartbeat_interval_ms": {
+ "name": "heartbeat_interval_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lease_timeout_ms": {
+ "name": "lease_timeout_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lease_expires_at": {
+ "name": "lease_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "closed_at": {
+ "name": "closed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "close_reason": {
+ "name": "close_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "host_daemon_sessions_host_status_idx": {
+ "name": "host_daemon_sessions_host_status_idx",
+ "columns": [
+ "host_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "host_daemon_sessions_host_latest_idx": {
+ "name": "host_daemon_sessions_host_latest_idx",
+ "columns": [
+ "host_id",
+ "updated_at",
+ "created_at",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "host_daemon_sessions_closed_prune_idx": {
+ "name": "host_daemon_sessions_closed_prune_idx",
+ "columns": [
+ "status",
+ "closed_at",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "host_daemon_sessions_host_id_hosts_id_fk": {
+ "name": "host_daemon_sessions_host_id_hosts_id_fk",
+ "tableFrom": "host_daemon_sessions",
+ "tableTo": "hosts",
+ "columnsFrom": [
+ "host_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "hosts": {
+ "name": "hosts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connect_machine_id": {
+ "name": "connect_machine_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_permission_mode": {
+ "name": "max_permission_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'full'"
+ },
+ "destroyed_at": {
+ "name": "destroyed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_rejected_protocol_version": {
+ "name": "last_rejected_protocol_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "hosts_last_seen_idx": {
+ "name": "hosts_last_seen_idx",
+ "columns": [
+ "last_seen_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugins": {
+ "name": "plugins",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provenance": {
+ "name": "provenance",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'direct'"
+ },
+ "catalog_entry_id": {
+ "name": "catalog_entry_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'path'"
+ },
+ "source_path": {
+ "name": "source_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_builtin_name": {
+ "name": "source_builtin_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_npm_package": {
+ "name": "source_npm_package",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_npm_registry": {
+ "name": "source_npm_registry",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_npm_requested_spec": {
+ "name": "source_npm_requested_spec",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_npm_spec_kind": {
+ "name": "source_npm_spec_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_url": {
+ "name": "source_git_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_subdirectory": {
+ "name": "source_git_subdirectory",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_requested_ref": {
+ "name": "source_git_requested_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_ref_kind": {
+ "name": "source_git_ref_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "npm_resolved_version": {
+ "name": "npm_resolved_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "npm_integrity": {
+ "name": "npm_integrity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "git_resolved_commit": {
+ "name": "git_resolved_commit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_update_check_at": {
+ "name": "last_update_check_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "available_compatible_version": {
+ "name": "available_compatible_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "newest_incompatible_version": {
+ "name": "newest_incompatible_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "update_status_detail": {
+ "name": "update_status_detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_failure_version": {
+ "name": "last_failure_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_failure_at": {
+ "name": "last_failure_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_failure_detail": {
+ "name": "last_failure_detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "active_artifact_id": {
+ "name": "active_artifact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "normalization_version": {
+ "name": "normalization_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "root_dir": {
+ "name": "root_dir",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "removed_at": {
+ "name": "removed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "installed_at": {
+ "name": "installed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "plugins_active_artifact_id_plugin_artifacts_id_fk": {
+ "name": "plugins_active_artifact_id_plugin_artifacts_id_fk",
+ "tableFrom": "plugins",
+ "tableTo": "plugin_artifacts",
+ "columnsFrom": [
+ "active_artifact_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "maintenance_scan_cursors": {
+ "name": "maintenance_scan_cursors",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "policy": {
+ "name": "policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_kind": {
+ "name": "item_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "output_path": {
+ "name": "output_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_created_at": {
+ "name": "last_created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_event_id": {
+ "name": "last_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "maintenance_scan_cursors_path_idx": {
+ "name": "maintenance_scan_cursors_path_idx",
+ "columns": [
+ "policy",
+ "version",
+ "item_kind",
+ "output_path"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "pending_interactions": {
+ "name": "pending_interactions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "origin_kind": {
+ "name": "origin_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'provider'"
+ },
+ "turn_id": {
+ "name": "turn_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_thread_id": {
+ "name": "provider_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_request_id": {
+ "name": "provider_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "renderer_id": {
+ "name": "renderer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resolution": {
+ "name": "resolution",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status_reason": {
+ "name": "status_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "pending_interactions_provider_request_idx": {
+ "name": "pending_interactions_provider_request_idx",
+ "columns": [
+ "provider_id",
+ "provider_thread_id",
+ "provider_request_id"
+ ],
+ "isUnique": true
+ },
+ "pending_interactions_thread_created_idx": {
+ "name": "pending_interactions_thread_created_idx",
+ "columns": [
+ "thread_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "pending_interactions_thread_status_created_idx": {
+ "name": "pending_interactions_thread_status_created_idx",
+ "columns": [
+ "thread_id",
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "pending_interactions_status_created_idx": {
+ "name": "pending_interactions_status_created_idx",
+ "columns": [
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "pending_interactions_plugin_status_created_idx": {
+ "name": "pending_interactions_plugin_status_created_idx",
+ "columns": [
+ "plugin_id",
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "pending_interactions_thread_id_threads_id_fk": {
+ "name": "pending_interactions_thread_id_threads_id_fk",
+ "tableFrom": "pending_interactions",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_artifacts": {
+ "name": "plugin_artifacts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "npm_resolved_version": {
+ "name": "npm_resolved_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "git_resolved_commit": {
+ "name": "git_resolved_commit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integrity": {
+ "name": "integrity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "validation_result": {
+ "name": "validation_result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "validated_at": {
+ "name": "validated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "plugin_artifacts_plugin_idx": {
+ "name": "plugin_artifacts_plugin_idx",
+ "columns": [
+ "plugin_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_kv": {
+ "name": "plugin_kv",
+ "columns": {
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "plugin_kv_plugin_id_key_pk": {
+ "columns": [
+ "plugin_id",
+ "key"
+ ],
+ "name": "plugin_kv_plugin_id_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_schedules": {
+ "name": "plugin_schedules",
+ "columns": {
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cron": {
+ "name": "cron",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "next_run_at": {
+ "name": "next_run_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_status": {
+ "name": "last_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "plugin_schedules_plugin_id_name_pk": {
+ "columns": [
+ "plugin_id",
+ "name"
+ ],
+ "name": "plugin_schedules_plugin_id_name_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_settings": {
+ "name": "plugin_settings",
+ "columns": {
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "plugin_settings_plugin_id_key_pk": {
+ "columns": [
+ "plugin_id",
+ "key"
+ ],
+ "name": "plugin_settings_plugin_id_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_state_snapshots": {
+ "name": "plugin_state_snapshots",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_artifact_id": {
+ "name": "from_artifact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "to_artifact_id": {
+ "name": "to_artifact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshot_path": {
+ "name": "snapshot_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "database_path": {
+ "name": "database_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "state_path": {
+ "name": "state_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "secrets_path": {
+ "name": "secrets_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "registration_path": {
+ "name": "registration_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rollback_candidate_version": {
+ "name": "rollback_candidate_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rollback_source_fingerprint": {
+ "name": "rollback_source_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rollback_bb_version": {
+ "name": "rollback_bb_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rollback_sdk_version": {
+ "name": "rollback_sdk_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rollback_detail": {
+ "name": "rollback_detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retained_until": {
+ "name": "retained_until",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "plugin_state_snapshots_plugin_idx": {
+ "name": "plugin_state_snapshots_plugin_idx",
+ "columns": [
+ "plugin_id"
+ ],
+ "isUnique": false
+ },
+ "plugin_state_snapshots_retention_idx": {
+ "name": "plugin_state_snapshots_retention_idx",
+ "columns": [
+ "retained_until"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "project_execution_defaults": {
+ "name": "project_execution_defaults",
+ "columns": {
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "service_tier": {
+ "name": "service_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reasoning_level": {
+ "name": "reasoning_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission_mode": {
+ "name": "permission_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "project_execution_defaults_project_idx": {
+ "name": "project_execution_defaults_project_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "project_execution_defaults_project_id_projects_id_fk": {
+ "name": "project_execution_defaults_project_id_projects_id_fk",
+ "tableFrom": "project_execution_defaults",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "project_sources": {
+ "name": "project_sources",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "project_sources_project_idx": {
+ "name": "project_sources_project_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "project_sources_host_idx": {
+ "name": "project_sources_host_idx",
+ "columns": [
+ "host_id"
+ ],
+ "isUnique": false
+ },
+ "project_sources_project_host_idx": {
+ "name": "project_sources_project_host_idx",
+ "columns": [
+ "project_id",
+ "host_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "project_sources_project_id_projects_id_fk": {
+ "name": "project_sources_project_id_projects_id_fk",
+ "tableFrom": "project_sources",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "project_sources_host_id_hosts_id_fk": {
+ "name": "project_sources_host_id_hosts_id_fk",
+ "tableFrom": "project_sources",
+ "tableTo": "hosts",
+ "columnsFrom": [
+ "host_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "project_sources_shape_check": {
+ "name": "project_sources_shape_check",
+ "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )"
+ }
+ }
+ },
+ "projects": {
+ "name": "projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'standard'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "git_remote_url": {
+ "name": "git_remote_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_key": {
+ "name": "sort_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'V'"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "projects_updated_idx": {
+ "name": "projects_updated_idx",
+ "columns": [
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "projects_deleted_idx": {
+ "name": "projects_deleted_idx",
+ "columns": [
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "projects_sort_idx": {
+ "name": "projects_sort_idx",
+ "columns": [
+ "sort_key",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "projects_personal_singleton_idx": {
+ "name": "projects_personal_singleton_idx",
+ "columns": [
+ "kind"
+ ],
+ "isUnique": true,
+ "where": "\"projects\".\"kind\" = 'personal'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "prompt_history_entries": {
+ "name": "prompt_history_entries",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_sequence": {
+ "name": "request_sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "input": {
+ "name": "input",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "prompt_history_entries_thread_request_idx": {
+ "name": "prompt_history_entries_thread_request_idx",
+ "columns": [
+ "thread_id",
+ "request_sequence"
+ ],
+ "isUnique": true
+ },
+ "prompt_history_entries_project_scope_created_idx": {
+ "name": "prompt_history_entries_project_scope_created_idx",
+ "columns": [
+ "project_id",
+ "scope",
+ "created_at",
+ "request_sequence",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "prompt_history_entries_thread_scope_created_idx": {
+ "name": "prompt_history_entries_thread_scope_created_idx",
+ "columns": [
+ "thread_id",
+ "scope",
+ "created_at",
+ "request_sequence",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "prompt_history_entries_project_id_projects_id_fk": {
+ "name": "prompt_history_entries_project_id_projects_id_fk",
+ "tableFrom": "prompt_history_entries",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "prompt_history_entries_thread_id_threads_id_fk": {
+ "name": "prompt_history_entries_thread_id_threads_id_fk",
+ "tableFrom": "prompt_history_entries",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "queued_thread_messages": {
+ "name": "queued_thread_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sender_thread_id": {
+ "name": "sender_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reasoning_level": {
+ "name": "reasoning_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission_mode": {
+ "name": "permission_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "service_tier": {
+ "name": "service_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_with_next": {
+ "name": "group_with_next",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "claimed_at": {
+ "name": "claimed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "claim_token": {
+ "name": "claim_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_key": {
+ "name": "sort_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "queued_thread_messages_thread_created_idx": {
+ "name": "queued_thread_messages_thread_created_idx",
+ "columns": [
+ "thread_id",
+ "created_at",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "queued_thread_messages_thread_sort_idx": {
+ "name": "queued_thread_messages_thread_sort_idx",
+ "columns": [
+ "thread_id",
+ "sort_key",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "queued_thread_messages_thread_id_threads_id_fk": {
+ "name": "queued_thread_messages_thread_id_threads_id_fk",
+ "tableFrom": "queued_thread_messages",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "system_experiments": {
+ "name": "system_experiments",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "terminal_sessions": {
+ "name": "terminal_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "environment_id": {
+ "name": "environment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "daemon_session_id": {
+ "name": "daemon_session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "initial_cwd": {
+ "name": "initial_cwd",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cols": {
+ "name": "cols",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rows": {
+ "name": "rows",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exit_code": {
+ "name": "exit_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "close_reason": {
+ "name": "close_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_user_input_at": {
+ "name": "last_user_input_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "terminal_sessions_thread_status_updated_idx": {
+ "name": "terminal_sessions_thread_status_updated_idx",
+ "columns": [
+ "thread_id",
+ "status",
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "terminal_sessions_environment_status_idx": {
+ "name": "terminal_sessions_environment_status_idx",
+ "columns": [
+ "environment_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "terminal_sessions_host_status_idx": {
+ "name": "terminal_sessions_host_status_idx",
+ "columns": [
+ "host_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "terminal_sessions_daemon_session_idx": {
+ "name": "terminal_sessions_daemon_session_idx",
+ "columns": [
+ "daemon_session_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "terminal_sessions_thread_id_threads_id_fk": {
+ "name": "terminal_sessions_thread_id_threads_id_fk",
+ "tableFrom": "terminal_sessions",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "terminal_sessions_environment_id_environments_id_fk": {
+ "name": "terminal_sessions_environment_id_environments_id_fk",
+ "tableFrom": "terminal_sessions",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "terminal_sessions_host_id_hosts_id_fk": {
+ "name": "terminal_sessions_host_id_hosts_id_fk",
+ "tableFrom": "terminal_sessions",
+ "tableTo": "hosts",
+ "columnsFrom": [
+ "host_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": {
+ "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk",
+ "tableFrom": "terminal_sessions",
+ "tableTo": "host_daemon_sessions",
+ "columnsFrom": [
+ "daemon_session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_dynamic_context_file_states": {
+ "name": "thread_dynamic_context_file_states",
+ "columns": {
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_key": {
+ "name": "file_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_status": {
+ "name": "content_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "shown_at": {
+ "name": "shown_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "thread_dynamic_context_file_states_thread_file_idx": {
+ "name": "thread_dynamic_context_file_states_thread_file_idx",
+ "columns": [
+ "thread_id",
+ "file_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "thread_dynamic_context_file_states_thread_id_threads_id_fk": {
+ "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk",
+ "tableFrom": "thread_dynamic_context_file_states",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_search_segments": {
+ "name": "thread_search_segments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_seq": {
+ "name": "source_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "thread_search_segments_source_idx": {
+ "name": "thread_search_segments_source_idx",
+ "columns": [
+ "thread_id",
+ "source_kind",
+ "source_key"
+ ],
+ "isUnique": true
+ },
+ "thread_search_segments_thread_source_seq_idx": {
+ "name": "thread_search_segments_thread_source_seq_idx",
+ "columns": [
+ "thread_id",
+ "source_seq"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "thread_search_segments_thread_id_threads_id_fk": {
+ "name": "thread_search_segments_thread_id_threads_id_fk",
+ "tableFrom": "thread_search_segments",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_sections": {
+ "name": "thread_sections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "thread_sections_name_idx": {
+ "name": "thread_sections_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_tabs": {
+ "name": "thread_tabs",
+ "columns": {
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tabs_json": {
+ "name": "tabs_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "thread_tabs_thread_id_threads_id_fk": {
+ "name": "thread_tabs_thread_id_threads_id_fk",
+ "tableFrom": "thread_tabs",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "threads": {
+ "name": "threads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "environment_id": {
+ "name": "environment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model_override": {
+ "name": "model_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_level_override": {
+ "name": "reasoning_level_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title_fallback": {
+ "name": "title_fallback",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'starting'"
+ },
+ "parent_thread_id": {
+ "name": "parent_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_thread_id": {
+ "name": "source_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "origin_kind": {
+ "name": "origin_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "child_origin": {
+ "name": "child_origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "origin_plugin_id": {
+ "name": "origin_plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'visible'"
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pinned_at": {
+ "name": "pinned_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pin_sort_key": {
+ "name": "pin_sort_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_read_at": {
+ "name": "last_read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "latest_attention_at": {
+ "name": "latest_attention_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "threads_project_updated_idx": {
+ "name": "threads_project_updated_idx",
+ "columns": [
+ "project_id",
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "threads_project_archived_deleted_idx": {
+ "name": "threads_project_archived_deleted_idx",
+ "columns": [
+ "project_id",
+ "archived_at",
+ "deleted_at",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "threads_pin_sort_idx": {
+ "name": "threads_pin_sort_idx",
+ "columns": [
+ "archived_at",
+ "deleted_at",
+ "pin_sort_key",
+ "id"
+ ],
+ "isUnique": false,
+ "where": "\"threads\".\"pinned_at\" IS NOT NULL"
+ },
+ "threads_environment_idx": {
+ "name": "threads_environment_idx",
+ "columns": [
+ "environment_id"
+ ],
+ "isUnique": false
+ },
+ "threads_parent_idx": {
+ "name": "threads_parent_idx",
+ "columns": [
+ "parent_thread_id"
+ ],
+ "isUnique": false
+ },
+ "threads_source_origin_idx": {
+ "name": "threads_source_origin_idx",
+ "columns": [
+ "source_thread_id",
+ "origin_kind"
+ ],
+ "isUnique": false
+ },
+ "threads_origin_plugin_archived_idx": {
+ "name": "threads_origin_plugin_archived_idx",
+ "columns": [
+ "origin_plugin_id",
+ "archived_at"
+ ],
+ "isUnique": false
+ },
+ "threads_section_archived_deleted_idx": {
+ "name": "threads_section_archived_deleted_idx",
+ "columns": [
+ "section_id",
+ "archived_at",
+ "deleted_at",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "threads_archived_status_idx": {
+ "name": "threads_archived_status_idx",
+ "columns": [
+ "archived_at",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "threads_environment_archived_deleted_idx": {
+ "name": "threads_environment_archived_deleted_idx",
+ "columns": [
+ "environment_id",
+ "archived_at",
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "threads_active_maintenance_idx": {
+ "name": "threads_active_maintenance_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false,
+ "where": "\"threads\".\"deleted_at\" IS NULL"
+ }
+ },
+ "foreignKeys": {
+ "threads_project_id_projects_id_fk": {
+ "name": "threads_project_id_projects_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "threads_environment_id_environments_id_fk": {
+ "name": "threads_environment_id_environments_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "threads_section_id_thread_sections_id_fk": {
+ "name": "threads_section_id_thread_sections_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "thread_sections",
+ "columnsFrom": [
+ "section_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "threads_parent_thread_id_threads_id_fk": {
+ "name": "threads_parent_thread_id_threads_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "parent_thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "threads_source_thread_id_threads_id_fk": {
+ "name": "threads_source_thread_id_threads_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "source_thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index c3987d2b50..969783aa3d 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -645,6 +645,13 @@
"when": 1786416023798,
"tag": "0091_daffy_dark_phoenix",
"breakpoints": true
+ },
+ {
+ "idx": 92,
+ "version": "6",
+ "when": 1786468375011,
+ "tag": "0092_windy_doctor_faustus",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts
index 5791d56855..9997841562 100644
--- a/packages/db/src/data/events.ts
+++ b/packages/db/src/data/events.ts
@@ -44,7 +44,14 @@ import type {
} from "../connection.js";
import { alias, unionAll } from "drizzle-orm/sqlite-core";
import type { DbNotifier } from "../notifier.js";
-import { environments, events, threads } from "../schema.js";
+import {
+ environments,
+ events,
+ promptHistoryEntries,
+ threadDynamicContextFileStates,
+ threadSearchSegments,
+ threads,
+} from "../schema.js";
import { createEventId } from "../ids.js";
import { truncatedEventDataColumn } from "./event-output-truncation.js";
import { deriveStoredEventItemFieldsFromSource } from "../stored-event-item-fields.js";
@@ -188,6 +195,62 @@ export interface CompletedStoredTurnRow {
turnId: string;
}
+export interface DeleteThreadEventSuffixArgs {
+ cutoffSequence: number;
+ oldMaxSequence: number;
+ threadId: string;
+}
+
+export interface DeleteThreadEventSuffixResult {
+ deletedEventCount: number;
+}
+
+/**
+ * Deletes one conversation suffix and its sequence-scoped projections.
+ * Callers append a marker above oldMaxSequence first so sequence numbers are
+ * never reused after the rewrite.
+ */
+export function deleteThreadEventSuffixInTransaction(
+ db: DbTransaction,
+ args: DeleteThreadEventSuffixArgs,
+): DeleteThreadEventSuffixResult {
+ db.delete(promptHistoryEntries)
+ .where(
+ and(
+ eq(promptHistoryEntries.threadId, args.threadId),
+ gte(promptHistoryEntries.requestSequence, args.cutoffSequence),
+ lte(promptHistoryEntries.requestSequence, args.oldMaxSequence),
+ ),
+ )
+ .run();
+ db.delete(threadSearchSegments)
+ .where(
+ and(
+ eq(threadSearchSegments.threadId, args.threadId),
+ gte(threadSearchSegments.sourceSeq, args.cutoffSequence),
+ lte(threadSearchSegments.sourceSeq, args.oldMaxSequence),
+ ),
+ )
+ .run();
+ // Dynamic-context state is not sequence-addressable. Clearing it is safe:
+ // the next turn may re-send unchanged context, while retaining it could hide
+ // context that only the removed turn had observed.
+ db.delete(threadDynamicContextFileStates)
+ .where(eq(threadDynamicContextFileStates.threadId, args.threadId))
+ .run();
+ const result = db
+ .delete(events)
+ .where(
+ and(
+ eq(events.threadId, args.threadId),
+ gte(events.sequence, args.cutoffSequence),
+ lte(events.sequence, args.oldMaxSequence),
+ ),
+ )
+ .run();
+ return { deletedEventCount: result.changes };
+}
+
export interface ListThreadIdsWithLatestHostDaemonRestartInterruptionArgs {
threadIds: readonly string[];
}
diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts
index f861e9a49b..b63ac96285 100644
--- a/packages/db/src/data/index.ts
+++ b/packages/db/src/data/index.ts
@@ -298,6 +298,8 @@ export {
appendStoredThreadEvent,
appendStoredThreadEventInTransaction,
appendStoredThreadEventsInTransaction,
+ deleteThreadEventSuffixInTransaction,
+ getHighWaterMarks,
findStoredClientTurnRequestSequenceByRequestId,
findStoredEventRow,
getActiveStoredTurnId,
@@ -369,6 +371,8 @@ export type {
AppendDaemonEventsResult,
AppendStoredThreadEventArgs,
CompletedStoredTurnRow,
+ DeleteThreadEventSuffixArgs,
+ DeleteThreadEventSuffixResult,
FindStoredClientTurnRequestSequenceByRequestIdArgs,
GetStoredTurnRequestEventForTurnArgs,
GetRootStoredTurnStartedSequenceArgs,
@@ -516,6 +520,7 @@ export {
deleteClaimedQueuedThreadMessageInTransaction,
deleteQueuedThreadMessage,
getQueuedThreadMessage,
+ hasQueuedThreadMessages,
listIdleThreadsWithQueuedMessages,
listQueuedThreadMessages,
releaseQueuedMessageClaim,
diff --git a/packages/db/src/data/queued-thread-messages.ts b/packages/db/src/data/queued-thread-messages.ts
index 13598a940c..e3420cbb32 100644
--- a/packages/db/src/data/queued-thread-messages.ts
+++ b/packages/db/src/data/queued-thread-messages.ts
@@ -562,10 +562,28 @@ export function getQueuedThreadMessage(db: DbConnection, id: string) {
);
}
-export function listQueuedThreadMessages(db: DbConnection, threadId: string) {
+export function listQueuedThreadMessages(
+ db: DbQueryConnection,
+ threadId: string,
+) {
return listUnclaimedQueuedThreadMessages(db, threadId);
}
+/** Includes messages currently claimed by the queue drain worker. */
+export function hasQueuedThreadMessages(
+ db: DbQueryConnection,
+ threadId: string,
+): boolean {
+ return (
+ db
+ .select({ id: queuedThreadMessages.id })
+ .from(queuedThreadMessages)
+ .where(eq(queuedThreadMessages.threadId, threadId))
+ .limit(1)
+ .get() !== undefined
+ );
+}
+
export function listIdleThreadsWithQueuedMessages(
db: DbConnection,
): QueuedMessageThreadRow[] {
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index 417388441d..29677a5b59 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -610,7 +610,10 @@ export const threadSearchSegments = sqliteTable(
table.sourceKind,
table.sourceKey,
),
- index("thread_search_segments_thread_idx").on(table.threadId),
+ index("thread_search_segments_thread_source_seq_idx").on(
+ table.threadId,
+ table.sourceSeq,
+ ),
],
);
diff --git a/packages/db/test/experiments.test.ts b/packages/db/test/experiments.test.ts
index 501b02c3ea..a3b42d7833 100644
--- a/packages/db/test/experiments.test.ts
+++ b/packages/db/test/experiments.test.ts
@@ -37,6 +37,7 @@ describe("experiments", () => {
.map((row) => row.key),
).toEqual([
"claudeCodeMockCliTraffic",
+ "editMessages",
"futureExperiment",
"newOnboarding",
"toolsHub",
diff --git a/packages/db/test/query-plans.test.ts b/packages/db/test/query-plans.test.ts
index 291d9cbc0d..34fadf3042 100644
--- a/packages/db/test/query-plans.test.ts
+++ b/packages/db/test/query-plans.test.ts
@@ -527,6 +527,28 @@ describe("slow query index plans", () => {
db.$client.close();
});
+ it("uses the thread and sequence index for search segment suffix deletes", () => {
+ const { db } = setup();
+
+ const details = queryPlanDetails({
+ db,
+ params: ["thread-query-plan", 10, 20],
+ sql: `
+ DELETE FROM thread_search_segments
+ WHERE thread_id = ?
+ AND source_seq >= ?
+ AND source_seq <= ?
+ `,
+ });
+
+ expect(details).toMatch(
+ /USING (?:COVERING )?INDEX thread_search_segments_thread_source_seq_idx/,
+ );
+ expect(details).not.toContain("SCAN thread_search_segments");
+
+ db.$client.close();
+ });
+
it("uses the completed item truncation partial index for emitted cursor scans", () => {
const { db, logger, thread } = setup();
const createdBefore = Date.now();
diff --git a/packages/domain/src/change-kinds.ts b/packages/domain/src/change-kinds.ts
index 7d1f17a0fa..1c0270da77 100644
--- a/packages/domain/src/change-kinds.ts
+++ b/packages/domain/src/change-kinds.ts
@@ -9,6 +9,7 @@ export const THREAD_CHANGE_KINDS = [
"thread-created",
"thread-deleted",
"events-appended",
+ "history-rewritten",
"interactions-changed",
"status-changed",
"title-changed",
diff --git a/packages/domain/src/experiments.ts b/packages/domain/src/experiments.ts
index 77021f793d..346f70827a 100644
--- a/packages/domain/src/experiments.ts
+++ b/packages/domain/src/experiments.ts
@@ -14,6 +14,7 @@ import { z } from "zod";
*/
export const experimentKeys = [
"claudeCodeMockCliTraffic",
+ "editMessages",
"newOnboarding",
"toolsHub",
] as const;
diff --git a/packages/domain/src/provider-event.ts b/packages/domain/src/provider-event.ts
index c1652ab630..a8d9170477 100644
--- a/packages/domain/src/provider-event.ts
+++ b/packages/domain/src/provider-event.ts
@@ -409,6 +409,8 @@ const unscopedProviderEventSchema = z.discriminatedUnion("type", [
providerThreadId: z.string().nullable(),
status: threadEventTurnStatusSchema,
error: z.object({ message: z.string() }).optional(),
+ /** Provider-native point through which a replacement branch should retain history. */
+ providerCheckpointId: z.string().min(1).optional(),
}),
z
.object({
diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts
index 755e532a9d..b150b3a5c6 100644
--- a/packages/host-daemon-contract/src/commands.ts
+++ b/packages/host-daemon-contract/src/commands.ts
@@ -36,7 +36,7 @@ import {
providerCliStatusResponseSchema,
} from "./local.js";
-export const HOST_DAEMON_PROTOCOL_VERSION = 100 as const;
+export const HOST_DAEMON_PROTOCOL_VERSION = 101 as const;
export {
BRANCH_LIST_LIMIT_MAX,
@@ -325,6 +325,24 @@ export const threadStartCommandSchema = hostDaemonThreadTargetSchema
refineGroupedInputMatchesFlatInput(value, ctx);
});
+export const threadRewindPrepareCommandSchema = hostDaemonThreadTargetSchema
+ .merge(hostDaemonThreadRuntimeContextSchema)
+ .extend({
+ type: z.literal("thread.rewind.prepare"),
+ /** Server-minted per-attempt staging id; each lease owns one staged fork. */
+ leaseId: z.string().min(1),
+ sourceProviderThreadId: z.string().min(1),
+ retainThroughProviderCheckpoint: z.string().min(1),
+ })
+ .strict();
+
+export const threadRewindDiscardCommandSchema = hostDaemonThreadTargetSchema
+ .extend({
+ type: z.literal("thread.rewind.discard"),
+ leaseId: z.string().min(1),
+ })
+ .strict();
+
export const turnSubmitTargetSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("start"),
@@ -1557,6 +1575,24 @@ function defineHostDaemonCommandDescriptor<
}
export const hostDaemonCommandRegistry = {
+ "thread.rewind.discard": defineHostDaemonCommandDescriptor({
+ type: "thread.rewind.discard",
+ schema: threadRewindDiscardCommandSchema,
+ resultSchema: emptyCommandResultSchema,
+ transport: "settled",
+ retryable: false,
+ flushEventsBeforeResult: true,
+ envLane: "read",
+ }),
+ "thread.rewind.prepare": defineHostDaemonCommandDescriptor({
+ type: "thread.rewind.prepare",
+ schema: threadRewindPrepareCommandSchema,
+ resultSchema: threadStartResultSchema,
+ transport: "settled",
+ retryable: false,
+ flushEventsBeforeResult: true,
+ envLane: "read",
+ }),
"thread.start": defineHostDaemonCommandDescriptor({
type: "thread.start",
schema: threadStartCommandSchema,
diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts
index 895afdc2d3..96ec7713a3 100644
--- a/packages/host-daemon-contract/src/session.ts
+++ b/packages/host-daemon-contract/src/session.ts
@@ -440,6 +440,8 @@ const hostDaemonOnlineRpcResponseSuccessSchema = z.discriminatedUnion(
onlineRpcResponseSuccessSchemaFor("workspace.diffFiles"),
onlineRpcResponseSuccessSchemaFor("workspace.diffPatch"),
onlineRpcResponseSuccessSchemaFor("workspace.pull_request"),
+ commandRpcResponseSuccessSchemaFor("thread.rewind.discard"),
+ commandRpcResponseSuccessSchemaFor("thread.rewind.prepare"),
commandRpcResponseSuccessSchemaFor("thread.start"),
commandRpcResponseSuccessSchemaFor("turn.submit"),
commandRpcResponseSuccessSchemaFor("thread.stop"),
diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts
index b58f2a9a17..550d834c05 100644
--- a/packages/host-daemon-contract/test/contract.test.ts
+++ b/packages/host-daemon-contract/test/contract.test.ts
@@ -484,6 +484,10 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = {
};
const SETTLED_RESPONSE_RESULT_FIXTURES: SettledResponseResultFixtures = {
+ "thread.rewind.discard": {},
+ "thread.rewind.prepare": {
+ providerThreadId: "provider-thread-rewind",
+ },
"thread.start": {
providerThreadId: "provider-thread-123",
},
@@ -1051,12 +1055,10 @@ describe("host-daemon local schemas", () => {
});
describe("host-daemon command schemas", () => {
- // Version 100 makes Codex inference timeoutMs an end-to-end daemon deadline
- // and classifies streamed service-unavailable failures for bounded retries.
- // An older daemon can outlive the server's wait or hide a retryable failure,
- // so it must update before connecting.
- it("uses protocol version 100 for Codex inference deadlines", () => {
- expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(100);
+ // Version 101 builds on Codex inference deadlines in version 100 with
+ // provider-native history checkpointing and ownership-leased staged rewinds.
+ it("uses protocol version 101 for leased staged thread rewind cleanup", () => {
+ expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(101);
});
it("binds Plan cancellation to a required turn id and typed result", () => {
diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts
index 5b13c7b03a..355b1eb822 100644
--- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts
+++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts
@@ -149,6 +149,7 @@ declare const changedMessageSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{
"thread-created": "thread-created";
"thread-deleted": "thread-deleted";
"events-appended": "events-appended";
+ "history-rewritten": "history-rewritten";
"interactions-changed": "interactions-changed";
"status-changed": "status-changed";
"title-changed": "title-changed";
@@ -238,6 +239,7 @@ type Environment = z$1.infer;
declare const experimentsSchema: z$1.ZodRecord, z$1.ZodBoolean>;
@@ -308,8 +310,8 @@ declare const providerPendingInteractionSchema: z$1.ZodObject<{
id: z$1.ZodString;
threadId: z$1.ZodString;
status: z$1.ZodEnum<{
- pending: "pending";
interrupted: "interrupted";
+ pending: "pending";
resolving: "resolving";
resolved: "resolved";
}>;
@@ -451,8 +453,8 @@ declare const pluginPendingInteractionSchema: z$1.ZodObject<{
id: z$1.ZodString;
threadId: z$1.ZodString;
status: z$1.ZodEnum<{
- pending: "pending";
interrupted: "interrupted";
+ pending: "pending";
resolving: "resolving";
resolved: "resolved";
}>;
@@ -673,6 +675,7 @@ declare const threadEventSchema: z$1.ZodPipe>;
+ providerCheckpointId: z$1.ZodOptional;
}, z$1.core.$strip>, z$1.ZodObject<{
type: z$1.ZodLiteral<"turn/input/accepted">;
threadId: z$1.ZodString;
@@ -699,8 +702,8 @@ declare const threadEventSchema: z$1.ZodPipe;
@@ -744,10 +747,10 @@ declare const threadEventSchema: z$1.ZodPipe;
approvalStatus: z$1.ZodNullable;
}, z$1.core.$strip>>;
status: z$1.ZodEnum<{
- pending: "pending";
completed: "completed";
failed: "failed";
interrupted: "interrupted";
+ pending: "pending";
}>;
approvalStatus: z$1.ZodNullable>;
status: z$1.ZodEnum<{
- pending: "pending";
completed: "completed";
failed: "failed";
interrupted: "interrupted";
+ pending: "pending";
}>;
result: z$1.ZodOptional;
error: z$1.ZodOptional;
@@ -881,17 +884,17 @@ declare const threadEventSchema: z$1.ZodPipe;
taskStatus: z$1.ZodEnum<{
- pending: "pending";
completed: "completed";
- running: "running";
- paused: "paused";
failed: "failed";
+ paused: "paused";
+ pending: "pending";
+ running: "running";
killed: "killed";
stopped: "stopped";
}>;
@@ -907,8 +910,8 @@ declare const threadEventSchema: z$1.ZodPipe;
approvalStatus: z$1.ZodNullable;
}, z$1.core.$strip>>;
status: z$1.ZodEnum<{
- pending: "pending";
completed: "completed";
failed: "failed";
interrupted: "interrupted";
+ pending: "pending";
}>;
approvalStatus: z$1.ZodNullable>;
status: z$1.ZodEnum<{
- pending: "pending";
completed: "completed";
failed: "failed";
interrupted: "interrupted";
+ pending: "pending";
}>;
result: z$1.ZodOptional;
error: z$1.ZodOptional;
@@ -1113,17 +1116,17 @@ declare const threadEventSchema: z$1.ZodPipe;
taskStatus: z$1.ZodEnum<{
- pending: "pending";
completed: "completed";
- running: "running";
- paused: "paused";
failed: "failed";
+ paused: "paused";
+ pending: "pending";
+ running: "running";
killed: "killed";
stopped: "stopped";
}>;
@@ -1139,8 +1142,8 @@ declare const threadEventSchema: z$1.ZodPipe;
taskStatus: z$1.ZodEnum<{
- pending: "pending";
completed: "completed";
- running: "running";
- paused: "paused";
failed: "failed";
+ paused: "paused";
+ pending: "pending";
+ running: "running";
killed: "killed";
stopped: "stopped";
}>;
@@ -1268,8 +1271,8 @@ declare const threadEventSchema: z$1.ZodPipe;
taskStatus: z$1.ZodEnum<{
- pending: "pending";
completed: "completed";
- running: "running";
- paused: "paused";
failed: "failed";
+ paused: "paused";
+ pending: "pending";
+ running: "running";
killed: "killed";
stopped: "stopped";
}>;
@@ -1340,8 +1343,8 @@ declare const threadEventSchema: z$1.ZodPipe>;
}, z$1.core.$strip>>;
explanation: z$1.ZodOptional;
@@ -1858,8 +1861,8 @@ declare const threadEventSchema: z$1.ZodPipe;
@@ -1910,8 +1913,8 @@ declare const threadEventSchema: z$1.ZodPipe;
@@ -2067,8 +2070,8 @@ type ThreadEventRow = {
declare const threadStatusSchema: z$1.ZodEnum<{
error: "error";
active: "active";
- idle: "idle";
starting: "starting";
+ idle: "idle";
stopping: "stopping";
}>;
type ThreadStatus = z$1.infer;
@@ -2080,9 +2083,9 @@ declare const threadTimelinePendingTodosSchema: z$1.ZodObject<{
id: z$1.ZodString;
text: z$1.ZodString;
status: z$1.ZodEnum<{
+ completed: "completed";
pending: "pending";
in_progress: "in_progress";
- completed: "completed";
}>;
}, z$1.core.$strip>>;
}, z$1.core.$strip>;
@@ -2886,8 +2889,8 @@ declare const environmentDiffFileResponseSchema: z$1.ZodObject<{
path: z$1.ZodString;
content: z$1.ZodString;
contentEncoding: z$1.ZodEnum<{
- base64: "base64";
utf8: "utf8";
+ base64: "base64";
}>;
mimeType: z$1.ZodOptional;
sizeBytes: z$1.ZodNumber;
@@ -2900,8 +2903,8 @@ declare const environmentArchiveThreadsResponseSchema: z$1.ZodObject<{
type EnvironmentArchiveThreadsResponse = z$1.infer;
declare const pullRequestMergeMethodSchema: z$1.ZodEnum<{
merge: "merge";
- squash: "squash";
rebase: "rebase";
+ squash: "squash";
}>;
type PullRequestMergeMethod = z$1.infer;
declare const commitActionResponseSchema: z$1.ZodObject<{
@@ -2932,8 +2935,8 @@ declare const pullRequestMergeActionResponseSchema: z$1.ZodObject<{
action: z$1.ZodLiteral<"pull_request_merge">;
method: z$1.ZodEnum<{
merge: "merge";
- squash: "squash";
rebase: "rebase";
+ squash: "squash";
}>;
message: z$1.ZodString;
}, z$1.core.$strip>;
@@ -3383,6 +3386,213 @@ interface HostDaemonCommandDescriptor;
+ leaseId: z$1.ZodString;
+ }, z$1.core.$strict>, z$1.ZodObject<{}, z$1.core.$strip>, "settled", false>;
+ "thread.rewind.prepare": HostDaemonCommandDescriptor<"thread.rewind.prepare", z$1.ZodObject<{
+ environmentId: z$1.ZodString;
+ threadId: z$1.ZodString;
+ workspaceContext: z$1.ZodObject<{
+ workspacePath: z$1.ZodString;
+ workspaceProvisionType: z$1.ZodEnum<{
+ unmanaged: "unmanaged";
+ "managed-worktree": "managed-worktree";
+ personal: "personal";
+ }>;
+ }, z$1.core.$strip>;
+ projectId: z$1.ZodString;
+ providerId: z$1.ZodString;
+ acpLaunchSpec: z$1.ZodOptional;
+ env: z$1.ZodRecord;
+ cwd: z$1.ZodOptional;
+ modelCli: z$1.ZodOptional;
+ selectFlag: z$1.ZodOptional;
+ primaryModels: z$1.ZodArray;
+ }, z$1.core.$strict>, z$1.ZodTransform<{
+ listArgs: string[];
+ primaryModels: string[];
+ selectFlag?: string | undefined;
+ } | undefined, {
+ listArgs: string[];
+ primaryModels: string[];
+ selectFlag?: string | undefined;
+ }>>>;
+ reasoningCli: z$1.ZodOptional>;
+ levelValues: z$1.ZodOptional & z$1.core.$partial, z$1.ZodString>>;
+ defaultLevel: z$1.ZodOptional>;
+ }, z$1.core.$strict>>;
+ nativeReasoning: z$1.ZodOptional>;
+ levelValues: z$1.ZodOptional & z$1.core.$partial, z$1.ZodString>>;
+ defaultLevel: z$1.ZodOptional>;
+ }, z$1.core.$strict>>;
+ nativeSkillRoots: z$1.ZodOptional>;
+ project: z$1.ZodDefault>;
+ }, z$1.core.$strict>>;
+ permissionCli: z$1.ZodOptional>;
+ workspaceWrite: z$1.ZodOptional>;
+ readonly: z$1.ZodOptional>;
+ insertAfterArgs: z$1.ZodOptional;
+ }, z$1.core.$strict>>;
+ }, z$1.core.$strict>>;
+ options: z$1.ZodIntersection;
+ reasoningLevel: z$1.ZodEnum<{
+ none: "none";
+ low: "low";
+ medium: "medium";
+ high: "high";
+ xhigh: "xhigh";
+ ultracode: "ultracode";
+ max: "max";
+ ultra: "ultra";
+ }>;
+ claudeCodePermissionMode: z$1.ZodOptional>;
+ claudeCodeMockCliTraffic: z$1.ZodOptional>;
+ workflowsEnabled: z$1.ZodBoolean;
+ memoryEnabled: z$1.ZodOptional;
+ providerSubagentsEnabled: z$1.ZodOptional;
+ }, z$1.core.$strip>, z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{
+ permissionMode: z$1.ZodLiteral<"accept-edits">;
+ permissionScope: z$1.ZodLiteral<"workspace">;
+ approvalReviewer: z$1.ZodLiteral<"user">;
+ permissionEscalation: z$1.ZodEnum<{
+ ask: "ask";
+ deny: "deny";
+ }>;
+ }, z$1.core.$strip>, z$1.ZodObject<{
+ permissionMode: z$1.ZodLiteral<"auto">;
+ permissionScope: z$1.ZodLiteral<"workspace">;
+ approvalReviewer: z$1.ZodLiteral<"automatic">;
+ permissionEscalation: z$1.ZodEnum<{
+ ask: "ask";
+ deny: "deny";
+ }>;
+ }, z$1.core.$strip>, z$1.ZodObject<{
+ permissionMode: z$1.ZodLiteral<"full">;
+ permissionScope: z$1.ZodLiteral<"full">;
+ approvalReviewer: z$1.ZodNull;
+ permissionEscalation: z$1.ZodNull;
+ }, z$1.core.$strip>], "permissionMode">>;
+ instructions: z$1.ZodString;
+ dynamicTools: z$1.ZodArray>;
+ injectedSkillSources: z$1.ZodArray;
+ treeHash: z$1.ZodString;
+ entryPath: z$1.ZodString;
+ sourceType: z$1.ZodEnum<{
+ builtin: "builtin";
+ "data-dir": "data-dir";
+ }>;
+ }, z$1.core.$strict>, z$1.ZodObject<{
+ name: z$1.ZodString;
+ description: z$1.ZodString;
+ kind: z$1.ZodLiteral<"workspace-path">;
+ sourceType: z$1.ZodLiteral<"project">;
+ sourceRootPath: z$1.ZodString;
+ skillFilePath: z$1.ZodString;
+ }, z$1.core.$strict>, z$1.ZodObject<{
+ name: z$1.ZodString;
+ description: z$1.ZodString;
+ kind: z$1.ZodLiteral<"host-path">;
+ sourceType: z$1.ZodEnum<{
+ "shared-user": "shared-user";
+ "shared-project": "shared-project";
+ }>;
+ sourceRootPath: z$1.ZodString;
+ skillFilePath: z$1.ZodString;
+ }, z$1.core.$strict>], "kind">>;
+ disallowedTools: z$1.ZodOptional>;
+ instructionMode: z$1.ZodEnum<{
+ append: "append";
+ replace: "replace";
+ }>;
+ type: z$1.ZodLiteral<"thread.rewind.prepare">;
+ leaseId: z$1.ZodString;
+ sourceProviderThreadId: z$1.ZodString;
+ retainThroughProviderCheckpoint: z$1.ZodString;
+ }, z$1.core.$strict>, z$1.ZodObject<{
+ providerThreadId: z$1.ZodString;
+ }, z$1.core.$strip>, "settled", false>;
"thread.start": HostDaemonCommandDescriptor<"thread.start", z$1.ZodObject<{
environmentId: z$1.ZodString;
threadId: z$1.ZodString;
@@ -6934,6 +7144,7 @@ declare const systemConfigResponseSchema: z$1.ZodObject<{
}, z$1.core.$strict>>;
experiments: z$1.ZodRecord, z$1.ZodBoolean>;
@@ -7239,6 +7450,7 @@ declare const timelineConversationRowSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodO
count: z$1.ZodNumber;
}, z$1.core.$strip>], "kind">>;
turnRequest: z$1.ZodObject<{
+ isGrouped: z$1.ZodBoolean;
kind: z$1.ZodEnum<{
message: "message";
steer: "steer";
@@ -8443,6 +8655,137 @@ declare const continueAfterProviderRateLimitResponseSchema: z$1.ZodObject<{
requestId: z$1.ZodString;
}, z$1.core.$strip>;
type ContinueAfterProviderRateLimitResponse = z$1.infer;
+declare const editMessageRequestSchema: z$1.ZodObject<{
+ input: z$1.ZodArray>;
+ type: z$1.ZodLiteral<"text">;
+ text: z$1.ZodString;
+ mentions: z$1.ZodDefault, z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{
+ kind: z$1.ZodLiteral<"thread">;
+ threadId: z$1.ZodString;
+ projectId: z$1.ZodOptional;
+ label: z$1.ZodString;
+ }, z$1.core.$strip>, z$1.ZodObject<{
+ kind: z$1.ZodLiteral<"project">;
+ projectId: z$1.ZodString;
+ label: z$1.ZodString;
+ }, z$1.core.$strip>, z$1.ZodObject<{
+ kind: z$1.ZodLiteral<"section">;
+ sectionId: z$1.ZodString;
+ label: z$1.ZodString;
+ }, z$1.core.$strip>, z$1.ZodObject<{
+ kind: z$1.ZodLiteral<"path">;
+ source: z$1.ZodEnum<{
+ workspace: "workspace";
+ "thread-storage": "thread-storage";
+ }>;
+ entryKind: z$1.ZodEnum<{
+ file: "file";
+ directory: "directory";
+ }>;
+ path: z$1.ZodString;
+ label: z$1.ZodString;
+ }, z$1.core.$strip>, z$1.ZodObject<{
+ kind: z$1.ZodLiteral<"command">;
+ trigger: z$1.ZodEnum<{
+ "/": "/";
+ }>;
+ name: z$1.ZodString;
+ source: z$1.ZodEnum<{
+ command: "command";
+ skill: "skill";
+ }>;
+ origin: z$1.ZodEnum<{
+ user: "user";
+ project: "project";
+ builtin: "builtin";
+ }>;
+ label: z$1.ZodString;
+ argumentHint: z$1.ZodNullable;
+ }, z$1.core.$strip>, z$1.ZodObject<{
+ kind: z$1.ZodLiteral<"plugin">;
+ pluginId: z$1.ZodString;
+ icon: z$1.ZodOptional>;
+ itemId: z$1.ZodString;
+ label: z$1.ZodString;
+ }, z$1.core.$strip>], "kind">>;
+ }, z$1.core.$strip>>>;
+ }, z$1.core.$strip>, z$1.ZodObject<{
+ visibility: z$1.ZodOptional>;
+ type: z$1.ZodLiteral<"image">;
+ url: z$1.ZodString;
+ }, z$1.core.$strip>, z$1.ZodObject<{
+ visibility: z$1.ZodOptional>;
+ type: z$1.ZodLiteral<"localImage">;
+ path: z$1.ZodString;
+ }, z$1.core.$strip>, z$1.ZodObject<{
+ visibility: z$1.ZodOptional>;
+ type: z$1.ZodLiteral<"localFile">;
+ path: z$1.ZodString;
+ name: z$1.ZodOptional;
+ sizeBytes: z$1.ZodOptional;
+ mimeType: z$1.ZodOptional;
+ }, z$1.core.$strip>], "type">>;
+ senderThreadId: z$1.ZodOptional;
+ model: z$1.ZodOptional;
+ serviceTier: z$1.ZodOptional>;
+ reasoningLevel: z$1.ZodOptional>;
+ permissionMode: z$1.ZodOptional, z$1.ZodLiteral<"workspace-write">]>, z$1.ZodTransform<"auto" | "accept-edits" | "full", "auto" | "accept-edits" | "full" | "workspace-write">>>;
+ executionInputSources: z$1.ZodOptional>;
+ serviceTier: z$1.ZodOptional>;
+ reasoningLevel: z$1.ZodOptional>;
+ permissionMode: z$1.ZodOptional>;
+ }, z$1.core.$strict>>;
+ operationId: z$1.ZodString;
+ expectedRequestSequence: z$1.ZodOptional;
+}, z$1.core.$strict>;
+type EditMessageRequest = z$1.infer;
+declare const editMessageResponseSchema: z$1.ZodObject<{
+ ok: z$1.ZodLiteral;
+ operationId: z$1.ZodString;
+ requestSequence: z$1.ZodNumber;
+}, z$1.core.$strict>;
+type EditMessageResponse = z$1.infer;
declare const createQueuedMessageRequestSchema: z$1.ZodObject<{
input: z$1.ZodArray;
hostReconnectGraceExpiresAt: z$1.ZodNullable;
}, z$1.core.$strip>;
+ activeBackgroundAgentCount: z$1.ZodNumber;
canSpawnChild: z$1.ZodBoolean;
}, z$1.core.$strip>;
type ThreadResponse = z$1.infer;
@@ -9125,6 +9469,7 @@ declare const threadWithIncludesResponseSchema: z$1.ZodObject<{
}>;
hostReconnectGraceExpiresAt: z$1.ZodNullable;
}, z$1.core.$strip>;
+ activeBackgroundAgentCount: z$1.ZodNumber;
canSpawnChild: z$1.ZodBoolean;
environment: z$1.ZodOptional;
defaultExecutionOptions(args: ThreadStatusArgs): Promise;
delete(args: ThreadDeleteArgs): Promise;
+ editMessage(args: ThreadEditMessageArgs): Promise;
events: ThreadEventsArea;
fork(args: ThreadForkArgs): Promise;
get(args: ThreadGetArgs): Promise;
diff --git a/packages/plugin-sdk/src/testing/fixtures.ts b/packages/plugin-sdk/src/testing/fixtures.ts
index 4b92545931..6caee8e2e0 100644
--- a/packages/plugin-sdk/src/testing/fixtures.ts
+++ b/packages/plugin-sdk/src/testing/fixtures.ts
@@ -34,6 +34,7 @@ export function makeThreadResponse(
createdAt: 0,
updatedAt: 0,
runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null },
+ activeBackgroundAgentCount: 0,
canSpawnChild: true,
...overrides,
};
diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts
index 11ca3e9523..11ef199674 100644
--- a/packages/sdk/src/areas/threads.ts
+++ b/packages/sdk/src/areas/threads.ts
@@ -14,6 +14,8 @@ import type {
CreateQueuedMessageRequest,
ContinueAfterProviderRateLimitResponse,
CreateThreadRequest,
+ EditMessageRequest,
+ EditMessageResponse,
ForkThreadRequest,
DeleteThreadRequest,
PromptHistoryResponse,
@@ -113,6 +115,7 @@ export type ThreadSendResult = { ok: true };
export type ThreadRateLimitRecoveryResult = ProviderRateLimitRecoveryStatus;
export type ThreadContinueAfterRateLimitResult =
ContinueAfterProviderRateLimitResponse;
+export type ThreadEditMessageResult = EditMessageResponse;
export type ThreadStopResult = { ok: true };
export type ThreadCompactResult = { ok: true };
export type ThreadBannerActionResult = { ok: true };
@@ -183,6 +186,10 @@ export interface ThreadSendArgs extends SendMessageRequest {
threadId: string;
}
+export interface ThreadEditMessageArgs extends EditMessageRequest {
+ threadId: string;
+}
+
export interface ThreadActionArgs {
threadId: string;
}
@@ -431,6 +438,7 @@ export interface ThreadsArea {
args: ThreadStatusArgs,
): Promise;
delete(args: ThreadDeleteArgs): Promise;
+ editMessage(args: ThreadEditMessageArgs): Promise;
events: ThreadEventsArea;
fork(args: ThreadForkArgs): Promise;
get(args: ThreadGetArgs): Promise;
@@ -922,6 +930,15 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea {
);
return { ok: true };
},
+ async editMessage(input) {
+ const { threadId, ...json } = input;
+ return transport.readJson(
+ transport.api.v1.threads[":id"]["edit-message"].$post({
+ param: { id: threadId },
+ json,
+ }),
+ );
+ },
events,
async fork(input) {
return transport.readJson(
diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts
index 4a43130e75..2c93103278 100644
--- a/packages/sdk/test/public-types.test.ts
+++ b/packages/sdk/test/public-types.test.ts
@@ -356,6 +356,7 @@ type ExpectedThreadsKey =
| "conversationOutline"
| "defaultExecutionOptions"
| "delete"
+ | "editMessage"
| "events"
| "fork"
| "get"
diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts
index 6b14ab53eb..dde43b94d9 100644
--- a/packages/sdk/test/sdk.test.ts
+++ b/packages/sdk/test/sdk.test.ts
@@ -1054,6 +1054,46 @@ describe("@bb/sdk", () => {
]);
});
+ it("submits an atomic message edit", async () => {
+ const queue = createFetchQueue([
+ {
+ body: {
+ ok: true,
+ operationId: "edit-op-1",
+ requestSequence: 43,
+ },
+ },
+ ]);
+ const sdk = createBbSdk({
+ transport: createHttpTransport({
+ baseUrl: "http://bb.test",
+ fetch: queue.fetch,
+ runtime: "node",
+ }),
+ });
+
+ await expect(
+ sdk.threads.editMessage({
+ threadId: "thr_edit",
+ operationId: "edit-op-1",
+ expectedRequestSequence: 41,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ }),
+ ).resolves.toMatchObject({ requestSequence: 43 });
+
+ expect(queue.requests).toEqual([
+ {
+ bodyText: JSON.stringify({
+ operationId: "edit-op-1",
+ expectedRequestSequence: 41,
+ input: [{ type: "text", text: "Replacement", mentions: [] }],
+ }),
+ method: "POST",
+ url: "http://bb.test/api/v1/threads/thr_edit/edit-message",
+ },
+ ]);
+ });
+
it("preserves explicit thread spawn origin for CLI callers", async () => {
const queue = createFetchQueue([{ body: { id: "thr_1" }, status: 201 }]);
const sdk = createBbSdk({
diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts
index 5749df5378..e7c7cb31da 100644
--- a/packages/server-contract/src/api/threads.ts
+++ b/packages/server-contract/src/api/threads.ts
@@ -272,6 +272,25 @@ export type ContinueAfterProviderRateLimitResponse = z.infer<
typeof continueAfterProviderRateLimitResponseSchema
>;
+export const editMessageRequestSchema = sendMessageRequestSchema
+ .omit({ mode: true })
+ .extend({
+ operationId: z.string().min(1),
+ /** Omission targets the latest editable message with no staleness guard. */
+ expectedRequestSequence: z.number().int().nonnegative().optional(),
+ })
+ .strict();
+export type EditMessageRequest = z.infer;
+
+export const editMessageResponseSchema = z
+ .object({
+ ok: z.literal(true),
+ operationId: z.string().min(1),
+ requestSequence: z.number().int().nonnegative(),
+ })
+ .strict();
+export type EditMessageResponse = z.infer;
+
export const sendQueuedMessageModeSchema = z.enum(["auto", "steer"]);
export type SendQueuedMessageMode = z.infer;
@@ -387,6 +406,7 @@ export type ThreadSearchResponse = z.infer;
// be created under it. Computed on the server so clients never recompute the
// depth cap.
export const threadResponseSchema = threadWithRuntimeSchema.extend({
+ activeBackgroundAgentCount: z.number().int().nonnegative(),
canSpawnChild: z.boolean(),
});
export type ThreadResponse = z.infer;
diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts
index e37d8b259c..6f1b8ed69d 100644
--- a/packages/server-contract/src/public-api.ts
+++ b/packages/server-contract/src/public-api.ts
@@ -60,6 +60,8 @@ import type {
CreateQueuedMessageRequest,
CreateThreadSectionRequest,
CreateThreadRequest,
+ EditMessageRequest,
+ EditMessageResponse,
ForkThreadRequest,
RestartTerminalRequest,
DeleteThreadSectionRequest,
@@ -275,6 +277,7 @@ import {
resolvePendingInteractionRequestSchema,
respondPluginInteractionRequestSchema,
sendMessageRequestSchema,
+ editMessageRequestSchema,
setQueuedMessageGroupBoundaryRequestSchema,
sendQueuedMessageRequestSchema,
systemExecutionOptionsQuerySchema,
@@ -992,6 +995,14 @@ export const publicApiRoutes = {
),
response: jsonResponse(),
}),
+ editMessage: defineRoute({
+ path: "/threads/:id/edit-message",
+ method: "post",
+ request: jsonRequest(
+ editMessageRequestSchema,
+ ),
+ response: jsonResponse(),
+ }),
/** @deprecated App code uses dedicated composer queries. */
composerBootstrap: defineRoute({
path: "/threads/:id/composer-bootstrap",
diff --git a/packages/server-contract/src/thread-timeline.ts b/packages/server-contract/src/thread-timeline.ts
index 960364e45c..3f221f3efd 100644
--- a/packages/server-contract/src/thread-timeline.ts
+++ b/packages/server-contract/src/thread-timeline.ts
@@ -92,6 +92,7 @@ export const timelineConversationTurnRequestStatusValues = [
"accepted",
] as const;
export const timelineConversationTurnRequestSchema = z.object({
+ isGrouped: z.boolean(),
kind: z.enum(timelineConversationTurnRequestKindValues),
status: z.enum(timelineConversationTurnRequestStatusValues),
});
diff --git a/packages/templates/src/generated/plugin-sdk-dts.generated.ts b/packages/templates/src/generated/plugin-sdk-dts.generated.ts
index 789c365df4..d1198fd876 100644
--- a/packages/templates/src/generated/plugin-sdk-dts.generated.ts
+++ b/packages/templates/src/generated/plugin-sdk-dts.generated.ts
@@ -2,6 +2,6 @@
// Generated by packages/templates/scripts/generate-templates.mjs from
// @bb/plugin-sdk/bundled-types. Do not edit directly.
-export const PLUGIN_SDK_DTS = "// Portable type declarations for `@bb/plugin-sdk`. Unpublished BB\n// workspace contracts are flattened; public subpaths may reuse the\n// package root without requiring any other @bb/* package.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/get-bb/bb\n\nimport * as react from 'react';\nimport { ComponentType, ReactNode } from 'react';\nimport * as z from 'zod';\nimport { z as z$1 } from 'zod';\nimport Database from 'better-sqlite3';\nimport { Context } from 'hono';\n\n/**\n * App-wide server-backed preferences.\n * Client-local settings stay in the frontend localStorage helpers instead.\n */\ndeclare const appSettingsSchema: z$1.ZodObject<{\n caffeinate: z$1.ZodBoolean;\n showKeyboardHints: z$1.ZodBoolean;\n steerActiveThreadOnEnter: z$1.ZodBoolean;\n showUnhandledProviderEvents: z$1.ZodBoolean;\n codexMemoryEnabled: z$1.ZodBoolean;\n claudeCodeMemoryEnabled: z$1.ZodBoolean;\n codexSubagentsDisabled: z$1.ZodBoolean;\n claudeCodeSubagentsDisabled: z$1.ZodBoolean;\n claudeCodeWorkflowsDisabled: z$1.ZodBoolean;\n onboardingCompletedAt: z$1.ZodNullable;\n}, z$1.core.$strict>;\ntype AppSettings = z$1.infer;\n\ndeclare const appKeybindingOverridesSchema: z$1.ZodArray;\n shortcut: z$1.ZodNullable>;\n}, z$1.core.$strict>>;\ntype AppKeybindingOverrides = z$1.infer;\n\ndeclare const appThemeSchema: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n}, z$1.core.$strip>;\ntype AppTheme = z$1.infer;\n/**\n * The complete appearance selection a client sends when changing the palette\n * and/or favicon tint. The server validates `themeId` (built-in id or an\n * existing custom theme) and resolves the CSS from disk for custom themes.\n * Callers changing only one facet must carry the other facet forward explicitly.\n */\ndeclare const appThemeSelectionSchema: z$1.ZodObject<{\n themeId: z$1.ZodString;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n}, z$1.core.$strip>;\ntype AppThemeSelection = z$1.infer;\n\ndeclare const changedMessageSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"thread\">;\n id: z$1.ZodOptional;\n metadata: z$1.ZodOptional;\n eventTypes: z$1.ZodOptional>>>>;\n hasPendingInteraction: z$1.ZodOptional;\n projectId: z$1.ZodOptional;\n }, z$1.core.$strict>>;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"project\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"environment\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"host\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"system\">;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>], \"entity\">;\ntype ChangedMessage = z$1.infer;\n\ndeclare const environmentSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n name: z$1.ZodNullable;\n projectId: z$1.ZodString;\n hostId: z$1.ZodString;\n path: z$1.ZodNullable;\n managed: z$1.ZodBoolean;\n isGitRepo: z$1.ZodBoolean;\n isWorktree: z$1.ZodBoolean;\n workspaceProvisionType: z$1.ZodEnum<{\n unmanaged: \"unmanaged\";\n \"managed-worktree\": \"managed-worktree\";\n personal: \"personal\";\n }>;\n branchName: z$1.ZodNullable;\n baseBranch: z$1.ZodNullable;\n defaultBranch: z$1.ZodNullable;\n mergeBaseBranch: z$1.ZodNullable;\n status: z$1.ZodEnum<{\n error: \"error\";\n provisioning: \"provisioning\";\n ready: \"ready\";\n retiring: \"retiring\";\n destroying: \"destroying\";\n destroyed: \"destroyed\";\n }>;\n createdAt: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n}, z$1.core.$strip>;\ntype Environment = z$1.infer;\n\ndeclare const experimentsSchema: z$1.ZodRecord, z$1.ZodBoolean>;\ntype Experiments = z$1.infer;\n\ndeclare const hostSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n name: z$1.ZodString;\n type: z$1.ZodEnum<{\n persistent: \"persistent\";\n }>;\n status: z$1.ZodEnum<{\n connected: \"connected\";\n disconnected: \"disconnected\";\n }>;\n maxPermissionMode: z$1.ZodEnum<{\n full: \"full\";\n auto: \"auto\";\n \"accept-edits\": \"accept-edits\";\n }>;\n lastSeenAt: z$1.ZodNullable;\n lastRejectedProtocolVersion: z$1.ZodNullable;\n createdAt: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n}, z$1.core.$strip>;\ntype Host = z$1.infer;\n\ninterface JsonObject {\n [key: string]: JsonValue$1;\n}\ntype JsonValue$1 = string | number | boolean | null | JsonValue$1[] | JsonObject;\n\ndeclare const pendingInteractionResolutionSchema: z$1.ZodUnion;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"allow_for_session\">;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"deny\">;\n}, z$1.core.$strip>], \"decision\">, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_answer\">;\n answers: z$1.ZodRecord;\n freeText: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin_submitted\">;\n}, z$1.core.$strip>]>;\ntype PendingInteractionResolution = z$1.infer;\ndeclare const providerPendingInteractionSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n threadId: z$1.ZodString;\n status: z$1.ZodEnum<{\n pending: \"pending\";\n interrupted: \"interrupted\";\n resolving: \"resolving\";\n resolved: \"resolved\";\n }>;\n statusReason: z$1.ZodNullable;\n createdAt: z$1.ZodNumber;\n expiresAt: z$1.ZodOptional>;\n resolvedAt: z$1.ZodNullable;\n turnId: z$1.ZodString;\n providerId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n providerRequestId: z$1.ZodString;\n origin: z$1.ZodOptional;\n providerId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n providerRequestId: z$1.ZodString;\n }, z$1.core.$strip>>;\n payload: z$1.ZodUnion;\n subject: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n itemId: z$1.ZodString;\n command: z$1.ZodString;\n cwd: z$1.ZodNullable;\n actions: z$1.ZodArray;\n command: z$1.ZodString;\n name: z$1.ZodString;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"listFiles\">;\n command: z$1.ZodString;\n path: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"search\">;\n command: z$1.ZodString;\n query: z$1.ZodNullable;\n path: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unknown\">;\n command: z$1.ZodString;\n }, z$1.core.$strip>], \"type\">>;\n sessionGrant: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"file_change\">;\n itemId: z$1.ZodString;\n writeScope: z$1.ZodNullable;\n sessionGrant: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"permission_grant\">;\n itemId: z$1.ZodString;\n toolName: z$1.ZodNullable;\n permissions: z$1.ZodObject<{\n network: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plan\">;\n itemId: z$1.ZodString;\n plan: z$1.ZodString;\n planFilePath: z$1.ZodNullable;\n }, z$1.core.$strip>], \"kind\">;\n reason: z$1.ZodNullable;\n availableDecisions: z$1.ZodArray>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_question\">;\n questions: z$1.ZodArray;\n multiSelect: z$1.ZodBoolean;\n options: z$1.ZodOptional;\n }, z$1.core.$strip>>>;\n allowFreeText: z$1.ZodBoolean;\n }, z$1.core.$strip>>;\n }, z$1.core.$strip>]>;\n resolution: z$1.ZodNullable