Skip to content

Commit 1c98838

Browse files
committed
fix(panel): make assignment delivery self-healing
1 parent 1a779ad commit 1c98838

10 files changed

Lines changed: 414 additions & 171 deletions

File tree

packages/browseros-agent/apps/app/lib/browseros/conversationPanelBroker.browser.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,9 @@ export function createConversationPanelBroker(): ConversationPanelBroker {
2020
},
2121
hasShownConfetti: () => firstRunConfettiShownStorage.getValue(),
2222
markConfettiShown: () => firstRunConfettiShownStorage.setValue(true),
23+
reportError: (error, context) => {
24+
// biome-ignore lint/suspicious/noConsole: MV3 background failures otherwise have no durable diagnostic surface.
25+
console.warn('[conversation-panel-broker]', context, error)
26+
},
2327
})
2428
}
Lines changed: 143 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import { describe, expect, it, mock } from 'bun:test'
2-
import type { ConversationPanelSnapshot } from '@browseros/shared/schemas/conversation-panels'
2+
import type { ConversationPanelAssignments } from '@browseros/shared/schemas/conversation-panels'
33
import {
44
ConversationPanelBroker,
55
type ConversationPanelBrokerDeps,
6+
type ConversationPanelBrokerErrorContext,
67
} from './conversationPanelBroker'
8+
import type { ConversationPanelViews } from './conversationPanelStorage'
79

810
describe('ConversationPanelBroker', () => {
911
it('opens every touched tab and retains its conversation mapping', async () => {
1012
const fixture = createFixture()
1113

12-
await fixture.broker.accept(
13-
snapshot([
14-
tab(10, 'conversation-1', 'run-1', 'running'),
15-
tab(11, 'conversation-1', 'run-1', 'running'),
14+
await fixture.broker.reconcile(
15+
assignments([
16+
assignment(10, 'conversation-1', 'run-1', 'running'),
17+
assignment(11, 'conversation-1', 'run-1', 'running'),
1618
]),
1719
)
1820

@@ -33,14 +35,14 @@ describe('ConversationPanelBroker', () => {
3335

3436
it('does not let an older run overwrite a tab claimed by a newer run', async () => {
3537
const fixture = createFixture()
36-
await fixture.broker.accept(
37-
snapshot([tab(20, 'older', 'old-run', 'running')]),
38+
await fixture.broker.reconcile(
39+
assignments([assignment(20, 'older', 'old-run', 'running')]),
3840
)
39-
const newer = snapshot([tab(20, 'newer', 'new-run', 'running')])
40-
await fixture.broker.accept(newer)
41+
const newer = assignments([assignment(20, 'newer', 'new-run', 'running')])
42+
await fixture.broker.reconcile(newer)
4143

4244
// A stale run finishing cannot appear in the server's canonical mapping.
43-
await fixture.broker.accept(newer)
45+
await fixture.broker.reconcile(newer)
4446

4547
expect(fixture.views['20']).toMatchObject({
4648
conversationId: 'newer',
@@ -53,18 +55,20 @@ describe('ConversationPanelBroker', () => {
5355
})
5456
})
5557

56-
it('treats a reconnect snapshot as authoritative and reopens running tabs', async () => {
57-
const fixture = createFixture({
58-
'99': tab(99, 'stale', 'stale-run', 'running'),
59-
})
60-
const event: ConversationPanelSnapshot = {
61-
tabs: [
62-
tab(30, 'active', 'active-run', 'running'),
63-
tab(31, 'done', 'done-run', 'completed'),
64-
],
65-
}
58+
it('treats reconnect assignments as authoritative', async () => {
59+
const fixture = createFixture()
60+
await fixture.broker.reconcile(
61+
assignments([assignment(99, 'stale', 'stale-run', 'running')]),
62+
)
63+
fixture.opened.length = 0
64+
fixture.glow.length = 0
6665

67-
await fixture.broker.accept(event)
66+
await fixture.broker.reconcile(
67+
assignments([
68+
assignment(30, 'active', 'active-run', 'running'),
69+
assignment(31, 'done', 'done-run', 'completed'),
70+
]),
71+
)
6872

6973
expect(fixture.views['99']).toBeUndefined()
7074
expect(fixture.opened).toEqual([{ tabId: 30, windowId: 1 }])
@@ -78,17 +82,17 @@ describe('ConversationPanelBroker', () => {
7882

7983
it('deactivates a finished run and shows first-run confetti once', async () => {
8084
const fixture = createFixture()
81-
await fixture.broker.accept(
82-
snapshot([
83-
tab(40, 'conversation-4', 'run-4', 'running'),
84-
tab(41, 'conversation-4', 'run-4', 'running'),
85+
await fixture.broker.reconcile(
86+
assignments([
87+
assignment(40, 'conversation-4', 'run-4', 'running'),
88+
assignment(41, 'conversation-4', 'run-4', 'running'),
8589
]),
8690
)
8791

88-
await fixture.broker.accept(
89-
snapshot([
90-
tab(40, 'conversation-4', 'run-4', 'completed'),
91-
tab(41, 'conversation-4', 'run-4', 'completed'),
92+
await fixture.broker.reconcile(
93+
assignments([
94+
assignment(40, 'conversation-4', 'run-4', 'completed'),
95+
assignment(41, 'conversation-4', 'run-4', 'completed'),
9296
]),
9397
)
9498

@@ -108,29 +112,127 @@ describe('ConversationPanelBroker', () => {
108112
])
109113
expect(fixture.markConfettiShown).toHaveBeenCalledTimes(1)
110114
})
115+
116+
it('retries the complete handoff after a transient storage failure', async () => {
117+
const fixture = createFixture({
118+
writeViews: async (_views, call) => {
119+
if (call === 1) throw new Error('session storage not ready')
120+
},
121+
})
122+
const current = assignments([
123+
assignment(50, 'conversation-5', 'run-5', 'running'),
124+
])
125+
126+
await expect(fixture.broker.reconcile(current)).rejects.toThrow(
127+
'session storage not ready',
128+
)
129+
await fixture.broker.reconcile(current)
130+
131+
expect(fixture.opened).toEqual([{ tabId: 50, windowId: 1 }])
132+
expect(fixture.views['50']).toMatchObject({ runId: 'run-5' })
133+
})
134+
135+
it('retries initialization after a transient storage-read failure', async () => {
136+
const fixture = createFixture({
137+
readViews: async (call) => {
138+
if (call === 1) throw new Error('session storage not ready')
139+
return {}
140+
},
141+
})
142+
const current = assignments([
143+
assignment(55, 'conversation-5', 'run-5', 'running'),
144+
])
145+
146+
await expect(fixture.broker.reconcile(current)).rejects.toThrow(
147+
'session storage not ready',
148+
)
149+
await fixture.broker.reconcile(current)
150+
151+
expect(fixture.opened).toEqual([{ tabId: 55, windowId: 1 }])
152+
})
153+
154+
it('reasserts open panels on a heartbeat without restarting their glow', async () => {
155+
const fixture = createFixture()
156+
const current = assignments([
157+
assignment(60, 'conversation-6', 'run-6', 'running'),
158+
])
159+
160+
await fixture.broker.reconcile(current)
161+
await fixture.broker.reconcile(current)
162+
163+
expect(fixture.opened).toEqual([
164+
{ tabId: 60, windowId: 1 },
165+
{ tabId: 60, windowId: 1 },
166+
])
167+
expect(fixture.glow).toEqual([
168+
{ tabId: 60, isActive: true, conversationId: 'conversation-6' },
169+
])
170+
})
171+
172+
it('heals a transient panel-open failure on the next heartbeat', async () => {
173+
const fixture = createFixture({
174+
openPanel: async (_target, call) => {
175+
if (call === 1) throw new Error('side panel temporarily unavailable')
176+
},
177+
})
178+
const current = assignments([
179+
assignment(70, 'conversation-7', 'run-7', 'running'),
180+
])
181+
182+
await fixture.broker.reconcile(current)
183+
await fixture.broker.reconcile(current)
184+
185+
expect(fixture.opened).toEqual([{ tabId: 70, windowId: 1 }])
186+
expect(fixture.errors).toEqual([
187+
expect.objectContaining({
188+
context: { phase: 'open-panel', tabId: 70 },
189+
}),
190+
])
191+
})
111192
})
112193

113-
function createFixture(
114-
initialViews: Record<string, ReturnType<typeof tab>> = {},
115-
) {
116-
const views = { ...initialViews }
194+
interface FixtureOptions {
195+
readViews?(call: number): Promise<ConversationPanelViews>
196+
writeViews?(views: ConversationPanelViews, call: number): Promise<void>
197+
openPanel?(
198+
target: { tabId: number; windowId: number },
199+
call: number,
200+
): Promise<void>
201+
}
202+
203+
function createFixture(options: FixtureOptions = {}) {
204+
const views: ConversationPanelViews = {}
117205
const opened: Array<{ tabId: number; windowId: number }> = []
118206
const glow: Array<{
119207
tabId: number
120208
conversationId: string
121209
isActive: boolean
122210
showConfetti?: boolean
123211
}> = []
212+
const errors: Array<{
213+
error: unknown
214+
context: ConversationPanelBrokerErrorContext
215+
}> = []
124216
const markConfettiShown = mock(async () => {})
217+
let readViewsCalls = 0
218+
let writeViewsCalls = 0
219+
let openPanelCalls = 0
125220
const deps: ConversationPanelBrokerDeps = {
126221
resolveServerUrl: async () => 'http://127.0.0.1:9000',
127222
fetch: mock(async () => new Response()),
128223
getTab: async (tabId) => ({ id: tabId, windowId: 1 }),
129224
openPanel: async (target) => {
225+
openPanelCalls += 1
226+
await options.openPanel?.(target, openPanelCalls)
130227
opened.push(target)
131228
},
132-
readViews: async () => ({ ...views }),
229+
readViews: async () => {
230+
readViewsCalls += 1
231+
return (await options.readViews?.(readViewsCalls)) ?? { ...views }
232+
},
133233
writeViews: async (next) => {
234+
writeViewsCalls += 1
235+
await options.writeViews?.(next, writeViewsCalls)
134236
for (const key of Object.keys(views)) delete views[key]
135237
Object.assign(views, next)
136238
},
@@ -139,17 +241,19 @@ function createFixture(
139241
},
140242
hasShownConfetti: async () => false,
141243
markConfettiShown,
244+
reportError: (error, context) => errors.push({ error, context }),
142245
}
143246
return {
144247
broker: new ConversationPanelBroker(deps),
248+
errors,
145249
glow,
146250
markConfettiShown,
147251
opened,
148252
views,
149253
}
150254
}
151255

152-
function tab(
256+
function assignment(
153257
tabId: number,
154258
conversationId: string,
155259
runId: string,
@@ -158,8 +262,8 @@ function tab(
158262
return { tabId, conversationId, runId, status }
159263
}
160264

161-
function snapshot(
162-
tabs: ConversationPanelSnapshot['tabs'],
163-
): ConversationPanelSnapshot {
164-
return { tabs }
265+
function assignments(
266+
values: ConversationPanelAssignments['assignments'],
267+
): ConversationPanelAssignments {
268+
return { assignments: values }
165269
}

0 commit comments

Comments
 (0)