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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/app/src/composer/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export type ComposerSession = {
data: {
location: { command: Pick<Data["location"]["command"], "list"> }
session: {
mutate: <T>(
sessionID: string,
operation: (mutation: {
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
}) => Promise<T>,
) => Promise<T>
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
setStatus: Data["session"]["setStatus"]
}
Expand Down
46 changes: 46 additions & 0 deletions packages/app/src/composer/submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ function session(input: {
location: { command: { list: () => [] } },
session: {
setStatus: (_sessionID, status) => input.statuses?.push(status),
mutate: async (_sessionID, operation) =>
operation({
prompt: async (value) => {
input.calls.push("prompt")
await input.prompt(value)
},
}),
prompt: async (value) => {
input.calls.push("prompt")
await input.prompt(value)
Expand Down Expand Up @@ -143,6 +150,45 @@ describe("Composer submission", () => {
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
})

test("reserves prompt admission before later session mutations", async () => {
const state = createMemoryComposerState({ prompt: "replace history" }).capture()
const calls: string[] = []
const target = session({
calls,
current: () => ({ agent: "build", model: { id: "model-1", providerID: "provider-1", variant: "balanced" } }),
prompt: async () => undefined,
})
const prompt = target.data.session.prompt
let previous = Promise.resolve<unknown>(undefined)
target.data.session.mutate = (sessionID, operation) => {
const request = previous.then(() => operation({ prompt }))
previous = request.then(
() => undefined,
() => undefined,
)
return request
}
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}

const submitted = submitInput(adapter).submit(new Event("submit"))
const redo = target.data.session.mutate(target.id, async () => {
calls.push("redo")
})
await Promise.all([submitted, redo])

expect(calls).toEqual(["prompt", "redo"])
})

test("starts and promotes a New Session once before admitting its first prompt", async () => {
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
const promoted = createMemoryComposerState({ prompt: "restored draft" }).capture()
Expand Down
88 changes: 45 additions & 43 deletions packages/app/src/composer/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,53 +303,55 @@ async function sendCommand(
})
}

async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
return session.data.session.mutate(session.id, async (mutation) => {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,

const admission = {
id: value.id,
sessionID: session.id,
delivery: value.delivery,
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
skills: request.skills,
metadata: {
displayText: request.displayText,
comments: request.comments,
agent: value.selection.agent,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
...value.selection.model,
...(value.selection.variant ? { variant: value.selection.variant } : {}),
},
})
}
}

const admission = {
id: value.id,
sessionID: session.id,
delivery: value.delivery,
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
skills: request.skills,
metadata: {
displayText: request.displayText,
comments: request.comments,
agent: value.selection.agent,
model: {
...value.selection.model,
...(value.selection.variant ? { variant: value.selection.variant } : {}),
},
},
}
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
}
await mutation.prompt(admission).catch(() => mutation.prompt(admission))
})
}

async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
Expand Down
10 changes: 10 additions & 0 deletions packages/app/src/new-session/composer-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ export function createNewSessionComposerAdapter(props: {
location: data.location,
session: {
setStatus: data.session.setStatus,
mutate: (sessionID, operation) =>
data.session.mutate(sessionID, (mutation) =>
operation({
prompt: (input) =>
mutation.prompt({
...input,
gate: Promise.all([input.gate, afterCreation(async () => undefined)]),
}),
}),
),
prompt: (input) =>
data.session.prompt({
...input,
Expand Down
46 changes: 26 additions & 20 deletions packages/app/src/session/composer/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,25 +53,31 @@ export function createSessionQueue(input: {
delivery: ComposerDelivery
},
) => {
if (change.type === "reorder") return rewrite(change.inboxIDs)
const replacement = await editedPromptInput(
input.sessionID,
location().directory,
change.item,
change.prompt,
change.text,
)
// Admit before cancelling so a failed replacement never discards the original.
const admitted = await data.session.prompt({
...replacement,
id: change.replacement,
delivery: change.delivery,
...(change.delivery === "queue" ? { resume: false } : {}),
if (change.type === "reorder")
return data.session.mutate(input.sessionID, (reservation) => rewrite(change.inboxIDs, reservation.prompt))
return data.session.mutate(input.sessionID, async (reservation) => {
const replacement = await editedPromptInput(
input.sessionID,
location().directory,
change.item,
change.prompt,
change.text,
)
// Admit before cancelling so a failed replacement never discards the original.
const admitted = await reservation.prompt({
...replacement,
id: change.replacement,
delivery: change.delivery,
...(change.delivery === "queue" ? { resume: false } : {}),
})
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.original })
cancelEdit()
if (change.delivery === "queue")
await rewrite(
change.inboxIDs.map((id) => (id === change.original ? admitted.id : id)),
reservation.prompt,
)
})
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.original })
cancelEdit()
if (change.delivery === "queue")
await rewrite(change.inboxIDs.map((id) => (id === change.original ? admitted.id : id)))
},
onError: notify,
onSettled: () => data.session.pending.sync(input.sessionID).catch(() => undefined),
Expand All @@ -97,7 +103,7 @@ export function createSessionQueue(input: {
})
onCleanup(() => cancelEdit())

const rewrite = async (inboxIDs: string[]) => {
const rewrite = async (inboxIDs: string[], admit: typeof data.session.prompt) => {
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
if (pending.some((item) => item.delivery === "queue" && item.type !== "user"))
throw new Error("Queued control items block reordering")
Expand All @@ -109,7 +115,7 @@ export function createSessionQueue(input: {

// Existing inbox APIs cannot reorder rows, so replace only the changed suffix.
for (const item of ordered.slice(changed)) {
await data.session.prompt({
await admit({
sessionID: input.sessionID,
text: item.payload.text,
files: item.payload.files?.map((file) => ({
Expand Down
8 changes: 4 additions & 4 deletions packages/app/src/session/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ describe("session controller invariants", () => {
})

test("selects user history strictly before the revert boundary", () => {
const messages: SessionMessageInfo[] = [user("msg_a"), assistant, user("msg_b"), user("msg_c")]
const messages: SessionMessageInfo[] = [user("msg_fff"), assistant, user("msg_000"), user("msg_zzz")]
const users = selectSessionUserMessages(messages)

expect(users.map((message) => message.id)).toEqual(["msg_a", "msg_b", "msg_c"])
expect(selectVisibleSessionUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_a"])
expect(selectVisibleSessionUserMessages(users.slice(2), "msg_b")).toEqual([])
expect(users.map((message) => message.id)).toEqual(["msg_fff", "msg_000", "msg_zzz"])
expect(selectVisibleSessionUserMessages(users, "msg_000").map((message) => message.id)).toEqual(["msg_fff"])
expect(selectVisibleSessionUserMessages(users.slice(2), "msg_000")).toEqual([])
expect(selectVisibleSessionUserMessages(users)).toBe(users)
})

Expand Down
69 changes: 69 additions & 0 deletions packages/app/src/session/revert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { expect, test } from "bun:test"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { loadRevertBoundary, loadUndoTarget } from "./session-domain"

const user = (id: string): SessionMessageUser => ({ id, type: "user", text: id, time: { created: 1 } })

test("loads older pages until the revert boundary is available", async () => {
const messages = [user("msg_newest")]
const pages = [[user("msg_middle")], [user("msg_before"), user("msg_boundary")]]
let loads = 0

const result = await loadRevertBoundary({
messageID: "msg_boundary",
messages: () => messages,
more: () => pages.length > 0,
loadMore: async () => {
messages.unshift(...(pages.shift() ?? []))
loads += 1
},
})

expect(loads).toBe(2)
expect(result?.map((message) => message.id)).toEqual(["msg_before", "msg_boundary", "msg_middle", "msg_newest"])
})

test("stops when the revert boundary is not available", async () => {
const messages = [user("msg_newest")]

expect(
await loadRevertBoundary({
messageID: "msg_boundary",
messages: () => messages,
more: () => false,
loadMore: async () => undefined,
}),
).toBeUndefined()
})

test("loads older pages before selecting the next undo target", async () => {
const messages = [user("msg_newest")]
const pages = [[user("msg_previous"), user("msg_boundary")]]

const result = await loadUndoTarget({
messageID: "msg_boundary",
messages: () => messages,
more: () => pages.length > 0,
loadMore: async () => {
messages.unshift(...(pages.shift() ?? []))
},
})

expect(result).toEqual({ message: user("msg_previous"), previous: undefined })
})

test("loads past a page-leading boundary to resolve the undo target and its predecessor", async () => {
const messages = [user("msg_boundary"), user("msg_newest")]
const pages = [[user("msg_previous"), user("msg_target")]]

const result = await loadUndoTarget({
messageID: "msg_boundary",
messages: () => messages,
more: () => pages.length > 0,
loadMore: async () => {
messages.unshift(...(pages.shift() ?? []))
},
})

expect(result).toEqual({ message: user("msg_target"), previous: user("msg_previous") })
})
Loading
Loading