Skip to content

Commit d9ecbbc

Browse files
thdxrkitlangtonrekram1-node
authored
fix: show the Go usage limit's reset time, Go page link and a composer tray (#60284)
Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
1 parent d3ad3da commit d9ecbbc

11 files changed

Lines changed: 358 additions & 65 deletions

File tree

‎packages/app/e2e/regression/composer.spec.ts‎

Lines changed: 119 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,12 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a
107107
id: "opencode",
108108
name: "OpenCode",
109109
models: {
110-
"big-pickle": { id: "big-pickle", name: "Big Pickle", cost: { input: 0, output: 0 }, limit: { context: 200_000 } },
110+
"big-pickle": {
111+
id: "big-pickle",
112+
name: "Big Pickle",
113+
cost: { input: 0, output: 0 },
114+
limit: { context: 200_000 },
115+
},
111116
},
112117
},
113118
...(state.authorized
@@ -117,7 +122,12 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a
117122
integrationID: "opencode-go",
118123
name: "OpenCode Go",
119124
models: {
120-
"glm-5-go": { id: "glm-5-go", name: "GLM-5 Go", cost: { input: 1, output: 1 }, limit: { context: 200_000 } },
125+
"glm-5-go": {
126+
id: "glm-5-go",
127+
name: "GLM-5 Go",
128+
cost: { input: 1, output: 1 },
129+
limit: { context: 200_000 },
130+
},
121131
},
122132
},
123133
]
@@ -131,7 +141,9 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a
131141
id: "opencode",
132142
name: "OpenCode Console",
133143
methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }],
134-
connections: state.authorized ? [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }] : [],
144+
connections: state.authorized
145+
? [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }]
146+
: [],
135147
},
136148
{
137149
id: "opencode-go",
@@ -261,62 +273,116 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a
261273
await expect(view.editor).toHaveText("")
262274
})
263275

264-
test("the Go usage limit restores the prompt and leaves a muted notice without a dialog", async ({ page }) => {
265-
await page.clock.setFixedTime(T0)
266-
const sessionID = "ses_go_limit"
267-
const model = { id: "glm-5-go", providerID: "opencode-go" }
268-
269-
const view = await openSession(page, {
270-
name: "GoLimit",
271-
sessions: [{ id: sessionID, agent: "build", model }],
272-
onFileWrite: () => undefined,
273-
pageMessages: () => ({
274-
items: [
275-
{ id: "msg_go_user", type: "user", text: "Refactor the parser", time: { created: T0 } },
276-
{ id: "msg_go_assistant", type: "assistant", agent: "build", model, content: [], time: { created: T0 + 1 } },
277-
],
278-
}),
279-
})
280-
281-
await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible()
276+
for (const action of ["tray", "picker"] as const) {
277+
test(`the Go usage limit restores the prompt and dismisses the tray when switching models through the ${action}`, async ({
278+
page,
279+
context,
280+
}) => {
281+
await page.clock.setFixedTime(T0)
282+
await context.route("https://opencode.ai/**", (route) => route.fulfill({ body: "Go" }))
283+
const sessionID = "ses_go_limit"
284+
const model = { id: "glm-5-go", providerID: "opencode-go" }
285+
286+
const view = await openSession(page, {
287+
name: "GoLimit",
288+
sessions: [{ id: sessionID, agent: "build", model }],
289+
onFileWrite: () => undefined,
290+
provider: () => ({
291+
all: [
292+
{
293+
id: "opencode",
294+
name: "OpenCode",
295+
models: {
296+
"big-pickle": {
297+
id: "big-pickle",
298+
name: "Big Pickle",
299+
cost: { input: 0, output: 0 },
300+
limit: { context: 200_000 },
301+
},
302+
},
303+
},
304+
{
305+
id: "opencode-go",
306+
name: "OpenCode Go",
307+
models: {
308+
"glm-5-go": {
309+
id: "glm-5-go",
310+
name: "GLM-5 Go",
311+
cost: { input: 1, output: 1 },
312+
limit: { context: 200_000 },
313+
},
314+
},
315+
},
316+
],
317+
connected: ["opencode", "opencode-go"],
318+
default: { build: "opencode-go/glm-5-go" },
319+
}),
320+
pageMessages: () => ({
321+
items: [
322+
{ id: "msg_go_user", type: "user", text: "Refactor the parser", time: { created: T0 } },
323+
{ id: "msg_go_assistant", type: "assistant", agent: "build", model, content: [], time: { created: T0 + 1 } },
324+
],
325+
}),
326+
})
282327

283-
const durable = (seq: number) => ({ aggregateID: sessionID, seq, version: 1 as const })
284-
const location = { directory: view.directory }
328+
await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible()
285329

286-
const body = {
287-
type: "error",
288-
error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" },
289-
metadata: { workspace: "wrk_go_limit", limitName: "5 hour" },
290-
}
330+
// The refusal OpenCode Console sends a Go subscriber at a limit, as core records it.
331+
const body = {
332+
type: "error",
333+
error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" },
334+
metadata: { workspace: "wrk_go_limit", limitName: "5 hour" },
335+
}
291336

292-
await view.push([
293-
{
294-
id: "evt_go_limit_1",
295-
created: T0 + 2,
296-
type: "session.step.failed",
297-
location,
298-
durable: durable(1),
299-
data: {
300-
sessionID,
301-
assistantMessageID: "msg_go_assistant",
302-
error: {
303-
type: "provider.go-limit",
304-
message: "OpenCode Go usage limit reached.",
305-
status: 429,
306-
response: { body: JSON.stringify(body) },
307-
retryAfterMs: 3_600_000,
337+
await view.push([
338+
{
339+
id: "evt_go_limit_1",
340+
created: T0 + 2,
341+
type: "session.step.failed",
342+
location: { directory: view.directory },
343+
durable: { aggregateID: sessionID, seq: 1, version: 1 },
344+
data: {
345+
sessionID,
346+
assistantMessageID: "msg_go_assistant",
347+
error: {
348+
type: "provider.go-limit",
349+
message: "OpenCode Go 5 hour usage limit reached.",
350+
status: 429,
351+
response: { body: JSON.stringify(body) },
352+
retryAfterMs: 3_600_000,
353+
},
308354
},
309355
},
310-
},
311-
])
356+
])
312357

313-
await expect(
314-
page.getByRole("status").filter({ hasText: /^You've reached your OpenCode Go usage limit\. It resets at / }),
315-
).toBeVisible()
316-
await expect(view.editor).toHaveText("Refactor the parser")
317-
await expect(page.getByText("OpenCode Go usage limit reached.")).toHaveCount(0)
318-
await expect(page.getByRole("dialog")).toHaveCount(0)
319-
})
358+
const tray = page.locator('[data-component="session-go-limit-tray"]')
359+
await expect(tray).toContainText(/Go 5 hour usage limit reached\. It resets at .+?\./)
360+
await expect(
361+
page.getByRole("status").filter({ hasText: /^You've reached your OpenCode Go usage limit\. It resets at / }),
362+
).toBeVisible()
363+
await expect(view.editor).toHaveText("Refactor the parser")
364+
await expect(page.getByText("OpenCode Go 5 hour usage limit reached.")).toHaveCount(0)
365+
await expect(page.getByRole("dialog")).toHaveCount(0)
366+
367+
// Extra Usage is turned on from the workspace's Go page in OpenCode Console.
368+
const popup = page.waitForEvent("popup")
369+
await tray.getByRole("button", { name: "Turn on Extra Usage", exact: true }).click()
370+
await expect(await popup).toHaveURL("https://opencode.ai/console/wrk_go_limit/go")
371+
372+
if (action === "tray") await tray.getByRole("button", { name: "Switch to a free model", exact: true }).click()
373+
374+
if (action === "picker") {
375+
await page.locator('[data-action="composer-model"]').click()
376+
const picker = page.getByRole("dialog", { name: "Select model", exact: true })
377+
await picker.getByRole("combobox", { name: "Search models", exact: true }).fill("Big Pickle")
378+
await picker.getByRole("option", { name: /Big Pickle/ }).click()
379+
}
380+
381+
await expect(page.locator('[data-action="composer-model"]')).toContainText("Big Pickle")
382+
await expect(tray).toHaveCount(0)
383+
await expect(view.editor).toHaveText("Refactor the parser")
384+
})
385+
}
320386

321387
test("ChatGPT's usage limit opens the ChatGPT usage dialog", async ({ page }) => {
322388
await page.clock.setFixedTime(T0)

‎packages/app/src/runtime/i18n/en.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,11 @@ export const dict = {
602602
"session.background.moveRunning": "Move to background",
603603
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
604604
"command.session.background": "Move to background",
605+
"session.goLimit.title": "Go usage limit reached.",
606+
"session.goLimit.titleNamed": "Go {{name}} usage limit reached.",
607+
"session.goLimit.reset": "It resets at {{time}}.",
608+
"session.goLimit.extraUsage": "Turn on Extra Usage",
609+
"session.goLimit.switchFree": "Switch to a free model",
605610
"session.queue.count.one": "{{count}} queued",
606611
"session.queue.count.other": "{{count}} queued",
607612
"session.queue.steer": "Steer",
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js"
2+
import { Option, Schema } from "effect"
3+
import type { SessionStepFailed } from "@opencode/client/promise"
4+
import { Button } from "@opencode/ui/button"
5+
import { Icon } from "@opencode/ui/icon"
6+
import { IconButton } from "@opencode/ui/icon-button"
7+
import { useLanguage } from "@/runtime/i18n/language"
8+
import { usePlatform } from "@/runtime/platform/platform"
9+
import { useWorkspaceLocation } from "@/workspaces/location"
10+
import { isFreeModel } from "@/providers/models/models"
11+
import type { ModelSelection } from "@/providers/models/selection"
12+
import { formatLimitReset } from "../usage-exceeded-dialogs"
13+
import { Grow } from "./spring-height"
14+
15+
// OpenCode Console names the workspace and the limit (5 hour, weekly or monthly) in its Go usage limit refusal.
16+
const decodeGoLimitBody = Schema.decodeUnknownOption(
17+
Schema.fromJsonString(
18+
Schema.Struct({
19+
metadata: Schema.Struct({ workspace: Schema.optional(Schema.String), limitName: Schema.optional(Schema.String) }),
20+
}),
21+
),
22+
)
23+
24+
type GoLimit = { name?: string; reset?: string; link: string }
25+
26+
function goLimit(locale: string, event: SessionStepFailed): GoLimit {
27+
const metadata = Option.getOrUndefined(decodeGoLimitBody(event.data.error.response?.body))?.metadata
28+
const workspace = metadata?.workspace
29+
30+
return {
31+
name: metadata?.limitName,
32+
reset: formatLimitReset(locale, event.created, event.data.error.retryAfterMs),
33+
// The Go page is where the workspace turns on Extra Usage to keep going on its balance.
34+
link: workspace ? `https://opencode.ai/console/${encodeURIComponent(workspace)}/go` : "https://opencode.ai/console",
35+
}
36+
}
37+
38+
// A tray that grows out from under the composer, like the queue panel, when an OpenCode Go subscriber reaches a usage
39+
// limit: when Go is back, and how to keep going now.
40+
export function SessionGoLimitTray(props: {
41+
sessionID: string | undefined
42+
selection: () => ModelSelection | undefined
43+
}) {
44+
const language = useLanguage()
45+
const platform = usePlatform()
46+
const location = useWorkspaceLocation()
47+
const [limit, setLimit] = createSignal<GoLimit>()
48+
49+
const model = createMemo(() => {
50+
const current = props.selection()?.current()
51+
52+
return current && `${current.provider.id}:${current.id}`
53+
})
54+
55+
createEffect(on(model, () => setLimit(undefined), { defer: true }))
56+
57+
onCleanup(
58+
location().event.on("session.step.failed", (event) => {
59+
if (!props.sessionID || event.data.sessionID !== props.sessionID) return
60+
61+
if (event.data.error.type === "provider.go-limit") setLimit(goLimit(language.locale(), event))
62+
}),
63+
)
64+
65+
// The next step means the user found a way to keep going, so the tray has done its job.
66+
onCleanup(
67+
location().event.on("session.step.started", (event) => {
68+
if (event.data.sessionID === props.sessionID) setLimit(undefined)
69+
}),
70+
)
71+
72+
const title = () => {
73+
const name = limit()?.name
74+
75+
return name ? language.t("session.goLimit.titleNamed", { name }) : language.t("session.goLimit.title")
76+
}
77+
78+
const freeModel = () => props.selection()?.list().find(isFreeModel)
79+
80+
const switchToFree = () => {
81+
const model = freeModel()
82+
83+
if (!model) return
84+
props.selection()?.set({ providerID: model.provider.id, modelID: model.id }, { recent: true })
85+
setLimit(undefined)
86+
}
87+
88+
return (
89+
<Grow when={limit()} fade class="relative z-0">
90+
{(current) => (
91+
<div
92+
data-component="session-go-limit-tray"
93+
role="status"
94+
class="relative z-0 -mb-3 flex items-center gap-2.5 rounded-xl bg-v2-background-bg-base ps-3 pe-1.5 pt-1.5 pb-[18px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)]"
95+
>
96+
<div class="min-w-0 flex-1 truncate text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted [font-variant-numeric:tabular-nums]">
97+
<span class="font-[530] text-v2-text-text-base">{title()}</span>
98+
<Show when={current.reset}>{(time) => <> {language.t("session.goLimit.reset", { time: time() })}</>}</Show>
99+
</div>
100+
<div class="flex shrink-0 items-center gap-1">
101+
<Button
102+
data-action="session-go-limit-extra-usage"
103+
type="button"
104+
size="small"
105+
variant={freeModel() ? "ghost-faint" : "contrast"}
106+
class="![font-weight:530]"
107+
onClick={() => platform.openExternal(current.link)}
108+
>
109+
{language.t("session.goLimit.extraUsage")}
110+
</Button>
111+
<Show when={freeModel()}>
112+
<Button
113+
data-action="session-go-limit-free"
114+
type="button"
115+
size="small"
116+
variant="contrast"
117+
class="![font-weight:530]"
118+
onClick={switchToFree}
119+
>
120+
{language.t("session.goLimit.switchFree")}
121+
</Button>
122+
</Show>
123+
<IconButton
124+
data-action="session-go-limit-dismiss"
125+
type="button"
126+
size="small"
127+
variant="ghost-muted"
128+
icon={<Icon name="outline-xmark" />}
129+
aria-label={language.t("common.dismiss")}
130+
onClick={() => setLimit(undefined)}
131+
/>
132+
</div>
133+
</div>
134+
)}
135+
</Grow>
136+
)
137+
}

‎packages/app/src/session/composer/region.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { useSessionFork } from "../fork"
3737
import { SessionComposerRegion } from "./session-composer-region"
3838
import { createSessionComposerController, type SessionComposerController } from "./controller"
3939
import { SessionQueuePanel } from "./queue-panel"
40+
import { SessionGoLimitTray } from "./go-limit-tray"
4041
import { SessionActivityChips, type SessionActivityItem } from "./activity-chips"
4142
import { SessionComposerLane, useComposerLaneAlignment } from "./composer-lane"
4243
import { SessionTodoPill } from "./todo-pill"
@@ -578,6 +579,10 @@ export function ActiveSessionComposerRegion(props: {
578579
<SessionPluginPills session={props.view()} screen={props.screen} alignment={alignment} />
579580
</SessionComposerLane>
580581
<SessionQueuePanel queue={props.model.queue} hidden={props.model.region.state.blocked()} />
582+
<SessionGoLimitTray
583+
sessionID={props.session.identity.params.id}
584+
selection={() => props.model.composer.model.selection}
585+
/>
581586
</Show>
582587
}
583588
composer={

‎packages/app/src/session/usage-exceeded-dialogs.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const MAX_RETRY_AFTER_MS = 7 * 86_400_000
2222

2323
const CHATGPT_USAGE_LIMIT_WINDOW = 86_400_000 // 24 hrs
2424

25-
function formatLimitReset(locale: string, created: number, retryAfterMs: number | undefined) {
25+
export function formatLimitReset(locale: string, created: number, retryAfterMs: number | undefined) {
2626
if (
2727
retryAfterMs === undefined ||
2828
!Number.isFinite(retryAfterMs) ||

0 commit comments

Comments
 (0)