diff --git a/.opencode/.gitignore b/.opencode/.gitignore index 03445edaf2..d3bf7f8d3b 100644 --- a/.opencode/.gitignore +++ b/.opencode/.gitignore @@ -1,4 +1,6 @@ -plans/ -bun.lock +node_modules +plans package.json -package-lock.json +bun.lock +.gitignore +package-lock.json \ No newline at end of file diff --git a/.opencode/agent/translator.md b/.opencode/agent/translator.md index 6ef6d0847a..a987d01927 100644 --- a/.opencode/agent/translator.md +++ b/.opencode/agent/translator.md @@ -1,7 +1,7 @@ --- description: Translate content for a specified locale while preserving technical terms mode: subagent -model: opencode/gemini-3.1-pro +model: opencode/gpt-5.4 --- You are a professional translator and localization specialist. diff --git a/.opencode/tool/github-pr-search.ts b/.opencode/tool/github-pr-search.ts index 587fdfaaf2..927e68fd73 100644 --- a/.opencode/tool/github-pr-search.ts +++ b/.opencode/tool/github-pr-search.ts @@ -1,7 +1,5 @@ /// import { tool } from "@opencode-ai/plugin" -import DESCRIPTION from "./github-pr-search.txt" - async function githubFetch(endpoint: string, options: RequestInit = {}) { const response = await fetch(`https://api.github.com${endpoint}`, { ...options, @@ -24,7 +22,16 @@ interface PR { } export default tool({ - description: DESCRIPTION, + description: `Use this tool to search GitHub pull requests by title and description. + +This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including: +- PR number and title +- Author +- State (open/closed/merged) +- Labels +- Description snippet + +Use the query parameter to search for keywords that might appear in PR titles or descriptions.`, args: { query: tool.schema.string().describe("Search query for PR titles and descriptions"), limit: tool.schema.number().describe("Maximum number of results to return").default(10), diff --git a/.opencode/tool/github-pr-search.txt b/.opencode/tool/github-pr-search.txt deleted file mode 100644 index 1b658e71c4..0000000000 --- a/.opencode/tool/github-pr-search.txt +++ /dev/null @@ -1,10 +0,0 @@ -Use this tool to search GitHub pull requests by title and description. - -This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including: -- PR number and title -- Author -- State (open/closed/merged) -- Labels -- Description snippet - -Use the query parameter to search for keywords that might appear in PR titles or descriptions. diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index ed80f49d54..c06d2407fe 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -1,7 +1,5 @@ /// import { tool } from "@opencode-ai/plugin" -import DESCRIPTION from "./github-triage.txt" - const TEAM = { desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], zen: ["fwang", "MrMushrooooom"], @@ -40,7 +38,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) { } export default tool({ - description: DESCRIPTION, + description: `Use this tool to assign and/or label a GitHub issue. + +Choose labels and assignee using the current triage policy and ownership rules. +Pick the most fitting labels for the issue and assign one owner. + +If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`, args: { assignee: tool.schema .enum(ASSIGNEES as [string, ...string[]]) diff --git a/.opencode/tool/github-triage.txt b/.opencode/tool/github-triage.txt deleted file mode 100644 index 4369ed2351..0000000000 --- a/.opencode/tool/github-triage.txt +++ /dev/null @@ -1,6 +0,0 @@ -Use this tool to assign and/or label a GitHub issue. - -Choose labels and assignee using the current triage policy and ownership rules. -Pick the most fitting labels for the issue and assign one owner. - -If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random. 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/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 8ee899f18e..297cdb9fc9 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" +import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" 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/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 2914ebbdd0..59658f2247 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -644,6 +644,8 @@ export const dict = { "تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.", "workspace.lite.promo.subscribe": "الاشتراك في Go", "workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...", + "workspace.lite.promo.otherMethods": "طرق دفع أخرى", + "workspace.lite.promo.selectMethod": "اختر طريقة الدفع", "download.title": "OpenCode | تنزيل", "download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 982bfb85bd..36edd3192b 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -654,6 +654,8 @@ export const dict = { "O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Preços e limites de uso podem mudar conforme aprendemos com o uso inicial e feedback.", "workspace.lite.promo.subscribe": "Assinar Go", "workspace.lite.promo.subscribing": "Redirecionando...", + "workspace.lite.promo.otherMethods": "Outros métodos de pagamento", + "workspace.lite.promo.selectMethod": "Selecionar método de pagamento", "download.title": "OpenCode | Baixar", "download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index a2dda137d0..1246f12135 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -651,6 +651,8 @@ export const dict = { "Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af tidlig brug og feedback.", "workspace.lite.promo.subscribe": "Abonner på Go", "workspace.lite.promo.subscribing": "Omdirigerer...", + "workspace.lite.promo.otherMethods": "Andre betalingsmetoder", + "workspace.lite.promo.selectMethod": "Vælg betalingsmetode", "download.title": "OpenCode | Download", "download.meta.description": "Download OpenCode til macOS, Windows og Linux", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 48d9e9cc97..bf26856e35 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -654,6 +654,8 @@ export const dict = { "Der Plan wurde hauptsächlich für internationale Nutzer entwickelt, wobei die Modelle in den USA, der EU und Singapur gehostet werden, um einen stabilen weltweiten Zugriff zu gewährleisten. Preise und Nutzungslimits können sich ändern, während wir aus der frühen Nutzung und dem Feedback lernen.", "workspace.lite.promo.subscribe": "Go abonnieren", "workspace.lite.promo.subscribing": "Leite weiter...", + "workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden", + "workspace.lite.promo.selectMethod": "Zahlungsmethode auswählen", "download.title": "OpenCode | Download", "download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index bd90f42a2d..212f5167a4 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -646,6 +646,8 @@ export const dict = { "The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Pricing and usage limits may change as we learn from early usage and feedback.", "workspace.lite.promo.subscribe": "Subscribe to Go", "workspace.lite.promo.subscribing": "Redirecting...", + "workspace.lite.promo.otherMethods": "Other payment methods", + "workspace.lite.promo.selectMethod": "Select payment method", "download.title": "OpenCode | Download", "download.meta.description": "Download OpenCode for macOS, Windows, and Linux", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 43a3e9d7f4..9299efb3d2 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -654,6 +654,8 @@ export const dict = { "El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., la UE y Singapur para un acceso global estable. Los precios y los límites de uso pueden cambiar a medida que aprendemos del uso inicial y los comentarios.", "workspace.lite.promo.subscribe": "Suscribirse a Go", "workspace.lite.promo.subscribing": "Redirigiendo...", + "workspace.lite.promo.otherMethods": "Otros métodos de pago", + "workspace.lite.promo.selectMethod": "Seleccionar método de pago", "download.title": "OpenCode | Descargar", "download.meta.description": "Descarga OpenCode para macOS, Windows y Linux", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 38b284c46a..d31226afe8 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -661,6 +661,8 @@ export const dict = { "Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Les tarifs et les limites d'utilisation peuvent changer à mesure que nous apprenons des premières utilisations et des commentaires.", "workspace.lite.promo.subscribe": "S'abonner à Go", "workspace.lite.promo.subscribing": "Redirection...", + "workspace.lite.promo.otherMethods": "Autres méthodes de paiement", + "workspace.lite.promo.selectMethod": "Sélectionner la méthode de paiement", "download.title": "OpenCode | Téléchargement", "download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index c4ee970530..3a99abe850 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -652,6 +652,8 @@ export const dict = { "Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati in US, EU e Singapore per un accesso globale stabile. I prezzi e i limiti di utilizzo potrebbero cambiare man mano che impariamo dall'utilizzo iniziale e dal feedback.", "workspace.lite.promo.subscribe": "Abbonati a Go", "workspace.lite.promo.subscribing": "Reindirizzamento...", + "workspace.lite.promo.otherMethods": "Altri metodi di pagamento", + "workspace.lite.promo.selectMethod": "Seleziona metodo di pagamento", "download.title": "OpenCode | Download", "download.meta.description": "Scarica OpenCode per macOS, Windows e Linux", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 0d506a0f36..9bdc0a5272 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -653,6 +653,8 @@ export const dict = { "このプランは主にグローバルユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。料金と利用制限は、初期の利用状況やフィードバックに基づいて変更される可能性があります。", "workspace.lite.promo.subscribe": "Goを購読する", "workspace.lite.promo.subscribing": "リダイレクト中...", + "workspace.lite.promo.otherMethods": "その他の支払い方法", + "workspace.lite.promo.selectMethod": "支払い方法を選択", "download.title": "OpenCode | ダウンロード", "download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 5e11a1af7d..7dd4bfbf23 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -645,6 +645,8 @@ export const dict = { "이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU 및 싱가포르에 모델이 호스팅되어 있습니다. 가격 및 사용 한도는 초기 사용을 통해 학습하고 피드백을 수집함에 따라 변경될 수 있습니다.", "workspace.lite.promo.subscribe": "Go 구독하기", "workspace.lite.promo.subscribing": "리디렉션 중...", + "workspace.lite.promo.otherMethods": "기타 결제 수단", + "workspace.lite.promo.selectMethod": "결제 수단 선택", "download.title": "OpenCode | 다운로드", "download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 282872a98d..334f3dfb05 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -651,6 +651,8 @@ export const dict = { "Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Priser og bruksgrenser kan endres etter hvert som vi lærer fra tidlig bruk og tilbakemeldinger.", "workspace.lite.promo.subscribe": "Abonner på Go", "workspace.lite.promo.subscribing": "Omdirigerer...", + "workspace.lite.promo.otherMethods": "Andre betalingsmetoder", + "workspace.lite.promo.selectMethod": "Velg betalingsmetode", "download.title": "OpenCode | Last ned", "download.meta.description": "Last ned OpenCode for macOS, Windows og Linux", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index d33cc4c8ce..9daa538cb9 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -652,6 +652,8 @@ export const dict = { "Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Ceny i limity użycia mogą ulec zmianie w miarę analizy wczesnego użycia i zbierania opinii.", "workspace.lite.promo.subscribe": "Subskrybuj Go", "workspace.lite.promo.subscribing": "Przekierowywanie...", + "workspace.lite.promo.otherMethods": "Inne metody płatności", + "workspace.lite.promo.selectMethod": "Wybierz metodę płatności", "download.title": "OpenCode | Pobierz", "download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 918ea8d4b7..58a4a0d91d 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -658,6 +658,8 @@ export const dict = { "План предназначен в первую очередь для международных пользователей. Модели размещены в США, ЕС и Сингапуре для стабильного глобального доступа. Цены и лимиты использования могут меняться по мере того, как мы изучаем раннее использование и собираем отзывы.", "workspace.lite.promo.subscribe": "Подписаться на Go", "workspace.lite.promo.subscribing": "Перенаправление...", + "workspace.lite.promo.otherMethods": "Другие способы оплаты", + "workspace.lite.promo.selectMethod": "Выберите способ оплаты", "download.title": "OpenCode | Скачать", "download.meta.description": "Скачать OpenCode для macOS, Windows и Linux", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index e6572bc8f0..75c8b53b30 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -648,6 +648,8 @@ export const dict = { "แผนนี้ออกแบบมาสำหรับผู้ใช้งานต่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์อยู่ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงที่เสถียรทั่วโลก ราคาและขีดจำกัดการใช้งานอาจมีการเปลี่ยนแปลงตามที่เราได้เรียนรู้จากการใช้งานในช่วงแรกและข้อเสนอแนะ", "workspace.lite.promo.subscribe": "สมัครสมาชิก Go", "workspace.lite.promo.subscribing": "กำลังเปลี่ยนเส้นทาง...", + "workspace.lite.promo.otherMethods": "วิธีการชำระเงินอื่นๆ", + "workspace.lite.promo.selectMethod": "เลือกวิธีการชำระเงิน", "download.title": "OpenCode | ดาวน์โหลด", "download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 37863bf2f8..2d7ea4ea87 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -655,6 +655,8 @@ export const dict = { "Plan öncelikle uluslararası kullanıcılar için tasarlanmıştır; modeller istikrarlı küresel erişim için ABD, AB ve Singapur'da barındırılmaktadır. Erken kullanımdan öğrendikçe ve geri bildirim topladıkça fiyatlandırma ve kullanım limitleri değişebilir.", "workspace.lite.promo.subscribe": "Go'ya Abone Ol", "workspace.lite.promo.subscribing": "Yönlendiriliyor...", + "workspace.lite.promo.otherMethods": "Diğer ödeme yöntemleri", + "workspace.lite.promo.selectMethod": "Ödeme yöntemini seçin", "download.title": "OpenCode | İndir", "download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 6fcf96f1e3..2cf88a0f98 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -626,6 +626,8 @@ export const dict = { "该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保全球范围内的稳定访问体验。定价和使用额度可能会根据早期用户的使用情况和反馈持续调整与优化。", "workspace.lite.promo.subscribe": "订阅 Go", "workspace.lite.promo.subscribing": "正在重定向...", + "workspace.lite.promo.otherMethods": "其他付款方式", + "workspace.lite.promo.selectMethod": "选择付款方式", "download.title": "OpenCode | 下载", "download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index ec34af07db..a6155b7d4a 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -626,6 +626,8 @@ export const dict = { "該計畫主要面向國際用戶設計,模型部署在美國、歐盟和新加坡,以確保全球範圍內的穩定存取體驗。定價和使用額度可能會根據早期用戶的使用情況和回饋持續調整與優化。", "workspace.lite.promo.subscribe": "訂閱 Go", "workspace.lite.promo.subscribing": "重新導向中...", + "workspace.lite.promo.otherMethods": "其他付款方式", + "workspace.lite.promo.selectMethod": "選擇付款方式", "download.title": "OpenCode | 下載", "download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode", 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..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")}

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

    - + +
    + 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")} - +
    + + +
    +
    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" }