Skip to content

Commit 0dbd3f0

Browse files
committed
fix(app): enforce durable prompt intent admission
1 parent af0bf41 commit 0dbd3f0

15 files changed

Lines changed: 1177 additions & 35 deletions

File tree

packages/app/src/components/prompt-input/submit.test.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,15 @@ const preparedDrafts: Array<{
2727
outputLanguage?: string
2828
text?: string
2929
}> = []
30-
const sentPromptAsync: Array<{ directory: string; metadata?: unknown; text?: string }> = []
30+
const preparedIntents: Array<{ intentID?: string; source?: string }> = []
31+
const sentPromptAsync: Array<{
32+
directory: string
33+
metadata?: unknown
34+
text?: string
35+
intentID?: string
36+
intentSource?: string
37+
intentVariant?: string
38+
}> = []
3139
const promptPrepareEvents: string[] = []
3240
const promptPrepareProgress: string[] = []
3341

@@ -61,11 +69,20 @@ const clientFor = (directory: string) => {
6169
return { data: undefined }
6270
},
6371
prompt: async () => ({ data: undefined }),
64-
promptAsync: async (payload?: { metadata?: unknown; parts?: Array<{ type: string; text?: string }> }) => {
72+
promptAsync: async (payload?: {
73+
metadata?: unknown
74+
parts?: Array<{ type: string; text?: string }>
75+
intentID?: string
76+
intentSource?: string
77+
intentVariant?: string
78+
}) => {
6579
const sent = {
6680
directory,
6781
metadata: payload?.metadata,
6882
text: payload?.parts?.find((part) => part.type === "text")?.text,
83+
intentID: payload?.intentID,
84+
intentSource: payload?.intentSource,
85+
intentVariant: payload?.intentVariant,
6986
}
7087
sentPromptAsync.push(sent)
7188
if (sent.text === "prompt waits after admission") {
@@ -82,7 +99,13 @@ const clientFor = (directory: string) => {
8299
request: async (payload: {
83100
url?: string
84101
path?: { sessionID?: string }
85-
body?: { mode?: string; output_language?: string; parts?: Array<{ type: string; text?: string }> }
102+
body?: {
103+
mode?: string
104+
output_language?: string
105+
intent_id?: string
106+
intent_source?: string
107+
parts?: Array<{ type: string; text?: string }>
108+
}
86109
signal?: AbortSignal
87110
}) => {
88111
const text = payload.body?.parts?.find((part) => part.type === "text")?.text
@@ -93,6 +116,7 @@ const clientFor = (directory: string) => {
93116
outputLanguage: payload.body?.output_language,
94117
text,
95118
})
119+
preparedIntents.push({ intentID: payload.body?.intent_id, source: payload.body?.intent_source })
96120
if (text === "prepare fails") {
97121
throw new Error("POST /session/ses_1/prompt_prepare returned 400", {
98122
cause: {
@@ -122,6 +146,7 @@ const clientFor = (directory: string) => {
122146
route: text === "hello" ? "general" : "code",
123147
goal: "Prepared goal",
124148
preview: "# Prepared prompt",
149+
intent_id: payload.body?.intent_id,
125150
}
126151
return {
127152
data: new ReadableStream<Uint8Array>({
@@ -318,6 +343,7 @@ beforeEach(() => {
318343
sentShell.length = 0
319344
syncedDirectories.length = 0
320345
preparedDrafts.length = 0
346+
preparedIntents.length = 0
321347
sentPromptAsync.length = 0
322348
promptPrepareEvents.length = 0
323349
promptPrepareProgress.length = 0
@@ -496,6 +522,10 @@ describe("prompt submit worktree selection", () => {
496522
{ directory: "/repo/main", sessionID: "session-1", mode: "intelligence", outputLanguage: "english", text: "ls" },
497523
])
498524
expect(sentPromptAsync[0]?.text).toBe("Edited prepared goal")
525+
expect(preparedIntents[0]?.intentID).toBe(sentPromptAsync[0]?.intentID)
526+
expect(preparedIntents[0]?.source).toBe("intelligence")
527+
expect(sentPromptAsync[0]?.intentSource).toBe("intelligence")
528+
expect(sentPromptAsync[0]?.intentVariant).toBe("rewritten")
499529
expect(sentPromptAsync[0]?.metadata).toEqual({
500530
deepagent: {
501531
prompt_pipeline: {
@@ -586,6 +616,8 @@ describe("prompt submit worktree selection", () => {
586616
},
587617
])
588618
expect(sentPromptAsync[0]?.text).toBe("hello")
619+
expect(preparedIntents[0]?.intentID).toBe(sentPromptAsync[0]?.intentID)
620+
expect(sentPromptAsync[0]?.intentVariant).toBe("original")
589621
expect(sentPromptAsync[0]?.metadata).toEqual({
590622
deepagent: {
591623
agent_mode_override: "general",

packages/app/src/components/prompt-input/submit.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export type DeepAgentPromptPrepareResult = {
5757
route: "code" | "general"
5858
goal: string
5959
preview: string
60+
intent_id?: string
6061
}
6162

6263
export type DeepAgentPromptConfirmResult = { editedGoal: string }
@@ -94,6 +95,8 @@ type FollowupSendInput = {
9495
sync: ReturnType<typeof useSync>
9596
draft: FollowupDraft
9697
messageID?: string
98+
intentID?: string
99+
intentSource?: "composer" | "intelligence" | "followup" | "rewrite"
97100
optimisticBusy?: boolean
98101
before?: () => Promise<boolean> | boolean
99102
onBeforeSubmit?: () => void
@@ -121,6 +124,8 @@ async function prepareDeepAgentPromptDraft(input: {
121124
mode: DeepAgentPromptModeForConfirmation
122125
outputLanguage: DeepAgentPromptOutputLanguage
123126
parts: SessionPromptAsyncInput["parts"]
127+
intentID: string
128+
intentSource: "composer" | "intelligence" | "followup" | "rewrite"
124129
signal?: AbortSignal
125130
onProgress?: (preview: string) => void
126131
}) {
@@ -132,6 +137,8 @@ async function prepareDeepAgentPromptDraft(input: {
132137
body: {
133138
mode: input.mode,
134139
output_language: input.outputLanguage,
140+
intent_id: input.intentID,
141+
intent_source: input.intentSource,
135142
parts: input.parts,
136143
},
137144
headers: {
@@ -253,6 +260,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
253260
}
254261

255262
const messageID = input.messageID ?? Identifier.ascending("message")
263+
const intentID = input.intentID ?? Identifier.ascending("message")
256264
const buildParts = (promptText: string) =>
257265
buildRequestParts({
258266
prompt: input.draft.prompt,
@@ -287,9 +295,14 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
287295
mode,
288296
outputLanguage: input.promptOutputLanguage ?? "english",
289297
parts: preparedParts.requestParts,
298+
intentID,
299+
intentSource: input.intentSource ?? "intelligence",
290300
signal: input.promptPrepareSignal,
291301
onProgress: input.onPromptPrepareProgress,
292302
})
303+
if (prepared.intent_id && prepared.intent_id !== intentID) {
304+
throw new Error("Prompt draft prepare returned a different intent")
305+
}
293306
} catch (err) {
294307
setIdle()
295308
input.onPromptPrepareEnd?.()
@@ -370,6 +383,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
370383
agent: input.draft.agent,
371384
model: input.draft.model,
372385
messageID,
386+
intentID,
387+
intentSource: input.intentSource ?? (mode ? "intelligence" : "composer"),
388+
intentVariant: confirmedDraft ? "rewritten" : "original",
373389
parts: submittedParts.requestParts,
374390
variant: input.draft.variant,
375391
metadata,
@@ -763,6 +779,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
763779

764780
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
765781
const messageID = Identifier.ascending("message")
782+
const intentID = Identifier.ascending("message")
766783
const preparesPromptDraft = promptPipelineMode(draft.metadata) === "intelligence"
767784
const controller = new AbortController()
768785

@@ -851,6 +868,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
851868
serverSync,
852869
draft,
853870
messageID,
871+
intentID,
872+
intentSource: preparesPromptDraft ? "intelligence" : "composer",
854873
optimisticBusy: sessionDirectory === projectDirectory,
855874
before: waitForWorktree,
856875
onBeforeSubmit: () => {

packages/app/src/pages/session.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,6 +1457,8 @@ export default function Page() {
14571457
sync,
14581458
serverSync,
14591459
draft: item,
1460+
intentID: item.id,
1461+
intentSource: "followup",
14601462
optimisticBusy: item.sessionDirectory === sdk.directory,
14611463
confirmPromptDraft,
14621464
}).catch((err) => {

packages/core/src/database/migration.gen.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Effect } from "effect"
2+
import type { DatabaseMigration } from "../migration"
3+
4+
export default {
5+
id: "20260806051000_session_prompt_intent",
6+
up(tx) {
7+
return Effect.gen(function* () {
8+
yield* tx.run(`
9+
CREATE TABLE session_intent (
10+
intent_id TEXT PRIMARY KEY,
11+
session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE,
12+
source TEXT NOT NULL CHECK (source IN ('composer', 'intelligence', 'followup', 'rewrite')),
13+
state TEXT NOT NULL CHECK (state IN ('preparing', 'admitting', 'admitted', 'canceled', 'superseded', 'failed')),
14+
selected_variant TEXT CHECK (selected_variant IN ('original', 'rewritten')),
15+
selected_payload_hash TEXT,
16+
delivery TEXT CHECK (delivery IN ('turn', 'steer', 'queue', 'goal_steer')),
17+
admitted_message_id TEXT,
18+
correlation_id TEXT,
19+
owner_token TEXT,
20+
lease_expires_at INTEGER,
21+
version INTEGER NOT NULL DEFAULT 0,
22+
time_created INTEGER NOT NULL,
23+
time_selected INTEGER,
24+
time_admitted INTEGER,
25+
time_updated INTEGER NOT NULL,
26+
UNIQUE (session_id, intent_id)
27+
)
28+
`)
29+
yield* tx.run(`
30+
CREATE INDEX session_intent_session_state_idx
31+
ON session_intent (session_id, state, time_created)
32+
`)
33+
})
34+
},
35+
} satisfies DatabaseMigration.Migration

packages/core/src/session/sql.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,37 @@ export const SessionSteerTable = sqliteTable(
212212
],
213213
)
214214

215+
export const SessionIntentTable = sqliteTable(
216+
"session_intent",
217+
{
218+
intent_id: text().primaryKey(),
219+
session_id: text()
220+
.$type<SessionSchema.ID>()
221+
.notNull()
222+
.references(() => SessionTable.id, { onDelete: "cascade" }),
223+
source: text().$type<"composer" | "intelligence" | "followup" | "rewrite">().notNull(),
224+
state: text()
225+
.$type<"preparing" | "admitting" | "admitted" | "canceled" | "superseded" | "failed">()
226+
.notNull(),
227+
selected_variant: text().$type<"original" | "rewritten">(),
228+
selected_payload_hash: text(),
229+
delivery: text().$type<"turn" | SessionInput.Delivery>(),
230+
admitted_message_id: text(),
231+
correlation_id: text(),
232+
owner_token: text(),
233+
lease_expires_at: integer(),
234+
version: integer().notNull().default(0),
235+
time_created: integer().notNull(),
236+
time_selected: integer(),
237+
time_admitted: integer(),
238+
time_updated: integer().notNull(),
239+
},
240+
(table) => [
241+
uniqueIndex("session_intent_session_intent_idx").on(table.session_id, table.intent_id),
242+
index("session_intent_session_state_idx").on(table.session_id, table.state, table.time_created),
243+
],
244+
)
245+
215246
export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
216247
session_id: text()
217248
.$type<SessionSchema.ID>()

packages/deepagent-code/src/server/routes/instance/httpapi/groups/session.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
WorkspaceRoutingQuery,
2121
WorkspaceRoutingQueryFields,
2222
} from "../middleware/workspace-routing"
23-
import { ApiNotFoundError, InvalidRequestError, PermissionNotFoundError, SessionBusyError } from "../errors"
23+
import { ApiNotFoundError, ConflictError, InvalidRequestError, PermissionNotFoundError, SessionBusyError } from "../errors"
2424
import { described } from "./metadata"
2525
import { QueryBoolean } from "./query"
2626
import { ProviderV2 } from "@deepagent-code/core/provider"
@@ -77,6 +77,8 @@ export const PromptPreparePayload = Schema.Struct({
7777
// normalizes internally. Do NOT drop "wish" from this union.
7878
mode: Schema.Literals(["wish", "intelligence"]),
7979
output_language: Schema.optional(Schema.Literals(["chinese", "english"])),
80+
intent_id: Schema.optional(Schema.String),
81+
intent_source: Schema.optional(Schema.Literals(["composer", "intelligence", "followup", "rewrite"])),
8082
parts: SessionPrompt.PromptInput.fields.parts,
8183
})
8284
export const PromptPrepareResult = Schema.Struct({
@@ -88,6 +90,7 @@ export const PromptPrepareResult = Schema.Struct({
8890
route: Schema.Union([Schema.Literal("code"), Schema.Literal("general")]),
8991
goal: Schema.String,
9092
preview: Schema.String,
93+
intent_id: Schema.optional(Schema.String),
9194
})
9295
// A3 macro-round: the latest persisted next-round suggestion for human approval. `null` body when
9396
// no suggestion exists yet.
@@ -495,7 +498,7 @@ export const SessionApi = HttpApi.make("session")
495498
query: WorkspaceRoutingQuery,
496499
payload: PromptPreparePayload,
497500
success: described(PromptPrepareResult, "Prepared prompt draft"),
498-
error: [HttpApiError.BadRequest, InvalidRequestError, ApiNotFoundError],
501+
error: [HttpApiError.BadRequest, ConflictError, InvalidRequestError, ApiNotFoundError],
499502
}).annotateMerge(
500503
OpenApi.annotations({
501504
identifier: "session.prompt_prepare",
@@ -508,7 +511,7 @@ export const SessionApi = HttpApi.make("session")
508511
query: WorkspaceRoutingQuery,
509512
payload: PromptPreparePayload,
510513
success: Schema.String,
511-
error: [HttpApiError.BadRequest, InvalidRequestError, ApiNotFoundError],
514+
error: [HttpApiError.BadRequest, ConflictError, InvalidRequestError, ApiNotFoundError],
512515
}).annotateMerge(
513516
OpenApi.annotations({
514517
identifier: "session.prompt_prepare_stream",
@@ -535,7 +538,7 @@ export const SessionApi = HttpApi.make("session")
535538
query: WorkspaceRoutingQuery,
536539
payload: PromptPayload,
537540
success: described(HttpApiSchema.NoContent, "Prompt accepted"),
538-
error: [HttpApiError.BadRequest, ApiNotFoundError],
541+
error: [HttpApiError.BadRequest, ConflictError, ApiNotFoundError],
539542
}).annotateMerge(
540543
OpenApi.annotations({
541544
identifier: "session.prompt_async",

0 commit comments

Comments
 (0)