mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 11:39:45 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc85350950 | |||
| 5e16a5dc66 |
@@ -907,7 +907,7 @@ export type Endpoint5_31Output =
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
|
||||
|
||||
export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
|
||||
export type Endpoint5_32Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_32Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
|
||||
|
||||
|
||||
@@ -596,10 +596,7 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
|
||||
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveEffect<Endpoint5_32Output>()(
|
||||
raw["session.interrupt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { continue: input["continue"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
|
||||
@@ -875,7 +875,6 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`,
|
||||
query: { continue: input["continue"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
|
||||
@@ -3888,10 +3888,7 @@ export type SessionLogInput = {
|
||||
|
||||
export type SessionLogOutput = SessionLogItem
|
||||
|
||||
export type SessionInterruptInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly continue?: { readonly continue?: boolean | undefined }["continue"]
|
||||
}
|
||||
export type SessionInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionInterruptOutput = void
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const log = yield* client.session
|
||||
.log({ sessionID: Session.ID.make("ses_test"), after: Event.Seq.make(0) })
|
||||
.pipe(Stream.runCollect)
|
||||
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test"), continue: true })
|
||||
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
|
||||
const message = yield* client.session.message({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_model"),
|
||||
|
||||
@@ -543,7 +543,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
const context = await client.session.context({ sessionID: "ses_test" })
|
||||
const log = []
|
||||
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
|
||||
await client.session.interrupt({ sessionID: "ses_test", continue: true })
|
||||
await client.session.interrupt({ sessionID: "ses_test" })
|
||||
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
@@ -568,7 +568,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
||||
["GET", "http://localhost:3000/api/experimental/session/ses_test/log?after=0"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt?continue=true"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||
])
|
||||
const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body
|
||||
|
||||
@@ -267,7 +267,7 @@ export interface Interface {
|
||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { continue?: boolean }) => Effect.Effect<void>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly synthetic: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -848,9 +848,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
),
|
||||
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID, options)),
|
||||
),
|
||||
interrupt: Effect.fn("Session.interrupt")((sessionID) => Effect.uninterruptible(execution.interrupt(sessionID))),
|
||||
revert: {
|
||||
stage: Effect.fn("Session.revert.stage")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface Interface {
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { continue?: boolean }) => Effect.Effect<void>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
active: coordinator.active,
|
||||
interrupt: (sessionID, options) => coordinator.interrupt(sessionID, "user", { preserveWake: options?.continue }),
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
|
||||
@@ -10,8 +10,8 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops the active execution and waits for cleanup. Clears its doorbell unless preservation is requested. */
|
||||
readonly interrupt: (key: Key, reason?: Reason, options?: { preserveWake?: boolean }) => Effect.Effect<void>
|
||||
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
@@ -124,12 +124,12 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
start(key, false)
|
||||
})
|
||||
|
||||
const interrupt = (key: Key, reason?: Reason, options?: { preserveWake?: boolean }): Effect.Effect<void> =>
|
||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution?.owner === undefined || execution.stopping) return Effect.void
|
||||
execution.stopping = true
|
||||
if (!options?.preserveWake) execution.pendingWake = false
|
||||
execution.pendingWake = false
|
||||
execution.interruptionReason = reason
|
||||
return Fiber.interrupt(execution.owner)
|
||||
})
|
||||
|
||||
@@ -343,7 +343,7 @@ const layer = Layer.effect(
|
||||
Effect.flatMap(toolOutput.truncate),
|
||||
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
||||
Effect.catchTag("Tool.Error", (error) =>
|
||||
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
|
||||
publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
|
||||
),
|
||||
),
|
||||
).pipe(Effect.forkScoped),
|
||||
|
||||
@@ -92,8 +92,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
progress?: Tool.Metadata
|
||||
}
|
||||
>()
|
||||
const failureSnapshot = (tool: { readonly progress?: Tool.Metadata }) =>
|
||||
tool.progress === undefined ? {} : { metadata: tool.progress }
|
||||
const failureSnapshot = (tool: { readonly progress?: Tool.Metadata }, metadata?: Tool.Metadata) => {
|
||||
if (tool.progress === undefined) return metadata === undefined ? {} : { metadata }
|
||||
if (metadata === undefined) return { metadata: tool.progress }
|
||||
return { metadata: { ...tool.progress, ...metadata } }
|
||||
}
|
||||
const assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
@@ -272,7 +275,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* flushFragments()
|
||||
})
|
||||
|
||||
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error) {
|
||||
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error, metadata?: Tool.Metadata) {
|
||||
const tool = tools.get(id)
|
||||
if (!tool || tool.settled) return false
|
||||
tool.settled = true
|
||||
@@ -281,7 +284,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id,
|
||||
error,
|
||||
...failureSnapshot(tool),
|
||||
...failureSnapshot(tool, metadata),
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
return true
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as WebSearchTool from "./websearch"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { Form } from "../../form"
|
||||
import { KV } from "../../kv"
|
||||
import { Permission } from "../../permission"
|
||||
@@ -52,7 +53,13 @@ export const Plugin = {
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
})
|
||||
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
|
||||
ctx.websearch.query(input).pipe(
|
||||
websearch.default().pipe(
|
||||
Effect.flatMap((provider) => {
|
||||
if (!provider) return ctx.websearch.query(input)
|
||||
return context
|
||||
.progress({ provider: provider.id })
|
||||
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID: provider.id })))
|
||||
}),
|
||||
Effect.catch((error) => {
|
||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||
return providerSelectionLock
|
||||
@@ -152,9 +159,33 @@ export const Plugin = {
|
||||
: NO_RESULTS
|
||||
return { output, content, metadata: { provider: output.provider } }
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
|
||||
),
|
||||
Effect.mapError((error) => {
|
||||
const fallback = `Unable to search the web for ${input.query}`
|
||||
if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error })
|
||||
const status = HttpClientError.isHttpClientError(error.cause) ? error.cause.response?.status : undefined
|
||||
switch (status) {
|
||||
case 429:
|
||||
return new ToolFailure({
|
||||
message: "Web search rate limited (HTTP 429)",
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
case 401:
|
||||
return new ToolFailure({
|
||||
message: "Web search authentication failed (HTTP 401)",
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
case undefined:
|
||||
return new ToolFailure({ message: fallback, error, metadata: { provider: error.providerID } })
|
||||
default:
|
||||
return new ToolFailure({
|
||||
message: `Web search request failed (HTTP ${status})`,
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
}
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -301,37 +301,6 @@ describe("SessionRunCoordinator", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("continues pending work after interruption when preserving the wake", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++runs).pipe(
|
||||
Effect.flatMap((run) =>
|
||||
run === 1
|
||||
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never))
|
||||
: Deferred.succeed(secondStarted, undefined),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* coordinator.wake("session")
|
||||
yield* coordinator.interrupt("session", "user", { preserveWake: true })
|
||||
yield* Deferred.await(secondStarted)
|
||||
|
||||
const exit = yield* Fiber.await(resumed)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||
yield* coordinator.awaitIdle("session")
|
||||
expect(runs).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("runs a wake registered during interruption cleanup", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -132,8 +132,7 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
interrupt: (sessionID, options) =>
|
||||
coordinator.interrupt(sessionID, undefined, { preserveWake: options?.continue }),
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -126,6 +126,19 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a
|
||||
})
|
||||
})
|
||||
|
||||
test("local failure metadata completes the progress snapshot", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
await Effect.runPromise(publisher.progress(call.id, { phase: "running", provider: "old" }))
|
||||
await Effect.runPromise(
|
||||
publisher.failTool(call.id, { type: "tool.execution", message: "failed" }, { provider: "exa" }),
|
||||
)
|
||||
|
||||
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
|
||||
metadata: { phase: "running", provider: "exa" },
|
||||
})
|
||||
})
|
||||
|
||||
test("failure snapshot retains canonical progress above the default byte limit", async () => {
|
||||
const { published, publisher } = capture("anthropic", { interruptProgress: true })
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
|
||||
@@ -391,8 +391,7 @@ const execution = Layer.effect(
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
interrupt: (sessionID, options) =>
|
||||
coordinator.interrupt(sessionID, undefined, { preserveWake: options?.continue }),
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
@@ -7,6 +8,7 @@ import { Form } from "@opencode-ai/core/form"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { WebSearchTool } from "@opencode-ai/core/tool/plugin/websearch"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -41,6 +43,7 @@ let formResponse: Form.TerminalState = { status: "cancelled" }
|
||||
const formResponses: Form.TerminalState[] = []
|
||||
let queryBarrier: Deferred.Deferred<void> | undefined
|
||||
let synchronizedQueries = 0
|
||||
let queryError: WebSearch.Error | undefined
|
||||
let result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
@@ -56,6 +59,7 @@ beforeEach(() => {
|
||||
formResponses.length = 0
|
||||
queryBarrier = undefined
|
||||
synchronizedQueries = 0
|
||||
queryError = undefined
|
||||
result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
@@ -94,6 +98,7 @@ const websearch = Layer.succeed(
|
||||
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
|
||||
yield* Deferred.await(queryBarrier)
|
||||
}
|
||||
if (queryError) return yield* queryError
|
||||
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
|
||||
if (typeof stored === "string")
|
||||
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
|
||||
@@ -376,4 +381,55 @@ describe("WebSearchTool registration", () => {
|
||||
expect(queries).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports safe HTTP failures with the attempted provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const tools = yield* registry.snapshot()
|
||||
values.set("websearch:provider", "exa")
|
||||
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
{ status: 403, message: "Web search request failed (HTTP 403)" },
|
||||
{ status: 429, message: "Web search rate limited (HTTP 429)" },
|
||||
{ status: 401, message: "Web search authentication failed (HTTP 401)" },
|
||||
],
|
||||
({ status, message }, index) =>
|
||||
Effect.gen(function* () {
|
||||
const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret")
|
||||
queryError = new WebSearch.RequestError({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
cause: new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.StatusCodeError({
|
||||
request,
|
||||
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
|
||||
description: "non 2xx status code",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
const progress: Tool.Metadata[] = []
|
||||
const error = yield* tools
|
||||
.execute({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: `call-http-${index}`,
|
||||
name: "websearch",
|
||||
input: { query: "effect" },
|
||||
},
|
||||
progress: (metadata) => Effect.sync(() => progress.push(metadata)),
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
const sessionError = toSessionError(error)
|
||||
expect(sessionError).toEqual({ type: "tool.execution", message })
|
||||
expect(sessionError.message).not.toContain("secret")
|
||||
expect(error.metadata).toEqual({ provider: "exa" })
|
||||
expect(progress).toEqual([{ provider: "exa" }])
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -647,7 +647,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.interrupt", "/api/session/:sessionID/interrupt", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: { continue: BooleanFromString.pipe(Schema.optional) },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
@@ -656,8 +655,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.interrupt",
|
||||
summary: "Interrupt session execution",
|
||||
description:
|
||||
"Interrupt active execution owned by this OpenCode process. When continue=true, pending work starts after interruption. Idle interruption is a no-op.",
|
||||
description: "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -772,7 +772,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.interrupt",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.interrupt(ctx.params.sessionID, { continue: ctx.query.continue })
|
||||
yield* session.interrupt(ctx.params.sessionID)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -64,6 +64,7 @@ export type PromptProps = {
|
||||
onEmptySubmit?: () => boolean | Promise<boolean>
|
||||
ref?: (ref: PromptRef | undefined) => void
|
||||
hint?: JSX.Element
|
||||
runningHint?: JSX.Element
|
||||
right?: JSX.Element
|
||||
showPlaceholder?: boolean
|
||||
placeholders?: {
|
||||
@@ -432,7 +433,6 @@ export function Prompt(props: PromptProps) {
|
||||
if (store.interrupt >= 2) {
|
||||
void client.api.session.interrupt({
|
||||
sessionID: props.sessionID,
|
||||
continue: true,
|
||||
})
|
||||
setStore("interrupt", 0)
|
||||
}
|
||||
@@ -1659,6 +1659,7 @@ export function Prompt(props: PromptProps) {
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
{props.runningHint}
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={move.progress()}>
|
||||
|
||||
@@ -374,7 +374,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
void (
|
||||
state.stream
|
||||
? state.stream.then((item) => item.handle.interruptActiveTurn())
|
||||
: state.sdk.session.interrupt({ sessionID: state.sessionID, continue: true })
|
||||
: state.sdk.session.interrupt({ sessionID: state.sessionID })
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
@@ -401,7 +401,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
},
|
||||
onSubagentInterrupt: (sessionID) => {
|
||||
log?.write("send.subagent.interrupt", { sessionID })
|
||||
void state.sdk.session.interrupt({ sessionID, continue: true }).catch(() => {})
|
||||
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
|
||||
},
|
||||
onSubagentSelect: (sessionID) => {
|
||||
state.selectSubagent?.(sessionID)
|
||||
|
||||
@@ -312,7 +312,12 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
const current = child.tools.get(key)
|
||||
const output = toolOutputText(part.name, toolDisplayContent(part.state))
|
||||
if (part.state.status === "running") {
|
||||
if (!current || current.part.state.status === "streaming")
|
||||
const ready = part.name !== "websearch" || typeof part.state.metadata.provider === "string"
|
||||
const awaitingProvider =
|
||||
current?.part.name === "websearch" &&
|
||||
current.part.state.status === "running" &&
|
||||
typeof current.part.state.metadata.provider !== "string"
|
||||
if (ready && (!current || current.part.state.status === "streaming" || awaitingProvider))
|
||||
setFrame(child, frame, toolCommit(part, messageID, "start", undefined, input.directory))
|
||||
if (output) setFrame(child, frame, toolCommit(part, messageID, "progress", output, input.directory))
|
||||
child.tools.set(key, { part })
|
||||
|
||||
@@ -120,6 +120,7 @@ type ToolState = {
|
||||
part: SessionMessageAssistantTool
|
||||
output: string
|
||||
version: number
|
||||
started: boolean
|
||||
}
|
||||
|
||||
type State = {
|
||||
@@ -609,7 +610,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
state.toolSources.set(key, part)
|
||||
if (part.state.status === "streaming") {
|
||||
state.tools.set(key, { part, output: "", version: 0 })
|
||||
state.tools.set(key, { part, output: "", version: 0, started: false })
|
||||
return
|
||||
}
|
||||
const current = state.tools.get(key)
|
||||
@@ -618,16 +619,18 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
const version = current && !prefix ? current.version + 1 : (current?.version ?? 0)
|
||||
const delta = current && prefix ? output.slice(current.output.length) : output
|
||||
if (part.state.status === "running") {
|
||||
if (render && (!current || current.part.state.status === "streaming"))
|
||||
const started = current?.started === true
|
||||
const ready = part.name !== "websearch" || typeof part.state.metadata.provider === "string"
|
||||
if (render && !started && ready)
|
||||
write([toolCommit(part, messageID, "start", undefined, input.location?.directory, version)], {
|
||||
phase: "running",
|
||||
status: `running ${part.name}`,
|
||||
})
|
||||
if (render && delta) write([toolCommit(part, messageID, "progress", delta, input.location?.directory, version)])
|
||||
state.tools.set(key, { part, output, version })
|
||||
state.tools.set(key, { part, output, version, started: started || (render && ready) })
|
||||
return
|
||||
}
|
||||
if (render && (!current || current.part.state.status === "streaming"))
|
||||
if (render && !current?.started)
|
||||
write([toolCommit(part, messageID, "start", undefined, input.location?.directory, version)])
|
||||
state.finishedTools.add(key)
|
||||
state.tools.delete(key)
|
||||
@@ -1525,7 +1528,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
state.wait = active
|
||||
const interrupt = () => {
|
||||
active.interrupted = true
|
||||
void sdk.session.interrupt({ sessionID: input.sessionID, continue: true }).catch(() => {})
|
||||
void sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
}
|
||||
next.signal?.addEventListener("abort", interrupt, { once: true })
|
||||
try {
|
||||
@@ -1783,7 +1786,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (state.wait) state.wait.interrupted = true
|
||||
await sdk.session.interrupt({ sessionID: input.sessionID, continue: true }).catch(() => {})
|
||||
await sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
},
|
||||
selectSubagent(sessionID) {
|
||||
subagents.select(sdk, sessionID)
|
||||
|
||||
@@ -217,7 +217,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
run() {
|
||||
const entry = selectedEntry()
|
||||
if (!entry || entry.status !== "running") return
|
||||
void client.api.session.interrupt({ sessionID: entry.sessionID, continue: true })
|
||||
void client.api.session.interrupt({ sessionID: entry.sessionID })
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -114,6 +114,7 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||
const TRANSCRIPT_TAIL_ROWS = 40
|
||||
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
||||
const TRANSCRIPT_BACKFILL_DELAY = 120
|
||||
const BACKGROUND_TOOL_HINT_DELAY = 3_000
|
||||
type PendingAction = "steer" | "queue" | "cancel"
|
||||
|
||||
const context = createContext<{
|
||||
@@ -1056,7 +1057,6 @@ export function Session() {
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<BackgroundToolHint messages={messages()} />
|
||||
<Show when={session()?.revert?.messageID}>
|
||||
<RevertMessage
|
||||
count={messagesFromRevert().filter((message) => message.type === "user").length}
|
||||
@@ -1112,6 +1112,7 @@ export function Session() {
|
||||
return mutatePending("steer", next.id)
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
runningHint={<BackgroundToolHint messages={messages()} />}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -1326,26 +1327,42 @@ function turnTokenToolSummary(tool: SessionMessageAssistantTool) {
|
||||
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
|
||||
const theme = useTheme()
|
||||
const shortcut = Keymap.useShortcut("session.background")
|
||||
const visible = createMemo(() => {
|
||||
const tool = createMemo(() => {
|
||||
const current = props.messages.findLast(
|
||||
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
|
||||
)
|
||||
return (
|
||||
current?.content.some((part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return false
|
||||
const display = toolDisplay(part.name)
|
||||
return display === "shell" || display === "subagent"
|
||||
}) ?? false
|
||||
)
|
||||
return current?.content.find((part): part is SessionMessageAssistantTool => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return false
|
||||
const display = toolDisplay(part.name)
|
||||
return display === "shell" || display === "subagent"
|
||||
})
|
||||
})
|
||||
const toolID = () => tool()?.id
|
||||
const toolStartedAt = () => {
|
||||
const current = tool()
|
||||
if (!current) return
|
||||
return current.time.ran ?? current.time.created
|
||||
}
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
createEffect(
|
||||
on([toolID, toolStartedAt], ([id, startedAt]) => {
|
||||
setVisible(false)
|
||||
if (!id || startedAt === undefined) return
|
||||
const remaining = Math.max(0, BACKGROUND_TOOL_HINT_DELAY - (Date.now() - startedAt))
|
||||
if (remaining === 0) {
|
||||
setVisible(true)
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => setVisible(true), remaining)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
}),
|
||||
)
|
||||
return (
|
||||
<Show when={visible() && shortcut()}>
|
||||
{(value) => (
|
||||
<box marginTop={1} paddingLeft={3} flexShrink={0}>
|
||||
<text fg={theme.text.subdued}>
|
||||
Press <span style={{ fg: theme.text.default }}>{value()}</span> to move running work to the background
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
|
||||
<span style={{ fg: theme.text.default }}>{value()}</span> background
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type PermissionRequest,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { createSessionTransport } from "../../src/mini/stream-v2.transport"
|
||||
import { entryBody } from "../../src/mini/entry.body"
|
||||
import type { StreamCommit } from "../../src/mini/types"
|
||||
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||
import { canonicalToolPart } from "./fixture/tool-part"
|
||||
@@ -1496,7 +1497,7 @@ describe("V2 mini transport", () => {
|
||||
await transport.interruptActiveTurn()
|
||||
|
||||
expect(prompt).toHaveBeenCalled()
|
||||
expect(interrupt).toHaveBeenCalledWith({ sessionID: "ses_1", continue: true })
|
||||
expect(interrupt).toHaveBeenCalledWith({ sessionID: "ses_1" })
|
||||
expect(firstPrompt).not.toHaveBeenCalled()
|
||||
expect(firstInterrupt).not.toHaveBeenCalled()
|
||||
await transport.close()
|
||||
@@ -2201,6 +2202,80 @@ describe("V2 mini transport", () => {
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("waits for the attempted web search provider before rendering its title", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
})
|
||||
events.push({
|
||||
id: "evt_websearch_input",
|
||||
created: 1,
|
||||
type: "session.tool.input.started",
|
||||
durable: durable("ses_1"),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_websearch",
|
||||
id: "call_websearch",
|
||||
name: "websearch",
|
||||
},
|
||||
})
|
||||
events.push({
|
||||
id: "evt_websearch_called",
|
||||
created: 2,
|
||||
type: "session.tool.called",
|
||||
durable: durable("ses_1", 1),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_websearch",
|
||||
id: "call_websearch",
|
||||
input: { query: "effect" },
|
||||
executed: true,
|
||||
},
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
expect(ui.commits.filter((item) => item.part?.id === "call_websearch")).toEqual([])
|
||||
|
||||
events.push({
|
||||
id: "evt_websearch_progress",
|
||||
created: 3,
|
||||
type: "session.tool.progress",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_websearch",
|
||||
id: "call_websearch",
|
||||
metadata: { provider: "exa" },
|
||||
},
|
||||
})
|
||||
events.push({
|
||||
id: "evt_websearch_failed",
|
||||
created: 4,
|
||||
type: "session.tool.failed",
|
||||
durable: durable("ses_1", 2, 2),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_websearch",
|
||||
id: "call_websearch",
|
||||
error: { type: "tool.execution", message: "Web search request failed (HTTP 403)" },
|
||||
metadata: { provider: "exa" },
|
||||
executed: true,
|
||||
},
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
|
||||
const commits = ui.commits.filter((item) => item.part?.id === "call_websearch")
|
||||
expect(commits.map((item) => item.phase)).toEqual(["start", "final"])
|
||||
const start = commits[0]
|
||||
if (!start) throw new Error("Expected web search start commit")
|
||||
expect(entryBody(start)).toEqual({ type: "text", content: '◈ Exa Web Search "effect"' })
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("falls back to the default model when selecting a variant on a fresh session", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
@@ -2315,7 +2390,7 @@ describe("V2 mini transport", () => {
|
||||
idle.resolve()
|
||||
await turn
|
||||
|
||||
expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1", continue: true })
|
||||
expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" })
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user