Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
env:
TUI_SMOKE_SKIP_BUILD: "1"
- run: bun run test
- run: npm install -g opencode-ai@1.17.13
- run: npm install -g opencode-ai@1.18.18
- run: bun run test:e2e
- run: bun run format:check
- run: bun run lint
147 changes: 98 additions & 49 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,9 @@ type PluginSessionClient = {
status?: () => Promise<unknown> | unknown
}

const DESKTOP_NOTICE_PROBE_LIMIT = 4
const DESKTOP_NOTICE_PROBE_DELAY_MS = 25

type PerfTrace = {
requestId: string
start: number
Expand Down Expand Up @@ -539,12 +542,7 @@ async function sendIgnoredMessage(
promptContext.latestUserMessageId,
)
: undefined
if (!messageID) {
throw new Error(
'OpenCode assistant ordering is unavailable for the fallback notification.',
)
}
request.body.messageID = messageID
if (messageID) request.body.messageID = messageID
}
if (promptContext?.agent) request.body.agent = promptContext.agent
if (promptContext?.model) request.body.model = promptContext.model
Expand Down Expand Up @@ -865,6 +863,9 @@ const anthropicAuthPlugin = async (
const serverFallbackTargets = new Map<string, string>()
const pendingDesktopNotices = new Map<string, string[]>()
const desktopNoticeFlushes = new Map<string, Promise<void>>()
const desktopNoticePostIdleUpdates = new Set<string>()
const desktopNoticeSafeSessions = new Set<string>()
const desktopNoticeProbes = new Map<string, number>()
const stickySessionRouter = new StickySessionRouter({
path:
process.env.OPENCODE_ANTHROPIC_AUTH_ROUTING_STATE_FILE ||
Expand Down Expand Up @@ -1781,11 +1782,8 @@ const anthropicAuthPlugin = async (

if (!desktopText || isTuiConnected(notice.sessionId)) return
// OpenCode's prompt endpoints run revert cleanup before honoring noReply.
// Creating a notification while an assistant is still streaming can race the
// active run and enqueue an extra provider turn. Queue it until OpenCode
// publishes the assistant's completed message update or becomes idle, then
// place it directly before that assistant in ID order. The status probe below
// closes the race where both events precede a delayed cache warm/outcome.
// OpenCode awaits event handlers before it evaluates the loop exit condition.
// Escape the post-idle session update, then probe outside that critical section.
const queue = pendingDesktopNotices.get(notice.sessionId) ?? []
queue.push(desktopText)
if (queue.length > 4) queue.splice(0, queue.length - 4)
Expand All @@ -1796,40 +1794,77 @@ const anthropicAuthPlugin = async (
if (oldest) pendingDesktopNotices.delete(oldest)
else break
}
void flushDesktopNoticesIfIdle(notice.sessionId)
if (desktopNoticeSafeSessions.has(notice.sessionId)) {
scheduleDesktopNoticeProbe(notice.sessionId)
}
}

async function flushDesktopNoticesIfIdle(sessionId: string): Promise<void> {
const session = ctx.client.session as PluginSessionClient | undefined
if (typeof session?.status !== 'function') return
function scheduleDesktopNoticeProbe(sessionId: string, attempt = 0) {
if (
!pendingDesktopNotices.has(sessionId) ||
desktopNoticeProbes.has(sessionId)
) {
return
}
desktopNoticeProbes.set(sessionId, attempt)
const run = () => {
if (desktopNoticeProbes.get(sessionId) !== attempt) return
desktopNoticeProbes.delete(sessionId)
void flushDesktopNoticesIfIdle(sessionId, attempt)
}
if (attempt === 0) {
setImmediate(run)
} else {
setTimeout(run, DESKTOP_NOTICE_PROBE_DELAY_MS * attempt)
}
}

try {
const response = await Promise.resolve(session.status())
const responseRecord =
response !== null && typeof response === 'object'
? (response as Record<string, unknown>)
: undefined
const data =
responseRecord && Object.hasOwn(responseRecord, 'data')
? responseRecord.data
: responseRecord
if (data === null || typeof data !== 'object' || Array.isArray(data))
return
const status = (data as Record<string, unknown>)[sessionId]
if (status === undefined) {
await flushDesktopNotices(sessionId)
function rearmDesktopNoticeProbe(sessionId: string, attempt: number) {
if (attempt + 1 < DESKTOP_NOTICE_PROBE_LIMIT) {
scheduleDesktopNoticeProbe(sessionId, attempt + 1)
}
}

async function flushDesktopNoticesIfIdle(sessionId: string, attempt: number) {
if (
!desktopNoticeSafeSessions.has(sessionId) ||
!pendingDesktopNotices.has(sessionId)
) {
return
}
const session = ctx.client.session as PluginSessionClient | undefined
if (typeof session?.status === 'function') {
try {
const response = await Promise.resolve(session.status())
const responseRecord =
response !== null && typeof response === 'object'
? (response as Record<string, unknown>)
: undefined
const data =
responseRecord && Object.hasOwn(responseRecord, 'data')
? responseRecord.data
: responseRecord
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
rearmDesktopNoticeProbe(sessionId, attempt)
return
}
const status = (data as Record<string, unknown>)[sessionId]
// OpenCode 1.17 and 1.18 omit idle sessions from this map.
if (
status !== undefined &&
(!status ||
typeof status !== 'object' ||
(status as { type?: unknown }).type !== 'idle')
) {
rearmDesktopNoticeProbe(sessionId, attempt)
return
}
} catch {
rearmDesktopNoticeProbe(sessionId, attempt)
return
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
if (
status !== null &&
typeof status === 'object' &&
(status as { type?: unknown }).type === 'idle'
) {
await flushDesktopNotices(sessionId)
}
} catch {
// Event-driven flushing remains the compatibility path for older hosts.
}
await flushDesktopNotices(sessionId)
}

function flushDesktopNotices(sessionId: string): Promise<void> {
Expand Down Expand Up @@ -2570,9 +2605,6 @@ const anthropicAuthPlugin = async (
info?: {
id?: string
sessionID?: string
role?: string
finish?: string
time?: { completed?: number }
}
status?: { type?: string }
}
Expand All @@ -2584,23 +2616,40 @@ const anthropicAuthPlugin = async (

if (
value.type === 'session.status' &&
value.properties?.status?.type === 'idle'
value.properties?.status?.type !== 'idle'
) {
await flushDesktopNotices(sessionId)
desktopNoticePostIdleUpdates.delete(sessionId)
desktopNoticeSafeSessions.delete(sessionId)
}

if (value.type === 'session.idle') {
desktopNoticePostIdleUpdates.add(sessionId)
while (desktopNoticePostIdleUpdates.size > 128) {
const oldest = desktopNoticePostIdleUpdates.values().next().value
if (oldest) desktopNoticePostIdleUpdates.delete(oldest)
else break
}
}

if (
value.type === 'message.updated' &&
info?.role === 'assistant' &&
info.finish !== 'tool-calls' &&
typeof info.time?.completed === 'number'
value.type === 'session.updated' &&
desktopNoticePostIdleUpdates.delete(sessionId)
) {
await flushDesktopNotices(sessionId)
desktopNoticeSafeSessions.add(sessionId)
while (desktopNoticeSafeSessions.size > 128) {
const oldest = desktopNoticeSafeSessions.values().next().value
if (oldest) desktopNoticeSafeSessions.delete(oldest)
else break
}
scheduleDesktopNoticeProbe(sessionId)
}

if (value.type === 'session.deleted') {
fableRecoveryNotices.delete(sessionId)
pendingDesktopNotices.delete(sessionId)
desktopNoticePostIdleUpdates.delete(sessionId)
desktopNoticeSafeSessions.delete(sessionId)
desktopNoticeProbes.delete(sessionId)
}
},
config: async (config: { command?: Record<string, unknown> }) => {
Expand Down
106 changes: 74 additions & 32 deletions packages/opencode/src/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7694,6 +7694,33 @@ describe('auth.loader', () => {
},
},
})
expect(mockClient.session.promptAsync).not.toHaveBeenCalled()

await plugin.event?.({
event: {
type: 'session.status',
properties: {
sessionID: 'ses_server_fallback',
status: { type: 'idle' },
},
},
})
expect(mockClient.session.promptAsync).not.toHaveBeenCalled()

await plugin.event?.({
event: {
type: 'session.idle',
properties: { sessionID: 'ses_server_fallback' },
},
})
expect(mockClient.session.promptAsync).not.toHaveBeenCalled()

await plugin.event?.({
event: {
type: 'session.updated',
properties: { sessionID: 'ses_server_fallback' },
},
})
await waitForMockCall(mockClient.session.promptAsync)
expect(mockClient.session.promptAsync.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
Expand All @@ -7711,8 +7738,8 @@ describe('auth.loader', () => {

const restoredResponse = await result.fetch(MESSAGES_URL, request)
// OpenCode can publish the assistant-completed event before the wrapped
// response emits its final fallback outcome. The later idle event must flush
// a notice queued after that completion event without starting another turn.
// response emits its final fallback outcome. The post-idle session update must
// flush a notice queued after that completion event without starting another turn.
await plugin.event?.({
event: {
type: 'message.updated',
Expand Down Expand Up @@ -7741,11 +7768,14 @@ describe('auth.loader', () => {
)
await plugin.event?.({
event: {
type: 'session.status',
properties: {
sessionID: 'ses_server_fallback',
status: { type: 'idle' },
},
type: 'session.idle',
properties: { sessionID: 'ses_server_fallback' },
},
})
await plugin.event?.({
event: {
type: 'session.updated',
properties: { sessionID: 'ses_server_fallback' },
},
})
await waitForMockCall({
Expand Down Expand Up @@ -7830,7 +7860,7 @@ describe('auth.loader', () => {

const latestUserMessageId = 'msg_000000000100AAAAAAAAAAAAAA'
const latestAssistantMessageId = 'msg_000000000200BBBBBBBBBBBBBB'
let sessionIdle = false
let noticeStatusChecks = 0
const mockClient = createMockClient(
[
{
Expand All @@ -7856,8 +7886,18 @@ describe('auth.loader', () => {
},
},
],
(): Record<string, { type: string }> =>
sessionIdle ? {} : { ses_fable_filter: { type: 'busy' } },
(): Record<string, { type: string }> => {
if (noticeStatusChecks++ === 0) {
throw new Error('transient status failure')
}
if (noticeStatusChecks === 2) {
return [] as unknown as Record<string, { type: string }>
}
if (noticeStatusChecks === 3) {
return { ses_fable_filter: { type: 'busy' } }
}
return {}
},
)
const plugin = await getPlugin(mockClient)
const result = await plugin.auth.loader(
Expand Down Expand Up @@ -7911,17 +7951,17 @@ describe('auth.loader', () => {
await firstOpus.text()
await plugin.event?.({
event: {
type: 'message.updated',
properties: {
info: {
id: latestAssistantMessageId,
sessionID: 'ses_fable_filter',
role: 'assistant',
time: { completed: Date.now() },
},
},
type: 'session.idle',
properties: { sessionID: 'ses_fable_filter' },
},
})
await plugin.event?.({
event: {
type: 'session.updated',
properties: { sessionID: 'ses_fable_filter' },
},
})
await waitForMockCall(mockClient.session.promptAsync)
expect(mockClient.session.promptAsync).toHaveBeenCalledTimes(1)
expect(mockClient.session.promptAsync.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
Expand Down Expand Up @@ -8032,14 +8072,16 @@ describe('auth.loader', () => {

// Reproduce the host race: OpenCode can publish idle while the final cache
// warm is still pending, before the restoration notice has been queued.
sessionIdle = true
await plugin.event?.({
event: {
type: 'session.status',
properties: {
sessionID: 'ses_fable_filter',
status: { type: 'idle' },
},
type: 'session.idle',
properties: { sessionID: 'ses_fable_filter' },
},
})
await plugin.event?.({
event: {
type: 'session.updated',
properties: { sessionID: 'ses_fable_filter' },
},
})
expect(mockClient.session.promptAsync).toHaveBeenCalledTimes(1)
Expand All @@ -8049,13 +8091,13 @@ describe('auth.loader', () => {
await restored.text()
expect(normalModels.at(-1)).toBe('claude-fable-5')

for (
let attempt = 0;
attempt < 100 && mockClient.session.promptAsync.mock.calls.length < 2;
attempt++
) {
await new Promise((resolve) => setTimeout(resolve, 1))
}
await waitForMockCall({
mock: {
get calls() {
return mockClient.session.promptAsync.mock.calls.slice(1)
},
},
})
expect(mockClient.session.promptAsync).toHaveBeenCalledTimes(2)
expect(mockClient.session.promptAsync.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
Expand Down