Compare commits

...

1 Commits

Author SHA1 Message Date
Dax Raad 0a8da2c985 fix(api): require session selection 2026-08-07 01:32:23 +00:00
12 changed files with 84 additions and 70 deletions
+9 -6
View File
@@ -94,11 +94,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
prepare: async (next) => { prepare: async (next) => {
const selected = const selected =
next.model ?? next.model ??
(options.variant (await client.model
? await client.model .default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } }) .then((result) => result.data))
.then((result) => result.data)
: undefined)
const model = selected const model = selected
? { ? {
providerID: selected.providerID, providerID: selected.providerID,
@@ -108,7 +106,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
: undefined : undefined
if ((options.variant ?? explicit?.variant) && !model) if ((options.variant ?? explicit?.variant) && !model)
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id) throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
return { model, agent: next.agent } const agent =
next.agent ??
(await client.agent
.list({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data.find((item) => item.mode !== "subagent" && !item.hidden)?.id))
return { model, agent }
}, },
}).catch((error) => { }).catch((error) => {
if (!(error instanceof RunTargetError)) throw error if (!(error instanceof RunTargetError)) throw error
+5 -2
View File
@@ -56,13 +56,16 @@ export async function resolveSessionTarget(input: {
agent: input.agent ?? selected?.agent, agent: input.agent ?? selected?.agent,
signal: input.signal, signal: input.signal,
}) })
if (!selected && (!prepared.agent || !prepared.model)) {
throw new SessionTargetMutationError(new Error("Creating a session requires an agent and model"))
}
const session = const session =
selected ?? selected ??
(await input.client.session (await input.client.session
.create( .create(
{ {
agent: prepared.agent, agent: prepared.agent!,
model: prepared.model, model: prepared.model!,
location: { directory: location.directory, workspaceID: location.workspaceID }, location: { directory: location.directory, workspaceID: location.workspaceID },
}, },
...requestOptions(input.signal), ...requestOptions(input.signal),
+12 -6
View File
@@ -61,7 +61,11 @@ describe("session target resolver", () => {
spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1")) spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1"))
const create = spyOn(client.session, "create").mockImplementation(async (input) => { const create = spyOn(client.session, "create").mockImplementation(async (input) => {
order.push("create") order.push("create")
expect(input).toMatchObject({ agent: "prepared", location: { directory: "/server", workspaceID: "work_1" } }) expect(input).toMatchObject({
agent: "prepared",
model: { providerID: "openai", id: "gpt-5" },
location: { directory: "/server", workspaceID: "work_1" },
})
return session("ses_fresh", "/server", "work_1") return session("ses_fresh", "/server", "work_1")
}) })
@@ -71,20 +75,22 @@ describe("session target resolver", () => {
prepare: async (input) => { prepare: async (input) => {
order.push("prepare") order.push("prepare")
expect(input.location.workspaceID).toBe("work_1") expect(input.location.workspaceID).toBe("work_1")
return { model: input.model, agent: "prepared" } return { model: { providerID: "openai", id: "gpt-5" }, agent: "prepared" }
}, },
}) })
expect(create).toHaveBeenCalledTimes(1) expect(create).toHaveBeenCalledTimes(1)
expect(order).toEqual(["prepare", "create"]) expect(order).toEqual(["prepare", "create"])
}) })
test("uses the agent resolved by the server for a fresh Session", async () => { test("requires an explicit agent and model for a fresh Session", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" }) const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project")) spyOn(client.location, "get").mockResolvedValue(location("/project"))
spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" }) const create = spyOn(client.session, "create")
const target = await resolveSessionTarget({ client, prepare }) await expect(resolveSessionTarget({ client, prepare })).rejects.toThrow(
expect(target.agent).toBe("review") "Creating a session requires an agent and model",
)
expect(create).not.toHaveBeenCalled()
}) })
test("does not retry an ambiguous Session creation", async () => { test("does not retry an ambiguous Session creation", async () => {
+3 -3
View File
@@ -120,12 +120,12 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
export type Endpoint5_1Input = { export type Endpoint5_1Input = {
readonly id?: Session.ID | undefined readonly id?: Session.ID | undefined
readonly title?: string | undefined readonly title?: string | undefined
readonly agent?: Agent.ID | undefined readonly agent: Agent.ID
readonly model?: Model.Ref | undefined readonly model: Model.Ref
readonly location?: Location.Ref | undefined readonly location?: Location.Ref | undefined
} }
export type Endpoint5_1Output = Session.Info export type Endpoint5_1Output = Session.Info
export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E> export type SessionCreateOperation<E = never> = (input: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
export type Endpoint5_2Input = { export type Endpoint5_2Input = {
readonly info: Session.Info readonly info: Session.Info
@@ -305,15 +305,15 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) => const Endpoint5_1 = (raw: RawClient["server.session"]) => (input: Endpoint5_1Input) =>
preserveEffect<Endpoint5_1Output>()( preserveEffect<Endpoint5_1Output>()(
raw["session.create"]({ raw["session.create"]({
payload: { payload: {
id: input?.["id"], id: input["id"],
title: input?.["title"], title: input["title"],
agent: input?.["agent"], agent: input["agent"],
model: input?.["model"], model: input["model"],
location: input?.["location"], location: input["location"],
}, },
}).pipe( }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
@@ -464,17 +464,17 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
create: (input?: SessionCreateInput, requestOptions?: RequestOptions) => create: (input: SessionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionCreateOutput }>( request<{ readonly data: SessionCreateOutput }>(
{ {
method: "POST", method: "POST",
path: `/api/session`, path: `/api/session`,
body: { body: {
id: input?.["id"], id: input["id"],
title: input?.["title"], title: input["title"],
agent: input?.["agent"], agent: input["agent"],
model: input?.["model"], model: input["model"],
location: input?.["location"], location: input["location"],
}, },
successStatus: 200, successStatus: 200,
declaredStatuses: [401, 400], declaredStatuses: [401, 400],
+12 -12
View File
@@ -2436,36 +2436,36 @@ export type SessionCreateInput = {
readonly id?: { readonly id?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["id"] }["id"]
readonly title?: { readonly title?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["title"] }["title"]
readonly agent?: { readonly agent: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["agent"] }["agent"]
readonly model?: { readonly model: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["model"] }["model"]
readonly location?: { readonly location?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"] }["location"]
} }
+2
View File
@@ -181,6 +181,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const page = yield* client.session.list({ limit: 10 }) const page = yield* client.session.list({ limit: 10 })
const active = yield* client.session.active() const active = yield* client.session.active()
const created = yield* client.session.create({ const created = yield* client.session.create({
agent: Agent.ID.make("build"),
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }), location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
}) })
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") }) yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
+6 -2
View File
@@ -454,7 +454,11 @@ test("session methods use the public HTTP contract", async () => {
const page = await client.session.list({ limit: 10, order: "desc", parentID: null }) const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
const active = await client.session.active() const active = await client.session.active()
const created = await client.session.create({ location: { directory: "/tmp/project" } }) const created = await client.session.create({
agent: "build",
model: { id: "claude", providerID: "anthropic" },
location: { directory: "/tmp/project" },
})
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" }) await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.session.switchModel({ await client.session.switchModel({
sessionID: "ses_test", sessionID: "ses_test",
@@ -528,7 +532,7 @@ test("middleware errors remain declared client errors", async () => {
}) })
try { try {
await client.session.create({}) await client.session.create({ agent: "build", model: { id: "claude", providerID: "anthropic" } })
throw new Error("Expected request to fail") throw new Error("Expected request to fail")
} catch (error) { } catch (error) {
expect(isUnauthorizedError(error)).toBe(true) expect(isUnauthorizedError(error)).toBe(true)
+5 -5
View File
@@ -340,12 +340,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
hook: (name, callback) => hooks.register("session", name, callback), hook: (name, callback) => hooks.register("session", name, callback),
create: (input) => create: (input) =>
runtime.session.create({ runtime.session.create({
id: input?.id, id: input.id,
title: input?.title, title: input.title,
agent: input?.agent, agent: input.agent,
model: input?.model, model: input.model,
location: location:
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }), input.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}), }),
get: (input) => runtime.session.get(input.sessionID), get: (input) => runtime.session.get(input.sessionID),
prompt: runtime.session.prompt, prompt: runtime.session.prompt,
+15 -19
View File
@@ -269,25 +269,21 @@ export function fromPromise(plugin: Plugin) {
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) => create: (input) =>
run( run(
host.session.create( host.session.create({
input === undefined id: input.id == null ? undefined : Session.ID.make(input.id),
? undefined agent: Agent.ID.make(input.agent),
: { model: model(input.model),
id: input.id == null ? undefined : Session.ID.make(input.id), location:
agent: input.agent == null ? undefined : Agent.ID.make(input.agent), input.location == null
model: input.model == null ? undefined : model(input.model), ? undefined
location: : Location.Ref.make({
input.location == null directory: AbsolutePath.make(input.location.directory),
? undefined workspaceID:
: Location.Ref.make({ input.location.workspaceID === undefined
directory: AbsolutePath.make(input.location.directory), ? undefined
workspaceID: : Workspace.ID.make(input.location.workspaceID),
input.location.workspaceID === undefined }),
? undefined }),
: Workspace.ID.make(input.location.workspaceID),
}),
},
),
), ),
get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })), get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })),
prompt: (input) => prompt: (input) =>
+3 -3
View File
@@ -151,8 +151,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
payload: Schema.Struct({ payload: Schema.Struct({
id: Session.ID.pipe(Schema.optional), id: Session.ID.pipe(Schema.optional),
title: Schema.String.pipe(Schema.optional), title: Schema.String.pipe(Schema.optional),
agent: Agent.ID.pipe(Schema.optional), agent: Agent.ID,
model: Model.Ref.pipe(Schema.optional), model: Model.Ref,
location: Location.Ref.pipe(Schema.optional), location: Location.Ref.pipe(Schema.optional),
}), }),
success: Schema.Struct({ data: Session.Info }), success: Schema.Struct({ data: Session.Info }),
@@ -160,7 +160,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.create", identifier: "v2.session.create",
summary: "Create session", summary: "Create session",
description: "Create a session at the requested location.", description: "Create a session with an explicit agent and model at the requested location.",
}), }),
), ),
) )