Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/client/src/effect/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -915,7 +915,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 }
export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type Endpoint5_32Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>

Expand Down
5 changes: 4 additions & 1 deletion packages/client/src/effect/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,10 @@ 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"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.interrupt"]({
params: { sessionID: input["sessionID"] },
query: { continue: input["continue"] },
}).pipe(Effect.mapError(mapClientError)),
)

const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/promise/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,7 @@ 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,
Expand Down
5 changes: 4 additions & 1 deletion packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3892,7 +3892,10 @@ export type SessionLogInput = {

export type SessionLogOutput = SessionLogItem

export type SessionInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInterruptInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly continue?: { readonly continue?: boolean | undefined }["continue"]
}

export type SessionInterruptOutput = void

Expand Down
4 changes: 2 additions & 2 deletions packages/client/test/promise.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
await client.session.interrupt({ sessionID: "ses_test", continue: true })
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })

expect(page.cursor.next).toBe("next")
Expand All @@ -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"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt?continue=true"],
["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
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => Effect.Effect<void>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
readonly synthetic: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
Expand Down Expand Up @@ -835,7 +835,14 @@ const layer = Layer.effect(
}),
),
),
interrupt: Effect.fn("Session.interrupt")((sessionID) => Effect.uninterruptible(execution.interrupt(sessionID))),
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* execution.interrupt(sessionID)
if (options?.continue && (yield* SessionPending.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
}),
),
),
revert: {
stage: Effect.fn("Session.revert.stage")(function* (input) {
const session = yield* result.get(input.sessionID)
Expand Down
31 changes: 31 additions & 0 deletions packages/core/test/session-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,44 @@ describe("Session.prompt", () => {
yield* setup
const session = yield* Session.Service
interruptCalls.length = 0
wakeCalls.length = 0

yield* session.interrupt(sessionID)
expect(interruptCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([])
expect(yield* session.messages({ sessionID })).toEqual([])
}),
)

it.effect("continues after interruption when pending work remains", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
yield* session.synthetic({ sessionID, text: "Continue after interrupt", resume: false })
interruptCalls.length = 0
wakeCalls.length = 0

yield* session.interrupt(sessionID, { continue: true })

expect(interruptCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([sessionID])
}),
)

it.effect("does not continue after interruption without pending work", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
interruptCalls.length = 0
wakeCalls.length = 0

yield* session.interrupt(sessionID, { continue: true })

expect(interruptCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([])
}),
)

it.effect("delegates interruption without requiring a recorded Session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
Expand Down
4 changes: 3 additions & 1 deletion packages/protocol/src/groups/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,7 @@ 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,
})
Expand All @@ -655,7 +656,8 @@ 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. Idle interruption is a no-op.",
description:
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable pending work remains after interruption.",
}),
),
)
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
yield* session.interrupt(ctx.params.sessionID, { continue: ctx.query.continue })
return HttpApiSchema.NoContent.make()
}),
)
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/mini/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
: state.sdk.session.interrupt({ sessionID: state.sessionID, continue: true })
)
.catch(() => {})
.finally(() => {
Expand Down
4 changes: 2 additions & 2 deletions packages/tui/src/mini/stream-v2.transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1528,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 }).catch(() => {})
void sdk.session.interrupt({ sessionID: input.sessionID, continue: true }).catch(() => {})
}
next.signal?.addEventListener("abort", interrupt, { once: true })
try {
Expand Down Expand Up @@ -1786,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 }).catch(() => {})
await sdk.session.interrupt({ sessionID: input.sessionID, continue: true }).catch(() => {})
},
selectSubagent(sessionID) {
subagents.select(sdk, sessionID)
Expand Down
4 changes: 2 additions & 2 deletions packages/tui/test/mini/stream-v2.transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1497,7 +1497,7 @@ describe("V2 mini transport", () => {
await transport.interruptActiveTurn()

expect(prompt).toHaveBeenCalled()
expect(interrupt).toHaveBeenCalledWith({ sessionID: "ses_1" })
expect(interrupt).toHaveBeenCalledWith({ sessionID: "ses_1", continue: true })
expect(firstPrompt).not.toHaveBeenCalled()
expect(firstInterrupt).not.toHaveBeenCalled()
await transport.close()
Expand Down Expand Up @@ -2390,7 +2390,7 @@ describe("V2 mini transport", () => {
idle.resolve()
await turn

expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" })
expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1", continue: true })
await transport.close()
})

Expand Down
Loading