From 71ec1e686d44457533162115fb868e29ed06f171 Mon Sep 17 00:00:00 2001 From: noobezlol Date: Mon, 1 Jun 2026 07:06:43 +0530 Subject: [PATCH] fix(session): aggregate status across child directories --- .../instance/httpapi/handlers/session.ts | 2 +- packages/opencode/src/session/status.ts | 33 +++++++++-- .../opencode/test/server/httpapi-sdk.test.ts | 57 ++++++++++++++++++- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 773fb412365b..bd2875efffc7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -73,7 +73,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const status = Effect.fn("SessionHttpApi.status")(function* () { - return Object.fromEntries(yield* statusSvc.list()) + return Object.fromEntries(yield* statusSvc.list({ recursive: true })) }) const requireSession = Effect.fn("SessionHttpApi.requireSession")(function* (sessionID: SessionID) { diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index a7a6c5f87ef8..42840b841fd2 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -4,6 +4,7 @@ import { NonNegativeInt } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" +import path from "node:path" export const Info = Schema.Union([ Schema.Struct({ @@ -50,7 +51,7 @@ export const Event = { export interface Interface { readonly get: (sessionID: SessionID) => Effect.Effect - readonly list: () => Effect.Effect> + readonly list: (input?: { recursive?: boolean }) => Effect.Effect> readonly set: (sessionID: SessionID, status: Info) => Effect.Effect } @@ -60,9 +61,17 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service + const directories = new Map>() const state = yield* InstanceState.make( - Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map())), + Effect.fn("SessionStatus.state")((ctx) => + Effect.gen(function* () { + const result = new Map() + directories.set(ctx.directory, result) + yield* Effect.addFinalizer(() => Effect.sync(() => directories.delete(ctx.directory))) + return result + }), + ), ) const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) { @@ -70,8 +79,19 @@ export const layer = Layer.effect( return data.get(sessionID) ?? { type: "idle" as const } }) - const list = Effect.fn("SessionStatus.list")(function* () { - return new Map(yield* InstanceState.get(state)) + const list = Effect.fn("SessionStatus.list")(function* (input?: { recursive?: boolean }) { + const current = yield* InstanceState.directory + const data = yield* InstanceState.get(state) + if (!input?.recursive) return new Map(data) + + const result = new Map() + for (const [directory, statuses] of directories) { + if (!isSameOrChildDirectory(current, directory)) continue + for (const [sessionID, status] of statuses) { + result.set(sessionID, status) + } + } + return result }) const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) { @@ -89,6 +109,11 @@ export const layer = Layer.effect( }), ) +function isSameOrChildDirectory(parent: string, directory: string) { + const relative = path.relative(parent, directory) + return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)) +} + export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) export * as SessionStatus from "./status" diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 972891f9a4fe..94f82b8b33ee 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -21,7 +21,7 @@ import { TestLLMServer } from "../lib/llm-server" import path from "path" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" -import { awaitWithTimeout, testEffect } from "../lib/effect" +import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" import { testProviderConfig } from "../lib/test-provider" import { ProviderV2 } from "@opencode-ai/core/provider" import { Database } from "@opencode-ai/core/database/database" @@ -576,6 +576,61 @@ describe("HttpApi SDK", () => { ), ) + serverPathParity("aggregates session status from child directories", (serverPath) => + withFakeLlmProject(serverPath, { setup: writeStandardFiles }, ({ sdk, directory, llm }) => + Effect.gen(function* () { + const childDirectory = path.join(directory, "backend") + yield* writeStandardFiles(childDirectory) + + const childSdk = yield* client(serverPath, childDirectory) + const child = yield* capture(() => childSdk.session.create({ title: "child" })) + const childID = String(record(child.data).id) + + const releasePrompt = yield* Deferred.make() + yield* llm.hold("done", Effect.runPromise(Deferred.await(releasePrompt))) + const asyncPrompt = yield* capture(() => + childSdk.session.promptAsync({ + sessionID: childID, + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "keep running" }], + }), + ) + expect(asyncPrompt.status).toBe(204) + yield* awaitWithTimeout(llm.wait(1), "timed out waiting for child prompt request", "5 seconds") + yield* pollWithTimeout( + capture(() => childSdk.session.status()).pipe( + Effect.map((result) => (record(result.data)[childID] ? result : undefined)), + ), + "timed out waiting for child session status", + "5 seconds", + ) + + const status = yield* capture(() => sdk.session.status()) + const statusMap = record(status.data) + const parentStatusCode = status.status + const parentChildStatus = statusMap[childID] + + yield* Deferred.succeed(releasePrompt, void 0) + yield* pollWithTimeout( + capture(() => childSdk.session.status()).pipe( + Effect.map((result) => (record(result.data)[childID] ? undefined : result)), + ), + "timed out waiting for child session to finish", + "5 seconds", + ) + + expect(parentStatusCode).toBe(200) + expect(parentChildStatus).toEqual({ type: "busy" }) + + return { + status: parentStatusCode, + childStatus: parentChildStatus, + } + }), + ), + ) + serverPathParity("matches generated SDK session message and part routes", (serverPath) => withStandardProject(serverPath, ({ sdk, directory }) => Effect.gen(function* () {