Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/app/src/components/dialog-fork.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@deepagent-code/sdk/v2/client"
import { base64Encode } from "@deepagent-code/core/util/encode"
import { useLanguage } from "@/context/language"
import { Identifier } from "@/utils/id"

interface ForkableMessage {
id: string
Expand All @@ -30,6 +31,8 @@ export const DialogFork: Component = () => {
const prompt = usePrompt()
const dialog = useDialog()
const language = useLanguage()
const pendingIntents = new Map<string, string>()
const pendingRequests = new Set<string>()

const messages = createMemo((): ForkableMessage[] => {
const sessionID = params.id
Expand Down Expand Up @@ -68,13 +71,19 @@ export const DialogFork: Component = () => {
})
const dir = base64Encode(sdk.directory)

const intentKey = `${sessionID}:${item.id}`
if (pendingRequests.has(intentKey)) return
const intentID = pendingIntents.get(intentKey) ?? Identifier.ascending("fork")
pendingIntents.set(intentKey, intentID)
pendingRequests.add(intentKey)
sdk.client.session
.fork({ sessionID, messageID: item.id })
.fork({ sessionID, messageID: item.id, intentID })
.then((forked) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })
return
}
pendingIntents.delete(intentKey)
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.data.id })
navigate(`/${dir}/session/${forked.data.id}`)
Expand All @@ -83,6 +92,7 @@ export const DialogFork: Component = () => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => pendingRequests.delete(intentKey))
}

return (
Expand Down
151 changes: 143 additions & 8 deletions packages/app/src/components/prompt-input/submit.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import type { ContextItem, Prompt } from "@/context/prompt"

let createPromptSubmit: typeof import("./submit").createPromptSubmit

Expand All @@ -16,6 +16,7 @@ const optimistic: Array<{
}
}> = []
const optimisticSeeded: boolean[] = []
const optimisticRemoved: string[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: string[] = []
Expand All @@ -32,6 +33,8 @@ const sentPromptAsync: Array<{
directory: string
metadata?: unknown
text?: string
parts?: Array<{ id?: string; type: string; text?: string }>
messageID?: string
intentID?: string
intentSource?: string
intentVariant?: string
Expand All @@ -47,8 +50,10 @@ let variant: string | undefined
let promptMode: "direct" | "intelligence" | "wish" = "direct"
let appLocale = "en"
let releaseDelayedPrompt: (() => void) | undefined
const rejectedAdmissionReceipts = new Set<string>()

const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const promptContextItems: Array<ContextItem & { key: string }> = []
const flushAsyncSubmit = () => new Promise((resolve) => setTimeout(resolve, 0))

const clientFor = (directory: string) => {
Expand All @@ -72,6 +77,7 @@ const clientFor = (directory: string) => {
promptAsync: async (payload?: {
metadata?: unknown
parts?: Array<{ type: string; text?: string }>
messageID?: string
intentID?: string
intentSource?: string
intentVariant?: string
Expand All @@ -80,17 +86,26 @@ const clientFor = (directory: string) => {
directory,
metadata: payload?.metadata,
text: payload?.parts?.find((part) => part.type === "text")?.text,
parts: payload?.parts,
messageID: payload?.messageID,
intentID: payload?.intentID,
intentSource: payload?.intentSource,
intentVariant: payload?.intentVariant,
}
sentPromptAsync.push(sent)
if (
(sent.text === "receipt lost" || sent.text === "Edited retry goal") &&
!rejectedAdmissionReceipts.has(sent.text)
) {
rejectedAdmissionReceipts.add(sent.text)
throw new Error("connection closed after durable admission")
}
if (sent.text === "prompt waits after admission") {
await new Promise<void>((resolve) => {
releaseDelayedPrompt = resolve
})
}
return { data: undefined }
return { data: { messageID: "msg_server_admitted", delivery: "steer" } }
},
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
Expand Down Expand Up @@ -139,8 +154,8 @@ const clientFor = (directory: string) => {
}
}
const result = {
prompt_draft_id: "prompt_draft:test:1",
context_plan_id: "context_plan:test:1",
prompt_draft_id: `prompt_draft:test:${preparedDrafts.length}`,
context_plan_id: `context_plan:test:${preparedDrafts.length}`,
state: "draft_ready",
mode: payload.body?.mode ?? "intelligence",
route: text === "hello" ? "general" : "code",
Expand Down Expand Up @@ -227,9 +242,14 @@ beforeAll(async () => {
reset: () => undefined,
set: () => undefined,
context: {
add: () => undefined,
remove: () => undefined,
items: () => [],
add: (item: ContextItem) => {
promptContextItems.push({ ...item, key: `restored:${promptContextItems.length}:${item.path}` })
},
remove: (key: string) => {
const index = promptContextItems.findIndex((item) => item.key === key)
if (index >= 0) promptContextItems.splice(index, 1)
},
items: () => promptContextItems,
},
}),
}))
Expand Down Expand Up @@ -274,7 +294,9 @@ beforeAll(async () => {
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
)
},
remove: () => undefined,
remove: (value: { messageID: string }) => {
optimisticRemoved.push(value.messageID)
},
},
},
set: () => undefined,
Expand Down Expand Up @@ -338,6 +360,7 @@ beforeEach(() => {
enabledAutoAccept.length = 0
optimistic.length = 0
optimisticSeeded.length = 0
optimisticRemoved.length = 0
promoted.length = 0
params = {}
sentShell.length = 0
Expand All @@ -347,13 +370,15 @@ beforeEach(() => {
sentPromptAsync.length = 0
promptPrepareEvents.length = 0
promptPrepareProgress.length = 0
promptContextItems.length = 0
promptValue[0] = { type: "text", content: "ls", start: 0, end: 2 }
selected = "/repo/worktree-a"
variant = undefined
promptMode = "direct"
appLocale = "en"
releaseDelayedPrompt?.()
releaseDelayedPrompt = undefined
rejectedAdmissionReceipts.clear()
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
})

Expand Down Expand Up @@ -455,6 +480,7 @@ describe("prompt submit worktree selection", () => {
model: { providerID: "provider", modelID: "model", variant: "high" },
},
})
expect(optimisticRemoved).toHaveLength(1)
})

test("seeds new sessions before optimistic prompts are added", async () => {
Expand Down Expand Up @@ -784,4 +810,113 @@ describe("prompt submit worktree selection", () => {
expect(sentPromptAsync).toHaveLength(1)
promptValue[0] = { type: "text", content: "ls", start: 0, end: 2 }
})

test("reuses submission identity when the durable admission receipt is lost", async () => {
params = { id: "session-1" }
promptValue[0] = { type: "text", content: "receipt lost", start: 0, end: 12 }
const context = {
type: "file" as const,
path: "src/retry.ts",
comment: "keep this review context",
commentID: "comment-1",
key: "original-ui-key",
}
promptContextItems.push(context, {
type: "file",
path: "src/reference.ts",
key: "reference-ui-key",
})

const submit = createPromptSubmit({
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
onSubmit: () => undefined,
})

const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
await flushAsyncSubmit()

expect(promptContextItems.find((item) => item.commentID === context.commentID)?.key).not.toBe(context.key)

await submit.handleSubmit(event)
await flushAsyncSubmit()

expect(sentPromptAsync).toHaveLength(2)
expect(sentPromptAsync[0]?.messageID).toBeDefined()
expect(sentPromptAsync[1]?.messageID).toBe(sentPromptAsync[0]?.messageID)
expect(sentPromptAsync[0]?.intentID).toBeDefined()
expect(sentPromptAsync[1]?.intentID).toBe(sentPromptAsync[0]?.intentID)
expect(sentPromptAsync[1]?.parts).toEqual(sentPromptAsync[0]?.parts)

promptContextItems.push({ ...context, key: "new-ui-key-after-success" })
await submit.handleSubmit(event)
await flushAsyncSubmit()

expect(sentPromptAsync).toHaveLength(3)
expect(sentPromptAsync[2]?.messageID).not.toBe(sentPromptAsync[0]?.messageID)
expect(sentPromptAsync[2]?.intentID).not.toBe(sentPromptAsync[0]?.intentID)
promptValue[0] = { type: "text", content: "ls", start: 0, end: 2 }
})

test("reuses the final prepared payload when an intelligence admission receipt is lost", async () => {
params = { id: "session-1" }
promptMode = "intelligence"
promptValue[0] = { type: "text", content: "intelligence receipt lost", start: 0, end: 25 }
let confirms = 0

const submit = createPromptSubmit({
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
confirmPromptDraft: async () => {
confirms += 1
return { editedGoal: "Edited retry goal" }
},
onSubmit: () => undefined,
})

const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
await flushAsyncSubmit()
await submit.handleSubmit(event)
await flushAsyncSubmit()

expect(preparedDrafts).toHaveLength(1)
expect(confirms).toBe(1)
expect(sentPromptAsync).toHaveLength(2)
expect(sentPromptAsync[1]?.messageID).toBe(sentPromptAsync[0]?.messageID)
expect(sentPromptAsync[1]?.intentID).toBe(sentPromptAsync[0]?.intentID)
expect(sentPromptAsync[1]?.parts).toEqual(sentPromptAsync[0]?.parts)
expect(sentPromptAsync[1]?.metadata).toEqual(sentPromptAsync[0]?.metadata)
expect(sentPromptAsync[1]?.metadata).toMatchObject({
deepagent: {
prompt_pipeline: {
confirmed_draft_id: "prompt_draft:test:1",
},
},
})
promptValue[0] = { type: "text", content: "ls", start: 0, end: 2 }
})
})
Loading
Loading