diff --git a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
index 2311be3215..6da5c42ed0 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
@@ -6,6 +6,14 @@ import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./payment-section.module.css"
import { useI18n } from "~/context/i18n"
+function money(amount: number, currency?: string) {
+ const formatter =
+ currency === "inr"
+ ? new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" })
+ : new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
+ return formatter.format(amount / 100_000_000)
+}
+
const getPaymentsInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
@@ -81,6 +89,10 @@ export function PaymentSection() {
const date = new Date(payment.timeCreated)
const amount =
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
+ const currency =
+ payment.enrichment?.type === "subscription" || payment.enrichment?.type === "lite"
+ ? payment.enrichment.currency
+ : undefined
return (
@@ -88,7 +100,7 @@ export function PaymentSection() {
{payment.id}
- ${((amount ?? 0) / 100000000).toFixed(2)}
+ {money(amount, currency)}
{" "}
diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css
index a760753d04..05daf43b7a 100644
--- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css
@@ -188,8 +188,45 @@
line-height: 1.4;
}
- [data-slot="subscribe-button"] {
- align-self: flex-start;
+ [data-slot="subscribe-actions"] {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
margin-top: var(--space-4);
}
+
+ [data-slot="subscribe-button"] {
+ align-self: stretch;
+ }
+
+ [data-slot="other-methods"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ }
+
+ [data-slot="other-methods-icons"] {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ [data-slot="modal-actions"] {
+ display: flex;
+ gap: var(--space-3);
+ margin-top: var(--space-4);
+
+ button {
+ flex: 1;
+ }
+ }
+
+ [data-slot="method-button"] {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: var(--space-2);
+ height: 48px;
+ }
}
diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
index ccdda5b450..2f8ad8aba4 100644
--- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
@@ -1,6 +1,7 @@
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { createMemo, For, Show } from "solid-js"
+import { Modal } from "~/component/modal"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
@@ -14,6 +15,8 @@ import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error"
+import { IconAlipay, IconUpi } from "~/component/icon"
+
const queryLiteSubscription = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
@@ -78,22 +81,25 @@ function formatResetTime(seconds: number, i18n: ReturnType) {
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
}
-const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
- "use server"
- return json(
- await withActor(
- () =>
- Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl })
- .then((data) => ({ error: undefined, data }))
- .catch((e) => ({
- error: e.message as string,
- data: undefined,
- })),
- workspaceID,
- ),
- { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
- )
-}, "liteCheckoutUrl")
+const createLiteCheckoutUrl = action(
+ async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
+ "use server"
+ return json(
+ await withActor(
+ () =>
+ Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({
+ error: e.message as string,
+ data: undefined,
+ })),
+ workspaceID,
+ ),
+ { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
+ )
+ },
+ "liteCheckoutUrl",
+)
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
@@ -147,23 +153,30 @@ export function LiteSection() {
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
const useBalanceSubmission = useSubmission(setLiteUseBalance)
const [store, setStore] = createStore({
- redirecting: false,
+ loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
+ showModal: false,
})
+ const busy = createMemo(() => !!store.loading)
+
async function onClickSession() {
+ setStore("loading", "session")
const result = await sessionAction(params.id!, window.location.href)
if (result.data) {
- setStore("redirecting", true)
window.location.href = result.data
+ return
}
+ setStore("loading", undefined)
}
- async function onClickSubscribe() {
- const result = await checkoutAction(params.id!, window.location.href, window.location.href)
+ async function onClickSubscribe(method?: "alipay" | "upi") {
+ setStore("loading", method ?? "checkout")
+ const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
if (result.data) {
- setStore("redirecting", true)
window.location.href = result.data
+ return
}
+ setStore("loading", undefined)
}
return (
@@ -179,12 +192,8 @@ export function LiteSection() {
{i18n.t("workspace.lite.subscription.message")}
-
- {sessionSubmission.pending || store.redirecting
+
+ {store.loading === "session"
? i18n.t("workspace.lite.loading")
: i18n.t("workspace.lite.subscription.manage")}
@@ -282,16 +291,64 @@ export function LiteSection() {
MiniMax M2.7
{i18n.t("workspace.lite.promo.footer")}
-
+ onClickSubscribe()}
+ >
+ {store.loading === "checkout"
+ ? i18n.t("workspace.lite.promo.subscribing")
+ : i18n.t("workspace.lite.promo.subscribe")}
+
+ setStore("showModal", true)}
+ >
+ {i18n.t("workspace.lite.promo.otherMethods")}
+
+
+
+
+
+
+
+
+
setStore("showModal", false)}
+ title={i18n.t("workspace.lite.promo.selectMethod")}
>
- {checkoutSubmission.pending || store.redirecting
- ? i18n.t("workspace.lite.promo.subscribing")
- : i18n.t("workspace.lite.promo.subscribe")}
-
+
+ onClickSubscribe("alipay")}
+ >
+
+
+
+ {store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
+
+ onClickSubscribe("upi")}
+ >
+
+
+
+ {store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
+
+
+
>
diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts
index ee41652ef2..66b9806985 100644
--- a/packages/console/core/src/billing.ts
+++ b/packages/console/core/src/billing.ts
@@ -239,10 +239,11 @@ export namespace Billing {
z.object({
successUrl: z.string(),
cancelUrl: z.string(),
+ method: z.enum(["alipay", "upi"]).optional(),
}),
async (input) => {
const user = Actor.assert("user")
- const { successUrl, cancelUrl } = input
+ const { successUrl, cancelUrl, method } = input
const email = await User.getAuthEmail(user.properties.userID)
const billing = await Billing.get()
@@ -250,38 +251,102 @@ export namespace Billing {
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
- const session = await Billing.stripe().checkout.sessions.create({
- mode: "subscription",
- billing_address_collection: "required",
- line_items: [{ price: LiteData.priceID(), quantity: 1 }],
- discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
- ...(billing.customerID
- ? {
- customer: billing.customerID,
- customer_update: {
- name: "auto",
- address: "auto",
- },
+ const createSession = () =>
+ Billing.stripe().checkout.sessions.create({
+ mode: "subscription",
+ discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
+ ...(billing.customerID
+ ? {
+ customer: billing.customerID,
+ customer_update: {
+ name: "auto",
+ address: "auto",
+ },
+ }
+ : {
+ customer_email: email!,
+ }),
+ ...(() => {
+ if (method === "alipay") {
+ return {
+ line_items: [{ price: LiteData.priceID(), quantity: 1 }],
+ payment_method_types: ["alipay"],
+ adaptive_pricing: {
+ enabled: false,
+ },
+ }
}
- : {
- customer_email: email!,
- }),
- currency: "usd",
- tax_id_collection: {
- enabled: true,
- },
- success_url: successUrl,
- cancel_url: cancelUrl,
- subscription_data: {
- metadata: {
- workspaceID: Actor.workspace(),
- userID: user.properties.userID,
- type: "lite",
+ if (method === "upi") {
+ return {
+ line_items: [
+ {
+ price_data: {
+ currency: "inr",
+ product: LiteData.productID(),
+ recurring: {
+ interval: "month",
+ interval_count: 1,
+ },
+ unit_amount: LiteData.priceInr(),
+ },
+ quantity: 1,
+ },
+ ],
+ payment_method_types: ["upi"] as any,
+ adaptive_pricing: {
+ enabled: false,
+ },
+ }
+ }
+ return {
+ line_items: [{ price: LiteData.priceID(), quantity: 1 }],
+ billing_address_collection: "required",
+ }
+ })(),
+ tax_id_collection: {
+ enabled: true,
},
- },
- })
+ success_url: successUrl,
+ cancel_url: cancelUrl,
+ subscription_data: {
+ metadata: {
+ workspaceID: Actor.workspace(),
+ userID: user.properties.userID,
+ type: "lite",
+ },
+ },
+ })
- return session.url
+ try {
+ const session = await createSession()
+ return session.url
+ } catch (e: any) {
+ if (
+ e.type !== "StripeInvalidRequestError" ||
+ !e.message.includes("You cannot combine currencies on a single customer")
+ )
+ throw e
+
+ // get pending payment intent
+ const intents = await Billing.stripe().paymentIntents.search({
+ query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`,
+ })
+ if (intents.data.length === 0) throw e
+
+ for (const intent of intents.data) {
+ // get checkout session
+ const sessions = await Billing.stripe().checkout.sessions.list({
+ customer: billing.customerID!,
+ payment_intent: intent.id,
+ })
+
+ // delete pending payment intent
+ await Billing.stripe().checkout.sessions.expire(sessions.data[0].id)
+ }
+
+ const session = await createSession()
+ return session.url
+ }
},
)
diff --git a/packages/console/core/src/lite.ts b/packages/console/core/src/lite.ts
index 8c5b63d0c7..2c4a09f711 100644
--- a/packages/console/core/src/lite.ts
+++ b/packages/console/core/src/lite.ts
@@ -10,6 +10,7 @@ export namespace LiteData {
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
+ export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
export const firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon)
export const planName = fn(z.void(), () => "lite")
}
diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts
index a5c70c2115..b06ca8966d 100644
--- a/packages/console/core/src/schema/billing.sql.ts
+++ b/packages/console/core/src/schema/billing.sql.ts
@@ -88,6 +88,7 @@ export const PaymentTable = mysqlTable(
enrichment: json("enrichment").$type<
| {
type: "subscription" | "lite"
+ currency?: "inr"
couponID?: string
}
| {
diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts
index 5e2693ad86..6b842639ad 100644
--- a/packages/console/core/sst-env.d.ts
+++ b/packages/console/core/sst-env.d.ts
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts
index 5e2693ad86..6b842639ad 100644
--- a/packages/console/function/sst-env.d.ts
+++ b/packages/console/function/sst-env.d.ts
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts
index 5e2693ad86..6b842639ad 100644
--- a/packages/console/resource/sst-env.d.ts
+++ b/packages/console/resource/sst-env.d.ts
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts
index 5e2693ad86..6b842639ad 100644
--- a/packages/enterprise/sst-env.d.ts
+++ b/packages/enterprise/sst-env.d.ts
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts
index 5e2693ad86..6b842639ad 100644
--- a/packages/function/sst-env.d.ts
+++ b/packages/function/sst-env.d.ts
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}
diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts
index 444676046e..2f1304d505 100644
--- a/packages/opencode/src/account/effect.ts
+++ b/packages/opencode/src/account/effect.ts
@@ -148,6 +148,12 @@ export namespace AccountEffect {
mapAccountServiceError("HTTP request failed"),
)
+ const executeEffect =
(request: Effect.Effect) =>
+ request.pipe(
+ Effect.flatMap((req) => http.execute(req)),
+ mapAccountServiceError("HTTP request failed"),
+ )
+
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
@@ -290,7 +296,7 @@ export namespace AccountEffect {
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
- const response = yield* executeEffectOk(
+ const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index b2dae0402c..e30d05e935 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -260,7 +260,10 @@ export namespace Agent {
return pipe(
await state(),
values(),
- sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]),
+ sortBy(
+ [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
+ [(x) => x.name, "asc"],
+ ),
)
}
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
index b11ad6a734..09bb492f63 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
@@ -9,6 +9,7 @@ import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import { setTimeout as sleep } from "node:timers/promises"
async function openWorkspace(input: {
dialog: ReturnType
@@ -56,7 +57,7 @@ async function openWorkspace(input: {
return
}
if (result.response.status >= 500 && result.response.status < 600) {
- await Bun.sleep(1000)
+ await sleep(1000)
continue
}
if (!result.data) {
diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts
index d1f884f35a..b519895b2f 100644
--- a/packages/opencode/src/control-plane/workspace.ts
+++ b/packages/opencode/src/control-plane/workspace.ts
@@ -1,4 +1,5 @@
import z from "zod"
+import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project"
@@ -117,7 +118,7 @@ export namespace Workspace {
const adaptor = await getAdaptor(space.type)
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
if (!res || !res.ok || !res.body) {
- await Bun.sleep(1000)
+ await sleep(1000)
continue
}
await parseSSE(res.body, stop, (event) => {
@@ -127,7 +128,7 @@ export namespace Workspace {
})
})
// Wait 250ms and retry if SSE connection fails
- await Bun.sleep(250)
+ await sleep(250)
}
}
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
index e8800f437b..1ade02e2b5 100644
--- a/packages/opencode/src/skill/skill.ts
+++ b/packages/opencode/src/skill/skill.ts
@@ -216,7 +216,7 @@ export namespace Skill {
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
yield* Fiber.join(loadFiber)
- const list = Object.values(state.skills)
+ const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
if (!agent) return list
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
})
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index 14ecea1075..9cabf47eb1 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/packages/opencode/src/tool/task.ts
@@ -33,10 +33,11 @@ export const TaskTool = Tool.define("task", async (ctx) => {
const accessibleAgents = caller
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
+ const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = DESCRIPTION.replace(
"{agents}",
- accessibleAgents
+ list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
)
diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts
index 94cd9eb94d..098e00de50 100644
--- a/packages/opencode/test/account/service.test.ts
+++ b/packages/opencode/test/account/service.test.ts
@@ -34,6 +34,26 @@ const encodeOrg = Schema.encodeSync(Org)
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
+const login = () =>
+ new Login({
+ code: DeviceCode.make("device-code"),
+ user: UserCode.make("user-code"),
+ url: "https://one.example.com/verify",
+ server: "https://one.example.com",
+ expiry: Duration.seconds(600),
+ interval: Duration.seconds(5),
+ })
+
+const deviceTokenClient = (body: unknown, status = 400) =>
+ HttpClient.make((req) =>
+ Effect.succeed(
+ req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404),
+ ),
+ )
+
+const poll = (body: unknown, status = 400) =>
+ AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
+
it.effect("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () {
yield* AccountRepo.use((r) =>
@@ -172,15 +192,6 @@ it.effect("config sends the selected org header", () =>
it.effect("poll stores the account and first org on success", () =>
Effect.gen(function* () {
- const login = new Login({
- code: DeviceCode.make("device-code"),
- user: UserCode.make("user-code"),
- url: "https://one.example.com/verify",
- server: "https://one.example.com",
- expiry: Duration.seconds(600),
- interval: Duration.seconds(5),
- })
-
const client = HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/auth/device/token"
@@ -198,7 +209,7 @@ it.effect("poll stores the account and first org on success", () =>
),
)
- const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
+ const res = yield* AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") {
@@ -215,3 +226,59 @@ it.effect("poll stores the account and first org on success", () =>
)
}),
)
+
+for (const [name, body, expectedTag] of [
+ [
+ "pending",
+ {
+ error: "authorization_pending",
+ error_description: "The authorization request is still pending",
+ },
+ "PollPending",
+ ],
+ [
+ "slow",
+ {
+ error: "slow_down",
+ error_description: "Polling too frequently, please slow down",
+ },
+ "PollSlow",
+ ],
+ [
+ "denied",
+ {
+ error: "access_denied",
+ error_description: "The authorization request was denied",
+ },
+ "PollDenied",
+ ],
+ [
+ "expired",
+ {
+ error: "expired_token",
+ error_description: "The device code has expired",
+ },
+ "PollExpired",
+ ],
+] as const) {
+ it.effect(`poll returns ${name} for ${body.error}`, () =>
+ Effect.gen(function* () {
+ const result = yield* poll(body)
+ expect(result._tag).toBe(expectedTag)
+ }),
+ )
+}
+
+it.effect("poll returns poll error for other OAuth errors", () =>
+ Effect.gen(function* () {
+ const result = yield* poll({
+ error: "server_error",
+ error_description: "An unexpected error occurred",
+ })
+
+ expect(result._tag).toBe("PollError")
+ if (result._tag === "PollError") {
+ expect(String(result.cause)).toContain("server_error")
+ }
+ }),
+)
diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts
index d6b6ebb33b..60c8e57c92 100644
--- a/packages/opencode/test/agent/agent.test.ts
+++ b/packages/opencode/test/agent/agent.test.ts
@@ -384,6 +384,32 @@ test("multiple custom agents can be defined", async () => {
})
})
+test("Agent.list keeps the default agent first and sorts the rest by name", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ default_agent: "plan",
+ agent: {
+ zebra: {
+ description: "Zebra",
+ mode: "subagent",
+ },
+ alpha: {
+ description: "Alpha",
+ mode: "subagent",
+ },
+ },
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const names = (await Agent.list()).map((a) => a.name)
+ expect(names[0]).toBe("plan")
+ expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b)))
+ },
+ })
+})
+
test("Agent.get returns undefined for non-existent agent", async () => {
await using tmp = await tmpdir()
await Instance.provide({
diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts
new file mode 100644
index 0000000000..47f5f6fc25
--- /dev/null
+++ b/packages/opencode/test/session/system.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import { Agent } from "../../src/agent/agent"
+import { Instance } from "../../src/project/instance"
+import { SystemPrompt } from "../../src/session/system"
+import { tmpdir } from "../fixture/fixture"
+
+describe("session.system", () => {
+ test("skills output is sorted by name and stable across calls", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ for (const [name, description] of [
+ ["zeta-skill", "Zeta skill."],
+ ["alpha-skill", "Alpha skill."],
+ ["middle-skill", "Middle skill."],
+ ]) {
+ const skillDir = path.join(dir, ".opencode", "skill", name)
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: ${name}
+description: ${description}
+---
+
+# ${name}
+`,
+ )
+ }
+ },
+ })
+
+ const home = process.env.OPENCODE_TEST_HOME
+ process.env.OPENCODE_TEST_HOME = tmp.path
+
+ try {
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
+ const first = await SystemPrompt.skills(build!)
+ const second = await SystemPrompt.skills(build!)
+
+ expect(first).toBe(second)
+
+ const alpha = first!.indexOf("alpha-skill ")
+ const middle = first!.indexOf("middle-skill ")
+ const zeta = first!.indexOf("zeta-skill ")
+
+ expect(alpha).toBeGreaterThan(-1)
+ expect(middle).toBeGreaterThan(alpha)
+ expect(zeta).toBeGreaterThan(middle)
+ },
+ })
+ } finally {
+ process.env.OPENCODE_TEST_HOME = home
+ }
+ })
+})
diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts
index 7cfaee1353..f622341d33 100644
--- a/packages/opencode/test/tool/skill.test.ts
+++ b/packages/opencode/test/tool/skill.test.ts
@@ -54,6 +54,56 @@ description: Skill for tool tests.
}
})
+ test("description sorts skills by name and is stable across calls", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ for (const [name, description] of [
+ ["zeta-skill", "Zeta skill."],
+ ["alpha-skill", "Alpha skill."],
+ ["middle-skill", "Middle skill."],
+ ]) {
+ const skillDir = path.join(dir, ".opencode", "skill", name)
+ await Bun.write(
+ path.join(skillDir, "SKILL.md"),
+ `---
+name: ${name}
+description: ${description}
+---
+
+# ${name}
+`,
+ )
+ }
+ },
+ })
+
+ const home = process.env.OPENCODE_TEST_HOME
+ process.env.OPENCODE_TEST_HOME = tmp.path
+
+ try {
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const first = await SkillTool.init()
+ const second = await SkillTool.init()
+
+ expect(first.description).toBe(second.description)
+
+ const alpha = first.description.indexOf("**alpha-skill**: Alpha skill.")
+ const middle = first.description.indexOf("**middle-skill**: Middle skill.")
+ const zeta = first.description.indexOf("**zeta-skill**: Zeta skill.")
+
+ expect(alpha).toBeGreaterThan(-1)
+ expect(middle).toBeGreaterThan(alpha)
+ expect(zeta).toBeGreaterThan(middle)
+ },
+ })
+ } finally {
+ process.env.OPENCODE_TEST_HOME = home
+ }
+ })
+
test("execute returns skill content block with files", async () => {
await using tmp = await tmpdir({
git: true,
diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts
new file mode 100644
index 0000000000..df319d8de1
--- /dev/null
+++ b/packages/opencode/test/tool/task.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, test } from "bun:test"
+import { Agent } from "../../src/agent/agent"
+import { Instance } from "../../src/project/instance"
+import { TaskTool } from "../../src/tool/task"
+import { tmpdir } from "../fixture/fixture"
+
+describe("tool.task", () => {
+ test("description sorts subagents by name and is stable across calls", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ agent: {
+ zebra: {
+ description: "Zebra agent",
+ mode: "subagent",
+ },
+ alpha: {
+ description: "Alpha agent",
+ mode: "subagent",
+ },
+ },
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const build = await Agent.get("build")
+ const first = await TaskTool.init({ agent: build })
+ const second = await TaskTool.init({ agent: build })
+
+ expect(first.description).toBe(second.description)
+
+ const alpha = first.description.indexOf("- alpha: Alpha agent")
+ const explore = first.description.indexOf("- explore:")
+ const general = first.description.indexOf("- general:")
+ const zebra = first.description.indexOf("- zebra: Zebra agent")
+
+ expect(alpha).toBeGreaterThan(-1)
+ expect(explore).toBeGreaterThan(alpha)
+ expect(general).toBeGreaterThan(explore)
+ expect(zebra).toBeGreaterThan(general)
+ },
+ })
+ })
+})
diff --git a/sst-env.d.ts b/sst-env.d.ts
index e6bcc7ab11..c9e567997b 100644
--- a/sst-env.d.ts
+++ b/sst-env.d.ts
@@ -171,6 +171,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
+ "priceInr": number
"product": string
"type": "sst.sst.Linkable"
}