Skip to content
Closed
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
48 changes: 27 additions & 21 deletions packages/opencode/src/question/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,25 @@ export namespace Question {
})
export type Info = z.infer<typeof Info>

export const Request = z
.object({
id: Identifier.schema("question"),
sessionID: Identifier.schema("session"),
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: z.string(),
callID: z.string(),
})
.optional(),
})
.meta({
ref: "QuestionRequest",
})
export const AskInput = z.object({
sessionID: Identifier.schema("session"),
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: z.string(),
callID: z.string(),
})
.optional(),
}).meta({
ref: 'QuestionAskInput'
})
export type AskInput = z.infer<typeof AskInput>

export const Request = AskInput.safeExtend({
id: Identifier.schema("question"),
}).meta({
ref: "QuestionRequest",
})
export type Request = z.infer<typeof Request>

export const Answer = z.array(z.string()).meta({
Expand Down Expand Up @@ -94,17 +98,17 @@ export namespace Question {
}
})

export async function ask(input: {
sessionID: string
questions: Info[]
tool?: { messageID: string; callID: string }
}): Promise<Answer[]> {
export async function ask<T extends boolean = true>(
input: AskInput,
options?: { awaitAnswers: T }
) {
const { awaitAnswers } = options ?? { awaitAnswers: true }
const s = await state()
const id = Identifier.ascending("question")

log.info("asking", { id, questions: input.questions.length })

return new Promise<Answer[]>((resolve, reject) => {
const answerPromise = new Promise<Answer[]>((resolve, reject) => {
const info: Request = {
id,
sessionID: input.sessionID,
Expand All @@ -118,6 +122,8 @@ export namespace Question {
}
Bus.publish(Event.Asked, info)
})

return (awaitAnswers ? answerPromise : id) as T extends true ? typeof answerPromise : typeof id
}

export async function reply(input: { requestID: string; answers: Answer[] }): Promise<void> {
Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/src/server/routes/question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { Question } from "../../question"
import { Session } from "../../session"
import z from "zod"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
Expand Down Expand Up @@ -30,6 +31,32 @@ export const QuestionRoutes = lazy(() =>
return c.json(questions)
},
)
.post(
"/ask",
describeRoute({
summary: "Ask a question",
description: "Ask a question to the AI assistant.",
operationId: "question.ask",
responses: {
200: {
description: "Created question request",
content: {
"application/json": {
schema: resolver(Question.Request.pick({ id: true })),
},
},
},
...errors(400, 404),
},
}),
validator("json", Question.AskInput),
async (c) => {
const json = c.req.valid("json")
await Session.get(json.sessionID)
const id = await Question.ask(json, { awaitAnswers: false })
return c.json({ id })
},
)
.post(
"/:requestID/reply",
describeRoute({
Expand Down
29 changes: 29 additions & 0 deletions packages/opencode/test/question/question.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,35 @@ test("ask - adds to pending list", async () => {
})
})

test("ask - returns ID when not awaiting answers", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const questions = [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
]

const id = await Question.ask({
sessionID: "ses_test",
questions,
}, { awaitAnswers: false })

const pending = await Question.list()
expect(pending.length).toBe(1)
expect(pending[0].questions).toEqual(questions)
expect(pending[0].id).toBe(id)
},
})
})

// reply tests

test("reply - resolves the pending ask with answers", async () => {
Expand Down
96 changes: 96 additions & 0 deletions packages/opencode/test/server/question.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Session } from "../../src/session"
import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Question } from "../../src/question"
import { Bus } from "../../src/bus"

const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })

const questions: Question.AskInput["questions"] = [
{
question: "What is your name?",
header: "Name",
options: [
{ description: "Julian", label: "Julian (the best name)" },
{ description: "Other", label: "Other" },
],
},
]

describe("question.ask endpoint", () => {
test("should return simple ID when asked a question", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
// #given
const session = await Session.create({})
const app = Server.App()

// #when
const ask = await app.request("/question/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: session.id, questions } satisfies Question.AskInput),
})
const responses = await app.request(`/question`, { method: "GET" })

// #then
expect(ask.status).toBe(200)
const { id } = await ask.json()
expect(id).toMatch(/^que_/)

expect(responses.status).toBe(200)
const [response] = await responses.json()
expect(response).toMatchObject({ id, questions, sessionID: expect.stringMatching(/^ses_/) })

await Session.remove(session.id)
},
})
})

test("should return 404 when session does not exist", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
// #given
const nonExistentSessionID = "ses_nonexistent123"

// #when
const app = Server.App()
const response = await app.request("/question/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: nonExistentSessionID, questions }),
})

// #then
expect(response.status).toBe(404)
},
})
})

test("should return 400 when session ID format is invalid", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
// #given
const invalidSessionID = "invalid_session_id"

// #when
const app = Server.App()
const response = await app.request("/question/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: invalidSessionID, questions }),
})

// #then
expect(response.status).toBe(400)
},
})
})
})
2 changes: 1 addition & 1 deletion packages/opencode/test/tool/question.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe("tool.question", () => {

beforeEach(() => {
askSpy = spyOn(QuestionModule.Question, "ask").mockImplementation(async () => {
return []
return [] as any
})
})

Expand Down
38 changes: 38 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ import type {
PtyUpdateErrors,
PtyUpdateResponses,
QuestionAnswer,
QuestionAskErrors,
QuestionAskInput,
QuestionAskResponses,
QuestionListResponses,
QuestionRejectErrors,
QuestionRejectResponses,
Expand Down Expand Up @@ -1962,6 +1965,41 @@ export class Question extends HeyApiClient {
})
}

/**
* Ask a question
*
* Ask a question to the AI assistant.
*/
public ask<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
questionAskInput?: QuestionAskInput
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ key: "questionAskInput", map: "body" },
],
},
],
)
return (options?.client ?? this.client).post<QuestionAskResponses, QuestionAskErrors, ThrowOnError>({
url: "/question/ask",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}

/**
* Reply to question request
*
Expand Down
47 changes: 46 additions & 1 deletion packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,6 @@ export type QuestionInfo = {
}

export type QuestionRequest = {
id: string
sessionID: string
/**
* Questions to ask
Expand All @@ -597,6 +596,7 @@ export type QuestionRequest = {
messageID: string
callID: string
}
id: string
}

export type EventQuestionAsked = {
Expand Down Expand Up @@ -2022,6 +2022,18 @@ export type SubtaskPartInput = {
command?: string
}

export type QuestionAskInput = {
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
tool?: {
messageID: string
callID: string
}
}

export type ProviderAuthMethod = {
type: "oauth" | "api"
label: string
Expand Down Expand Up @@ -3859,6 +3871,39 @@ export type QuestionListResponses = {

export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses]

export type QuestionAskData = {
body?: QuestionAskInput
path?: never
query?: {
directory?: string
}
url: "/question/ask"
}

export type QuestionAskErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Not found
*/
404: NotFoundError
}

export type QuestionAskError = QuestionAskErrors[keyof QuestionAskErrors]

export type QuestionAskResponses = {
/**
* Created question request
*/
200: {
id: string
}
}

export type QuestionAskResponse = QuestionAskResponses[keyof QuestionAskResponses]

export type QuestionReplyData = {
body?: {
/**
Expand Down
Loading