From 984cab79388511175d7f28e52db112643cd38dce Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 8 Jul 2026 22:07:45 -0400 Subject: [PATCH] feat(core): generalize session input inbox (#36005) --- packages/cli/src/mini/noninteractive.ts | 8 +- packages/cli/src/mini/stream-v2.subagent.ts | 9 +- packages/cli/src/mini/stream-v2.transport.ts | 10 +- packages/client/src/effect/api/api.ts | 9 +- .../client/src/effect/generated/client.ts | 24 +- .../client/src/promise/generated/client.ts | 22 +- .../client/src/promise/generated/types.ts | 318 ++++++++++++------ .../client/test/contract-identity.test.ts | 4 +- packages/client/test/effect.test.ts | 7 +- packages/client/test/promise.test.ts | 35 +- packages/core/schema.json | 214 ++++++++---- packages/core/src/database/migration.gen.ts | 1 + .../20260709013000_generic_session_input.ts | 77 +++++ packages/core/src/database/schema.gen.ts | 4 +- packages/core/src/session.ts | 121 +++++-- packages/core/src/session/input.ts | 183 +++++----- packages/core/src/session/message-updater.ts | 4 +- packages/core/src/session/projector.ts | 46 ++- packages/core/src/session/sql.ts | 7 +- packages/core/src/tool/subagent.ts | 2 +- packages/core/test/database-migration.test.ts | 55 ++- packages/core/test/session-compact.test.ts | 13 +- packages/core/test/session-create.test.ts | 49 +-- packages/core/test/session-projector.test.ts | 20 +- packages/core/test/session-prompt.test.ts | 296 ++++++++++++---- .../core/test/session-runner-recorded.test.ts | 7 +- packages/core/test/session-runner.test.ts | 96 ++++-- packages/core/test/session-title.test.ts | 8 +- packages/core/test/shared-schema.test.ts | 4 +- packages/core/test/tool-subagent.test.ts | 15 +- .../test/cli/run/noninteractive.test.ts | 2 +- .../test/cli/run/stream-v2.transport.test.ts | 136 +++----- packages/protocol/src/groups/session.ts | 15 +- packages/schema/src/session-event.ts | 32 +- packages/schema/src/session-input.ts | 66 +++- packages/schema/test/contract-hygiene.test.ts | 11 + packages/schema/test/event-manifest.test.ts | 4 +- packages/sdk-next/test/embedded.test.ts | 14 +- packages/sdk/js/script/build.ts | 4 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 19 +- packages/sdk/js/src/v2/gen/types.gen.ts | 206 ++++++++---- packages/server/src/handlers/session.ts | 81 +++-- packages/tui/src/component/prompt/index.tsx | 12 +- packages/tui/src/context/data.tsx | 47 +-- packages/tui/src/prompt/history.tsx | 2 +- packages/tui/src/routes/session/rows.ts | 20 +- packages/tui/test/cli/tui/data.test.tsx | 26 +- 47 files changed, 1594 insertions(+), 771 deletions(-) create mode 100644 packages/core/src/database/migration/20260709013000_generic_session_input.ts diff --git a/packages/cli/src/mini/noninteractive.ts b/packages/cli/src/mini/noninteractive.ts index bc356eb743..8defc7415b 100644 --- a/packages/cli/src/mini/noninteractive.ts +++ b/packages/cli/src/mini/noninteractive.ts @@ -153,7 +153,7 @@ export async function runNonInteractivePrompt(input: Input) { if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue const time = toMillis("created" in event ? event.created : undefined) - if (event.type === "session.prompt.promoted") { + if (event.type === "session.input.promoted") { if (event.data.inputID === messageID) { promoted = true continue @@ -409,10 +409,8 @@ export async function runNonInteractivePrompt(input: Input) { { sessionID: input.sessionID, id: messageID, - prompt: { - text: [input.message, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"), - files: prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), - }, + text: [input.message, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"), + files: prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), delivery: "steer", }, { signal: admission.signal }, diff --git a/packages/cli/src/mini/stream-v2.subagent.ts b/packages/cli/src/mini/stream-v2.subagent.ts index 091ff296ee..911d2d0d4e 100644 --- a/packages/cli/src/mini/stream-v2.subagent.ts +++ b/packages/cli/src/mini/stream-v2.subagent.ts @@ -457,12 +457,13 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac } const reduce = (child: ChildState, event: V2Event) => { - if (event.type === "session.prompt.admitted") { - child.prompts.set(event.data.inputID, event.data.prompt.text) + if (event.type === "session.input.admitted") { + if (event.data.input.type === "user") child.prompts.set(event.data.inputID, event.data.input.data.text) return } - if (event.type === "session.prompt.promoted") { - const prompt = child.prompts.get(event.data.inputID) ?? "" + if (event.type === "session.input.promoted") { + const prompt = child.prompts.get(event.data.inputID) + if (prompt === undefined) return child.prompts.delete(event.data.inputID) if (userFrame(child, event.data.inputID, prompt)) { touch(child, event.created) diff --git a/packages/cli/src/mini/stream-v2.transport.ts b/packages/cli/src/mini/stream-v2.transport.ts index ae854ef2e3..26b8266849 100644 --- a/packages/cli/src/mini/stream-v2.transport.ts +++ b/packages/cli/src/mini/stream-v2.transport.ts @@ -562,7 +562,7 @@ export async function createSessionTransport(input: StreamInput): Promise (file.text ? [file.text] : []))].join("\n\n"), - files: attachments.length ? attachments : undefined, - agents: agents.length ? agents : undefined, - }, + text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"), + files: attachments.length ? attachments : undefined, + agents: agents.length ? agents : undefined, delivery: "steer", }, { signal: next.signal }, diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index d5c6474f45..d1f726d077 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -131,7 +131,10 @@ type Endpoint5_10Request = Parameters = (input: Endpoint5_12Input) => Eff type Endpoint5_13Request = Parameters[0] export type Endpoint5_13Input = { readonly sessionID: Endpoint5_13Request["params"]["sessionID"] + readonly id?: Endpoint5_13Request["payload"]["id"] readonly text: Endpoint5_13Request["payload"]["text"] readonly description?: Endpoint5_13Request["payload"]["description"] readonly metadata?: Endpoint5_13Request["payload"]["metadata"] + readonly delivery?: Endpoint5_13Request["payload"]["delivery"] readonly resume?: Endpoint5_13Request["payload"]["resume"] } -export type Endpoint5_13Output = EffectValue> +export type Endpoint5_13Output = EffectValue>["data"] export type SessionSyntheticOperation = (input: Endpoint5_13Input) => Effect.Effect type Endpoint5_14Request = Parameters[0] diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index a0804cf07f..3b39da28a0 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -162,14 +162,25 @@ type Endpoint5_10Request = Parameters (input: Endpoint5_10Input) => raw["session.prompt"]({ params: { sessionID: input["sessionID"] }, - payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, + payload: { + id: input["id"], + text: input["text"], + files: input["files"], + agents: input["agents"], + metadata: input["metadata"], + delivery: input["delivery"], + resume: input["resume"], + }, }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -223,21 +234,28 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I type Endpoint5_13Request = Parameters[0] type Endpoint5_13Input = { readonly sessionID: Endpoint5_13Request["params"]["sessionID"] + readonly id?: Endpoint5_13Request["payload"]["id"] readonly text: Endpoint5_13Request["payload"]["text"] readonly description?: Endpoint5_13Request["payload"]["description"] readonly metadata?: Endpoint5_13Request["payload"]["metadata"] + readonly delivery?: Endpoint5_13Request["payload"]["delivery"] readonly resume?: Endpoint5_13Request["payload"]["resume"] } const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) => raw["session.synthetic"]({ params: { sessionID: input["sessionID"] }, payload: { + id: input["id"], text: input["text"], description: input["description"], metadata: input["metadata"], + delivery: input["delivery"], resume: input["resume"], }, - }).pipe(Effect.mapError(mapClientError)) + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) type Endpoint5_14Request = Parameters[0] type Endpoint5_14Input = { diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 63c7eac36a..606673944c 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -516,7 +516,15 @@ export function make(options: ClientOptions) { { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`, - body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, + body: { + id: input["id"], + text: input["text"], + files: input["files"], + agents: input["agents"], + metadata: input["metadata"], + delivery: input["delivery"], + resume: input["resume"], + }, successStatus: 200, declaredStatuses: [409, 400, 404, 401], empty: false, @@ -558,22 +566,24 @@ export function make(options: ClientOptions) { requestOptions, ), synthetic: (input: SessionSyntheticInput, requestOptions?: RequestOptions) => - request( + request<{ readonly data: SessionSyntheticOutput }>( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/synthetic`, body: { + id: input["id"], text: input["text"], description: input["description"], metadata: input["metadata"], + delivery: input["delivery"], resume: input["resume"], }, - successStatus: 204, - declaredStatuses: [404, 400, 401], - empty: true, + successStatus: 200, + declaredStatuses: [409, 404, 400, 401], + empty: false, }, requestOptions, - ), + ).then((value) => value.data), shell: (input: SessionShellInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 078695bf57..45e44c5217 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -543,73 +543,120 @@ export type SessionPromptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly id?: { readonly id?: string | null - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["id"] - readonly prompt: { + readonly text: { readonly id?: string | null - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null - }["prompt"] + }["text"] + readonly files?: { + readonly id?: string | null + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["files"] + readonly agents?: { + readonly id?: string | null + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["agents"] + readonly metadata?: { + readonly id?: string | null + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["metadata"] readonly delivery?: { readonly id?: string | null - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["delivery"] readonly resume?: { readonly id?: string | null - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly uri: string - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: JsonValue } readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["resume"] @@ -620,7 +667,10 @@ export type SessionPromptOutput = { readonly admittedSeq: number readonly id: string readonly sessionID: string - readonly prompt: { + readonly timeCreated: number + readonly promotedSeq?: number + readonly type: "user" + readonly data: { readonly text: string readonly files?: ReadonlyArray<{ readonly data: string @@ -634,10 +684,9 @@ export type SessionPromptOutput = { readonly name: string readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> + readonly metadata?: { readonly [x: string]: JsonValue } } readonly delivery: "steer" | "queue" - readonly timeCreated: number - readonly promotedSeq?: number } }["data"] @@ -821,7 +870,10 @@ export type SessionCommandOutput = { readonly admittedSeq: number readonly id: string readonly sessionID: string - readonly prompt: { + readonly timeCreated: number + readonly promotedSeq?: number + readonly type: "user" + readonly data: { readonly text: string readonly files?: ReadonlyArray<{ readonly data: string @@ -835,10 +887,9 @@ export type SessionCommandOutput = { readonly name: string readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> + readonly metadata?: { readonly [x: string]: JsonValue } } readonly delivery: "steer" | "queue" - readonly timeCreated: number - readonly promotedSeq?: number } }["data"] @@ -865,33 +916,72 @@ export type SessionSkillOutput = void export type SessionSyntheticInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly text: { + readonly id?: { + readonly id?: string | null readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["id"] + readonly text: { + readonly id?: string | null + readonly text: string + readonly description?: string | null + readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["text"] readonly description?: { + readonly id?: string | null readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["description"] readonly metadata?: { + readonly id?: string | null readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["metadata"] - readonly resume?: { + readonly delivery?: { + readonly id?: string | null readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["delivery"] + readonly resume?: { + readonly id?: string | null + readonly text: string + readonly description?: string | null + readonly metadata?: { readonly [x: string]: JsonValue } + readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null }["resume"] } -export type SessionSyntheticOutput = void +export type SessionSyntheticOutput = { + readonly data: { + readonly admittedSeq: number + readonly id: string + readonly sessionID: string + readonly timeCreated: number + readonly promotedSeq?: number + readonly type: "synthetic" + readonly data: { + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: JsonValue } + } + readonly delivery: "steer" | "queue" + } +}["data"] export type SessionShellInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] @@ -908,11 +998,11 @@ export type SessionCompactInput = { export type SessionCompactOutput = { readonly data: { - readonly type: "compaction" readonly admittedSeq: number readonly id: string readonly sessionID: string readonly timeCreated: number + readonly type: "compaction" readonly handledSeq?: number } }["data"] @@ -1229,7 +1319,7 @@ export type SessionLogOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.prompt.promoted" + readonly type: "session.input.promoted" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } @@ -1238,28 +1328,42 @@ export type SessionLogOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.prompt.admitted" + readonly type: "session.input.admitted" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly inputID: string - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly data: string - readonly mime: string - readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - } - readonly delivery: "steer" | "queue" + readonly input: + | { + readonly type: "user" + readonly data: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly data: string + readonly mime: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: unknown } + } + readonly delivery: "steer" | "queue" + } + | { + readonly type: "synthetic" + readonly data: { + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: unknown } + } + readonly delivery: "steer" | "queue" + } } } | { @@ -4588,7 +4692,7 @@ export type EventSubscribeOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.prompt.promoted" + readonly type: "session.input.promoted" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } @@ -4597,28 +4701,42 @@ export type EventSubscribeOutput = readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } - readonly type: "session.prompt.admitted" + readonly type: "session.input.admitted" readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly inputID: string - readonly prompt: { - readonly text: string - readonly files?: ReadonlyArray<{ - readonly data: string - readonly mime: string - readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - readonly agents?: ReadonlyArray<{ - readonly name: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> - } - readonly delivery: "steer" | "queue" + readonly input: + | { + readonly type: "user" + readonly data: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly data: string + readonly mime: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } + readonly name?: string + readonly description?: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly metadata?: { readonly [x: string]: unknown } + } + readonly delivery: "steer" | "queue" + } + | { + readonly type: "synthetic" + readonly data: { + readonly text: string + readonly description?: string + readonly metadata?: { readonly [x: string]: unknown } + } + readonly delivery: "steer" | "queue" + } } } | { diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index 553c120f7a..3b6c1b82b7 100644 --- a/packages/client/test/contract-identity.test.ts +++ b/packages/client/test/contract-identity.test.ts @@ -37,7 +37,9 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => expect(ProjectV2.Current).toBe(Project.Current) expect(ProjectV2.Directory).toBe(Project.Directory) expect(ProjectV2.Directories).toBe(Project.Directories) - expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted) + expect(CoreSessionInput.Message).toBe(SessionInput.Message) + expect(CoreSessionInput.User).toBe(SessionInput.User) + expect(CoreSessionInput.Synthetic).toBe(SessionInput.Synthetic) expect(CoreSessionMessage.Info).toBe(SessionMessage.Info) expect(Api.groups["server.session"].identifier).toBe("server.session") expect(Api.groups["server.project"].identifier).toBe("server.project") diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 6247d25610..c1254ab7d3 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -178,7 +178,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => { }) const admitted = yield* client.session.prompt({ sessionID: Session.ID.make("ses_test"), - prompt: Prompt.make({ text: "Hello" }), + text: "Hello", resume: false, }) yield* client.session.compact({ sessionID: Session.ID.make("ses_test") }) @@ -201,7 +201,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => { expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype) expect(result.created.id).toBe("ses_test") expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype) - expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) + expect(Object.getPrototypeOf(result.admitted.data)).toBe(Object.prototype) expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(result.context).toEqual([]) expect(logQueries[0]).toEqual({ after: "0" }) @@ -259,7 +259,8 @@ const admission = { admittedSeq: 0, id: "msg_test", sessionID: "ses_test", - prompt: { text: "Hello" }, + type: "user", + data: { text: "Hello" }, delivery: "steer", timeCreated: 1_717_171_717_000, }, diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index d21150877f..015e9ebf5e 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -274,6 +274,7 @@ test("session methods use the public HTTP contract", async () => { }) } if (url.includes("/prompt")) return Response.json(admission) + if (url.includes("/synthetic")) return Response.json(syntheticAdmission) if (url.endsWith("/compact")) return Response.json(compactionAdmission) if (url.includes("/context")) return Response.json({ data: [] }) if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) @@ -294,7 +295,13 @@ test("session methods use the public HTTP contract", async () => { }) const admitted = await client.session.prompt({ sessionID: "ses_test", - prompt: { text: "Hello" }, + text: "Hello", + resume: false, + }) + const synthetic = await client.session.synthetic({ + sessionID: "ses_test", + text: "Completed", + delivery: "queue", resume: false, }) await client.session.compact({ sessionID: "ses_test" }) @@ -309,6 +316,7 @@ test("session methods use the public HTTP contract", async () => { expect(active).toEqual({ ses_test: { type: "running" } }) expect(created.id).toBe("ses_test") expect(admitted.id).toBe("msg_test") + expect(synthetic).toMatchObject({ type: "synthetic", data: { text: "Completed" }, delivery: "queue" }) expect(context).toEqual([]) expect(log).toEqual([modelSwitchedEvent, synced]) expect(message).toEqual(modelSwitchedMessage) @@ -319,6 +327,7 @@ test("session methods use the public HTTP contract", async () => { ["POST", "http://localhost:3000/api/session/ses_test/agent"], ["POST", "http://localhost:3000/api/session/ses_test/model"], ["POST", "http://localhost:3000/api/session/ses_test/prompt"], + ["POST", "http://localhost:3000/api/session/ses_test/synthetic"], ["POST", "http://localhost:3000/api/session/ses_test/compact"], ["POST", "http://localhost:3000/api/session/ses_test/wait"], ["GET", "http://localhost:3000/api/session/ses_test/context"], @@ -329,7 +338,14 @@ test("session methods use the public HTTP contract", async () => { const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body if (typeof body !== "string") throw new Error("Expected JSON request body") expect(JSON.parse(body)).toEqual({ - prompt: { text: "Hello" }, + text: "Hello", + resume: false, + }) + const syntheticBody = requests.find((request) => request.url.endsWith("/synthetic"))?.init?.body + if (typeof syntheticBody !== "string") throw new Error("Expected JSON synthetic request body") + expect(JSON.parse(syntheticBody)).toEqual({ + text: "Completed", + delivery: "queue", resume: false, }) }) @@ -392,12 +408,25 @@ const admission = { admittedSeq: 0, id: "msg_test", sessionID: "ses_test", - prompt: { text: "Hello" }, + type: "user", + data: { text: "Hello" }, delivery: "steer", timeCreated: 1_717_171_717_000, }, } +const syntheticAdmission = { + data: { + admittedSeq: 1, + id: "msg_synthetic", + sessionID: "ses_test", + type: "synthetic", + data: { text: "Completed" }, + delivery: "queue", + timeCreated: 1_717_171_717_000, + }, +} + const compactionAdmission = { data: { type: "compaction", diff --git a/packages/core/schema.json b/packages/core/schema.json index d419909444..acfa072f6e 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,10 @@ { "version": "7", "dialect": "sqlite", - "id": "b0355fd9-bf41-42e3-9dca-76107de27ecd", - "prevIds": ["95328a41-789d-44de-9643-6ac6ecd6b4ec"], + "id": "beb91b32-23e2-405f-99b8-1e8763db94f8", + "prevIds": [ + "00000000-0000-0000-0000-000000000000" + ], "ddl": [ { "name": "workspace", @@ -1016,11 +1018,11 @@ }, { "type": "text", - "notNull": false, + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "prompt", + "name": "data", "entityType": "columns", "table": "session_input" }, @@ -1575,9 +1577,13 @@ "table": "session_share" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1586,9 +1592,13 @@ "table": "workspace" }, { - "columns": ["active_account_id"], + "columns": [ + "active_account_id" + ], "tableTo": "account", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1597,9 +1607,13 @@ "table": "account_state" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "tableTo": "event_sequence", - "columnsTo": ["aggregate_id"], + "columnsTo": [ + "aggregate_id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1608,9 +1622,13 @@ "table": "event" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1619,9 +1637,13 @@ "table": "permission" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1630,9 +1652,13 @@ "table": "project_directory" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1641,9 +1667,13 @@ "table": "instruction_checkpoint" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1652,9 +1682,13 @@ "table": "instruction_entry" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1663,9 +1697,13 @@ "table": "message" }, { - "columns": ["message_id"], + "columns": [ + "message_id" + ], "tableTo": "message", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1674,9 +1712,13 @@ "table": "part" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1685,9 +1727,13 @@ "table": "session_input" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1696,9 +1742,13 @@ "table": "session_message" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1707,9 +1757,13 @@ "table": "session" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1718,9 +1772,13 @@ "table": "todo" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1729,140 +1787,184 @@ "table": "session_share" }, { - "columns": ["email", "url"], + "columns": [ + "email", + "url" + ], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": ["project_id", "directory"], + "columns": [ + "project_id", + "directory" + ], "nameExplicit": false, "name": "project_directory_pk", "entityType": "pks", "table": "project_directory" }, { - "columns": ["session_id", "key"], + "columns": [ + "session_id", + "key" + ], "nameExplicit": false, "name": "instruction_entry_pk", "entityType": "pks", "table": "instruction_entry" }, { - "columns": ["session_id", "position"], + "columns": [ + "session_id", + "position" + ], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": ["name"], + "columns": [ + "name" + ], "nameExplicit": false, "name": "data_migration_pk", "table": "data_migration", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "credential_pk", "table": "credential", "entityType": "pks" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "event_pk", "table": "event", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "instruction_checkpoint_pk", "table": "instruction_checkpoint", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_input_pk", "table": "session_input", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", @@ -1994,10 +2096,6 @@ "value": "promoted_seq", "isExpression": false }, - { - "value": "type", - "isExpression": false - }, { "value": "delivery", "isExpression": false @@ -2010,7 +2108,7 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_input_session_pending_type_delivery_seq_idx", + "name": "session_input_session_pending_delivery_seq_idx", "entityType": "indexes", "table": "session_input" }, @@ -2198,4 +2296,4 @@ } ], "renames": [] -} +} \ No newline at end of file diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 5b1735573d..e2f17df568 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -49,5 +49,6 @@ export const migrations = ( import("./migration/20260706223930_add-session-fork"), import("./migration/20260707010146_durable_session_inbox"), import("./migration/20260707120000_migrate_prelaunch_v2_state"), + import("./migration/20260709013000_generic_session_input"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260709013000_generic_session_input.ts b/packages/core/src/database/migration/20260709013000_generic_session_input.ts new file mode 100644 index 0000000000..660830bf1e --- /dev/null +++ b/packages/core/src/database/migration/20260709013000_generic_session_input.ts @@ -0,0 +1,77 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260709013000_generic_session_input", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`PRAGMA foreign_keys=OFF;`) + yield* tx.run(` + DELETE FROM \`event\` + WHERE \`type\` IN ('session.prompt.admitted.1', 'session.prompt.promoted.1') + AND json_extract(\`data\`, '$.inputID') IN ( + SELECT \`id\` FROM \`session_input\` WHERE \`type\` = 'prompt' AND \`prompt\` IS NULL + ); + `) + yield* tx.run(` + CREATE TABLE \`__new_session_input\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`type\` text NOT NULL, + \`data\` text NOT NULL, + \`delivery\` text, + \`admitted_seq\` integer NOT NULL, + \`promoted_seq\` integer, + \`time_created\` integer NOT NULL, + CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + INSERT INTO \`__new_session_input\`( + \`id\`, \`session_id\`, \`type\`, \`data\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\` + ) + SELECT + \`id\`, \`session_id\`, CASE WHEN \`type\` = 'prompt' THEN 'user' ELSE \`type\` END, + CASE WHEN \`type\` = 'prompt' THEN \`prompt\` ELSE '{}' END, + \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\` + FROM \`session_input\` + WHERE \`type\` != 'prompt' OR \`prompt\` IS NOT NULL; + `) + yield* tx.run(`DROP TABLE \`session_input\`;`) + yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`) + yield* tx.run(`PRAGMA foreign_keys=ON;`) + yield* tx.run( + `CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE \`type\` = 'compaction' and \`promoted_seq\` is null;`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`, + ) + yield* tx.run(` + UPDATE \`event\` + SET + \`type\` = 'session.input.admitted.1', + \`data\` = json_object( + 'sessionID', json_extract(\`data\`, '$.sessionID'), + 'inputID', json_extract(\`data\`, '$.inputID'), + 'input', json_object( + 'type', 'user', + 'data', json_extract(\`data\`, '$.prompt'), + 'delivery', json_extract(\`data\`, '$.delivery') + ) + ) + WHERE \`type\` = 'session.prompt.admitted.1'; + `) + yield* tx.run(` + UPDATE \`event\` + SET \`type\` = 'session.input.promoted.1' + WHERE \`type\` = 'session.prompt.promoted.1'; + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 0bb6050bf6..bc7cc5f7ab 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -171,7 +171,7 @@ export default { \`id\` text PRIMARY KEY, \`session_id\` text NOT NULL, \`type\` text NOT NULL, - \`prompt\` text, + \`data\` text NOT NULL, \`delivery\` text, \`admitted_seq\` integer NOT NULL, \`promoted_seq\` integer, @@ -262,7 +262,7 @@ export default { yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`) yield* tx.run( - `CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`, + `CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, ) yield* tx.run( `CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index ad53940971..3d3a3a5e45 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -122,6 +122,13 @@ export class PromptConflictError extends Schema.TaggedErrorClass()( + "Session.SyntheticConflictError", + { + sessionID: SessionSchema.ID, + inputID: SessionMessage.ID, + }, +) {} export class AttachmentError extends Schema.TaggedErrorClass()("Session.AttachmentError", { uri: Schema.String, message: Schema.String, @@ -147,6 +154,7 @@ export type Error = | MessageDecodeError | OperationUnavailableError | PromptConflictError + | SyntheticConflictError | AttachmentError | CompactionConflictError | BusyError @@ -204,10 +212,13 @@ export interface Interface { readonly prompt: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID - prompt: PromptInput.Prompt + text: string + files?: PromptInput.Prompt["files"] + agents?: PromptInput.Prompt["agents"] + metadata?: Record delivery?: SessionInput.Delivery resume?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly command: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -220,7 +231,7 @@ export interface Interface { delivery?: SessionInput.Delivery resume?: boolean }) => Effect.Effect< - SessionInput.Admitted, + SessionInput.User, NotFoundError | PromptConflictError | AttachmentError | CommandV2.NotFoundError | CommandV2.EvaluationError > readonly shell: (input: { @@ -243,12 +254,14 @@ export interface Interface { readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect readonly synthetic: (input: { + id?: SessionMessage.ID sessionID: SessionSchema.ID text: string description?: string metadata?: Record + delivery?: SessionInput.Delivery resume?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly revert: { readonly stage: (input: { sessionID: SessionSchema.ID @@ -487,15 +500,19 @@ const layer = Layer.effect( // continues from the reverted boundary rather than stale post-boundary history. if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) - const prompt = yield* resolvePrompt(input.prompt).pipe(Effect.provideService(FSUtil.Service, fs)) + const prompt = yield* resolvePrompt({ text: input.text, files: input.files, agents: input.agents }).pipe( + Effect.provideService(FSUtil.Service, fs), + ) const messageID = input.id ?? SessionMessage.ID.create() - const delivery = input.delivery ?? "steer" - const expected = { sessionID: input.sessionID, messageID, prompt, delivery } + const admittedInput = SessionInput.Message.make({ + type: "user", + data: { ...prompt, metadata: input.metadata }, + delivery: input.delivery ?? "steer", + }) const admitted = yield* SessionInput.admit(db, events, { id: messageID, sessionID: input.sessionID, - prompt, - delivery, + input: admittedInput, }).pipe( Effect.catchDefect((defect) => defect instanceof SessionInput.LifecycleConflict @@ -503,7 +520,10 @@ const layer = Layer.effect( : Effect.die(defect), ), ) - if (!SessionInput.equivalent(admitted, expected)) + if ( + admitted.type !== "user" || + !SessionInput.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput }) + ) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) if (input.resume !== false) { if (activeShells.has(admitted.sessionID)) return admitted @@ -539,7 +559,9 @@ const layer = Layer.effect( return yield* result.prompt({ id: input.id, sessionID: input.sessionID, - prompt: { text: evaluated.text, files: input.files, agents: input.agents }, + text: evaluated.text, + files: input.files, + agents: input.agents, delivery: input.delivery, resume: input.resume, }) @@ -664,35 +686,60 @@ const layer = Layer.effect( yield* result.get(sessionID) const backgrounded = yield* jobs.backgroundAll({ sessionID }) if (backgrounded.length === 0) return - yield* result.synthetic({ - sessionID, - text: [ - "User requested that active blocking work be moved to the background.", - "", - "Backgrounded work:", - ...backgrounded.map((job) => `- ${job.type}: ${job.title && job.title.length > 0 ? job.title : job.id}`), - "", - "The backgrounded work is still unfinished. Move on to other work if you can. If there is nothing else useful to do, finish your response. Do not wait, sleep, poll, or report the backgrounded work as complete until a later completion notification is added to the conversation.", - ].join("\n"), - }) + yield* result + .synthetic({ + sessionID, + text: [ + "User requested that active blocking work be moved to the background.", + "", + "Backgrounded work:", + ...backgrounded.map((job) => `- ${job.type}: ${job.title && job.title.length > 0 ? job.title : job.id}`), + "", + "The backgrounded work is still unfinished. Move on to other work if you can. If there is nothing else useful to do, finish your response. Do not wait, sleep, poll, or report the backgrounded work as complete until a later completion notification is added to the conversation.", + ].join("\n"), + }) + .pipe(Effect.catchTag("Session.SyntheticConflictError", Effect.die)) }), resume: Effect.fn("V2Session.resume")(function* (sessionID) { yield* result.get(sessionID) yield* execution.resume(sessionID) }), - synthetic: Effect.fn("V2Session.synthetic")(function* (input) { - yield* result.get(input.sessionID) - yield* events.publish(SessionEvent.Synthetic, { - sessionID: input.sessionID, - text: input.text, - description: input.description, - metadata: input.metadata, - }) - if (input.resume === false) return - yield* execution - .resume(input.sessionID) - .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) - }), + synthetic: Effect.fn("V2Session.synthetic")((input) => + Effect.uninterruptible( + Effect.gen(function* () { + yield* result.get(input.sessionID) + const inputID = input.id ?? SessionMessage.ID.create() + const admittedInput = SessionInput.Message.make({ + type: "synthetic", + data: { + text: input.text, + description: input.description, + metadata: input.metadata, + }, + delivery: input.delivery ?? "steer", + }) + const admitted = yield* SessionInput.admit(db, events, { + id: inputID, + sessionID: input.sessionID, + input: admittedInput, + }).pipe( + Effect.catchDefect((defect) => + defect instanceof SessionInput.LifecycleConflict + ? new SyntheticConflictError({ sessionID: input.sessionID, inputID }) + : Effect.die(defect), + ), + ) + if ( + admitted.type !== "synthetic" || + !SessionInput.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput }) + ) + return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID }) + if (input.resume !== false && !(yield* result.get(input.sessionID)).revert) + yield* execution.wake(input.sessionID) + return admitted + }), + ), + ), interrupt: Effect.fn("V2Session.interrupt")((sessionID) => Effect.uninterruptible(execution.interrupt(sessionID)), ), @@ -710,10 +757,12 @@ const layer = Layer.effect( clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) { const session = yield* result.get(sessionID) if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID }) - return yield* SessionRevert.clear(session).pipe( + const revert = yield* SessionRevert.clear(session).pipe( Effect.provideService(EventV2.Service, events), Effect.provide(locations.get(session.location)), ) + yield* execution.wake(sessionID) + return revert }), commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) { const session = yield* result.get(sessionID) diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 41eb36ea5b..e3e62ac139 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -2,22 +2,32 @@ export * as SessionInput from "./input" import { and, asc, eq, isNull } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" -import { Admitted, Compaction, Delivery, Info, PromptEntry } from "@opencode-ai/schema/session-input" +import { + Compaction, + Delivery, + Info, + Message, + Synthetic, + SyntheticData, + User, + UserData, +} from "@opencode-ai/schema/session-input" import type { Database } from "../database/database" import type { EventV2 } from "../event" import { KeyedMutex } from "../effect/keyed-mutex" import { SessionEvent } from "./event" import { SessionMessage } from "./message" -import { Prompt } from "@opencode-ai/schema/prompt" import { SessionSchema } from "./schema" import { SessionInputTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] -export { Admitted, Compaction, Delivery, Info, PromptEntry } +export { Compaction, Delivery, Info, Message, Synthetic, SyntheticData, User, UserData } -const decodePrompt = Schema.decodeUnknownSync(Prompt) -const encodePrompt = Schema.encodeSync(Prompt) +const decodeUser = Schema.decodeUnknownSync(UserData) +const encodeUser = Schema.encodeSync(UserData) +const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData) +const encodeSynthetic = Schema.encodeSync(SyntheticData) const inboxLocks = KeyedMutex.makeUnsafe() export class LifecycleConflict extends Schema.TaggedErrorClass()("SessionInput.LifecycleConflict", { @@ -37,27 +47,26 @@ const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => { type: "compaction", ...(row.promoted_seq === null ? {} : { handledSeq: row.promoted_seq }), }) - if (!row.prompt || !row.delivery) throw new LifecycleConflict({ id: base.id }) - return PromptEntry.make({ - ...base, - type: "prompt", - prompt: decodePrompt(row.prompt), - delivery: row.delivery, - ...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }), - }) + if (!row.delivery) throw new LifecycleConflict({ id: base.id }) + if (row.type === "user") + return User.make({ + ...base, + type: "user", + data: decodeUser(row.data), + delivery: row.delivery, + ...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }), + }) + if (row.type === "synthetic") + return Synthetic.make({ + ...base, + type: "synthetic", + data: decodeSynthetic(row.data), + delivery: row.delivery, + ...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }), + }) + throw new LifecycleConflict({ id: base.id }) } -const toAdmitted = (entry: PromptEntry): Admitted => - Admitted.make({ - admittedSeq: entry.admittedSeq, - id: entry.id, - sessionID: entry.sessionID, - prompt: entry.prompt, - delivery: entry.delivery, - timeCreated: entry.timeCreated, - ...(entry.promotedSeq === undefined ? {} : { promotedSeq: entry.promotedSeq }), - }) - export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) { const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie) return row === undefined ? undefined : fromRow(row) @@ -89,44 +98,43 @@ export const pendingCompaction = Effect.fn("SessionInput.pendingCompaction")(fun export const admit = Effect.fn("SessionInput.admit")(function* ( db: DatabaseService, events: EventV2.Interface, - input: { + request: { readonly id: SessionMessage.ID readonly sessionID: SessionSchema.ID - readonly prompt: Prompt - readonly delivery: Delivery + readonly input: Message }, ) { - const existing = yield* find(db, input.id) + const existing = yield* find(db, request.id) if (existing !== undefined) { - if (existing.type !== "prompt") return yield* Effect.die(new LifecycleConflict({ id: input.id })) - return toAdmitted(existing) + if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id })) + return existing } return yield* events - .publish(SessionEvent.PromptAdmitted, { - inputID: input.id, - sessionID: input.sessionID, - prompt: input.prompt, - delivery: input.delivery, + .publish(SessionEvent.InputAdmitted, { + inputID: request.id, + sessionID: request.sessionID, + input: request.input, }) .pipe( - Effect.flatMap((event) => - event.durable === undefined - ? Effect.die(new Error("Prompt admission event is missing aggregate sequence")) - : Effect.succeed( - Admitted.make({ - admittedSeq: event.durable.seq, - id: input.id, - sessionID: input.sessionID, - prompt: input.prompt, - delivery: input.delivery, - timeCreated: event.created, - }), - ), - ), + Effect.flatMap((event) => { + if (event.durable === undefined) + return Effect.die(new Error("Session input admission event is missing aggregate sequence")) + const base = { + admittedSeq: event.durable.seq, + id: request.id, + sessionID: request.sessionID, + timeCreated: event.created, + } + return Effect.succeed( + request.input.type === "user" + ? User.make({ ...base, ...request.input }) + : Synthetic.make({ ...base, ...request.input }), + ) + }), Effect.catchDefect((defect) => - find(db, input.id).pipe( + find(db, request.id).pipe( Effect.flatMap((stored) => - stored?.type === "prompt" ? Effect.succeed(toAdmitted(stored)) : Effect.die(defect), + stored?.type === request.input.type ? Effect.succeed(stored) : Effect.die(defect), ), ), ), @@ -174,38 +182,37 @@ export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(functio export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* ( db: DatabaseService, - input: { + request: { readonly admittedSeq: number readonly id: SessionMessage.ID readonly sessionID: SessionSchema.ID - readonly prompt: Prompt - readonly delivery: Delivery + readonly input: Message readonly timeCreated: DateTime.Utc }, ) { const message = yield* db .select({ id: SessionMessageTable.id }) .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, input.id)) + .where(eq(SessionMessageTable.id, request.id)) .get() .pipe(Effect.orDie) - if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id })) const stored = yield* db .insert(SessionInputTable) .values({ - id: input.id, - session_id: input.sessionID, - type: "prompt", - admitted_seq: input.admittedSeq, - prompt: encodePrompt(input.prompt), - delivery: input.delivery, - time_created: DateTime.toEpochMillis(input.timeCreated), + id: request.id, + session_id: request.sessionID, + type: request.input.type, + data: request.input.type === "user" ? encodeUser(request.input.data) : encodeSynthetic(request.input.data), + delivery: request.input.delivery, + admitted_seq: request.admittedSeq, + time_created: DateTime.toEpochMillis(request.timeCreated), }) .onConflictDoNothing() .returning({ id: SessionInputTable.id }) .get() .pipe(Effect.orDie) - if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id })) }) export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompactionAdmitted")(function* ( @@ -230,6 +237,7 @@ export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompacti id: input.id, session_id: input.sessionID, type: "compaction", + data: {}, admitted_seq: input.admittedSeq, time_created: DateTime.toEpochMillis(input.timeCreated), }) @@ -246,7 +254,7 @@ export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompacti return yield* Effect.die(new LifecycleConflict({ id: input.id })) }) -export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromoted")(function* ( +export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* ( db: DatabaseService, input: { readonly id: SessionMessage.ID @@ -262,23 +270,16 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot and( eq(SessionInputTable.id, input.id), eq(SessionInputTable.session_id, input.sessionID), - eq(SessionInputTable.type, "prompt"), isNull(SessionInputTable.promoted_seq), ), ) .returning() .get() .pipe(Effect.orDie) - if (updated) { - const stored = fromRow(updated) - if (stored.type !== "prompt" || stored.sessionID !== input.sessionID) - return yield* Effect.die(new LifecycleConflict({ id: input.id })) - return stored - } - const stored = yield* find(db, input.id) + const stored = updated ? fromRow(updated) : yield* find(db, input.id) if ( !stored || - stored.type !== "prompt" || + stored.type === "compaction" || stored.sessionID !== input.sessionID || stored.promotedSeq !== input.promotedSeq ) @@ -322,7 +323,6 @@ export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( .where( and( eq(SessionInputTable.session_id, sessionID), - eq(SessionInputTable.type, "prompt"), isNull(SessionInputTable.promoted_seq), eq(SessionInputTable.delivery, delivery), ), @@ -334,16 +334,21 @@ export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( }) export const equivalent = ( - input: Admitted, - expected: { - readonly sessionID: SessionSchema.ID - readonly prompt: Prompt - readonly delivery: Delivery - }, -) => - input.delivery === expected.delivery && - input.sessionID === expected.sessionID && - JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt)) + input: User | Synthetic, + expected: { readonly sessionID: SessionSchema.ID; readonly input: Message }, +) => { + if ( + input.type !== expected.input.type || + input.delivery !== expected.input.delivery || + input.sessionID !== expected.sessionID + ) + return false + if (input.type === "user" && expected.input.type === "user") + return JSON.stringify(encodeUser(input.data)) === JSON.stringify(encodeUser(expected.input.data)) + if (input.type === "synthetic" && expected.input.type === "synthetic") + return JSON.stringify(encodeSynthetic(input.data)) === JSON.stringify(encodeSynthetic(expected.input.data)) + return false +} const publish = Effect.fn("SessionInput.publish")(function* ( db: DatabaseService, @@ -358,9 +363,9 @@ const publish = Effect.fn("SessionInput.publish")(function* ( rows, (row) => { const entry = fromRow(row) - if (entry.type !== "prompt") return Effect.die(new LifecycleConflict({ id: entry.id })) + if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id })) return events - .publish(SessionEvent.PromptPromoted, { + .publish(SessionEvent.InputPromoted, { sessionID, inputID: entry.id, }) @@ -369,7 +374,7 @@ const publish = Effect.fn("SessionInput.publish")(function* ( defect instanceof LifecycleConflict ? find(db, entry.id).pipe( Effect.flatMap((stored) => - stored?.type === "prompt" && stored.promotedSeq !== undefined + stored?.type !== "compaction" && stored?.promotedSeq !== undefined ? Effect.void : Effect.die(defect), ), @@ -397,7 +402,6 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* ( .where( and( eq(SessionInputTable.session_id, sessionID), - eq(SessionInputTable.type, "prompt"), isNull(SessionInputTable.promoted_seq), eq(SessionInputTable.delivery, "steer"), ), @@ -420,7 +424,6 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun .where( and( eq(SessionInputTable.session_id, sessionID), - eq(SessionInputTable.type, "prompt"), isNull(SessionInputTable.promoted_seq), eq(SessionInputTable.delivery, "queue"), ), diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 01e045ce5f..5d5ac25668 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -172,8 +172,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.renamed": () => Effect.void, "session.deleted": () => Effect.void, "session.forked": () => Effect.void, - "session.prompt.promoted": () => Effect.void, - "session.prompt.admitted": () => Effect.void, + "session.input.promoted": () => Effect.void, + "session.input.admitted": () => Effect.void, "session.execution.started": () => Effect.void, "session.execution.succeeded": () => clearCurrentRetry, "session.execution.failed": () => clearCurrentRetry, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index dcf79ff3a1..a3c2b53ae4 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -313,13 +313,13 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .values( inputRows.flatMap((row) => { const id = idMap.get(row.id) - return id && row.type === "prompt" + return id && row.type !== "compaction" ? [ { id, session_id: event.data.sessionID, - type: "prompt" as const, - prompt: row.prompt, + type: row.type, + data: row.data, delivery: row.delivery, admitted_seq: row.admitted_seq, promoted_seq: row.promoted_seq, @@ -629,27 +629,40 @@ const layer = Layer.effectDiscard( .pipe(Effect.orDie), ) yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event)) - yield* events.project(SessionEvent.PromptPromoted, (event) => + yield* events.project(SessionEvent.InputPromoted, (event) => Effect.gen(function* () { if (event.durable === undefined) return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) - const input = yield* SessionInput.projectPromptPromoted(db, { + const input = yield* SessionInput.projectPromoted(db, { id: event.data.inputID, sessionID: event.data.sessionID, promotedSeq: event.durable.seq, }) - yield* insertMessage(db, event, { - id: input.id, - type: "user", - metadata: event.metadata, - text: input.prompt.text, - files: input.prompt.files, - agents: input.prompt.agents, - time: { created: event.created }, - }) + yield* insertMessage( + db, + event, + input.type === "user" + ? { + id: input.id, + type: "user", + metadata: input.data.metadata, + text: input.data.text, + files: input.data.files, + agents: input.data.agents, + time: { created: event.created }, + } + : { + id: input.id, + type: "synthetic", + text: input.data.text, + description: input.data.description, + metadata: input.data.metadata, + time: { created: event.created }, + }, + ) }), ) - yield* events.project(SessionEvent.PromptAdmitted, (event) => + yield* events.project(SessionEvent.InputAdmitted, (event) => Effect.gen(function* () { if (event.durable === undefined) return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) @@ -657,8 +670,7 @@ const layer = Layer.effectDiscard( admittedSeq: event.durable.seq, id: event.data.inputID, sessionID: event.data.sessionID, - prompt: event.data.prompt, - delivery: event.data.delivery, + input: event.data.input, timeCreated: event.created, }) }), diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 6e09505e3c..e8cdebd90c 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -3,7 +3,6 @@ import { sql } from "drizzle-orm" import { directoryColumn, pathColumn } from "../database/path" import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" -import type { Prompt } from "@opencode-ai/schema/prompt" import type { SessionInput } from "./input" import type { FileDiff } from "@opencode-ai/schema/file-diff" import { PermissionV1 } from "../v1/permission" @@ -14,6 +13,7 @@ import { WorkspaceV2 } from "../workspace" import { Timestamps } from "../database/schema.sql" import type { Instructions } from "../instructions/index" import type { Session } from "@opencode-ai/schema/session" +import type { SyntheticData, UserData } from "@opencode-ai/schema/session-input" import type { RevertV1 } from "@opencode-ai/schema/session-revert" import type { Schema } from "effect" @@ -150,7 +150,7 @@ export const SessionInputTable = sqliteTable( .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), type: text().$type().notNull(), - prompt: text({ mode: "json" }).$type(), + data: text({ mode: "json" }).$type>().notNull(), delivery: text().$type(), admitted_seq: integer().notNull(), promoted_seq: integer(), @@ -159,10 +159,9 @@ export const SessionInputTable = sqliteTable( .$default(() => Date.now()), }, (table) => [ - index("session_input_session_pending_type_delivery_seq_idx").on( + index("session_input_session_pending_delivery_seq_idx").on( table.session_id, table.promoted_seq, - table.type, table.delivery, table.admitted_seq, ), diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index e6c994c216..b287d74234 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -152,7 +152,7 @@ export const Plugin = { const run = Effect.gen(function* () { // The child session owns its agent/model (set at create); prompt only admits input. - yield* runtime.session.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false }) + yield* runtime.session.prompt({ sessionID: child.id, text: input.prompt, resume: false }) yield* runtime.session.resume(child.id) return yield* latestAssistantText(child.id) }).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id))) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 10f02a7342..14b9983598 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -18,6 +18,7 @@ import simplifySessionInputMigration from "@opencode-ai/core/database/migration/ import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events" import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox" import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state" +import genericSessionInputMigration from "@opencode-ai/core/database/migration/20260709013000_generic_session_input" import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions" import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -369,7 +370,7 @@ describe("DatabaseMigration", () => { { name: "event_aggregate_type_seq_idx" }, { name: "session_input_session_admitted_seq_idx" }, { name: "session_input_session_pending_compaction_idx" }, - { name: "session_input_session_pending_type_delivery_seq_idx" }, + { name: "session_input_session_pending_delivery_seq_idx" }, { name: "session_input_session_promoted_seq_idx" }, { name: "session_message_session_seq_idx" }, { name: "session_message_session_time_created_id_idx" }, @@ -607,7 +608,7 @@ describe("DatabaseMigration", () => { sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`, ) yield* db.run( - sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'prompt', '{}', 'steer', 9, 1)`, + sql`INSERT INTO session_input (id, session_id, type, data, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'user', '{}', 'steer', 9, 1)`, ) yield* db.run( sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`, @@ -701,6 +702,56 @@ describe("DatabaseMigration", () => { ) }) + test("migrates prompt inbox rows and lifecycle events to generic user input", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) + yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`) + yield* db.run( + sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, prompt text, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, + ) + yield* db.run( + sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('input', 'session', 'prompt', '{"text":"hello"}', 'queue', 4, NULL, 1)`, + ) + yield* db.run( + sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('empty', 'session', 'prompt', NULL, 'steer', 6, NULL, 2)`, + ) + yield* db.run( + sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, created integer NOT NULL, type text NOT NULL, data text NOT NULL)`, + ) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('admitted', 'session', 4, 1, 'session.prompt.admitted.1', '{"sessionID":"session","inputID":"input","prompt":{"text":"hello"},"delivery":"queue"}')`, + ) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('promoted', 'session', 5, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"input"}')`, + ) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-admitted', 'session', 6, 2, 'session.prompt.admitted.1', '{"sessionID":"session","inputID":"empty","prompt":null,"delivery":"steer"}')`, + ) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-promoted', 'session', 7, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"empty"}')`, + ) + + yield* DatabaseMigration.applyOnly(db, [genericSessionInputMigration]) + + expect(yield* db.all(sql`SELECT id, type, data, delivery FROM session_input ORDER BY admitted_seq`)).toEqual([ + { id: "input", type: "user", data: '{"text":"hello"}', delivery: "queue" }, + ]) + expect(yield* db.all(sql`SELECT type, data FROM event ORDER BY seq`)).toEqual([ + { + type: "session.input.admitted.1", + data: '{"sessionID":"session","inputID":"input","input":{"type":"user","data":{"text":"hello"},"delivery":"queue"}}', + }, + { + type: "session.input.promoted.1", + data: '{"sessionID":"session","inputID":"input"}', + }, + ]) + }), + ) + }) + test("resets incompatible projected Session messages before adding sequence order", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index 703b098f63..814e8c1872 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -17,7 +17,6 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/schema/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" @@ -84,14 +83,16 @@ describe("SessionV2.compact", () => { const created = yield* session.create({ location }) const messageID = SessionMessage.ID.create() - const prompt = Prompt.make({ text: "Please compact this session history." }) - yield* events.publish(SessionEvent.PromptAdmitted, { + yield* events.publish(SessionEvent.InputAdmitted, { sessionID: created.id, inputID: messageID, - prompt, - delivery: "steer", + input: { + type: "user", + data: { text: "Please compact this session history." }, + delivery: "steer", + }, }) - yield* events.publish(SessionEvent.PromptPromoted, { + yield* events.publish(SessionEvent.InputPromoted, { sessionID: created.id, inputID: messageID, }) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 3c4bb65a44..e1b83b4f50 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -17,7 +17,6 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { PromptInput } from "@opencode-ai/schema/prompt-input" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -193,14 +192,12 @@ describe("SessionV2.create", () => { const parent = yield* session.create({ location, title: "Parent" }) const admitted = yield* session.prompt({ sessionID: parent.id, - prompt: PromptInput.Prompt.make({ text: "First" }), + text: "First", resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) - yield* events.publish(SessionEvent.Synthetic, { - sessionID: parent.id, - text: "parent note", - }) + yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false }) + yield* SessionInput.promoteSteers(db, events, parent.id) const forked = yield* session.fork({ sessionID: parent.id }) const parentContext = yield* session.context(parent.id) @@ -222,19 +219,25 @@ describe("SessionV2.create", () => { }) expect(yield* SessionInput.find(db, forkContext[0].id)).toMatchObject({ sessionID: forked.id, - prompt: { text: "First" }, + type: "user", + data: { text: "First" }, promotedSeq: 2, }) + expect(yield* SessionInput.find(db, forkContext[1].id)).toMatchObject({ + sessionID: forked.id, + type: "synthetic", + data: { text: "parent note" }, + }) yield* session.prompt({ sessionID: parent.id, - prompt: PromptInput.Prompt.make({ text: "Parent changed" }), + text: "Parent changed", resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) yield* session.prompt({ sessionID: forked.id, - prompt: PromptInput.Prompt.make({ text: "Child continues" }), + text: "Child continues", resume: false, }) yield* SessionInput.promoteSteers(db, events, forked.id) @@ -246,7 +249,7 @@ describe("SessionV2.create", () => { Array.from(yield* Stream.runCollect(logEvents(session, forked.id))).map( (event): number | undefined => event.durable?.seq, ), - ).toEqual([0, 4, 5]) + ).toEqual([0, 5, 6]) expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id }) }), ) @@ -259,13 +262,13 @@ describe("SessionV2.create", () => { const parent = yield* session.create({ location }) const first = yield* session.prompt({ sessionID: parent.id, - prompt: PromptInput.Prompt.make({ text: "First" }), + text: "First", resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) const second = yield* session.prompt({ sessionID: parent.id, - prompt: PromptInput.Prompt.make({ text: "Second" }), + text: "Second", resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) @@ -410,7 +413,7 @@ describe("SessionV2.create", () => { const created = yield* session.create({ location }) yield* session.prompt({ sessionID: created.id, - prompt: PromptInput.Prompt.make({ text: "Hello" }), + text: "Hello", resume: false, }) yield* SessionInput.promoteSteers(db, events, created.id) @@ -418,8 +421,12 @@ describe("SessionV2.create", () => { expect( Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)), ).toMatchObject([ - { durable: { seq: 1 }, type: "session.prompt.admitted", data: { prompt: { text: "Hello" } } }, - { durable: { seq: 2 }, type: "session.prompt.promoted" }, + { + durable: { seq: 1 }, + type: "session.input.admitted", + data: { input: { type: "user", data: { text: "Hello" }, delivery: "steer" } }, + }, + { durable: { seq: 2 }, type: "session.input.promoted" }, ]) }), ) @@ -432,7 +439,7 @@ describe("SessionV2.create", () => { const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location }) const admitted = yield* session.prompt({ sessionID: created.id, - prompt: PromptInput.Prompt.make({ text: "Replay lifecycle" }), + text: "Replay lifecycle", resume: false, }) yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id) @@ -476,7 +483,8 @@ describe("SessionV2.create", () => { expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ id: admitted.id, sessionID: created.id, - prompt: { text: "Replay lifecycle" }, + type: "user", + data: { text: "Replay lifecycle" }, delivery: "steer", admittedSeq: 1, }) @@ -486,7 +494,8 @@ describe("SessionV2.create", () => { expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ id: admitted.id, sessionID: created.id, - prompt: { text: "Replay lifecycle" }, + type: "user", + data: { text: "Replay lifecycle" }, delivery: "steer", admittedSeq: 1, promotedSeq: 2, @@ -504,8 +513,8 @@ describe("SessionV2.create", () => { .pipe(Effect.orDie)).map((event) => [event.seq, event.type]), ).toEqual([ [0, EventV2.versionedType(SessionV1.Event.Created.type, 1)], - [1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)], - [2, EventV2.versionedType(SessionEvent.PromptPromoted.type, 1)], + [1, EventV2.versionedType(SessionEvent.InputAdmitted.type, 1)], + [2, EventV2.versionedType(SessionEvent.InputPromoted.type, 1)], ]) }).pipe(Effect.provide(Layer.fresh(targetLayer))) }), diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 1eb7f6b6fd..7cc3583dff 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -15,7 +15,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/schema/prompt" import { Money } from "@opencode-ai/schema/money" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -266,28 +265,26 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service - yield* events.publish(SessionEvent.PromptAdmitted, { + yield* events.publish(SessionEvent.InputAdmitted, { sessionID, inputID: SessionMessage.ID.make("msg_first"), - prompt: Prompt.make({ text: "first" }), - delivery: "steer", + input: { type: "user", data: { text: "first" }, delivery: "steer" }, }) yield* events.publish( - SessionEvent.PromptPromoted, + SessionEvent.InputPromoted, { sessionID, inputID: SessionMessage.ID.make("msg_first"), }, { id: EventV2.ID.make("evt_z") }, ) - yield* events.publish(SessionEvent.PromptAdmitted, { + yield* events.publish(SessionEvent.InputAdmitted, { sessionID, inputID: SessionMessage.ID.make("msg_second"), - prompt: Prompt.make({ text: "second" }), - delivery: "steer", + input: { type: "user", data: { text: "second" }, delivery: "steer" }, }) yield* events.publish( - SessionEvent.PromptPromoted, + SessionEvent.InputPromoted, { sessionID, inputID: SessionMessage.ID.make("msg_second"), @@ -344,12 +341,11 @@ describe("SessionProjector", () => { const admitted = yield* SessionInput.admit(db, events, { id, sessionID, - prompt: Prompt.make({ text: "promote me" }), - delivery: "steer", + input: { type: "user", data: { text: "promote me" }, delivery: "steer" }, }) if (!admitted) return yield* Effect.die("Prompt admission failed") - const event = yield* events.publish(SessionEvent.PromptPromoted, { + const event = yield* events.publish(SessionEvent.InputPromoted, { sessionID, inputID: id, }) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 34b1ca1487..f9782170f4 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -18,7 +18,6 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { PromptInput } from "@opencode-ai/schema/prompt-input" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -174,16 +173,17 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", resume: false, }) - expect(message.prompt.text).toBe("Fix the failing tests") + expect(message.data.text).toBe("Fix the failing tests") expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* admitted(message.id)).toMatchObject({ id: message.id, sessionID, - prompt: { text: "Fix the failing tests" }, + type: "user", + data: { text: "Fix the failing tests" }, delivery: "steer", }) }), @@ -198,7 +198,7 @@ describe("SessionV2.prompt", () => { const boundary = yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "boundary" }), + text: "boundary", resume: false, }) yield* SessionInput.promoteSteers(db, events, sessionID) @@ -210,7 +210,7 @@ describe("SessionV2.prompt", () => { }) expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "after revert" }), resume: false }) + yield* session.prompt({ sessionID, text: "after revert", resume: false }) expect((yield* session.get(sessionID)).revert).toBeUndefined() expect( @@ -222,6 +222,35 @@ describe("SessionV2.prompt", () => { }), ) + it.effect("holds synthetic input behind a staged revert and discards it when committed", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const boundary = yield* session.prompt({ + sessionID, + text: "boundary", + resume: false, + }) + yield* SessionInput.promoteSteers(db, events, sessionID) + yield* events.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + revert: { messageID: boundary.id, files: [] }, + }) + wakeCalls.length = 0 + + const completion = yield* session.synthetic({ sessionID, text: "stale completion" }) + + expect(wakeCalls).toEqual([]) + expect(yield* SessionInput.find(db, completion.id)).toMatchObject({ type: "synthetic" }) + + yield* session.revert.commit(sessionID) + + expect(yield* SessionInput.find(db, completion.id)).toBeUndefined() + }), + ) + it.effect("resolves attachment MIME before admission", () => Effect.gen(function* () { yield* setup @@ -231,14 +260,12 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: { - text: "Inspect this image", - files: [{ uri, name: "image.png", mention: { start: 8, end: 17, text: "[Image 1]" } }], - }, + text: "Inspect this image", + files: [{ uri, name: "image.png", mention: { start: 8, end: 17, text: "[Image 1]" } }], resume: false, }) - expect(message.prompt.files).toEqual([ + expect(message.data.files).toEqual([ { data: uri.slice(uri.indexOf(",") + 1), mime: "image/png", @@ -248,8 +275,8 @@ describe("SessionV2.prompt", () => { }, ]) const stored = yield* admitted(message.id) - expect(stored?.type).toBe("prompt") - if (stored?.type === "prompt") expect(stored.prompt.files).toEqual(message.prompt.files) + expect(stored?.type).toBe("user") + if (stored?.type === "user") expect(stored.data.files).toEqual(message.data.files) }), ) @@ -265,21 +292,19 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: { - text: "Inspect this", - files: [{ uri: sourceUri.href, name: "main.ts" }], - }, + text: "Inspect this", + files: [{ uri: sourceUri.href, name: "main.ts" }], resume: false, }) - expect(message.prompt.files).toHaveLength(1) - expect(message.prompt.files?.[0]).toMatchObject({ + expect(message.data.files).toHaveLength(1) + expect(message.data.files?.[0]).toMatchObject({ mime: "text/plain", source: { type: "uri", uri: sourceUri.href }, name: "main.ts", }) expect( - Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64") + Buffer.from(message.data.files?.[0]?.data ?? "", "base64") .toString("utf8") .replace(/\r$/, ""), ).toBe('import { describe, expect } from "bun:test"') @@ -294,17 +319,18 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: { text: "Inspect this", files: [{ uri, name: "source" }] }, + text: "Inspect this", + files: [{ uri, name: "source" }], resume: false, }) - expect(message.prompt.files).toHaveLength(1) - expect(message.prompt.files?.[0]).toMatchObject({ + expect(message.data.files).toHaveLength(1) + expect(message.data.files?.[0]).toMatchObject({ mime: "application/x-directory", source: { type: "uri", uri }, name: "source", }) - expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain( + expect(Buffer.from(message.data.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain( "session-prompt.test.ts", ) }), @@ -327,11 +353,12 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: { text: "Inspect this image", files: [{ uri: pathToFileURL(source).href }] }, + text: "Inspect this image", + files: [{ uri: pathToFileURL(source).href }], resume: false, }) - expect(message.prompt.files).toEqual([ + expect(message.data.files).toEqual([ { data: bytes.toString("base64"), mime: "image/png", @@ -340,7 +367,7 @@ describe("SessionV2.prompt", () => { }, ]) const stored = yield* admitted(message.id) - expect(stored?.type === "prompt" ? stored.prompt.files : undefined).toEqual(message.prompt.files) + expect(stored?.type === "user" ? stored.data.files : undefined).toEqual(message.data.files) }), ) @@ -352,11 +379,12 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: { text: "Inspect this", files: [{ uri, name: "main.ts" }] }, + text: "Inspect this", + files: [{ uri, name: "main.ts" }], resume: false, }) - expect(message.prompt.files).toEqual([ + expect(message.data.files).toEqual([ { data: Buffer.from("export const value = 1\n").toString("base64"), mime: "text/plain", @@ -376,7 +404,8 @@ describe("SessionV2.prompt", () => { const error = yield* session .prompt({ sessionID, - prompt: { text: "Inspect this", files: [{ uri, name: "image.png" }] }, + text: "Inspect this", + files: [{ uri, name: "image.png" }], resume: false, }) .pipe(Effect.flip) @@ -402,22 +431,22 @@ describe("SessionV2.prompt", () => { const fiber = yield* publicEvents({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, text: "First", resume: false }) + yield* session.prompt({ sessionID, text: "Second", resume: false }) yield* SessionInput.promoteSteers(db, events, sessionID) const streamed = Array.from(yield* Fiber.join(fiber)) expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([ - [0, "session.prompt.admitted"], - [1, "session.prompt.admitted"], - [2, "session.prompt.promoted"], - [3, "session.prompt.promoted"], + [0, "session.input.admitted"], + [1, "session.input.admitted"], + [2, "session.input.promoted"], + [3, "session.input.promoted"], ]) expect( Array.from( yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect), ).map((event): [number | undefined, string] => [event.durable?.seq, event.type]), - ).toEqual([[1, "session.prompt.admitted"]]) + ).toEqual([[1, "session.input.admitted"]]) }), ) @@ -427,7 +456,7 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service const message = yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", resume: false, }) @@ -446,7 +475,7 @@ describe("SessionV2.prompt", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - const input = { sessionID, prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), resume: false } + const input = { sessionID, text: "Fix the failing tests", resume: false } const first = yield* session.prompt(input) const second = yield* session.prompt(input) @@ -464,7 +493,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", resume: false, } @@ -484,7 +513,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: PromptInput.Prompt.make({ text: "Recover committed prompt" }), + text: "Recover committed prompt", resume: false, } const first = yield* session.prompt(input) @@ -505,13 +534,13 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ sessionID, id: messageID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", }) const failure = yield* session .prompt({ sessionID, id: messageID, - prompt: PromptInput.Prompt.make({ text: "Delete the failing tests" }), + text: "Delete the failing tests", resume: false, }) .pipe(Effect.flip) @@ -530,14 +559,14 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ id: messageID, sessionID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", resume: false, }) const failure = yield* session .prompt({ id: messageID, sessionID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", delivery: "queue", resume: false, }) @@ -554,7 +583,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), + text: "Fix the failing tests", resume: false, } @@ -563,7 +592,7 @@ describe("SessionV2.prompt", () => { expect(messages[1]).toEqual(messages[0]) expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* admittedCount).toBe(1) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1) }), ) @@ -576,7 +605,7 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ id: messageID, sessionID, - prompt: PromptInput.Prompt.make({ text: "Promote once" }), + text: "Promote once", resume: false, }) @@ -585,7 +614,7 @@ describe("SessionV2.prompt", () => { { concurrency: "unbounded" }, ) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptPromoted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1) expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) expect(yield* session.messages({ sessionID })).toMatchObject([ { id: messageID, type: "user", text: "Promote once" }, @@ -603,9 +632,11 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ id: messageID, sessionID, - prompt: PromptInput.Prompt.make({ text: "Replay pending" }), + text: "Replay pending", resume: false, }) + const syntheticID = SessionMessage.ID.create() + yield* session.synthetic({ id: syntheticID, sessionID, text: "Replay synthetic", resume: false }) const recorded = yield* db .select() .from(EventTable) @@ -631,7 +662,16 @@ describe("SessionV2.prompt", () => { })), ) - expect(yield* admitted(messageID)).toMatchObject({ id: messageID, prompt: { text: "Replay pending" } }) + expect(yield* admitted(messageID)).toMatchObject({ + id: messageID, + type: "user", + data: { text: "Replay pending" }, + }) + expect(yield* admitted(syntheticID)).toMatchObject({ + id: syntheticID, + type: "synthetic", + data: { text: "Replay synthetic" }, + }) expect(yield* session.messages({ sessionID })).toEqual([]) expect(wakeCalls).toEqual([]) }), @@ -656,11 +696,9 @@ describe("SessionV2.prompt", () => { .onConflictDoNothing() .run() .pipe(Effect.orDie) - const prompt = PromptInput.Prompt.make({ text: "Fix the failing tests" }) - - yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) + yield* session.prompt({ id: messageID, sessionID, text: "Fix the failing tests", resume: false }) const failure = yield* session - .prompt({ id: messageID, sessionID: other, prompt, resume: false }) + .prompt({ id: messageID, sessionID: other, text: "Fix the failing tests", resume: false }) .pipe(Effect.flip) expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID: other, messageID }) @@ -692,7 +730,7 @@ describe("SessionV2.prompt", () => { .prompt({ id: messageID, sessionID, - prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }), + text: "Conflicting prompt", resume: false, }) .pipe(Effect.flip) @@ -709,7 +747,7 @@ describe("SessionV2.prompt", () => { executionCalls.length = 0 wakeCalls.length = 0 - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run by default" }) }) + yield* session.prompt({ sessionID, text: "Run by default" }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([sessionID]) @@ -725,7 +763,7 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Run explicitly" }), + text: "Run explicitly", resume: true, }) @@ -741,10 +779,150 @@ describe("SessionV2.prompt", () => { executionCalls.length = 0 wakeCalls.length = 0 - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Do not run" }), resume: false }) + yield* session.prompt({ sessionID, text: "Do not run", resume: false }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([]) }), ) + + it.effect("treats prompt metadata as durable retry identity", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const input = { + id: messageID, + sessionID, + text: "Deploy", + metadata: { source: "api" }, + resume: false, + } + + const first = yield* session.prompt(input) + const retried = yield* session.prompt(input) + const failure = yield* session.prompt({ ...input, metadata: { source: "plugin" } }).pipe(Effect.flip) + + expect(retried).toEqual(first) + expect(first.data.metadata).toEqual({ source: "api" }) + expect(failure._tag).toBe("Session.PromptConflictError") + }), + ) + + it.effect("durably admits synthetic input before transcript promotion", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + + const input = yield* session.synthetic({ + id: messageID, + sessionID, + text: "Background work completed", + description: "shell completion", + metadata: { job: "shell" }, + resume: false, + }) + + expect(yield* session.messages({ sessionID })).toEqual([]) + expect(yield* admitted(input.id)).toMatchObject({ + type: "synthetic", + sessionID, + delivery: "steer", + data: { + text: "Background work completed", + description: "shell completion", + metadata: { job: "shell" }, + }, + }) + + yield* SessionInput.promoteSteers(db, events, sessionID) + + expect(yield* session.messages({ sessionID })).toMatchObject([ + { + id: messageID, + type: "synthetic", + text: "Background work completed", + description: "shell completion", + metadata: { job: "shell" }, + }, + ]) + }), + ) + + it.effect("reconciles exact synthetic retries and rejects conflicting reuse", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const database = yield* Database.Service + const input = { id: messageID, sessionID, text: "Completed", resume: false } + + const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], { + concurrency: "unbounded", + }) + yield* SessionInput.promoteSteers(database.db, events, sessionID) + const promotedRetry = yield* session.synthetic(input) + const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip) + + expect(entries[1]).toEqual(entries[0]) + expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", promotedSeq: expect.any(Number) }) + expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID }) + expect(yield* admittedCount).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1) + }), + ) + + it.effect("keeps synthetic queue input pending until the queue boundary", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + + const input = yield* session.synthetic({ + sessionID, + text: "Queued completion", + delivery: "queue", + resume: false, + }) + + expect(input.delivery).toBe("queue") + expect(yield* SessionInput.promoteSteers(db, events, sessionID)).toBe(0) + expect(yield* session.messages({ sessionID })).toEqual([]) + expect(yield* SessionInput.promoteNextQueued(db, events, sessionID)).toBe(true) + expect(yield* session.messages({ sessionID })).toMatchObject([ + { id: input.id, type: "synthetic", text: "Queued completion" }, + ]) + }), + ) + + it.effect("promotes prompt and synthetic steers in admission order", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + + yield* session.prompt({ + sessionID, + text: "First prompt", + resume: false, + }) + yield* session.synthetic({ sessionID, text: "Background completion", resume: false }) + yield* session.prompt({ + sessionID, + text: "Second prompt", + resume: false, + }) + + yield* SessionInput.promoteSteers(db, events, sessionID) + + expect( + (yield* session.messages({ sessionID, order: "asc" })).map((message) => + message.type === "user" || message.type === "synthetic" ? message.text : message.type, + ), + ).toEqual(["First prompt", "Background completion", "Second prompt"]) + }), + ) }) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 36b8f85061..7fa33da84a 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -18,7 +18,6 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Snapshot } from "@opencode-ai/core/snapshot" import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionTitle } from "@opencode-ai/core/session/title" -import { PromptInput } from "@opencode-ai/schema/prompt-input" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" @@ -179,7 +178,7 @@ describe("SessionRunnerLLM recorded", () => { const session = yield* SessionV2.Service const prompt = yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Say hello in one short sentence." }), + text: "Say hello in one short sentence.", resume: false, }) @@ -200,8 +199,8 @@ describe("SessionRunnerLLM recorded", () => { .orderBy(EventTable.seq) .all()).map((event) => event.type), ).toEqual([ - "session.prompt.admitted.1", - "session.prompt.promoted.1", + "session.input.admitted.1", + "session.input.promoted.1", "session.step.started.1", "session.text.started.1", "session.text.ended.1", diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 35091f37b9..7aa6ef28c0 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -29,7 +29,6 @@ import { Snapshot } from "@opencode-ai/core/snapshot" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" -import { PromptInput } from "@opencode-ai/schema/prompt-input" import { Money } from "@opencode-ai/schema/money" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -72,6 +71,7 @@ const requests: LLMRequest[] = [] let response: LLMEvent[] = [] let responses: LLMEvent[][] | undefined let responseStream: Stream.Stream | undefined +let responseStreams: Stream.Stream[] | undefined let streamGate: Deferred.Deferred | undefined let streamStarted: Deferred.Deferred | undefined let streamFailure: LLMError | undefined @@ -86,6 +86,7 @@ const client = Layer.succeed( prepare: () => Effect.die("unused"), stream: ((request: LLMRequest) => { requests.push(request) + if (responseStreams) return responseStreams.shift() ?? Stream.empty if (responseStream) { const stream = responseStream responseStream = undefined @@ -389,8 +390,7 @@ const it = testEffect( ) const sessionID = SessionV2.ID.make("ses_runner_test") const otherSessionID = SessionV2.ID.make("ses_runner_other") -const admit = (session: SessionV2.Interface, text: string) => - session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text }), resume: false }) +const admit = (session: SessionV2.Interface, text: string) => session.prompt({ sessionID, text, resume: false }) const insertSession = (id: SessionV2.ID) => Effect.gen(function* () { @@ -427,6 +427,7 @@ const setup = Effect.gen(function* () { responses = undefined streamFailure = undefined responseStream = undefined + responseStreams = undefined streamGate = undefined streamStarted = undefined toolExecutionGate = undefined @@ -774,7 +775,7 @@ describe("SessionRunnerLLM", () => { const message = yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Run automatically" }), + text: "Run automatically", }) yield* session.wait(sessionID) @@ -785,6 +786,34 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("runs a follow-up when synthetic input arrives during an active continuation", () => + Effect.gen(function* () { + const session = yield* setup + const secondStarted = yield* Deferred.make() + const releaseSecond = yield* Deferred.make() + responseStreams = [ + Stream.fromIterable(reply.tool("call-echo", "echo", { text: "background started" })), + Stream.unwrap( + Deferred.succeed(secondStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSecond)), + Effect.as(Stream.fromIterable(reply.stop())), + ), + ), + Stream.fromIterable(reply.text("Handled completion", "text-completion")), + ] + yield* admit(session, "Start background work") + const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true })) + yield* Deferred.await(secondStarted) + + yield* session.synthetic({ sessionID, text: "Background work completed" }) + yield* Deferred.succeed(releaseSecond, undefined) + yield* Fiber.join(running) + + expect(requests).toHaveLength(3) + expect(userTexts(requests[2]!)).toContain("Background work completed") + }), + ) + it.effect("streams one request with registry definitions from chronological V2 user history", () => Effect.gen(function* () { const session = yield* setup @@ -813,7 +842,7 @@ describe("SessionRunnerLLM", () => { yield* session.prompt({ id: messageID, sessionID, - prompt: PromptInput.Prompt.make({ text: "First" }), + text: "First", resume: false, }) @@ -832,7 +861,7 @@ describe("SessionRunnerLLM", () => { ).toBeUndefined() systemUnavailable = false - yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "First" }) }) + yield* session.prompt({ id: messageID, sessionID, text: "First" }) yield* session.wait(sessionID) expect(requests).toHaveLength(1) @@ -1099,7 +1128,7 @@ describe("SessionRunnerLLM", () => { .run() .pipe(Effect.orDie) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Inspect files" }), resume: false }) + yield* session.prompt({ sessionID, text: "Inspect files", resume: false }) requests.length = 0 response = [] @@ -1120,7 +1149,7 @@ describe("SessionRunnerLLM", () => { const release = yield* Deferred.make() pluginFlushHook = Deferred.await(release) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Wait for plugins" }), resume: false }) + yield* session.prompt({ sessionID, text: "Wait for plugins", resume: false }) requests.length = 0 response = [] @@ -1409,9 +1438,10 @@ describe("SessionRunnerLLM", () => { expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined() yield* admit(session, "Steer after compaction") + yield* session.synthetic({ sessionID, text: "Completion after compaction", resume: false }) yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Queue after compaction" }), + text: "Queue after compaction", delivery: "queue", resume: false, }) @@ -1423,6 +1453,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(4) expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary") expect(userTexts(requests[2])).toContain("Steer after compaction") + expect(userTexts(requests[2])).toContain("Completion after compaction") expect(userTexts(requests[3])).toContain("Queue after compaction") expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined() expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({ @@ -1451,7 +1482,7 @@ describe("SessionRunnerLLM", () => { const compaction = yield* session.compact({ sessionID }) yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Continue after failure" }), + text: "Continue after failure", delivery: "queue", resume: false, }) @@ -2189,7 +2220,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Change direction" }) }) + yield* session.prompt({ sessionID, text: "Change direction" }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2221,7 +2252,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Wait until continuation ends" }), + text: "Wait until continuation ends", delivery: "queue", }) yield* Deferred.succeed(streamGate, undefined) @@ -2250,7 +2281,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Run after interrupt" }), + text: "Run after interrupt", delivery: "queue", }) yield* session.interrupt(sessionID) @@ -2284,7 +2315,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Steer after interrupt" }), + text: "Steer after interrupt", }) yield* session.interrupt(sessionID) expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) @@ -2315,8 +2346,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" }) + yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2335,7 +2366,7 @@ describe("SessionRunnerLLM", () => { yield* admit(session, "Start steering") yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Queue for later" }), + text: "Queue for later", delivery: "queue", resume: false, }) @@ -2362,16 +2393,17 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, text: "Queue first", delivery: "queue" }) + yield* session.prompt({ sessionID, text: "Queue second", delivery: "queue" }) streamGate = secondGate yield* Deferred.succeed(firstGate, undefined) while (requests.length < 2) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Steer before next queued input" }) }) + yield* session.prompt({ sessionID, text: "Steer before next queued input" }) yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Also steer before next queued input" }), + text: "Also steer before next queued input", }) + yield* session.synthetic({ sessionID, text: "Background completion before next queued input" }) yield* Deferred.succeed(secondGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2384,12 +2416,14 @@ describe("SessionRunnerLLM", () => { "Queue first", "Steer before next queued input", "Also steer before next queued input", + "Background completion before next queued input", ]) expect(userTexts(requests[3]!)).toEqual([ "Start working", "Queue first", "Steer before next queued input", "Also steer before next queued input", + "Background completion before next queued input", "Queue second", ]) }), @@ -2406,8 +2440,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First steer" }) }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second steer" }) }) + yield* session.prompt({ sessionID, text: "First steer" }) + yield* session.prompt({ sessionID, text: "Second steer" }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2433,7 +2467,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover with this" }) }) + yield* session.prompt({ sessionID, text: "Recover with this" }) yield* Deferred.succeed(streamGate, undefined) expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure) @@ -2592,7 +2626,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setup yield* session.prompt({ sessionID, - prompt: PromptInput.Prompt.make({ text: "Wait in queue" }), + text: "Wait in queue", delivery: "queue", resume: false, }) @@ -2611,7 +2645,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service const defect = new Error("fail after prompt promotion") let fail = true - yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void)) + yield* events.project(SessionEvent.InputPromoted, () => (fail ? Effect.die(defect) : Effect.void)) yield* admit(session, "Recover promoted input") expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) @@ -2631,7 +2665,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setup const events = yield* EventV2.Service yield* events.listen((event) => - event.type === SessionEvent.PromptPromoted.type + event.type === SessionEvent.InputPromoted.type ? Effect.die("fail after prompt promotion commits") : Effect.void, ) @@ -2651,7 +2685,7 @@ describe("SessionRunnerLLM", () => { yield* admit(session, "Run first") yield* session.prompt({ sessionID: otherSessionID, - prompt: PromptInput.Prompt.make({ text: "Run second" }), + text: "Run second", resume: false, }) @@ -2686,12 +2720,12 @@ describe("SessionRunnerLLM", () => { yield* insertSession(otherLongSessionID) yield* session.prompt({ sessionID: longSessionID, - prompt: PromptInput.Prompt.make({ text: "Run long session" }), + text: "Run long session", resume: false, }) yield* session.prompt({ sessionID: otherLongSessionID, - prompt: PromptInput.Prompt.make({ text: "Run other long session" }), + text: "Run other long session", resume: false, }) @@ -3253,7 +3287,7 @@ describe("SessionRunnerLLM", () => { const run = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Change direction" }) }) + yield* session.prompt({ sessionID, text: "Change direction" }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(run) streamGate = undefined diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index f962672494..aeb0963660 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -9,7 +9,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/schema/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionTable } from "@opencode-ai/core/session/sql" @@ -84,13 +83,12 @@ const prompt = (sessionID: SessionV2.ID, text: string) => Effect.gen(function* () { const events = yield* EventV2.Service const messageID = SessionMessage.ID.create() - yield* events.publish(SessionEvent.PromptAdmitted, { + yield* events.publish(SessionEvent.InputAdmitted, { sessionID, inputID: messageID, - prompt: Prompt.make({ text }), - delivery: "steer", + input: { type: "user", data: { text }, delivery: "steer" }, }) - yield* events.publish(SessionEvent.PromptPromoted, { + yield* events.publish(SessionEvent.InputPromoted, { sessionID, inputID: messageID, }) diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 100d44cfe2..61b03b7484 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -137,7 +137,9 @@ test("Core reuses the canonical shared schemas", async () => { [SessionV2.Info, Session.Info], [SessionV2.ListAnchor, Session.ListAnchor], [coreSessionInput.Delivery, SessionInput.Delivery], - [coreSessionInput.Admitted, SessionInput.Admitted], + [coreSessionInput.Message, SessionInput.Message], + [coreSessionInput.User, SessionInput.User], + [coreSessionInput.Synthetic, SessionInput.Synthetic], [coreSessionMessage.ID, SessionMessage.ID], [coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry], [coreSessionMessage.AgentSelected, SessionMessage.AgentSelected], diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 55223a9daa..e8d770d00a 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Layer, Schema } from "effect" +import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect" import { Money } from "@opencode-ai/schema/money" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -16,6 +16,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionStore } from "@opencode-ai/core/session/store" @@ -263,6 +264,13 @@ describe("SubagentTool", () => { const locations = yield* LocationServiceMap.Service const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) + const events = yield* EventV2.Service + const admitted = yield* events.subscribe(SessionEvent.InputAdmitted).pipe( + Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ) const settled = yield* settleTool(registry, { sessionID: parent.id, @@ -277,7 +285,10 @@ describe("SubagentTool", () => { const childID = outputSessionID(settled.output?.structured) expect(settled.output?.structured).toMatchObject({ status: "running" }) - yield* Effect.yieldNow + const admission = Array.from(yield* Fiber.join(admitted))[0] + expect(admission?.data.input.data.text).toContain(` message.type === "synthetic") expect(synthetic).toHaveLength(1) expect(synthetic[0]?.text).toContain(`[0], sessionID = "ses_1") { + return { + admittedSeq: 1, + id: input.id ?? "msg_prompt", + sessionID, + type: "user" as const, + data: { + text: input.text, + files: input.files, + agents: input.agents, + metadata: input.metadata, + }, + delivery: input.delivery ?? ("steer" as const), + timeCreated: 2, + } +} + function footer() { const commits: StreamCommit[] = [] const events: FooterEvent[] = [] @@ -170,19 +187,8 @@ describe("V2 mini transport", () => { let admitted = false spyOn(client.session, "prompt").mockImplementation((request) => { - const messageID = request.id ?? "msg_prompt" - const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ - data: { - admittedSeq: 1, - id: messageID, - sessionID: "ses_1", - prompt, - delivery: "steer" as const, - timeCreated: 2, - }, - }) as never + return ok({ data: promptAdmission(request) }) as never }) const turn = transport.runPromptTurn({ @@ -197,7 +203,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -255,7 +261,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -270,16 +276,7 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_1" }, }) }) - return ok({ - data: { - admittedSeq: 1, - id: input.id ?? "msg_prompt", - sessionID: "ses_1", - prompt: input.prompt ?? { text: "" }, - delivery: "steer" as const, - timeCreated: 2, - }, - }) as never + return ok({ data: promptAdmission(input) }) as never }) await transport.runPromptTurn({ @@ -310,8 +307,8 @@ describe("V2 mini transport", () => { includeFiles: true, }) - expect(request?.prompt?.text).toBe("Review @note.ts and @docs") - expect(request?.prompt?.files).toEqual([ + expect(request?.text).toBe("Review @note.ts and @docs") + expect(request?.files).toEqual([ { uri: pathToFileURL(filePath).href, name: "note.ts", @@ -350,7 +347,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -365,16 +362,7 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_1" }, }) }) - return ok({ - data: { - admittedSeq: 1, - id: input.id ?? "msg_prompt", - sessionID: "ses_1", - prompt: input.prompt ?? { text: "" }, - delivery: "steer" as const, - timeCreated: 2, - }, - }) + return ok({ data: promptAdmission(input) }) }) await transport.runPromptTurn({ @@ -407,8 +395,8 @@ describe("V2 mini transport", () => { expect(remoteRead).not.toHaveBeenCalled() expect(remoteList).not.toHaveBeenCalled() - expect(request?.prompt?.text).toBe("Review @note.ts and @docs") - expect(request?.prompt?.files).toEqual([ + expect(request?.text).toBe("Review @note.ts and @docs") + expect(request?.files).toEqual([ { uri: "file:///remote/project/note.ts", name: "note.ts", @@ -448,7 +436,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -463,16 +451,7 @@ describe("V2 mini transport", () => { data: { sessionID: "ses_1" }, }) }) - return ok({ - data: { - admittedSeq: 1, - id: input.id ?? "msg_prompt", - sessionID: "ses_1", - prompt: input.prompt ?? { text: "" }, - delivery: "steer" as const, - timeCreated: 2, - }, - }) + return ok({ data: promptAdmission(input) }) }) await transport.runPromptTurn({ @@ -496,8 +475,8 @@ describe("V2 mini transport", () => { includeFiles: true, }) - expect(request?.prompt?.text).toBe("Review @diagram.png") - expect(request?.prompt?.files).toEqual([ + expect(request?.text).toBe("Review @diagram.png") + expect(request?.files).toEqual([ { name: "diagram.png", uri: pathToFileURL(filePath).href, @@ -589,12 +568,8 @@ describe("V2 mini transport", () => { // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(client.session, "prompt").mockImplementation((request) => { - const messageID = request.id ?? "msg_prompt" - const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ - data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, - }) + return ok({ data: promptAdmission(request) }) }) const turn = transport.runPromptTurn({ @@ -661,12 +636,8 @@ describe("V2 mini transport", () => { // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(client.session, "prompt").mockImplementation((request) => { - const messageID = request.id ?? "msg_prompt" - const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ - data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, - }) + return ok({ data: promptAdmission(request) }) }) const turn = transport.runPromptTurn({ @@ -899,12 +870,8 @@ describe("V2 mini transport", () => { // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(client.session, "prompt").mockImplementation((request) => { - const messageID = request.id ?? "msg_prompt" - const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ - data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, - }) + return ok({ data: promptAdmission(request) }) }) const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined)) @@ -956,12 +923,8 @@ describe("V2 mini transport", () => { // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(client.session, "prompt").mockImplementation((request) => { - const messageID = request.id ?? "msg_prompt" - const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ - data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, - }) + return ok({ data: promptAdmission(request) }) }) const turn = transport.runPromptTurn({ @@ -976,7 +939,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -1015,12 +978,8 @@ describe("V2 mini transport", () => { // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime spyOn(client.session, "prompt").mockImplementation((request) => { - const messageID = request.id ?? "msg_prompt" - const prompt = request.prompt ?? { text: "" } admitted = true - return ok({ - data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, - }) + return ok({ data: promptAdmission(request) }) }) const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined)) const controller = new AbortController() @@ -1037,7 +996,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -1464,7 +1423,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_prompted", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_1"), data: { sessionID: "ses_1", @@ -1483,7 +1442,8 @@ describe("V2 mini transport", () => { admittedSeq: 1, id: input.id ?? "msg_cmd", sessionID: "ses_1", - prompt: { text: "evaluated template" }, + type: "user" as const, + data: { text: "evaluated template" }, delivery: "steer" as const, timeCreated: 2, }) @@ -1862,13 +1822,12 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_admitted", created: 1, - type: "session.prompt.admitted", + type: "session.input.admitted", durable: durable("ses_child"), data: { sessionID: "ses_child", inputID: "msg_child_prompt", - prompt: { text: "actual child prompt" }, - delivery: "steer", + input: { type: "user", data: { text: "actual child prompt" }, delivery: "steer" }, }, }) await Bun.sleep(0) @@ -1881,7 +1840,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_promoted", created: 2, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_child", 1), data: { sessionID: "ses_child", inputID: "msg_child_prompt" }, }) @@ -1928,13 +1887,12 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_admitted_race", created: 1, - type: "session.prompt.admitted", + type: "session.input.admitted", durable: durable("ses_child"), data: { sessionID: "ses_child", inputID: "msg_child_race", - prompt: { text: "prompt admitted before hydration" }, - delivery: "steer", + input: { type: "user", data: { text: "prompt admitted before hydration" }, delivery: "steer" }, }, }) await Bun.sleep(0) @@ -1943,7 +1901,7 @@ describe("V2 mini transport", () => { events.push({ id: "evt_child_promoted_race", created: 2, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable("ses_child", 1), data: { sessionID: "ses_child", inputID: "msg_child_race" }, }) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index e595716873..255ee420ab 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -293,11 +293,12 @@ export const makeSessionGroup = (sessionLo params: { sessionID: Session.ID }, payload: Schema.Struct({ id: SessionMessage.ID.pipe(Schema.optional), - prompt: PromptInput.Prompt, + ...PromptInput.Prompt.fields, + metadata: SessionInput.UserData.fields.metadata, delivery: SessionInput.Delivery.pipe(Schema.optional), resume: Schema.Boolean.pipe(Schema.optional), }), - success: Schema.Struct({ data: SessionInput.Admitted }), + success: Schema.Struct({ data: SessionInput.User }), error: [ConflictError, InvalidRequestError, SessionNotFoundError], }) .middleware(sessionLocationMiddleware) @@ -323,7 +324,7 @@ export const makeSessionGroup = (sessionLo delivery: SessionInput.Delivery.pipe(Schema.optional), resume: Schema.Boolean.pipe(Schema.optional), }), - success: Schema.Struct({ data: SessionInput.Admitted }), + success: Schema.Struct({ data: SessionInput.User }), error: [ConflictError, InvalidRequestError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError], }) .middleware(sessionLocationMiddleware) @@ -360,20 +361,22 @@ export const makeSessionGroup = (sessionLo HttpApiEndpoint.post("session.synthetic", "/api/session/:sessionID/synthetic", { params: { sessionID: Session.ID }, payload: Schema.Struct({ + id: SessionMessage.ID.pipe(Schema.optional), text: Schema.String, description: Schema.String.pipe(Schema.optional), metadata: SessionMessage.Synthetic.fields.metadata, + delivery: SessionInput.Delivery.pipe(Schema.optional), resume: Schema.Boolean.pipe(Schema.optional), }), - success: HttpApiSchema.NoContent, - error: SessionNotFoundError, + success: Schema.Struct({ data: SessionInput.Synthetic }), + error: [ConflictError, SessionNotFoundError], }) .middleware(sessionLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.synthetic", summary: "Add synthetic message", - description: "Append a synthetic message to a session and resume execution.", + description: "Durably admit synthetic session input and schedule execution unless resume is false.", }), ), ) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 9c2417e053..7d6e17e315 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -5,10 +5,9 @@ import { optional } from "./schema.js" import { Event } from "./event.js" import { ToolContent } from "./llm.js" import { FinishReason } from "./llm.js" -import { Delivery } from "./session-delivery.js" import { Model } from "./model.js" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js" -import { FileAttachment, Prompt } from "./prompt.js" +import { FileAttachment } from "./prompt.js" import { SessionID } from "./session-id.js" import { Location } from "./location.js" import { SessionMessage } from "./session-message.js" @@ -20,6 +19,7 @@ import { Skill as SkillSchema } from "./skill.js" import { Money } from "./money.js" import { Snapshot } from "./snapshot.js" import { TokenUsage } from "./token-usage.js" +import { SessionInput } from "./session-input.js" export { FileAttachment } @@ -35,12 +35,6 @@ export interface Source extends Schema.Schema.Type {} const Base = { sessionID: SessionID, } -const PromptFields = { - ...Base, - inputID: SessionMessage.ID, - prompt: Prompt, - delivery: Delivery, -} const options = { durable: { @@ -120,22 +114,26 @@ export const Forked = Event.durable({ }) export type Forked = typeof Forked.Type -export const PromptPromoted = Event.durable({ - type: "session.prompt.promoted", +export const InputPromoted = Event.durable({ + type: "session.input.promoted", ...options, schema: { sessionID: SessionID, inputID: SessionMessage.ID, }, }) -export type PromptPromoted = typeof PromptPromoted.Type +export type InputPromoted = typeof InputPromoted.Type -export const PromptAdmitted = Event.durable({ - type: "session.prompt.admitted", +export const InputAdmitted = Event.durable({ + type: "session.input.admitted", ...options, - schema: PromptFields, + schema: { + ...Base, + inputID: SessionMessage.ID, + input: SessionInput.Message, + }, }) -export type PromptAdmitted = typeof PromptAdmitted.Type +export type InputAdmitted = typeof InputAdmitted.Type export namespace Execution { export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base }) @@ -523,8 +521,8 @@ export const Definitions = Event.inventory( UsageUpdated, Deleted, Forked, - PromptPromoted, - PromptAdmitted, + InputPromoted, + InputAdmitted, Execution.Started, Execution.Succeeded, Execution.Failed, diff --git a/packages/schema/src/session-input.ts b/packages/schema/src/session-input.ts index d1837ba08f..0b76d661e5 100644 --- a/packages/schema/src/session-input.ts +++ b/packages/schema/src/session-input.ts @@ -11,34 +11,70 @@ import { SessionMessage } from "./session-message.js" export const Delivery = SessionDelivery.Delivery export type Delivery = SessionDelivery.Delivery -export interface Admitted extends Schema.Schema.Type {} -export const Admitted = Schema.Struct({ +export interface UserData extends Schema.Schema.Type {} +export const UserData = Schema.Struct({ + ...Prompt.fields, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), +}).annotate({ identifier: "SessionInput.UserData" }) + +export interface SyntheticData extends Schema.Schema.Type {} +export const SyntheticData = Schema.Struct({ + text: Schema.String, + description: Schema.String.pipe(optional), + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), +}).annotate({ identifier: "SessionInput.SyntheticData" }) + +export interface UserMessage extends Schema.Schema.Type {} +export const UserMessage = Schema.Struct({ + type: Schema.tag("user"), + data: UserData, + delivery: Delivery, +}).annotate({ identifier: "SessionInput.UserMessage" }) + +export interface SyntheticMessage extends Schema.Schema.Type {} +export const SyntheticMessage = Schema.Struct({ + type: Schema.tag("synthetic"), + data: SyntheticData, + delivery: Delivery, +}).annotate({ identifier: "SessionInput.SyntheticMessage" }) + +export const Message = Schema.Union([UserMessage, SyntheticMessage]).pipe( + Schema.toTaggedUnion("type"), + Schema.annotate({ identifier: "SessionInput.Message" }), +) +export type Message = typeof Message.Type + +const Admitted = { admittedSeq: NonNegativeInt, id: SessionMessage.ID, sessionID: SessionID, - prompt: Prompt, - delivery: Delivery, timeCreated: DateTimeUtcFromMillis, +} +const MessageLifecycle = { + ...Admitted, promotedSeq: NonNegativeInt.pipe(optional), -}).annotate({ identifier: "SessionInput.Admitted" }) +} -export interface PromptEntry extends Schema.Schema.Type {} -export const PromptEntry = Schema.Struct({ - type: Schema.tag("prompt"), - ...Admitted.fields, -}).annotate({ identifier: "SessionInput.PromptEntry" }) +export interface User extends Schema.Schema.Type {} +export const User = Schema.Struct({ + ...MessageLifecycle, + ...UserMessage.fields, +}).annotate({ identifier: "SessionInput.User" }) + +export interface Synthetic extends Schema.Schema.Type {} +export const Synthetic = Schema.Struct({ + ...MessageLifecycle, + ...SyntheticMessage.fields, +}).annotate({ identifier: "SessionInput.Synthetic" }) export interface Compaction extends Schema.Schema.Type {} export const Compaction = Schema.Struct({ + ...Admitted, type: Schema.tag("compaction"), - admittedSeq: NonNegativeInt, - id: SessionMessage.ID, - sessionID: SessionID, - timeCreated: DateTimeUtcFromMillis, handledSeq: NonNegativeInt.pipe(optional), }).annotate({ identifier: "SessionInput.Compaction" }) -export const Info = Schema.Union([PromptEntry, Compaction]).pipe( +export const Info = Schema.Union([User, Synthetic, Compaction]).pipe( Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "SessionInput.Info" }), ) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index f5f310d8d7..23f6521da5 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -39,6 +39,13 @@ describe("contract hygiene", () => { expect(Schema.decodeUnknownSync(Value)({ value: "1" })).toEqual({ value: 1 }) expect(Schema.encodeSync(Value)({ value: 1 })).toEqual({ value: "1" }) expect(Schema.encodeSync(Value)({ value: undefined })).toEqual({}) + expect( + Schema.encodeSync(SessionInput.SyntheticData)({ + text: "completed", + description: undefined, + metadata: undefined, + }), + ).toEqual({ text: "completed" }) }) test("model defaults and provider overlays preserve public invariants", () => { @@ -92,6 +99,10 @@ describe("contract hygiene", () => { Pty.Info, Session.ListAnchor, Session.Revert, + SessionInput.UserData, + SessionInput.SyntheticData, + SessionInput.User, + SessionInput.Synthetic, ].map((schema) => schema.ast.annotations?.identifier) expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 971ac68f24..3c31d09c2b 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -107,8 +107,8 @@ describe("public event manifest", () => { "session.moved.1", "session.renamed.1", "session.forked.1", - "session.prompt.promoted.1", - "session.prompt.admitted.1", + "session.input.promoted.1", + "session.input.admitted.1", "session.execution.started.1", "session.execution.succeeded.1", "session.execution.failed.1", diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 9dfe7e2a14..482f8c9501 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -225,7 +225,7 @@ it.live( const active = yield* opencode.sessions.active() const admitted = yield* opencode.sessions.prompt({ sessionID: id, - prompt: fixture.sdk.PromptInput.Prompt.make({ text: "Do not run" }), + text: "Do not run", resume: false, }) const context = yield* opencode.sessions.context({ sessionID: id }) @@ -236,10 +236,10 @@ it.live( const remainingContextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id }) const wake = yield* opencode.sessions.prompt({ sessionID: id, - prompt: fixture.sdk.PromptInput.Prompt.make({ text: "Promote this input" }), + text: "Promote this input", }) const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe( - Stream.filter((event) => event.type === "session.prompt.promoted" && event.data.inputID === wake.id), + Stream.filter((event) => event.type === "session.input.promoted" && event.data.inputID === wake.id), Stream.runHead, Effect.timeout("10 seconds"), Effect.map(Option.getOrThrow), @@ -280,7 +280,7 @@ it.live( expect(page.data.some((session) => session.id === id)).toBe(true) expect(active).toEqual({}) expect(admitted.sessionID).toBe(id) - expect(prompted.type).toBe("session.prompt.promoted") + expect(prompted.type).toBe("session.input.promoted") expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" })) expect(contextEntries).toEqual([ { key: "deploy-target", value: "production" }, @@ -310,13 +310,13 @@ it.live( const opencode = yield* fixture.sdk.OpenCode.create() const id = sessionID(fixture) const connected = yield* Latch.make(false) - const prompted = yield* Deferred.make>() + const prompted = yield* Deferred.make>() yield* opencode.events.subscribe().pipe( Stream.runForEach((event) => event.type === "server.connected" ? connected.open - : event.type === "session.prompt.promoted" && event.data.sessionID === id + : event.type === "session.input.promoted" && event.data.sessionID === id ? Deferred.succeed(prompted, event).pipe(Effect.asVoid) : Effect.void, ), @@ -326,7 +326,7 @@ it.live( yield* opencode.sessions.create({ id, location: location(fixture) }) yield* opencode.sessions.prompt({ sessionID: id, - prompt: fixture.sdk.PromptInput.Prompt.make({ text: "Observe this input" }), + text: "Observe this input", }) const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds")) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 20a4fcd803..8999db0590 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -62,7 +62,7 @@ if (schemas) { visit({ ...document, components: { ...document.components, schemas: undefined } }) for (const name of Object.keys(schemas)) { if ( - /^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+$/.test( + /^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionInputPromoted|SessionInputAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+$/.test( name, ) && !reachable.has(name) @@ -102,7 +102,7 @@ await createClient({ const generatedTypesPath = "./src/v2/gen/types.gen.ts" const generatedTypes = await Bun.file(generatedTypesPath).text() if ( - /export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+ =/.test( + /export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionInputPromoted|SessionInputAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+ =/.test( generatedTypes, ) ) { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 54e5759ac4..d26e05ef23 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -146,7 +146,6 @@ import type { ProjectUpdateErrors, ProjectUpdateResponses, PromptAgentAttachment, - PromptInput, PromptInputFileAttachment, ProviderAuthErrors, ProviderAuthResponses, @@ -6165,7 +6164,12 @@ export class Session3 extends HeyApiClient { parameters: { sessionID: string id?: string | null - prompt?: PromptInput + text?: string + files?: Array + agents?: Array + metadata?: { + [key: string]: unknown + } delivery?: "steer" | "queue" | null resume?: boolean | null }, @@ -6178,7 +6182,10 @@ export class Session3 extends HeyApiClient { args: [ { in: "path", key: "sessionID" }, { in: "body", key: "id" }, - { in: "body", key: "prompt" }, + { in: "body", key: "text" }, + { in: "body", key: "files" }, + { in: "body", key: "agents" }, + { in: "body", key: "metadata" }, { in: "body", key: "delivery" }, { in: "body", key: "resume" }, ], @@ -6290,16 +6297,18 @@ export class Session3 extends HeyApiClient { /** * Add synthetic message * - * Append a synthetic message to a session and resume execution. + * Durably admit synthetic session input and schedule execution unless resume is false. */ public synthetic( parameters: { sessionID: string + id?: string | null text?: string description?: string | null metadata?: { [key: string]: unknown } + delivery?: "steer" | "queue" | null resume?: boolean | null }, options?: Options, @@ -6310,9 +6319,11 @@ export class Session3 extends HeyApiClient { { args: [ { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, { in: "body", key: "text" }, { in: "body", key: "description" }, { in: "body", key: "metadata" }, + { in: "body", key: "delivery" }, { in: "body", key: "resume" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e0bad3abc0..efa4f18d35 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -23,8 +23,8 @@ export type Event = | EventSessionRenamed | EventSessionUsageUpdated | EventSessionForked - | EventSessionPromptPromoted - | EventSessionPromptAdmitted + | EventSessionInputPromoted + | EventSessionInputAdmitted | EventSessionExecutionStarted | EventSessionExecutionSucceeded | EventSessionExecutionFailed @@ -599,12 +599,6 @@ export type Part = | RetryPart | CompactionPart -export type Prompt = { - text: string - files?: Array - agents?: Array -} - export type Shell = { id: string status: "running" | "exited" | "timeout" | "killed" @@ -856,7 +850,7 @@ export type GlobalEvent = { } | { id: string - type: "session.prompt.promoted" + type: "session.input.promoted" properties: { sessionID: string inputID: string @@ -864,12 +858,11 @@ export type GlobalEvent = { } | { id: string - type: "session.prompt.admitted" + type: "session.input.admitted" properties: { sessionID: string inputID: string - prompt: Prompt - delivery: "steer" | "queue" + input: SessionInputMessage } } | { @@ -1720,8 +1713,8 @@ export type GlobalEvent = { | SyncEventSessionMoved | SyncEventSessionRenamed | SyncEventSessionForked - | SyncEventSessionPromptPromoted - | SyncEventSessionPromptAdmitted + | SyncEventSessionInputPromoted + | SyncEventSessionInputAdmitted | SyncEventSessionExecutionStarted | SyncEventSessionExecutionSucceeded | SyncEventSessionExecutionFailed @@ -2876,12 +2869,6 @@ export type MessageNotFoundError = { message: string } -export type PromptInput = { - text: string - files?: Array - agents?: Array -} - export type ConflictError = { _tag: "ConflictError" message: string @@ -3058,8 +3045,8 @@ export type V2Event = | SessionRenamed | SessionUsageUpdated | SessionForked - | SessionPromptPromoted - | SessionPromptAdmitted + | SessionInputPromoted + | SessionInputAdmitted | SessionExecutionStarted | SessionExecutionSucceeded | SessionExecutionFailed @@ -3391,6 +3378,37 @@ export type PromptAgentAttachment = { mention?: PromptMention } +export type SessionInputUserData = { + text: string + files?: Array + agents?: Array + metadata?: { + [key: string]: unknown + } +} + +export type SessionInputUserMessage = { + type: "user" + data: SessionInputUserData + delivery: "steer" | "queue" +} + +export type SessionInputSyntheticData = { + text: string + description?: string + metadata?: { + [key: string]: unknown + } +} + +export type SessionInputSyntheticMessage = { + type: "synthetic" + data: SessionInputSyntheticData + delivery: "steer" | "queue" +} + +export type SessionInputMessage = SessionInputUserMessage | SessionInputSyntheticMessage + export type SessionStructuredError = { type: string message: string @@ -3799,11 +3817,11 @@ export type SyncEventSessionForked = { } } -export type SyncEventSessionPromptPromoted = { +export type SyncEventSessionInputPromoted = { type: "sync" id: string syncEvent: { - type: "session.prompt.promoted.1" + type: "session.input.promoted.1" id: string seq: number aggregateID: string @@ -3814,19 +3832,18 @@ export type SyncEventSessionPromptPromoted = { } } -export type SyncEventSessionPromptAdmitted = { +export type SyncEventSessionInputAdmitted = { type: "sync" id: string syncEvent: { - type: "session.prompt.admitted.1" + type: "session.input.admitted.1" id: string seq: number aggregateID: string data: { sessionID: string inputID: string - prompt: Prompt - delivery: "steer" | "queue" + input: SessionInputMessage } } } @@ -4459,22 +4476,34 @@ export type PromptInputFileAttachment = { mention?: PromptMention } -export type SessionInputAdmitted = { +export type SessionInputUser = { admittedSeq: number id: string sessionID: string - prompt: Prompt - delivery: "steer" | "queue" timeCreated: number promotedSeq?: number + type: "user" + data: SessionInputUserData + delivery: "steer" | "queue" +} + +export type SessionInputSynthetic = { + admittedSeq: number + id: string + sessionID: string + timeCreated: number + promotedSeq?: number + type: "synthetic" + data: SessionInputSyntheticData + delivery: "steer" | "queue" } export type SessionInputCompaction = { - type: "compaction" admittedSeq: number id: string sessionID: string timeCreated: number + type: "compaction" handledSeq?: number } @@ -4866,13 +4895,13 @@ export type SessionForked = { } } -export type SessionPromptPromoted = { +export type SessionInputPromoted = { id: string created: number metadata?: { [key: string]: unknown } - type: "session.prompt.promoted" + type: "session.input.promoted" durable: { aggregateID: string seq: number @@ -4885,13 +4914,13 @@ export type SessionPromptPromoted = { } } -export type SessionPromptAdmitted = { +export type SessionInputAdmitted = { id: string created: number metadata?: { [key: string]: unknown } - type: "session.prompt.admitted" + type: "session.input.admitted" durable: { aggregateID: string seq: number @@ -4901,8 +4930,7 @@ export type SessionPromptAdmitted = { data: { sessionID: string inputID: string - prompt: Prompt - delivery: "steer" | "queue" + input: SessionInputMessage } } @@ -5548,8 +5576,8 @@ export type SessionEventDurable = | SessionRenamed | SessionDeleted | SessionForked - | SessionPromptPromoted - | SessionPromptAdmitted + | SessionInputPromoted + | SessionInputAdmitted | SessionExecutionStarted | SessionExecutionSucceeded | SessionExecutionFailed @@ -7182,23 +7210,22 @@ export type EventSessionForked = { } } -export type EventSessionPromptPromoted = { +export type EventSessionInputPromoted = { id: string - type: "session.prompt.promoted" + type: "session.input.promoted" properties: { sessionID: string inputID: string } } -export type EventSessionPromptAdmitted = { +export type EventSessionInputAdmitted = { id: string - type: "session.prompt.admitted" + type: "session.input.admitted" properties: { sessionID: string inputID: string - prompt: Prompt - delivery: "steer" | "queue" + input: SessionInputMessage } } @@ -8644,8 +8671,8 @@ export type V2EventV2 = | SessionUsageUpdatedV2 | SessionDeletedV2 | SessionForkedV2 - | SessionPromptPromotedV2 - | SessionPromptAdmittedV2 + | SessionInputPromotedV2 + | SessionInputAdmittedV2 | SessionExecutionStartedV2 | SessionExecutionSucceededV2 | SessionExecutionFailedV2 @@ -8801,22 +8828,34 @@ export type SessionInfoV2 = { export type PromptBase64V2 = string -export type SessionInputAdmittedV2 = { +export type SessionInputUserV2 = { admittedSeq: number id: string sessionID: string - prompt: Prompt - delivery: "steer" | "queue" timeCreated: number promotedSeq?: number + type: "user" + data: SessionInputUserData + delivery: "steer" | "queue" +} + +export type SessionInputSyntheticV2 = { + admittedSeq: number + id: string + sessionID: string + timeCreated: number + promotedSeq?: number + type: "synthetic" + data: SessionInputSyntheticData + delivery: "steer" | "queue" } export type SessionInputCompactionV2 = { - type: "compaction" admittedSeq: number id: string sessionID: string timeCreated: number + type: "compaction" handledSeq?: number } @@ -9115,13 +9154,13 @@ export type SessionForkedV2 = { } } -export type SessionPromptPromotedV2 = { +export type SessionInputPromotedV2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "session.prompt.promoted" + type: "session.input.promoted" durable: { aggregateID: string seq: number @@ -9134,13 +9173,42 @@ export type SessionPromptPromotedV2 = { } } -export type SessionPromptAdmittedV2 = { +export type SessionInputUserData1 = { + text: string + files?: Array + agents?: Array + metadata?: { + [key: string]: unknown + } +} + +export type SessionInputUserMessageV2 = { + type: "user" + data: SessionInputUserData1 + delivery: "steer" | "queue" +} + +export type SessionInputSyntheticData1 = { + text: string + description?: string + metadata?: { + [key: string]: unknown + } +} + +export type SessionInputSyntheticMessageV2 = { + type: "synthetic" + data: SessionInputSyntheticData1 + delivery: "steer" | "queue" +} + +export type SessionInputAdmittedV2 = { id: string created: number metadata?: { [key: string]: unknown } - type: "session.prompt.admitted" + type: "session.input.admitted" durable: { aggregateID: string seq: number @@ -9150,8 +9218,7 @@ export type SessionPromptAdmittedV2 = { data: { sessionID: string inputID: string - prompt: Prompt - delivery: "steer" | "queue" + input: SessionInputMessage } } @@ -15672,7 +15739,12 @@ export type V2SessionMoveResponse = V2SessionMoveResponses[keyof V2SessionMoveRe export type V2SessionPromptData = { body: { id?: string | null - prompt: PromptInput + text: string + files?: Array + agents?: Array + metadata?: { + [key: string]: unknown + } delivery?: "steer" | "queue" | null resume?: boolean | null } @@ -15709,7 +15781,7 @@ export type V2SessionPromptResponses = { * Success */ 200: { - data: SessionInputAdmittedV2 + data: SessionInputUserV2 } } @@ -15764,7 +15836,7 @@ export type V2SessionCommandResponses = { * Success */ 200: { - data: SessionInputAdmittedV2 + data: SessionInputUserV2 } } @@ -15811,11 +15883,13 @@ export type V2SessionSkillResponse = V2SessionSkillResponses[keyof V2SessionSkil export type V2SessionSyntheticData = { body: { + id?: string | null text: string description?: string | null metadata?: { [key: string]: unknown } + delivery?: "steer" | "queue" | null resume?: boolean | null } path: { @@ -15838,15 +15912,21 @@ export type V2SessionSyntheticErrors = { * SessionNotFoundError */ 404: SessionNotFoundError + /** + * ConflictError + */ + 409: ConflictErrorV2 } export type V2SessionSyntheticError = V2SessionSyntheticErrors[keyof V2SessionSyntheticErrors] export type V2SessionSyntheticResponses = { /** - * + * Success */ - 204: void + 200: { + data: SessionInputSyntheticV2 + } } export type V2SessionSyntheticResponse = V2SessionSyntheticResponses[keyof V2SessionSyntheticResponses] diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index e3160cc231..8bb4694363 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -206,37 +206,39 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.move", Effect.fn(function* (ctx) { - yield* moveSession.moveSession({ - sessionID: ctx.params.sessionID, - destination: ctx.payload.destination, - moveChanges: ctx.payload.moveChanges, - }).pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), + yield* moveSession + .moveSession({ + sessionID: ctx.params.sessionID, + destination: ctx.payload.destination, + moveChanges: ctx.payload.moveChanges, + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), ), - ), - Effect.catchTag("MoveSession.DestinationProjectMismatchError", () => - Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })), - ), - Effect.catchTag("MoveSession.ApplyChangesError", () => - Effect.fail( - new InvalidRequestError({ - message: - "Unable to apply your changes in the destination directory. The files may conflict with existing changes.", - }), + Effect.catchTag("MoveSession.DestinationProjectMismatchError", () => + Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })), ), - ), - Effect.catchTag("MoveSession.CaptureChangesError", (error) => - Effect.fail(new InvalidRequestError({ message: error.message })), - ), - Effect.catchTag("MoveSession.ResetSourceChangesError", (error) => - Effect.fail(new InvalidRequestError({ message: error.message })), - ), - ) + Effect.catchTag("MoveSession.ApplyChangesError", () => + Effect.fail( + new InvalidRequestError({ + message: + "Unable to apply your changes in the destination directory. The files may conflict with existing changes.", + }), + ), + ), + Effect.catchTag("MoveSession.CaptureChangesError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message })), + ), + Effect.catchTag("MoveSession.ResetSourceChangesError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message })), + ), + ) return HttpApiSchema.NoContent.make() }), ) @@ -248,7 +250,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .prompt({ sessionID: ctx.params.sessionID, id: ctx.payload.id, - prompt: ctx.payload.prompt, + text: ctx.payload.text, + files: ctx.payload.files, + agents: ctx.payload.agents, + metadata: ctx.payload.metadata, delivery: ctx.payload.delivery, resume: ctx.payload.resume, }) @@ -270,7 +275,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl ), ), Effect.catchTag("Session.AttachmentError", (error) => - Effect.fail(new InvalidRequestError({ message: error.message, field: "prompt.files" })), + Effect.fail(new InvalidRequestError({ message: error.message, field: "files" })), ), ), } @@ -362,12 +367,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.synthetic", Effect.fn(function* (ctx) { - yield* session + const data = yield* session .synthetic({ + id: ctx.payload.id, sessionID: ctx.params.sessionID, text: ctx.payload.text, description: ctx.payload.description, metadata: ctx.payload.metadata, + delivery: ctx.payload.delivery, resume: ctx.payload.resume, }) .pipe( @@ -379,8 +386,16 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl }), ), ), + Effect.catchTag("Session.SyntheticConflictError", (error) => + Effect.fail( + new ConflictError({ + message: `Synthetic input ID conflicts with an existing durable record: ${error.inputID}`, + resource: error.inputID, + }), + ), + ), ) - return HttpApiSchema.NoContent.make() + return { data } }), ) .handle( diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index aea91edd6f..b256e6ac6b 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -286,8 +286,8 @@ export function Prompt(props: PromptProps) { last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write if (tokens <= 0) return - const model = data.location - .model.list(session.location) + const model = data.location.model + .list(session.location) ?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined const cost = session.cost @@ -1139,11 +1139,9 @@ export function Prompt(props: PromptProps) { const error = await sdk.api.session .prompt({ sessionID, - prompt: { - text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"), - files: store.prompt.files, - agents: store.prompt.agents, - }, + text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"), + files: store.prompt.files, + agents: store.prompt.agents, }) .then( () => undefined, diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 4249df8fa2..dce20ed355 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -315,19 +315,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath) } break - case "session.prompt.promoted": { + case "session.input.promoted": { message.update(event.data.sessionID, (draft, index) => { const position = index.get(event.data.inputID) if (position === undefined) return const existing = draft[position] - if (existing?.type === "user" && store.session.input[event.data.sessionID]?.includes(event.data.inputID)) { - existing.time.created = event.created - draft.splice(position, 1) - draft.push(existing) - index.clear() - draft.forEach((message, indexValue) => index.set(message.id, indexValue)) - return - } + if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return + existing.time.created = event.created + draft.splice(position, 1) + draft.push(existing) + index.clear() + draft.forEach((message, indexValue) => index.set(message.id, indexValue)) }) setStore( "session", @@ -337,21 +335,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) break } - case "session.prompt.admitted": + case "session.input.admitted": if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID)) setStore("session", "input", event.data.sessionID, [ ...(store.session.input[event.data.sessionID] ?? []), event.data.inputID, ]) message.update(event.data.sessionID, (draft, index) => { - message.append(draft, index, { - id: event.data.inputID, - type: "user", - text: event.data.prompt.text, - files: event.data.prompt.files, - agents: event.data.prompt.agents, - time: { created: event.created }, - }) + message.append( + draft, + index, + event.data.input.type === "user" + ? { + id: event.data.inputID, + type: "user", + ...event.data.input.data, + time: { created: event.created }, + } + : { + id: event.data.inputID, + type: "synthetic", + ...event.data.input.data, + time: { created: event.created }, + }, + ) }) break case "session.instructions.updated": @@ -1069,7 +1076,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ console.error("Failed to refresh default location data", failure.reason) const key = locationKey(defaultLocation()) const locations = new Map( - Object.values(store.session.info).map((session) => [locationKey(session.location), session.location] as const), + Object.values(store.session.info).map( + (session) => [locationKey(session.location), session.location] as const, + ), ) const refreshed = await Promise.allSettled( Array.from(locations) diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index fa69fedbb2..7b01a2e9dc 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -16,7 +16,7 @@ export type PastedText = { } } -export type PromptInfo = Types.DeepMutable & { +export type PromptInfo = Types.DeepMutable> & { pasted: PastedText[] mode?: "normal" | "shell" } diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index c8764f3030..a148f9fd20 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -75,7 +75,7 @@ export function createSessionRows(sessionID: Accessor) { on( () => data.session.message.list(sessionID()).flatMap((message) => - message.type === "user" + message.type === "user" || message.type === "synthetic" ? [ { id: message.id, @@ -156,7 +156,7 @@ export function createSessionRows(sessionID: Accessor) { const isPending = (messageID: string) => { const message = data.session.message.get(sessionID(), messageID) - if (message?.type === "user") return data.session.input.has(sessionID(), messageID) + if (message?.type === "user" || message?.type === "synthetic") return data.session.input.has(sessionID(), messageID) return message?.type === "compaction" && message.status === "running" } @@ -168,11 +168,21 @@ export function createSessionRows(sessionID: Accessor) { const message = (event: { id: string; data: { sessionID: string } }) => { if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_")) } - const input = (event: { data: { sessionID: string; inputID: string } }) => { - if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID) + const input = (event: { + data: { + sessionID: string + inputID: string + input: { type: "user" } | { type: "synthetic"; data: { description?: string } } + } + }) => { + if ( + event.data.sessionID === sessionID() && + (event.data.input.type === "user" || event.data.input.data.description?.trim()) + ) + appendMessage(event.data.inputID) } const subscriptions = [ - data.on("session.prompt.admitted", input), + data.on("session.input.admitted", input), data.on("session.compaction.started", message), data.on("session.instructions.updated", message), data.on("session.synthetic", (event) => { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index d6648ac2f6..5cdcb51e89 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -595,13 +595,12 @@ test("completes exploration when a queued prompt is promoted", async () => { emitEvent(events, { id: "evt_prompt_admitted", created: 3, - type: "session.prompt.admitted", + type: "session.input.admitted", durable: durable(sessionID, 2), data: { sessionID, inputID: "message-user", - prompt: { text: "Continue" }, - delivery: "steer", + input: { type: "user", data: { text: "Continue" }, delivery: "steer" }, }, }) await wait(() => rows.at(-1)?.type === "message") @@ -610,7 +609,7 @@ test("completes exploration when a queued prompt is promoted", async () => { emitEvent(events, { id: "evt_prompt_promoted", created: 4, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable(sessionID, 3), data: { sessionID, inputID: "message-user" }, }) @@ -651,9 +650,9 @@ test("removes committed revert messages from local state", async () => { emitEvent(events, { id: EventV2.ID.create(), created: seq, - type: "session.prompt.admitted", + type: "session.input.admitted", durable: durable(sessionID, seq), - data: { sessionID, inputID, prompt: { text: inputID }, delivery: "steer" }, + data: { sessionID, inputID, input: { type: "user", data: { text: inputID }, delivery: "steer" } }, }) } await wait(() => data.session.message.ids(sessionID).length === 3) @@ -1817,9 +1816,7 @@ test("reconciles all pending form requests when the event stream reconnects", as await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old") expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep") - requests = [ - { id: "frm_new", sessionID: "ses_new", title: "Input requested", mode: "form" as const, fields: [] }, - ] + requests = [{ id: "frm_new", sessionID: "ses_new", title: "Input requested", mode: "form" as const, fields: [] }] events.disconnect() await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new") @@ -2022,13 +2019,12 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn emitEvent(events, { id: "evt_admitted_1", created: 0, - type: "session.prompt.admitted", + type: "session.input.admitted", durable: durable(sessionID), data: { sessionID, inputID: messageID, - prompt: { text: "hello" }, - delivery: "steer", + input: { type: "user", data: { text: "hello" }, delivery: "steer" }, }, }) await wait(() => sync.session.message.list(sessionID)?.length === 1) @@ -2043,7 +2039,7 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn emitEvent(events, { id: "evt_prompted_1", created: 0, - type: "session.prompt.promoted", + type: "session.input.promoted", durable: durable(sessionID, 1), data: { sessionID, @@ -2051,8 +2047,8 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn }, }) - await wait(() => received.at(-1) === "session.prompt.promoted") - expect(received.slice(-2)).toEqual(["session.prompt.admitted", "session.prompt.promoted"]) + await wait(() => received.at(-1) === "session.input.promoted") + expect(received.slice(-2)).toEqual(["session.input.admitted", "session.input.promoted"]) unsubscribe() const message = sync.session.message.list(sessionID)?.[0] expect(message?.type).toBe("user")