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
9 changes: 5 additions & 4 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useSync } from "@tui/context/sync"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { Log } from "@/util/log"
import { usePromptHistory, type PromptInfo } from "./history"
import { assign } from "./part"
import { usePromptStash } from "./stash"
Expand Down Expand Up @@ -277,7 +278,7 @@ export function Prompt(props: PromptProps) {
if (store.interrupt >= 2) {
sdk.client.session.abort({
sessionID: props.sessionID,
})
}).catch(() => {})
setStore("interrupt", 0)
}
dialog.clear()
Expand Down Expand Up @@ -608,10 +609,10 @@ export function Prompt(props: PromptProps) {
})

if (res.error) {
console.log("Creating a session failed:", res.error)
Log.Default.error("session creation failed", { error: res.error })

toast.show({
message: "Creating a session failed. Open console for more details.",
message: "Creating a session failed",
variant: "error",
})

Expand Down Expand Up @@ -687,7 +688,7 @@ export function Prompt(props: PromptProps) {
id: PartID.ascending(),
...x,
})),
})
}).catch(() => {})
} else {
sdk.client.session
.prompt({
Expand Down
25 changes: 16 additions & 9 deletions packages/opencode/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Bus } from "../../bus"
import { NamedError } from "@opencode-ai/util/error"
import { Instance } from "@/project/instance"

const log = Log.create({ service: "server" })

Expand Down Expand Up @@ -194,7 +195,7 @@ export const SessionRoutes = lazy(() =>
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
operationId: "session.create",
responses: {
...errors(400),
...errors(400, 409),
200: {
description: "Successfully created session",
content: {
Expand Down Expand Up @@ -381,7 +382,7 @@ export const SessionRoutes = lazy(() =>
}),
),
async (c) => {
await SessionPrompt.cancel(c.req.valid("param").sessionID)
await SessionPrompt.cancel(c.req.valid("param").sessionID).catch(() => {})
return c.json(true)
},
)
Expand Down Expand Up @@ -843,19 +844,25 @@ export const SessionRoutes = lazy(() =>
),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
async (c) => {
c.status(204)
c.header("Content-Type", "application/json")
return stream(c, async () => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const run = Instance.bind(() => {
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err })
log.error("prompt_async failed", {
sessionID,
error: err,
stack: err instanceof Error ? err.stack : undefined,
})
const error = MessageV2.fromError(err, { providerID: "unknown" as any })
Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
error,
})
})
})
run()
c.status(204)
return c.body(null)
},
)
.post(
Expand Down
33 changes: 32 additions & 1 deletion packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,10 @@ export namespace MessageV2 {
) {
continue
}
// Skip incomplete assistant messages (no finish, no error, and no meaningful parts)
if (!msg.info.finish && !msg.info.error && !msg.parts.some((part) => part.type !== "step-start")) {
continue
}
const assistantMessage: UIMessage = {
id: msg.info.id,
role: "assistant",
Expand Down Expand Up @@ -972,7 +976,7 @@ export namespace MessageV2 {
},
{ cause: e },
).toObject()
case APICallError.isInstance(e):
case APICallError.isInstance(e): {
const parsed = ProviderError.parseAPICallError({
providerID: ctx.providerID,
error: e,
Expand All @@ -998,6 +1002,33 @@ export namespace MessageV2 {
},
{ cause: e },
).toObject()
}
case e instanceof DOMException && e.name === "TimeoutError":
return new MessageV2.APIError(
{
message: "Request timed out",
isRetryable: true,
},
{ cause: e },
).toObject()
case e instanceof Error &&
"code" in e &&
typeof (e as SystemError).code === "string" &&
["ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "EPIPE", "ECONNABORTED", "EHOSTUNREACH"].includes(
(e as SystemError).code ?? "",
):
return new MessageV2.APIError(
{
message: `Network error: ${(e as SystemError).message}`,
isRetryable: true,
metadata: {
code: (e as SystemError).code ?? "",
syscall: (e as SystemError).syscall ?? "",
message: (e as SystemError).message ?? "",
},
},
{ cause: e },
).toObject()
case e instanceof Error:
return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
default:
Expand Down
9 changes: 8 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,14 @@ export namespace SessionProcessor {
})

const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
log.error("process", { error: e, stack: e instanceof Error ? e.stack : undefined })
log.error("process", {
error: e,
stack: e instanceof Error ? e.stack : undefined,
providerID: ctx.assistantMessage.providerID,
modelID: ctx.assistantMessage.modelID,
sessionID: ctx.sessionID,
agent: ctx.assistantMessage.agent,
})
const error = parse(e)
if (MessageV2.ContextOverflowError.isInstance(error)) {
ctx.needsCompaction = true
Expand Down
72 changes: 65 additions & 7 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,7 +946,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})

const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
const agentName = input.agent || (yield* agents.defaultAgent())
const agentName = input.agent ?? (yield* lastPrimaryAgent(input.sessionID))
const ag = yield* agents.get(agentName)
if (!ag) {
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
Expand Down Expand Up @@ -1308,6 +1308,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID)
yield* Effect.promise(() => SessionRevert.cleanup(session))

const text = input.parts.find((part): part is MessageV2.TextPart => part.type === "text" && !part.synthetic)
const slash = text?.text.trim()
if (slash?.startsWith("/") && !slash.startsWith("//")) {
const body = slash.slice(1)
const at = body.search(/\s/)
const cmd = at === -1 ? body : body.slice(0, at)
const args = at === -1 ? "" : body.slice(at + 1).trim()
return yield* command({
sessionID: input.sessionID,
messageID: input.messageID,
agent: input.agent,
model: input.model ? `${input.model.providerID}/${input.model.modelID}` : undefined,
variant: input.variant,
command: cmd,
arguments: args,
parts: input.parts.filter((part) => part.type === "file"),
})
}

const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)

Expand Down Expand Up @@ -1372,12 +1392,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
// Keep the loop running so tool results can be sent back to the model.
const hasToolCalls = lastAssistantMsg?.parts.some((part) => part.type === "tool") ?? false

if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastUser.id < lastAssistant.id
) {
if (shouldExitLoop(lastUser, lastAssistant, lastFinished, hasToolCalls)) {
log.info("exiting loop", { sessionID })
break
}
Expand Down Expand Up @@ -1873,6 +1888,49 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return runPromise((svc) => svc.command(CommandInput.parse(input)))
}

const lastPrimaryAgent = Effect.fnUntraced(function* (sessionID: SessionID) {
const msgs = yield* Effect.promise(async () => {
const result: MessageV2.WithParts[] = []
for await (const item of MessageV2.stream(sessionID)) result.push(item)
return result
})
let name: string | undefined
for (const item of msgs) {
if (item.info.role !== "user") continue
if (!item.info.agent) continue
const agent = yield* Effect.promise(() => Agent.get(item.info.agent).catch(() => undefined))
if (!agent || agent.hidden || agent.mode === "subagent") continue
name = agent.name
}
if (name) return name
return yield* Effect.promise(() => Agent.defaultAgent())
})

export function shouldExitLoop(
lastUser: MessageV2.User | undefined,
lastAssistant: MessageV2.Assistant | undefined,
lastFinished?: MessageV2.Assistant,
hasToolCalls = false,
): boolean {
if (!lastUser || hasToolCalls) return false
const done = lastFinished ?? lastAssistant
if (!done?.finish) return false
if (["tool-calls", "unknown"].includes(done.finish)) return false
if (!done.parentID) return true
return done.parentID === lastUser.id
}

export function shouldWrapSystemReminder(
msg: MessageV2.User | MessageV2.Assistant,
idx: number,
lastFinished: MessageV2.Assistant | undefined,
finishedIdx: number,
): boolean {
if (msg.role !== "user") return false
if (!lastFinished) return false
return idx > finishedIdx
}

/** @internal Exported for testing */
export function createStructuredOutputTool(input: {
schema: Record<string, any>
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/test/server/session-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,5 +155,7 @@ describe("session.prompt_async error handling", () => {
const route = src.slice(start, end)
expect(route).toContain(".catch(")
expect(route).toContain("Bus.publish(Session.Event.Error")
expect(route).toContain("Instance.bind(")
expect(route).not.toContain("return stream(c")
})
})
Loading
Loading