Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/tui/src/feature-plugins/system/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,15 @@ export default Plugin.define({
return
}
errored.add(sessionID)
notify(context, sessionID, event.data.error.message, "error")
context.ui.toast.show({ sessionID, title: "Session failed", message: event.data.error.message, variant: "error" })
if (event.data.error.type !== "provider.free-limit") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This skips the desktop notification and the toast. Because errored is still set, ended() also skips the "Session done" notification. So when the TUI is unfocused, or the free limit hits a session the user isn't viewing (another tab, a subagent), nothing tells them the work stopped. The inline notice and the prompt recall only help once they open that session. Could we drop just the red toast and keep a neutral notification, e.g. notify(..., "Free usage limit reached", "error"), or show the toast only when sessionID isn't the session on screen?

notify(context, sessionID, event.data.error.message, "error")
context.ui.toast.show({
sessionID,
title: "Session failed",
message: event.data.error.message,
variant: "error",
})
}
ended(sessionID)
}),
]
Expand Down
70 changes: 67 additions & 3 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,35 @@ export function Session(props: {
current.submit()
})
const dialog = useDialog()
const handledFreeLimits = new Set<string>()
// Restore the prompt when the free allowance stops it.
onCleanup(
data.on("session.step.failed", (event) => {
if (event.data.sessionID !== route.sessionID || event.data.error.type !== "provider.free-limit") return
if (handledFreeLimits.has(event.data.assistantMessageID)) return
handledFreeLimits.add(event.data.assistantMessageID)
const current = prompt()
const wasEmpty = Boolean(
current &&
!current.current.text.trim() &&
!current.current.files?.length &&
!current.current.agents?.length &&
!current.current.skills?.length &&
!current.current.pasted.length,
)
if (!wasEmpty || !current) return
const list = messages()
const end = list.findIndex((item) => item.id === event.data.assistantMessageID)
for (let i = end - 1; i >= 0; i--) {
const item = list[i]
if (!item || item.type === "shell" || item.type === "idle") break
if (item.type === "user" && !data.session.input.has(route.sessionID, item.id)) {
current.set({ ...projectedPromptInput(item), pasted: [] })
break
}
}
}),
)
const runPendingAction = createSingleFlight<string>()
const mutatePending = async (action: PendingAction, inboxID: string, failureLabel?: string) => {
const result = await runPendingAction(inboxID, async () => {
Expand Down Expand Up @@ -1908,12 +1937,20 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
turnTokensPerSecond(props.message, messages(), ctx.messageIndex(props.message.id), ctx.legacyTurns()),
)
const interrupted = createMemo(() => props.message.error?.message === "Step interrupted")
const freeLimit = createMemo(() => props.message.error?.type === "provider.free-limit")
return (
<>
<Show when={props.message.error && !interrupted() && !props.message.retry}>
<box paddingLeft={3}>
<text fg={theme.text.feedback.error.base}>Error: {errorMessage(props.message.error)}</text>
</box>
<Show
when={freeLimit()}
fallback={
<box paddingLeft={3}>
<text fg={theme.text.feedback.error.base}>Error: {errorMessage(props.message.error)}</text>
</box>
}
>
<FreeLimitNotice message={props.message} />
</Show>
</Show>
<AssistantRetry retry={props.message.retry} />
<box paddingLeft={3} marginTop={props.message.retry || (props.message.error && !interrupted()) ? 1 : 0}>
Expand Down Expand Up @@ -2384,6 +2421,33 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
)
}

function freeLimitReset(delay: number | undefined, base: number) {
if (delay === undefined || !Number.isFinite(delay) || delay < 0 || delay > 7 * 86_400_000 || !Number.isFinite(base))
return
const at = base + delay
if (Number.isNaN(new Date(at).getTime())) return
const now = Date.now()
return new Date(at).toDateString() === new Date(now).toDateString() && at - now < 86_400_000
? Locale.time(at)
: Locale.datetime(at)
}

function FreeLimitNotice(props: { message: Pick<SessionMessageAssistant, "error" | "time"> }) {
const theme = useTheme()
const reset = createMemo(() =>
freeLimitReset(props.message.error?.retryAfterMs, props.message.time.completed ?? props.message.time.created),
)
return (
<box paddingLeft={3} flexDirection="column">
<text fg={theme.text.muted}>You've used today's free messages.{reset() ? ` They reset at ${reset()}.` : ""}</text>
<text fg={theme.text.muted}>
Upgrade to OpenCode Go → <span style={{ fg: theme.text.base }}>/connect</span> · Switch model →{" "}
<span style={{ fg: theme.text.base }}>/models</span>
</text>
</box>
)
}

function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme()
const [seconds, setSeconds] = createSignal(0)
Expand Down
192 changes: 192 additions & 0 deletions packages/tui/test/app-lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1701,6 +1701,198 @@ test("server plugin failures share one notice and use source names before an ID
expect(setup.captureCharFrame()).toContain("Open plugins")
})

test("free usage limit lists next steps, recalls the delivered prompt once, and skips the failure toast", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test runs four scenarios one after another: the first recall, clearing plus a duplicate event, delivering the queued message, then a second failure. It checks each step by counting matches with frame.split(...).length === N, which makes it hard to follow and hard to debug when it fails. Could you cut it down to the core case (notice renders, the delivered prompt is recalled rather than the queued one, no Error:) and maybe one short test for the duplicate event? The Session failed check is already covered in notifications.test.ts.

await using state = await tmpdir()
const session = {
id: "ses_free_limit",
projectID: "proj_test",
location: { directory },
title: "Free limit",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
}
const model = { id: "big-pickle", providerID: "opencode" }
const error = {
type: "provider.free-limit",
message: "Free usage limit reached.",
status: 429,
retryAfterMs: 3_600_000,
}
await using setup = await createAppFixture({
width: 100,
state: state.path,
args: { sessionID: session.id },
config: { animations: false, tabs: { mode: "off" } },
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({
data: [
{ id: "msg_queued_user", type: "user", text: "Queued follow-up", time: { created: 3 } },
{
id: "msg_free_assistant",
type: "assistant",
agent: "build",
model,
content: [],
time: { created: 2 },
},
{ id: "msg_free_user", type: "user", text: "Refactor the parser", time: { created: 1 } },
],
cursor: {},
})
if (url.pathname === `/api/session/${session.id}/inbox`)
return json({
data: [
{
id: "msg_queued_user",
sessionID: session.id,
type: "user",
delivery: "queue",
payload: { text: "Queued follow-up" },
time: { created: 3 },
},
],
})
if (url.pathname === `/api/session/${session.id}/permission`) return json({ data: [] })
return undefined
},
})
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Refactor the parser"))
setup.events.emit({
id: "evt_free_limit_1",
created: 3,
type: "session.step.failed",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant", error },
})
setup.events.emit({
id: "evt_free_limit_exec",
created: 3,
type: "session.execution.failed",
durable: { aggregateID: session.id, seq: 2, version: 1 },
data: { sessionID: session.id, error },
})
await setup.waitForFrame((frame) => frame.includes("You've used today's free messages. They reset at"))
const frame = setup.captureCharFrame()
expect(frame).toContain("Upgrade to OpenCode Go → /connect · Switch model → /models")
expect(frame).not.toContain("Error:")
expect(frame).not.toContain("Session failed")
// The delivered prompt (not the queued one) shows once in the transcript and once more back in the composer.
await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 3)
expect(setup.captureCharFrame().split("Queued follow-up").length).toBe(2)

// Clear the composer: a duplicate event for the same step does not re-restore into the cleared composer.
setup.mockInput.pressKey("c", { ctrl: true })
await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2)
setup.events.emit({
id: "evt_free_limit_dup",
created: 3,
type: "session.step.failed",
durable: { aggregateID: session.id, seq: 3, version: 1 },
data: {
sessionID: session.id,
assistantMessageID: "msg_free_assistant",
error: { ...error, retryAfterMs: undefined },
},
})
await setup.waitForFrame((frame) => !frame.includes("They reset at"))
expect(setup.captureCharFrame().split("Refactor the parser").length).toBe(2)

// With the composer still empty, delivering the next turn and hitting the limit again recalls that turn's prompt.
setup.events.emit({
id: "evt_free_limit_delivered",
created: 4,
type: "session.inbox.delivered",
durable: { aggregateID: session.id, seq: 4, version: 1 },
data: { sessionID: session.id, inboxID: "msg_queued_user" },
})
setup.events.emit({
id: "evt_free_limit_2",
created: 5,
type: "session.step.started",
durable: { aggregateID: session.id, seq: 5, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_2", agent: "build", model, started: 5 },
})
setup.events.emit({
id: "evt_free_limit_3",
created: 6,
type: "session.step.failed",
durable: { aggregateID: session.id, seq: 6, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_2", error },
})
await setup.waitForFrame(
(frame) =>
frame.split("You've used today's free messages").length === 3 && frame.split("Queued follow-up").length === 3,
)
})

test("free usage limit does not overwrite a draft while typing", async () => {
await using state = await tmpdir()
const session = {
id: "ses_free_limit_typing",
projectID: "proj_test",
location: { directory },
title: "Free limit typing",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
}
const model = { id: "big-pickle", providerID: "opencode" }
const error = {
type: "provider.free-limit",
message: "Free usage limit reached.",
status: 429,
retryAfterMs: 3_600_000,
}
await using setup = await createAppFixture({
width: 100,
state: state.path,
args: { sessionID: session.id },
config: { animations: false, tabs: { mode: "off" } },
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({
data: [
{
id: "msg_free_assistant",
type: "assistant",
agent: "build",
model,
content: [],
time: { created: 2 },
},
{ id: "msg_free_user", type: "user", text: "Refactor the parser", time: { created: 1 } },
],
cursor: {},
})
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
return json({ data: [] })
return undefined
},
})
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Refactor the parser"))
await setup.mockInput.typeText("Draft in progress")
await setup.waitForFrame((frame) => frame.includes("Draft in progress"))
setup.events.emit({
id: "evt_free_limit_typing_1",
created: 3,
type: "session.step.failed",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant", error },
})
await setup.waitForFrame((frame) => frame.includes("You've used today's free messages. They reset at"))
const frame = setup.captureCharFrame()
expect(frame).toContain("Draft in progress")
expect(frame.split("Refactor the parser").length).toBe(2)
})

test.each([44, 100])(
"retry countdown updates and clears with the retry lifecycle at width %s",
async (width) => {
Expand Down
13 changes: 13 additions & 0 deletions packages/tui/test/cli/cmd/tui/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,19 @@ describe("internal notifications TUI plugin", () => {
harness.emit(executionStarted("retry"))
harness.emit(executionFailed("failed-again"))
expect(harness.toasts).toHaveLength(2)

harness.emit(executionStarted("free-limit"))
harness.emit({
id: "free-limit-failed",
created: 0,
type: "session.execution.failed",
data: {
sessionID: "session",
error: { type: "provider.free-limit", message: "Free usage limit reached." },
},
} as OpenCodeEvent)
expect(harness.toasts).toHaveLength(2)
expect(harness.notifications).toHaveLength(2)
})

test.each<Route>([{ type: "home" }, { type: "session", sessionID: "other" }])(
Expand Down
Loading