diff --git a/infra/console.ts b/infra/console.ts index 7b6f21001e..22652f2daa 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -122,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", { properties: { product: zenLiteProduct.id, price: zenLitePrice.id, + priceInr: 92900, firstMonth50Coupon: zenLiteCouponFirstMonth50.id, }, }) diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx index df7e067c28..0aaa302b3e 100644 --- a/packages/console/app/src/component/icon.tsx +++ b/packages/console/app/src/component/icon.tsx @@ -76,6 +76,19 @@ export function IconAlipay(props: JSX.SvgSVGAttributes) { ) } +export function IconUpi(props: JSX.SvgSVGAttributes) { + return ( + + + + + + ) +} + export function IconWechat(props: JSX.SvgSVGAttributes) { return ( diff --git a/packages/console/app/src/component/modal.css b/packages/console/app/src/component/modal.css index 1f47f395de..e71fd1a192 100644 --- a/packages/console/app/src/component/modal.css +++ b/packages/console/app/src/component/modal.css @@ -62,5 +62,6 @@ font-size: var(--font-size-lg); font-weight: 600; color: var(--color-text); + text-align: center; } } diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index 95cd9da21b..47fee05cf0 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -244,6 +244,7 @@ export async function POST(input: APIEvent) { customerID, enrichment: { type: productID === LiteData.productID() ? "lite" : "subscription", + currency: body.data.object.currency === "inr" ? "inr" : undefined, couponID, }, }), @@ -331,16 +332,17 @@ export async function POST(input: APIEvent) { ) if (!workspaceID) throw new Error("Workspace ID not found") - const amount = await Database.use((tx) => + const payment = await Database.use((tx) => tx .select({ amount: PaymentTable.amount, + enrichment: PaymentTable.enrichment, }) .from(PaymentTable) .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID))) - .then((rows) => rows[0]?.amount), + .then((rows) => rows[0]), ) - if (!amount) throw new Error("Payment not found") + if (!payment) throw new Error("Payment not found") await Database.transaction(async (tx) => { await tx @@ -350,12 +352,15 @@ export async function POST(input: APIEvent) { }) .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID))) - await tx - .update(BillingTable) - .set({ - balance: sql`${BillingTable.balance} - ${amount}`, - }) - .where(eq(BillingTable.workspaceID, workspaceID)) + // deduct balance only for top up + if (!payment.enrichment?.type) { + await tx + .update(BillingTable) + .set({ + balance: sql`${BillingTable.balance} - ${payment.amount}`, + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + } }) } })() diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx index 50e30585bd..4d9b0cabd5 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx @@ -3,7 +3,7 @@ import { createMemo, Match, Show, Switch, createEffect } from "solid-js" import { createStore } from "solid-js/store" import { Billing } from "@opencode-ai/console-core/billing.js" import { withActor } from "~/context/auth.withActor" -import { IconAlipay, IconCreditCard, IconStripe, IconWechat } from "~/component/icon" +import { IconAlipay, IconCreditCard, IconStripe, IconUpi, IconWechat } from "~/component/icon" import styles from "./billing-section.module.css" import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common" import { useI18n } from "~/context/i18n" @@ -211,6 +211,9 @@ export function BillingSection() { + + +
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..4a64eb1b24 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")}

- @@ -282,16 +291,60 @@ export function LiteSection() {
  • MiniMax M2.7
  • {i18n.t("workspace.lite.promo.footer")}

    - +
    + + +
    + setStore("showModal", false)} title="Select payment method"> +
    + + +
    +
    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/skill/skill.ts b/packages/opencode/src/skill/skill.ts index d7aeb911f3..5339691a01 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -204,7 +204,7 @@ export namespace Skill { const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { yield* Effect.promise(() => state.ensure()) - 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" }