refactor(session): effectify SessionPrompt service

Migrate SessionPrompt to the Effect service pattern (Interface, Service,
Layer, InstanceState, makeRuntime + async facades).

Key design decisions:

- Fiber-based cancellation replaces manual AbortController management.
  Effect.promise((signal) => ...) derives AbortSignals automatically;
  cancel() interrupts fibers and signals propagate to the AI SDK,
  shell processes, and tool execution.

- Deferred queue replaces Promise callback queue. Concurrent loop()
  callers get a Deferred that resolves when the running fiber finishes.
  On cancel or error, queued callers now receive proper errors instead
  of hanging forever.

- Separate loops/shells maps in InstanceState replace the single shared
  state object, with shell-to-loop handoff preserved: if callers queue
  a loop while a shell is running, shellE cleanup starts the loop.

- Heavy helper functions (createUserMessage, handleSubtask, shellImpl,
  resolveCommand, insertReminders, ensureTitle) stay as plain async
  functions called via Effect.promise, keeping the migration incremental.

- resolveTools and createStructuredOutputTool are unchanged (deeply tied
  to AI SDK tool callbacks).
This commit is contained in:
Kit Langton
2026-03-27 22:29:01 -04:00
parent 7e9b721e97
commit 5719a13cbf
4 changed files with 868 additions and 681 deletions
@@ -699,7 +699,7 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const params = c.req.valid("param")
SessionPrompt.assertNotBusy(params.sessionID)
await SessionPrompt.assertNotBusy(params.sessionID)
await Session.removeMessage({
sessionID: params.sessionID,
messageID: params.messageID,
+684 -678
View File
@@ -37,7 +37,6 @@ import { pathToFileURL, fileURLToPath } from "url"
import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error"
import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
@@ -49,6 +48,9 @@ import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncate"
import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process"
import { Deferred, Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -66,30 +68,452 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
const state = Instance.state(
() => {
const data: Record<
string,
{
abort: AbortController
callbacks: {
resolve(input: MessageV2.WithParts): void
reject(reason?: any): void
}[]
interface LoopEntry {
fiber?: Fiber.Fiber<MessageV2.WithParts, unknown>
queue: Deferred.Deferred<MessageV2.WithParts, unknown>[]
}
export interface Interface {
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void>
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
readonly loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts>
readonly shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts>
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionPrompt") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const status = yield* SessionStatus.Service
const scope = yield* Scope.Scope
const cache = yield* InstanceState.make(
Effect.fn("SessionPrompt.state")(function* () {
const loops = new Map<string, LoopEntry>()
const shells = new Map<string, Fiber.Fiber<MessageV2.WithParts, unknown>>()
yield* Effect.addFinalizer(() =>
Effect.forEach(
[...loops.values().flatMap((e) => e.fiber ? [e.fiber] : []), ...shells.values()],
(fiber) => Fiber.interrupt(fiber),
),
)
return { loops, shells }
}),
)
const assertNotBusy = Effect.fn("SessionPrompt.assertNotBusy")(function* (sessionID: SessionID) {
const s = yield* InstanceState.get(cache)
if (s.loops.has(sessionID) || s.shells.has(sessionID)) throw new Session.BusyError(sessionID)
})
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
log.info("cancel", { sessionID })
const s = yield* InstanceState.get(cache)
const loopEntry = s.loops.get(sessionID)
const shellEntry = s.shells.get(sessionID)
if (!loopEntry && !shellEntry) {
yield* status.set(sessionID, { type: "idle" })
return
}
> = {}
return data
},
async (current) => {
for (const item of Object.values(current)) {
item.abort.abort()
}
},
if (loopEntry) {
if (loopEntry.fiber) yield* Fiber.interrupt(loopEntry.fiber)
for (const d of loopEntry.queue) yield* Deferred.interrupt(d)
s.loops.delete(sessionID)
}
if (shellEntry) {
yield* Fiber.interrupt(shellEntry)
s.shells.delete(sessionID)
}
yield* status.set(sessionID, { type: "idle" })
})
const resolvePromptPartsE = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
return yield* Effect.promise(() => resolvePromptPartsImpl(template))
})
const promptE = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) {
const session = yield* Effect.promise(() => Session.get(input.sessionID))
yield* Effect.promise(() => SessionRevert.cleanup(session))
const message = yield* Effect.promise(() => createUserMessage(input))
yield* Effect.promise(() => Session.touch(input.sessionID))
const permissions: Permission.Ruleset = []
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
}
if (permissions.length > 0) {
session.permission = permissions
yield* Effect.promise(() => Session.setPermission({ sessionID: session.id, permission: permissions }))
}
if (input.noReply === true) return message
return yield* loopE({ sessionID: input.sessionID })
})
const runLoop = Effect.fn("SessionPrompt.run")(function* (sessionID: SessionID) {
let structured: unknown | undefined
let step = 0
const session = yield* Effect.promise(() => Session.get(sessionID))
while (true) {
yield* status.set(sessionID, { type: "busy" })
log.info("loop", { step, sessionID })
let msgs = yield* Effect.promise(() => MessageV2.filterCompacted(MessageV2.stream(sessionID)))
let lastUser: MessageV2.User | undefined
let lastAssistant: MessageV2.Assistant | undefined
let lastFinished: MessageV2.Assistant | undefined
let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i]
if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User
if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish)
lastFinished = msg.info as MessageV2.Assistant
if (lastUser && lastFinished) break
const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
if (task && !lastFinished) tasks.push(...task)
}
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
lastUser.id < lastAssistant.id
) {
log.info("exiting loop", { sessionID })
break
}
step++
if (step === 1)
yield* Effect.promise(() =>
ensureTitle({
session,
modelID: lastUser.model.modelID,
providerID: lastUser.model.providerID,
history: msgs,
}),
).pipe(Effect.ignore, Effect.forkIn(scope))
const model = yield* Effect.promise(() =>
Provider.getModel(lastUser!.model.providerID, lastUser!.model.modelID).catch((e) => {
if (Provider.ModelNotFoundError.isInstance(e)) {
const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : ""
Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({
message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`,
}).toObject(),
})
}
throw e
}),
)
const task = tasks.pop()
if (task?.type === "subtask") {
yield* Effect.promise((signal) =>
handleSubtask({ task, model, lastUser: lastUser!, sessionID, session, msgs, signal }),
)
continue
}
if (task?.type === "compaction") {
const result = yield* Effect.promise((signal) =>
SessionCompaction.process({
messages: msgs,
parentID: lastUser!.id,
abort: signal,
sessionID,
auto: task.auto,
overflow: task.overflow,
}),
)
if (result === "stop") break
continue
}
if (
lastFinished &&
lastFinished.summary !== true &&
(yield* Effect.promise(() => SessionCompaction.isOverflow({ tokens: lastFinished!.tokens, model })))
) {
yield* Effect.promise(() =>
SessionCompaction.create({ sessionID, agent: lastUser!.agent, model: lastUser!.model, auto: true }),
)
continue
}
const agent = yield* Effect.promise(() => Agent.get(lastUser!.agent))
if (!agent) {
const available = yield* Effect.promise(() =>
Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)),
)
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser!.agent}".${hint}` })
yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })
throw error
}
const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps
msgs = yield* Effect.promise(() => insertReminders({ messages: msgs, agent, session }))
const msg = yield* Effect.promise(() =>
Session.updateMessage({
id: MessageID.ascending(),
parentID: lastUser!.id,
role: "assistant",
mode: agent.name,
agent: agent.name,
variant: lastUser!.variant,
path: { cwd: Instance.directory, root: Instance.worktree },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: model.id,
providerID: model.providerID,
time: { created: Date.now() },
sessionID,
}),
)
const processor = yield* Effect.promise((signal) =>
Promise.resolve(SessionProcessor.create({
assistantMessage: msg as MessageV2.Assistant,
sessionID,
model,
abort: signal,
})),
)
const outcome: "break" | "continue" = yield* Effect.ensuring(
Effect.gen(function* () {
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
const tools = yield* Effect.promise(() =>
resolveTools({ agent, session, model, tools: lastUser!.tools, processor, bypassAgentCheck, messages: msgs }),
)
if (lastUser!.format?.type === "json_schema") {
tools["StructuredOutput"] = createStructuredOutputTool({
schema: lastUser!.format.schema,
onSuccess(output) {
structured = output
},
})
}
if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser!.id })
if (step > 1 && lastFinished) {
for (const m of msgs) {
if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue
for (const p of m.parts) {
if (p.type !== "text" || p.ignored || p.synthetic) continue
if (!p.text.trim()) continue
p.text = [
"<system-reminder>",
"The user sent the following message:",
p.text,
"",
"Please address this message and continue with your tasks.",
"</system-reminder>",
].join("\n")
}
}
}
yield* Effect.promise(() => Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }))
const [skills, env, instructions, modelMsgs] = yield* Effect.promise(() =>
Promise.all([
SystemPrompt.skills(agent),
SystemPrompt.environment(model),
InstructionPrompt.system(),
MessageV2.toModelMessages(msgs, model),
]),
)
const system = [...env, ...(skills ? [skills] : []), ...instructions]
const format = lastUser!.format ?? { type: "text" as const }
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
const result = yield* Effect.promise((signal) =>
processor.process({
user: lastUser!,
agent,
permission: session.permission,
abort: signal,
sessionID,
system,
messages: [
...modelMsgs,
...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : []),
],
tools,
model,
toolChoice: format.type === "json_schema" ? "required" : undefined,
}),
)
if (structured !== undefined) {
processor.message.structured = structured
processor.message.finish = processor.message.finish ?? "stop"
yield* Effect.promise(() => Session.updateMessage(processor.message))
return "break" as const
}
const finished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish)
if (finished && !processor.message.error) {
if (format.type === "json_schema") {
processor.message.error = new MessageV2.StructuredOutputError({
message: "Model did not produce structured output",
retries: 0,
}).toObject()
yield* Effect.promise(() => Session.updateMessage(processor.message))
return "break" as const
}
}
if (result === "stop") return "break" as const
if (result === "compact") {
yield* Effect.promise(() =>
SessionCompaction.create({
sessionID,
agent: lastUser!.agent,
model: lastUser!.model,
auto: true,
overflow: !processor.message.finish,
}),
)
}
return "continue" as const
}),
Effect.sync(() => InstructionPrompt.clear(processor.message.id)),
)
if (outcome === "break") break
continue
}
SessionCompaction.prune({ sessionID })
return yield* Effect.promise(async () => {
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user") continue
return item
}
throw new Error("Impossible")
})
})
type State = { loops: Map<string, LoopEntry>; shells: Map<string, Fiber.Fiber<MessageV2.WithParts, unknown>> }
const startLoop = Effect.fnUntraced(function* (s: State, sessionID: SessionID) {
const fiber = yield* runLoop(sessionID).pipe(
Effect.onExit((exit) =>
Effect.gen(function* () {
const entry = s.loops.get(sessionID)
if (entry) {
for (const d of entry.queue) yield* Deferred.done(d, exit)
}
s.loops.delete(sessionID)
yield* status.set(sessionID, { type: "idle" })
}),
),
Effect.forkChild,
)
const entry = s.loops.get(sessionID)
if (entry) {
// Queue already exists (created while shell was running) — attach fiber
entry.fiber = fiber
} else {
s.loops.set(sessionID, { fiber, queue: [] })
}
return yield* Fiber.join(fiber).pipe(Effect.orDie)
})
const loopE = Effect.fn("SessionPrompt.loop")(function* (input: z.infer<typeof LoopInput>) {
const s = yield* InstanceState.get(cache)
const existing = s.loops.get(input.sessionID)
if (existing) {
const d = yield* Deferred.make<MessageV2.WithParts, unknown>()
existing.queue.push(d)
return yield* Deferred.await(d).pipe(Effect.orDie)
}
// If a shell is running, queue — shell cleanup will start the loop
if (s.shells.has(input.sessionID)) {
const d = yield* Deferred.make<MessageV2.WithParts, unknown>()
s.loops.set(input.sessionID, { queue: [d] })
return yield* Deferred.await(d).pipe(Effect.orDie)
}
return yield* startLoop(s, input.sessionID)
})
const shellE = Effect.fn("SessionPrompt.shell")(function* (input: ShellInput) {
const s = yield* InstanceState.get(cache)
if (s.loops.has(input.sessionID) || s.shells.has(input.sessionID)) {
throw new Session.BusyError(input.sessionID)
}
const fiber = yield* Effect.promise((signal) => shellImpl(input, signal)).pipe(
Effect.ensuring(
Effect.gen(function* () {
const s = yield* InstanceState.get(cache)
s.shells.delete(input.sessionID)
// If callers queued a loop while the shell was running, start it
const pending = s.loops.get(input.sessionID)
if (pending && pending.queue.length > 0) {
yield* startLoop(s, input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
} else {
yield* status.set(input.sessionID, { type: "idle" })
}
}),
),
Effect.forkChild,
)
s.shells.set(input.sessionID, fiber)
return yield* Fiber.join(fiber).pipe(Effect.orDie)
})
const commandE = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) {
const resolved = yield* Effect.promise(() => resolveCommand(input))
const result = yield* promptE(resolved.promptInput)
yield* bus.publish(Command.Event.Executed, {
name: input.command,
sessionID: input.sessionID,
arguments: input.arguments,
messageID: result.info.id,
})
return result
})
return Service.of({
assertNotBusy,
cancel,
prompt: promptE,
loop: loopE,
shell: shellE,
command: commandE,
resolvePromptParts: resolvePromptPartsE,
})
}),
)
export function assertNotBusy(sessionID: SessionID) {
const match = state()[sessionID]
if (match) throw new Session.BusyError(sessionID)
const defaultLayer = layer.pipe(
Layer.provide(SessionStatus.layer),
Layer.provide(Bus.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function assertNotBusy(sessionID: SessionID) {
return runPromise((svc) => svc.assertNotBusy(sessionID))
}
export const PromptInput = z.object({
@@ -159,36 +583,78 @@ export namespace SessionPrompt {
})
export type PromptInput = z.infer<typeof PromptInput>
export const prompt = fn(PromptInput, async (input) => {
const session = await Session.get(input.sessionID)
await SessionRevert.cleanup(session)
export async function prompt(input: PromptInput) {
return runPromise((svc) => svc.prompt(input))
}
const message = await createUserMessage(input)
await Session.touch(input.sessionID)
export async function resolvePromptParts(template: string) {
return runPromise((svc) => svc.resolvePromptParts(template))
}
// this is backwards compatibility for allowing `tools` to be specified when
// prompting
const permissions: Permission.Ruleset = []
for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({
permission: tool,
action: enabled ? "allow" : "deny",
pattern: "*",
})
}
if (permissions.length > 0) {
session.permission = permissions
await Session.setPermission({ sessionID: session.id, permission: permissions })
}
export async function cancel(sessionID: SessionID) {
return runPromise((svc) => svc.cancel(sessionID))
}
if (input.noReply === true) {
return message
}
return loop({ sessionID: input.sessionID })
export const LoopInput = z.object({
sessionID: SessionID.zod,
})
export async function resolvePromptParts(template: string): Promise<PromptInput["parts"]> {
export async function loop(input: z.infer<typeof LoopInput>) {
return runPromise((svc) => svc.loop(input))
}
export const ShellInput = z.object({
sessionID: SessionID.zod,
agent: z.string(),
model: z
.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
})
.optional(),
command: z.string(),
})
export type ShellInput = z.infer<typeof ShellInput>
export async function shell(input: ShellInput) {
return runPromise((svc) => svc.shell(input))
}
export const CommandInput = z.object({
messageID: MessageID.zod.optional(),
sessionID: SessionID.zod,
agent: z.string().optional(),
model: z.string().optional(),
arguments: z.string(),
command: z.string(),
variant: z.string().optional(),
parts: z
.array(
z.discriminatedUnion("type", [
MessageV2.FilePart.omit({
messageID: true,
sessionID: true,
}).partial({
id: true,
}),
]),
)
.optional(),
})
export type CommandInput = z.infer<typeof CommandInput>
export async function command(input: CommandInput) {
return runPromise((svc) => svc.command(input))
}
async function lastModelImpl(sessionID: SessionID) {
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user" && item.info.model) return item.info.model
}
return Provider.defaultModel()
}
async function resolvePromptPartsImpl(template: string): Promise<PromptInput["parts"]> {
const parts: PromptInput["parts"] = [
{
type: "text",
@@ -239,533 +705,152 @@ export namespace SessionPrompt {
return parts
}
function start(sessionID: SessionID) {
const s = state()
if (s[sessionID]) return
const controller = new AbortController()
s[sessionID] = {
abort: controller,
callbacks: [],
async function handleSubtask(input: {
task: MessageV2.SubtaskPart
model: Provider.Model
lastUser: MessageV2.User
sessionID: SessionID
session: Session.Info
msgs: MessageV2.WithParts[]
signal: AbortSignal
}) {
const { task, model, lastUser, sessionID, session, msgs, signal } = input
const taskTool = await TaskTool.init()
const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model
const assistantMessage = (await Session.updateMessage({
id: MessageID.ascending(),
role: "assistant",
parentID: lastUser.id,
sessionID,
mode: task.agent,
agent: task.agent,
variant: lastUser.variant,
path: { cwd: Instance.directory, root: Instance.worktree },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: taskModel.id,
providerID: taskModel.providerID,
time: { created: Date.now() },
})) as MessageV2.Assistant
let part = (await Session.updatePart({
id: PartID.ascending(),
messageID: assistantMessage.id,
sessionID: assistantMessage.sessionID,
type: "tool",
callID: ulid(),
tool: TaskTool.id,
state: {
status: "running",
input: { prompt: task.prompt, description: task.description, subagent_type: task.agent, command: task.command },
time: { start: Date.now() },
},
})) as MessageV2.ToolPart
const taskArgs = {
prompt: task.prompt,
description: task.description,
subagent_type: task.agent,
command: task.command,
}
return controller.signal
}
function resume(sessionID: SessionID) {
const s = state()
if (!s[sessionID]) return
return s[sessionID].abort.signal
}
export async function cancel(sessionID: SessionID) {
log.info("cancel", { sessionID })
const s = state()
const match = s[sessionID]
if (!match) {
await SessionStatus.set(sessionID, { type: "idle" })
return
await Plugin.trigger("tool.execute.before", { tool: "task", sessionID, callID: part.id }, { args: taskArgs })
let executionError: Error | undefined
const taskAgent = await Agent.get(task.agent)
if (!taskAgent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` })
Bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })
throw error
}
match.abort.abort()
delete s[sessionID]
await SessionStatus.set(sessionID, { type: "idle" })
return
}
export const LoopInput = z.object({
sessionID: SessionID.zod,
resume_existing: z.boolean().optional(),
})
export const loop = fn(LoopInput, async (input) => {
const { sessionID, resume_existing } = input
const abort = resume_existing ? resume(sessionID) : start(sessionID)
if (!abort) {
return new Promise<MessageV2.WithParts>((resolve, reject) => {
const callbacks = state()[sessionID].callbacks
callbacks.push({ resolve, reject })
})
}
await using _ = defer(() => cancel(sessionID))
// Structured output state
// Note: On session resumption, state is reset but outputFormat is preserved
// on the user message and will be retrieved from lastUser below
let structuredOutput: unknown | undefined
let step = 0
const session = await Session.get(sessionID)
while (true) {
await SessionStatus.set(sessionID, { type: "busy" })
log.info("loop", { step, sessionID })
if (abort.aborted) break
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
let lastUser: MessageV2.User | undefined
let lastAssistant: MessageV2.Assistant | undefined
let lastFinished: MessageV2.Assistant | undefined
let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i]
if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User
if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish)
lastFinished = msg.info as MessageV2.Assistant
if (lastUser && lastFinished) break
const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
if (task && !lastFinished) {
tasks.push(...task)
}
}
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
if (
lastAssistant?.finish &&
![
"tool-calls",
// in v6 unknown became other but other existed in v5 too and was distinctly different
// I think there are certain providers that used to have bad stop reasons, not rlly sure which
// ones if any still have this?
// "unknown",
].includes(lastAssistant.finish) &&
lastUser.id < lastAssistant.id
) {
log.info("exiting loop", { sessionID })
break
}
step++
if (step === 1)
ensureTitle({
session,
modelID: lastUser.model.modelID,
providerID: lastUser.model.providerID,
history: msgs,
})
const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID).catch((e) => {
if (Provider.ModelNotFoundError.isInstance(e)) {
const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : ""
Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({
message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`,
}).toObject(),
})
}
throw e
})
const task = tasks.pop()
// pending subtask
// TODO: centralize "invoke tool" logic
if (task?.type === "subtask") {
const taskTool = await TaskTool.init()
const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model
const assistantMessage = (await Session.updateMessage({
id: MessageID.ascending(),
role: "assistant",
parentID: lastUser.id,
sessionID,
mode: task.agent,
agent: task.agent,
variant: lastUser.variant,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: taskModel.id,
providerID: taskModel.providerID,
time: {
created: Date.now(),
},
})) as MessageV2.Assistant
let part = (await Session.updatePart({
id: PartID.ascending(),
messageID: assistantMessage.id,
sessionID: assistantMessage.sessionID,
const taskCtx: Tool.Context = {
agent: task.agent,
messageID: assistantMessage.id,
sessionID,
abort: signal,
callID: part.callID,
extra: { bypassAgentCheck: true },
messages: msgs,
async metadata(val) {
part = (await Session.updatePart({
...part,
type: "tool",
callID: ulid(),
tool: TaskTool.id,
state: {
status: "running",
input: {
prompt: task.prompt,
description: task.description,
subagent_type: task.agent,
command: task.command,
},
time: {
start: Date.now(),
},
},
})) as MessageV2.ToolPart
const taskArgs = {
prompt: task.prompt,
description: task.description,
subagent_type: task.agent,
command: task.command,
}
await Plugin.trigger(
"tool.execute.before",
{
tool: "task",
sessionID,
callID: part.id,
},
{ args: taskArgs },
)
let executionError: Error | undefined
const taskAgent = await Agent.get(task.agent)
if (!taskAgent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID,
error: error.toObject(),
})
throw error
}
const taskCtx: Tool.Context = {
agent: task.agent,
messageID: assistantMessage.id,
sessionID: sessionID,
abort,
callID: part.callID,
extra: { bypassAgentCheck: true },
messages: msgs,
async metadata(input) {
part = (await Session.updatePart({
...part,
type: "tool",
state: {
...part.state,
...input,
},
} satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
},
async ask(req) {
await Permission.ask({
...req,
sessionID: sessionID,
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
})
},
}
const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => {
executionError = error
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return undefined
})
const attachments = result?.attachments?.map((attachment) => ({
...attachment,
id: PartID.ascending(),
state: { ...part.state, ...val },
} satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
},
async ask(req) {
await Permission.ask({
...req,
sessionID,
messageID: assistantMessage.id,
}))
await Plugin.trigger(
"tool.execute.after",
{
tool: "task",
sessionID,
callID: part.id,
args: taskArgs,
},
result,
)
assistantMessage.finish = "tool-calls"
assistantMessage.time.completed = Date.now()
await Session.updateMessage(assistantMessage)
if (result && part.state.status === "running") {
await Session.updatePart({
...part,
state: {
status: "completed",
input: part.state.input,
title: result.title,
metadata: result.metadata,
output: result.output,
attachments,
time: {
...part.state.time,
end: Date.now(),
},
},
} satisfies MessageV2.ToolPart)
}
if (!result) {
await Session.updatePart({
...part,
state: {
status: "error",
error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed",
time: {
start: part.state.status === "running" ? part.state.time.start : Date.now(),
end: Date.now(),
},
metadata: "metadata" in part.state ? part.state.metadata : undefined,
input: part.state.input,
},
} satisfies MessageV2.ToolPart)
}
if (task.command) {
// Add synthetic user message to prevent certain reasoning models from erroring
// If we create assistant messages w/ out user ones following mid loop thinking signatures
// will be missing and it can cause errors for models like gemini for example
const summaryUserMsg: MessageV2.User = {
id: MessageID.ascending(),
sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: lastUser.agent,
model: lastUser.model,
}
await Session.updateMessage(summaryUserMsg)
await Session.updatePart({
id: PartID.ascending(),
messageID: summaryUserMsg.id,
sessionID,
type: "text",
text: "Summarize the task tool output above and continue with your task.",
synthetic: true,
} satisfies MessageV2.TextPart)
}
continue
}
// pending compaction
if (task?.type === "compaction") {
const result = await SessionCompaction.process({
messages: msgs,
parentID: lastUser.id,
abort,
sessionID,
auto: task.auto,
overflow: task.overflow,
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
})
if (result === "stop") break
continue
}
// context overflow, needs compaction
if (
lastFinished &&
lastFinished.summary !== true &&
(await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model }))
) {
await SessionCompaction.create({
sessionID,
agent: lastUser.agent,
model: lastUser.model,
auto: true,
})
continue
}
// normal processing
const agent = await Agent.get(lastUser.agent)
if (!agent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID,
error: error.toObject(),
})
throw error
}
const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps
msgs = await insertReminders({
messages: msgs,
agent,
session,
})
const processor = await SessionProcessor.create({
assistantMessage: (await Session.updateMessage({
id: MessageID.ascending(),
parentID: lastUser.id,
role: "assistant",
mode: agent.name,
agent: agent.name,
variant: lastUser.variant,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: model.id,
providerID: model.providerID,
},
}
const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => {
executionError = error
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return undefined
})
const attachments = result?.attachments?.map((attachment) => ({
...attachment,
id: PartID.ascending(),
sessionID,
messageID: assistantMessage.id,
}))
await Plugin.trigger(
"tool.execute.after",
{ tool: "task", sessionID, callID: part.id, args: taskArgs },
result,
)
assistantMessage.finish = "tool-calls"
assistantMessage.time.completed = Date.now()
await Session.updateMessage(assistantMessage)
if (result && part.state.status === "running") {
await Session.updatePart({
...part,
state: {
status: "completed",
input: part.state.input,
title: result.title,
metadata: result.metadata,
output: result.output,
attachments,
time: { ...part.state.time, end: Date.now() },
},
} satisfies MessageV2.ToolPart)
}
if (!result) {
await Session.updatePart({
...part,
state: {
status: "error",
error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed",
time: {
created: Date.now(),
start: part.state.status === "running" ? part.state.time.start : Date.now(),
end: Date.now(),
},
sessionID,
})) as MessageV2.Assistant,
sessionID: sessionID,
model,
abort,
})
using _ = defer(() => InstructionPrompt.clear(processor.message.id))
// Check if user explicitly invoked an agent via @ in this turn
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
const tools = await resolveTools({
agent,
session,
model,
tools: lastUser.tools,
processor,
bypassAgentCheck,
messages: msgs,
})
// Inject StructuredOutput tool if JSON schema mode enabled
if (lastUser.format?.type === "json_schema") {
tools["StructuredOutput"] = createStructuredOutputTool({
schema: lastUser.format.schema,
onSuccess(output) {
structuredOutput = output
},
})
}
if (step === 1) {
SessionSummary.summarize({
sessionID: sessionID,
messageID: lastUser.id,
})
}
// Ephemerally wrap queued user messages with a reminder to stay on track
if (step > 1 && lastFinished) {
for (const msg of msgs) {
if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue
for (const part of msg.parts) {
if (part.type !== "text" || part.ignored || part.synthetic) continue
if (!part.text.trim()) continue
part.text = [
"<system-reminder>",
"The user sent the following message:",
part.text,
"",
"Please address this message and continue with your tasks.",
"</system-reminder>",
].join("\n")
}
}
}
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
// Build system prompt, adding structured output instruction if needed
const skills = await SystemPrompt.skills(agent)
const system = [
...(await SystemPrompt.environment(model)),
...(skills ? [skills] : []),
...(await InstructionPrompt.system()),
]
const format = lastUser.format ?? { type: "text" }
if (format.type === "json_schema") {
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
}
const result = await processor.process({
user: lastUser,
agent,
permission: session.permission,
abort,
metadata: "metadata" in part.state ? part.state.metadata : undefined,
input: part.state.input,
},
} satisfies MessageV2.ToolPart)
}
if (task.command) {
const summaryUserMsg: MessageV2.User = {
id: MessageID.ascending(),
sessionID,
system,
messages: [
...(await MessageV2.toModelMessages(msgs, model)),
...(isLastStep
? [
{
role: "assistant" as const,
content: MAX_STEPS,
},
]
: []),
],
tools,
model,
toolChoice: format.type === "json_schema" ? "required" : undefined,
})
// If structured output was captured, save it and exit immediately
// This takes priority because the StructuredOutput tool was called successfully
if (structuredOutput !== undefined) {
processor.message.structured = structuredOutput
processor.message.finish = processor.message.finish ?? "stop"
await Session.updateMessage(processor.message)
break
role: "user",
time: { created: Date.now() },
agent: lastUser.agent,
model: lastUser.model,
}
// Check if model finished (finish reason is not "tool-calls" or "unknown")
const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish)
if (modelFinished && !processor.message.error) {
if (format.type === "json_schema") {
// Model stopped without calling StructuredOutput tool
processor.message.error = new MessageV2.StructuredOutputError({
message: "Model did not produce structured output",
retries: 0,
}).toObject()
await Session.updateMessage(processor.message)
break
}
}
if (result === "stop") break
if (result === "compact") {
await SessionCompaction.create({
sessionID,
agent: lastUser.agent,
model: lastUser.model,
auto: true,
overflow: !processor.message.finish,
})
}
continue
await Session.updateMessage(summaryUserMsg)
await Session.updatePart({
id: PartID.ascending(),
messageID: summaryUserMsg.id,
sessionID,
type: "text",
text: "Summarize the task tool output above and continue with your task.",
synthetic: true,
} satisfies MessageV2.TextPart)
}
SessionCompaction.prune({ sessionID })
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user") continue
const queued = state()[sessionID]?.callbacks ?? []
for (const q of queued) {
q.resolve(item)
}
return item
}
throw new Error("Impossible")
})
async function lastModel(sessionID: SessionID) {
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user" && item.info.model) return item.info.model
}
return Provider.defaultModel()
}
/** @internal Exported for testing */
@@ -1004,7 +1089,7 @@ export namespace SessionPrompt {
throw error
}
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
const model = input.model ?? agent.model ?? (await lastModelImpl(input.sessionID))
const full =
!input.variant && agent.variant
? await Provider.getModel(model.providerID, model.modelID).catch(() => undefined)
@@ -1533,37 +1618,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return input.messages
}
export const ShellInput = z.object({
sessionID: SessionID.zod,
agent: z.string(),
model: z
.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
})
.optional(),
command: z.string(),
})
export type ShellInput = z.infer<typeof ShellInput>
export async function shell(input: ShellInput) {
const abort = start(input.sessionID)
if (!abort) {
throw new Session.BusyError(input.sessionID)
}
using _ = defer(() => {
// If no queued callbacks, cancel (the default)
const callbacks = state()[input.sessionID]?.callbacks ?? []
if (callbacks.length === 0) {
cancel(input.sessionID)
} else {
// Otherwise, trigger the session loop to process queued items
loop({ sessionID: input.sessionID, resume_existing: true }).catch((error) => {
log.error("session loop failed to resume after shell command", { sessionID: input.sessionID, error })
})
}
})
async function shellImpl(input: ShellInput, signal: AbortSignal): Promise<MessageV2.WithParts> {
const session = await Session.get(input.sessionID)
if (session.revert) {
await SessionRevert.cleanup(session)
@@ -1579,7 +1634,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
throw error
}
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
const model = input.model ?? agent.model ?? (await lastModelImpl(input.sessionID))
const userMsg: MessageV2.User = {
id: MessageID.ascending(),
sessionID: input.sessionID,
@@ -1647,9 +1702,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
},
}
await Session.updatePart(part)
const shell = Shell.preferred()
const sh = Shell.preferred()
const shellName = (
process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell)
process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)
).toLowerCase()
const invocations: Record<string, { args: string[] }> = {
@@ -1708,7 +1763,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ cwd, sessionID: input.sessionID, callID: part.callID },
{ env: {} },
)
const proc = spawn(shell, args, {
const proc = spawn(sh, args, {
cwd,
detached: process.platform !== "win32",
windowsHide: process.platform === "win32",
@@ -1749,7 +1804,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const kill = () => Shell.killTree(proc, { exited: () => exited })
if (abort.aborted) {
if (signal.aborted) {
aborted = true
await kill()
}
@@ -1759,12 +1814,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
void kill()
}
abort.addEventListener("abort", abortHandler, { once: true })
signal.addEventListener("abort", abortHandler, { once: true })
await new Promise<void>((resolve) => {
proc.on("close", () => {
exited = true
abort.removeEventListener("abort", abortHandler)
signal.removeEventListener("abort", abortHandler)
resolve()
})
})
@@ -1794,43 +1849,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return { info: msg, parts: [part] }
}
export const CommandInput = z.object({
messageID: MessageID.zod.optional(),
sessionID: SessionID.zod,
agent: z.string().optional(),
model: z.string().optional(),
arguments: z.string(),
command: z.string(),
variant: z.string().optional(),
parts: z
.array(
z.discriminatedUnion("type", [
MessageV2.FilePart.omit({
messageID: true,
sessionID: true,
}).partial({
id: true,
}),
]),
)
.optional(),
})
export type CommandInput = z.infer<typeof CommandInput>
const bashRegex = /!`([^`]+)`/g
// Match [Image N] as single token, quoted strings, or non-space sequences
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
/**
* Regular expression to match @ file references in text
* Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks
* Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references)
*/
export async function command(input: CommandInput) {
async function resolveCommand(input: CommandInput): Promise<{ promptInput: PromptInput }> {
log.info("command", input)
const command = await Command.get(input.command)
if (!command) {
const cmd = await Command.get(input.command)
if (!cmd) {
const available = await Command.list().then((cmds) => cmds.map((c) => c.name))
const hint = available.length ? ` Available commands: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` })
@@ -1840,12 +1868,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
throw error
}
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())
const agentName = cmd.agent ?? input.agent ?? (await Agent.defaultAgent())
const raw = input.arguments.match(argsRegex) ?? []
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
const templateCommand = await command.template
const templateCommand = await cmd.template
const placeholders = templateCommand.match(placeholderRegex) ?? []
let last = 0
@@ -1854,7 +1882,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (value > last) last = value
}
// Let the final placeholder swallow any extra arguments so prompts read naturally
const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
@@ -1865,8 +1892,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS")
let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
// If command doesn't explicitly handle arguments (no $N or $ARGUMENTS placeholders)
// but user provided arguments, append them to the template
if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
template = template + "\n\n" + input.arguments
}
@@ -1886,17 +1911,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
template = template.trim()
const taskModel = await (async () => {
if (command.model) {
return Provider.parseModel(command.model)
}
if (command.agent) {
const cmdAgent = await Agent.get(command.agent)
if (cmdAgent?.model) {
return cmdAgent.model
}
if (cmd.model) return Provider.parseModel(cmd.model)
if (cmd.agent) {
const cmdAgent = await Agent.get(cmd.agent)
if (cmdAgent?.model) return cmdAgent.model
}
if (input.model) return Provider.parseModel(input.model)
return await lastModel(input.sessionID)
return await lastModelImpl(input.sessionID)
})()
try {
@@ -1924,20 +1945,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
throw error
}
const templateParts = await resolvePromptParts(template)
const isSubtask = (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
const templateParts = await resolvePromptPartsImpl(template)
const isSubtask = (agent.mode === "subagent" && cmd.subtask !== false) || cmd.subtask === true
const parts = isSubtask
? [
{
type: "subtask" as const,
agent: agent.name,
description: command.description ?? "",
description: cmd.description ?? "",
command: input.command,
model: {
providerID: taskModel.providerID,
modelID: taskModel.modelID,
},
// TODO: how can we make task tool accept a more complex input?
model: { providerID: taskModel.providerID, modelID: taskModel.modelID },
prompt: templateParts.find((y) => y.type === "text")?.text ?? "",
},
]
@@ -1947,36 +1964,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const userModel = isSubtask
? input.model
? Provider.parseModel(input.model)
: await lastModel(input.sessionID)
: await lastModelImpl(input.sessionID)
: taskModel
await Plugin.trigger(
"command.execute.before",
{
command: input.command,
sessionID: input.sessionID,
arguments: input.arguments,
},
{ command: input.command, sessionID: input.sessionID, arguments: input.arguments },
{ parts },
)
const result = (await prompt({
sessionID: input.sessionID,
messageID: input.messageID,
model: userModel,
agent: userAgent,
parts,
variant: input.variant,
})) as MessageV2.WithParts
Bus.publish(Command.Event.Executed, {
name: input.command,
sessionID: input.sessionID,
arguments: input.arguments,
messageID: result.info.id,
})
return result
return {
promptInput: {
sessionID: input.sessionID,
messageID: input.messageID,
model: userModel,
agent: userAgent,
parts,
variant: input.variant,
},
}
}
async function ensureTitle(input: {
+2 -2
View File
@@ -21,7 +21,7 @@ export namespace SessionRevert {
export type RevertInput = z.infer<typeof RevertInput>
export async function revert(input: RevertInput) {
SessionPrompt.assertNotBusy(input.sessionID)
await SessionPrompt.assertNotBusy(input.sessionID)
const all = await Session.messages({ sessionID: input.sessionID })
let lastUser: MessageV2.User | undefined
const session = await Session.get(input.sessionID)
@@ -80,7 +80,7 @@ export namespace SessionRevert {
export async function unrevert(input: { sessionID: SessionID }) {
log.info("unreverting", input)
SessionPrompt.assertNotBusy(input.sessionID)
await SessionPrompt.assertNotBusy(input.sessionID)
const session = await Session.get(input.sessionID)
if (!session.revert) return session
if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot)
@@ -0,0 +1,181 @@
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionStatus } from "../../src/session/status"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
// Helper: seed a session with a user message + finished assistant message
// so loop() exits immediately without calling any LLM
async function seed(sessionID: SessionID) {
const userMsg: MessageV2.Info = {
id: MessageID.ascending(),
role: "user",
sessionID,
time: { created: Date.now() },
agent: "build",
model: { providerID: "openai" as any, modelID: "gpt-5.2" as any },
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: PartID.ascending(),
messageID: userMsg.id,
sessionID,
type: "text",
text: "hello",
})
const assistantMsg: MessageV2.Info = {
id: MessageID.ascending(),
role: "assistant",
parentID: userMsg.id,
sessionID,
mode: "build",
agent: "build",
cost: 0,
path: { cwd: "/tmp", root: "/tmp" },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: "gpt-5.2" as any,
providerID: "openai" as any,
time: { created: Date.now(), completed: Date.now() },
finish: "stop",
}
await Session.updateMessage(assistantMsg)
await Session.updatePart({
id: PartID.ascending(),
messageID: assistantMsg.id,
sessionID,
type: "text",
text: "hi there",
})
return { userMsg, assistantMsg }
}
describe("session.prompt concurrency", () => {
test("loop returns assistant message and sets status to idle", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
await seed(session.id)
const result = await SessionPrompt.loop({ sessionID: session.id })
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
const status = await SessionStatus.get(session.id)
expect(status.type).toBe("idle")
},
})
})
test("concurrent loop callers get the same result", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
await seed(session.id)
const [a, b] = await Promise.all([
SessionPrompt.loop({ sessionID: session.id }),
SessionPrompt.loop({ sessionID: session.id }),
])
expect(a.info.id).toBe(b.info.id)
expect(a.info.role).toBe("assistant")
},
})
})
test("assertNotBusy throws when loop is running", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
await seed(session.id)
// Start loop — it'll finish fast but we can race assertNotBusy
const loopPromise = SessionPrompt.loop({ sessionID: session.id })
// Give the loop fiber a tick to register in the map
await new Promise((r) => setTimeout(r, 5))
// The loop may already be done on fast machines, so this test
// verifies the mechanism works — if the loop is still running,
// assertNotBusy should throw
const busyCheck = SessionPrompt.assertNotBusy(session.id).then(
() => "not-busy",
(e) => (e instanceof Session.BusyError ? "busy" : "other-error"),
)
await loopPromise
// After loop completes, assertNotBusy should succeed
await SessionPrompt.assertNotBusy(session.id)
},
})
})
test("cancel sets status to idle", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
// Don't seed an assistant message — loop will try to call Provider.getModel
// and hang. We cancel before it can fail.
const userMsg: MessageV2.Info = {
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
time: { created: Date.now() },
agent: "build",
model: { providerID: "openai" as any, modelID: "gpt-5.2" as any },
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: PartID.ascending(),
messageID: userMsg.id,
sessionID: session.id,
type: "text",
text: "hello",
})
// Start loop — it will try to get model and either fail or hang
const loopPromise = SessionPrompt.loop({ sessionID: session.id }).catch(() => undefined)
// Give it a tick to start
await new Promise((r) => setTimeout(r, 10))
await SessionPrompt.cancel(session.id)
const status = await SessionStatus.get(session.id)
expect(status.type).toBe("idle")
// Wait for the loop to settle
await loopPromise
},
})
}, 10000)
test("cancel on idle session just sets idle", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
await SessionPrompt.cancel(session.id)
const status = await SessionStatus.get(session.id)
expect(status.type).toBe("idle")
},
})
})
})