From 9b52f078264d0c3708e56e3d5fe7d89ee972dc8d Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 12:01:13 -0700 Subject: [PATCH 1/2] Fix FreshOpenCode materialization persistence --- .../fresh-agent/adapters/opencode/adapter.ts | 11 ++ server/fresh-agent/sdk-events.ts | 6 + server/ws-handler.ts | 40 +++++- src/lib/fresh-agent-ws.ts | 24 +++- src/store/freshAgentSlice.ts | 49 ++++++++ src/store/panesSlice.ts | 73 +++++++++++ test/unit/client/lib/fresh-agent-ws.test.ts | 118 +++++++++++++++++- .../unit/client/store/tabRegistrySync.test.ts | 69 ++++++++++ .../opencode-serve-adapter.test.ts | 16 +++ .../server/ws-handler-fresh-agent.test.ts | 93 ++++++++++++-- 10 files changed, 482 insertions(+), 17 deletions(-) diff --git a/server/fresh-agent/adapters/opencode/adapter.ts b/server/fresh-agent/adapters/opencode/adapter.ts index cdababce6..ac0db2e01 100644 --- a/server/fresh-agent/adapters/opencode/adapter.ts +++ b/server/fresh-agent/adapters/opencode/adapter.ts @@ -156,6 +156,16 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen state.events.emit('event', { type: 'sdk.session.snapshot', sessionId: state.placeholderId, status }) } + function emitMaterialized(state: OpencodeSessionState): void { + if (!state.realSessionId) return + state.events.emit('event', { + type: 'freshAgent.session.materialized', + previousSessionId: state.placeholderId, + sessionId: state.realSessionId, + sessionRef: { provider: 'opencode', sessionId: state.realSessionId }, + }) + } + async function materializeOrSend(state: OpencodeSessionState, text: string, settings?: Partial): Promise { const normalized = settings ? normalizeOpencodeInput({ requestId: state.placeholderId, sessionType: 'freshopencode', provider: 'opencode', ...settings } as FreshAgentCreateRequest) @@ -173,6 +183,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen else if (effectiveCwd) state.cwd = effectiveCwd remember(state) bindServeStream(state) + emitMaterialized(state) } const realId = state.realSessionId! diff --git a/server/fresh-agent/sdk-events.ts b/server/fresh-agent/sdk-events.ts index d8909ee62..d8cff9129 100644 --- a/server/fresh-agent/sdk-events.ts +++ b/server/fresh-agent/sdk-events.ts @@ -17,6 +17,12 @@ export type FreshAgentProviderEvent = sessionId: string reason?: string } + | { + type: 'freshAgent.session.materialized' + previousSessionId: string + sessionId: string + sessionRef?: { provider: string; sessionId: string } + } | { type: 'freshAgent.session.init'; sessionId: string; cliSessionId?: string; model?: string; cwd?: string; tools?: Array<{ name: string }> } | { type: 'freshAgent.session.metadata'; sessionId: string; cliSessionId?: string; model?: string; cwd?: string; tools?: Array<{ name: string }> } | { type: 'freshAgent.assistant'; sessionId: string; content: ContentBlock[]; model?: string; usage?: Usage } diff --git a/server/ws-handler.ts b/server/ws-handler.ts index 3dba92697..e74e944f1 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -86,7 +86,7 @@ import { UiScreenshotResultSchema, WS_PROTOCOL_VERSION, } from '../shared/ws-protocol.js' -import { LiveTerminalHandleSchema, type RestoreError } from '../shared/session-contract.js' +import { LiveTerminalHandleSchema, sanitizeSessionRef, type RestoreError } from '../shared/session-contract.js' import { CODEX_DURABILITY_SCHEMA_VERSION, CodexDurabilityRefSchema } from '../shared/codex-durability.js' import { migrateLegacyFreshAgentContent } from '../shared/fresh-agent.js' import { UiLayoutSyncSchema } from './agent-api/layout-schema.js' @@ -1190,6 +1190,28 @@ export class WsHandler { } } + private freshAgentMaterializedMessage(locator: FreshAgentLocator, event: unknown) { + const normalized = normalizeFreshAgentProviderEvent(event) + if (!normalized || typeof normalized !== 'object' || Array.isArray(normalized)) return undefined + const materialized = normalized as Record + if (materialized.type !== 'freshAgent.session.materialized') return undefined + if (typeof materialized.sessionId !== 'string' || materialized.sessionId.length === 0) return undefined + const sessionRef = sanitizeSessionRef(materialized.sessionRef) ?? { + provider: locator.provider, + sessionId: materialized.sessionId, + } + return { + type: 'freshAgent.session.materialized', + previousSessionId: typeof materialized.previousSessionId === 'string' && materialized.previousSessionId.length > 0 + ? materialized.previousSessionId + : locator.sessionId, + sessionId: materialized.sessionId, + sessionType: locator.sessionType, + provider: locator.provider, + sessionRef, + } + } + private authorizeFreshAgentSession(state: ClientState, locator: FreshAgentLocator): void { state.freshAgentAuthorizations.add(this.freshAgentKey(locator)) } @@ -1257,6 +1279,21 @@ export class WsHandler { const listener = (event: unknown) => { if (!entry.active) return + const materialized = this.freshAgentMaterializedMessage(locator, event) + if (materialized) { + const materializedLocator = { + sessionId: materialized.sessionId, + sessionType: locator.sessionType, + provider: locator.provider, + } + this.authorizeFreshAgentSession(state, materializedLocator) + this.ensureFreshAgentSubscription(ws, state, materializedLocator) + if (materialized.sessionId !== locator.sessionId) { + this.cancelFreshAgentSubscription(state, locator) + } + this.safeSend(ws, materialized) + return + } this.safeSend(ws, this.freshAgentEventMessage(locator, event)) } @@ -3206,6 +3243,7 @@ export class WsHandler { sessionType: m.sessionType, provider: m.provider, }) + this.cancelFreshAgentSubscription(state, locator) this.send(ws, { type: 'freshAgent.session.materialized', previousSessionId: m.sessionId, diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index ca5781acd..02fef6bda 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -2,6 +2,8 @@ import type { AppDispatch } from '@/store/store' import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '@shared/fresh-agent' import type { SessionRef } from '@shared/session-contract' import { consumeCancelledCreate } from '@/lib/create-cancellation' +import { flushPersistedLayoutNow } from '@/store/persistControl' +import { materializeFreshAgentSession as materializeFreshAgentPaneSession } from '@/store/panesSlice' import { addAssistantMessage, addPermissionRequest, @@ -10,6 +12,7 @@ import { clearPendingCreateFailure, createFailed, markSessionLost, + materializeSession as materializeFreshAgentSessionState, removePermission, removeSession, registerPendingCreate, @@ -129,8 +132,27 @@ export function handleFreshAgentMessage(dispatch: AppDispatch, msg: Record) { + const previousLocator = { + sessionId: action.payload.previousSessionId, + sessionType: action.payload.sessionType, + provider: action.payload.provider, + } + const nextLocator = { + sessionId: action.payload.sessionId, + sessionType: action.payload.sessionType, + provider: action.payload.provider, + } + const previousKey = sessionKey(previousLocator) + const nextKey = sessionKey(nextLocator) + const previousSession = state.sessions[previousKey] + const nextSession = state.sessions[nextKey] + + if (previousSession || nextSession) { + state.sessions[nextKey] = { + ...(previousSession ?? createSession(nextLocator, 'connected')), + ...(nextSession ?? {}), + ...nextLocator, + sessionKey: nextKey, + threadId: action.payload.sessionId, + lost: false, + restoreFailureCode: undefined, + restoreFailureMessage: undefined, + } + if (previousKey !== nextKey) { + delete state.sessions[previousKey] + } + } else { + ensureSession(state, nextLocator, 'connected') + } + + for (const pending of Object.values(state.pendingCreates)) { + if (pending.sessionId === action.payload.previousSessionId || pending.sessionKey === previousKey) { + pending.sessionId = action.payload.sessionId + pending.sessionKey = nextKey + pending.sessionType = action.payload.sessionType + pending.provider = action.payload.provider + } + } + }, + freshAgentSnapshotReceived(state, action: PayloadAction<{ snapshot: FreshAgentSnapshot }>) { const snapshot = action.payload.snapshot const session = ensureSession(state, { @@ -558,6 +606,7 @@ export const { clearStreaming, createFailed, freshAgentSnapshotReceived, + materializeSession, markSessionLost, registerPendingCreate, removePermission, diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index 3f8393c97..11d0baf8d 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -4,12 +4,14 @@ import { normalizeFreshAgentEffortOverride, normalizeFreshAgentModelSelection, normalizeFreshAgentPendingLocalEcho, + type FreshAgentPaneContent, type LivePaneContentInput, type PanesState, type PaneContent, type PaneContentInput, type PaneNode, type PaneRefreshRequest, + type SessionLocator, type TerminalPaneContent, } from './paneTypes' import { derivePaneTitle } from '@/lib/derivePaneTitle' @@ -33,6 +35,14 @@ type HydratePanesMeta = { remoteLayoutPersistedAt?: number } +type FreshAgentSessionMaterializedPayload = { + previousSessionId: string + sessionId: string + sessionType: FreshAgentPaneContent['sessionType'] + provider: FreshAgentPaneContent['provider'] + sessionRef?: SessionLocator +} + function buildPreservedSessionRef( localContent: Extract, _preservedResumeSessionId?: string, @@ -512,6 +522,38 @@ function clearRestoreFallbackAttemptForPane(state: PanesState, tabId: string, pa } } +function freshAgentPaneMatchesMaterializedSession( + content: FreshAgentPaneContent, + materialized: FreshAgentSessionMaterializedPayload, +): boolean { + if (content.sessionType !== materialized.sessionType || content.provider !== materialized.provider) { + return false + } + + return [ + content.sessionId, + content.resumeSessionId, + content.sessionRef?.sessionId, + ].some((sessionId) => sessionId === materialized.previousSessionId || sessionId === materialized.sessionId) +} + +function buildMaterializedFreshAgentContent( + content: FreshAgentPaneContent, + materialized: FreshAgentSessionMaterializedPayload, +): FreshAgentPaneContent { + const sessionRef = sanitizeSessionRef(materialized.sessionRef) ?? { + provider: materialized.provider, + sessionId: materialized.sessionId, + } + return normalizePaneContent({ + ...content, + sessionId: materialized.sessionId, + resumeSessionId: materialized.sessionId, + sessionRef, + restoreError: undefined, + }, content) as FreshAgentPaneContent +} + function sessionRefsEqual(left?: { provider?: string; sessionId?: string }, right?: { provider?: string; sessionId?: string }): boolean { return left?.provider === right?.provider && left?.sessionId === right?.sessionId } @@ -1282,6 +1324,36 @@ export const panesSlice = createSlice({ reconcileRefreshRequestsForTab(state, tabId) }, + materializeFreshAgentSession: ( + state, + action: PayloadAction + ) => { + for (const [tabId, root] of Object.entries(state.layouts)) { + let changed = false + + function updateContent(node: PaneNode): PaneNode { + if (node.type === 'leaf') { + if (node.content.kind !== 'fresh-agent') return node + if (!freshAgentPaneMatchesMaterializedSession(node.content, action.payload)) return node + changed = true + return { + ...node, + content: buildMaterializedFreshAgentContent(node.content, action.payload), + } + } + return { + ...node, + children: [updateContent(node.children[0]), updateContent(node.children[1])], + } + } + + state.layouts[tabId] = updateContent(root) + if (changed) { + reconcileRefreshRequestsForTab(state, tabId) + } + } + }, + /** Partially merge fields into existing pane content (avoids stale-ref overwrites * when multiple effects dispatch in the same render batch). */ mergePaneContent: ( @@ -1729,6 +1801,7 @@ export const { replacePane, swapPanes, updatePaneContent, + materializeFreshAgentSession, mergePaneContent, restartFreshAgentCreate, requestPaneRefresh, diff --git a/test/unit/client/lib/fresh-agent-ws.test.ts b/test/unit/client/lib/fresh-agent-ws.test.ts index 20bdc1a4e..af4b44101 100644 --- a/test/unit/client/lib/fresh-agent-ws.test.ts +++ b/test/unit/client/lib/fresh-agent-ws.test.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { configureStore } from '@reduxjs/toolkit' import freshAgentReducer from '@/store/freshAgentSlice' +import panesReducer, { initLayout, type PanesState } from '@/store/panesSlice' import { handleFreshAgentMessage, registerFreshAgentCreate } from '@/lib/fresh-agent-ws' import { cancelCreate, _resetCancelledCreates } from '@/lib/create-cancellation' +import { flushPersistedLayoutNow } from '@/store/persistControl' function createFreshAgentStore() { return configureStore({ @@ -12,6 +14,38 @@ function createFreshAgentStore() { }) } +function emptyPanesState(): PanesState { + return { + layouts: {}, + activePane: {}, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + refreshRequestsByPane: {}, + restoreFallbackAttemptsByPane: {}, + } +} + +function createFreshAgentPaneStore(seenActionTypes: string[] = []) { + const actionRecorder = () => (next: (action: unknown) => unknown) => (action: { type?: string }) => { + if (typeof action.type === 'string') seenActionTypes.push(action.type) + return next(action) + } + + return configureStore({ + reducer: { + freshAgent: freshAgentReducer, + panes: panesReducer, + }, + preloadedState: { + panes: emptyPanesState(), + }, + middleware: (getDefault) => getDefault().prepend(actionRecorder), + }) +} + describe('fresh-agent-ws', () => { beforeEach(() => { _resetCancelledCreates() @@ -87,17 +121,91 @@ describe('fresh-agent-ws', () => { }) }) - it('recognizes freshAgent.session.materialized as a handled fresh-agent message', () => { - const store = createFreshAgentStore() + it('materializes FreshOpenCode pane and live session state from the global websocket handler', () => { + const actionTypes: string[] = [] + const store = createFreshAgentPaneStore(actionTypes) + const placeholderId = 'freshopencode-req-placeholder' + const durableId = 'ses_real_1' + + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + sessionId: placeholderId, + createRequestId: 'req-placeholder', + status: 'running', + resumeSessionId: placeholderId, + sessionRef: { provider: 'opencode', sessionId: placeholderId }, + restoreError: { + reason: 'fresh_agent_lost_session', + message: 'stale placeholder', + }, + }, + })) + + registerFreshAgentCreate(store.dispatch, 'req-placeholder', { + sessionType: 'freshopencode', + provider: 'opencode', + }) + expect(handleFreshAgentMessage(store.dispatch, { + type: 'freshAgent.created', + requestId: 'req-placeholder', + sessionId: placeholderId, + sessionType: 'freshopencode', + provider: 'opencode', + })).toBe(true) + expect(handleFreshAgentMessage(store.dispatch, { + type: 'freshAgent.event', + sessionId: placeholderId, + sessionType: 'freshopencode', + provider: 'opencode', + event: { + type: 'freshAgent.session.snapshot', + sessionId: placeholderId, + latestTurnId: null, + status: 'running', + }, + })).toBe(true) expect(handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.session.materialized', - previousSessionId: 'freshopencode-req-1', - sessionId: 'ses_real_1', + previousSessionId: placeholderId, + sessionId: durableId, sessionType: 'freshopencode', provider: 'opencode', - sessionRef: { provider: 'opencode', sessionId: 'ses_real_1' }, + sessionRef: { provider: 'opencode', sessionId: durableId }, })).toBe(true) + + const layout = store.getState().panes.layouts['tab-1'] + expect(layout.type).toBe('leaf') + if (layout.type !== 'leaf') throw new Error('expected leaf layout') + expect(layout.content).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + sessionId: durableId, + resumeSessionId: durableId, + sessionRef: { provider: 'opencode', sessionId: durableId }, + status: 'running', + }) + expect(layout.content.kind === 'fresh-agent' ? layout.content.restoreError : undefined).toBeUndefined() + + expect(store.getState().freshAgent.sessions[`freshopencode:opencode:${placeholderId}`]).toBeUndefined() + expect(store.getState().freshAgent.sessions[`freshopencode:opencode:${durableId}`]).toMatchObject({ + sessionId: durableId, + sessionKey: `freshopencode:opencode:${durableId}`, + threadId: durableId, + status: 'running', + lost: false, + }) + expect(store.getState().freshAgent.pendingCreates['req-placeholder']).toMatchObject({ + sessionId: durableId, + sessionKey: `freshopencode:opencode:${durableId}`, + }) + expect(actionTypes).toContain(flushPersistedLayoutNow.type) }) it('projects Claude freshAgent.event snapshot and lost-session transport updates into fresh-agent session state', () => { diff --git a/test/unit/client/store/tabRegistrySync.test.ts b/test/unit/client/store/tabRegistrySync.test.ts index 252589a87..deefeeb43 100644 --- a/test/unit/client/store/tabRegistrySync.test.ts +++ b/test/unit/client/store/tabRegistrySync.test.ts @@ -201,6 +201,75 @@ describe('tabRegistrySync', () => { stop() }) + it('publishes materialized fresh-agent session refs instead of stale placeholders', () => { + state = { + ...state, + panes: { + ...state.panes, + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + sessionId: 'freshopencode-req-sync', + createRequestId: 'req-sync', + status: 'running', + resumeSessionId: 'freshopencode-req-sync', + sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-sync' }, + }, + }, + }, + paneTitles: { 'tab-1': { 'pane-1': 'OpenCode' } }, + }, + } + const store = createStore() + const stop = startTabRegistrySync(store as any, ws) + + expect(ws.sendTabsSyncPush.mock.calls[0][0].records[0].panes[0].payload.sessionRef).toEqual({ + provider: 'opencode', + sessionId: 'freshopencode-req-sync', + }) + + ws.sendTabsSyncPush.mockClear() + state = { + ...state, + panes: { + ...state.panes, + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshopencode', + provider: 'opencode', + sessionId: 'ses_sync_1', + createRequestId: 'req-sync', + status: 'running', + resumeSessionId: 'ses_sync_1', + sessionRef: { provider: 'opencode', sessionId: 'ses_sync_1' }, + }, + }, + }, + }, + } + for (const listener of listeners) listener() + + expect(ws.sendTabsSyncPush).toHaveBeenCalledTimes(1) + const pushedPane = ws.sendTabsSyncPush.mock.calls[0][0].records[0].panes[0] + expect(pushedPane.payload.sessionRef).toEqual({ + provider: 'opencode', + sessionId: 'ses_sync_1', + }) + expect(pushedPane.payload).not.toHaveProperty('sessionId') + expect(pushedPane.payload).not.toHaveProperty('resumeSessionId') + + stop() + }) + it('includes selected closed retention when querying snapshots', () => { state = { ...state, diff --git a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts index 4aed08d33..5f38fdf45 100644 --- a/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts +++ b/test/unit/server/fresh-agent/opencode-serve-adapter.test.ts @@ -103,13 +103,17 @@ describe('OpenCode serve adapter: create + send', () => { it('emits running before first-send session materialization resolves', async () => { const manager = makeFakeManager() const createSession = createDeferred<{ id: string; directory?: string; title?: string }>() + const prompt = createDeferred() manager.createSession.mockReturnValueOnce(createSession.promise) + manager.promptAsync.mockReturnValueOnce(prompt.promise) const adapter = makeAdapter(manager) await adapter.create({ requestId: 'slow-create', sessionType: 'freshopencode', provider: 'opencode' }) const events: unknown[] = [] adapter.subscribe?.('freshopencode-slow-create', (e) => events.push(e)) const send = adapter.send?.('freshopencode-slow-create', { text: 'go' }) + let sendSettled = false + void send?.finally(() => { sendSettled = true }) await Promise.resolve() expect(events).toContainEqual({ @@ -120,6 +124,18 @@ describe('OpenCode serve adapter: create + send', () => { expect(manager.promptAsync).not.toHaveBeenCalled() createSession.resolve({ id: 'ses_real_1', title: 'T' }) + await vi.waitFor(() => { + expect(events).toContainEqual({ + type: 'freshAgent.session.materialized', + previousSessionId: 'freshopencode-slow-create', + sessionId: 'ses_real_1', + sessionRef: { provider: 'opencode', sessionId: 'ses_real_1' }, + }) + expect(manager.promptAsync).toHaveBeenCalled() + }) + expect(sendSettled).toBe(false) + + prompt.resolve() await expect(send).resolves.toEqual({ sessionId: 'ses_real_1', sessionRef: { provider: 'opencode', sessionId: 'ses_real_1' }, diff --git a/test/unit/server/ws-handler-fresh-agent.test.ts b/test/unit/server/ws-handler-fresh-agent.test.ts index 479ebe3f1..8a58efdf2 100644 --- a/test/unit/server/ws-handler-fresh-agent.test.ts +++ b/test/unit/server/ws-handler-fresh-agent.test.ts @@ -479,6 +479,10 @@ describe('WsHandler fresh-agent routing', () => { }) it('emits freshAgent.session.materialized when send returns a new session id', async () => { + const unsubscribeByKey = new Map>() + const placeholderLocator = { sessionId: 'freshopencode-req-1', sessionType: 'freshopencode', provider: 'opencode' } + const durableLocator = { sessionId: 'ses_real_1', sessionType: 'freshopencode', provider: 'opencode' } + const placeholderKey = JSON.stringify(placeholderLocator) const runtimeManager = { create: vi.fn().mockResolvedValue({ sessionId: 'freshopencode-req-1', @@ -486,7 +490,11 @@ describe('WsHandler fresh-agent routing', () => { runtimeProvider: 'opencode', sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-1' }, }), - subscribe: vi.fn().mockResolvedValue(() => undefined), + subscribe: vi.fn().mockImplementation(async (locator: unknown) => { + const off = vi.fn() + unsubscribeByKey.set(JSON.stringify(locator), off) + return off + }), send: vi.fn().mockResolvedValue({ sessionId: 'ses_real_1', sessionRef: { provider: 'opencode', sessionId: 'ses_real_1' }, @@ -524,19 +532,13 @@ describe('WsHandler fresh-agent routing', () => { })) await vi.waitFor(() => { - expect(runtimeManager.send).toHaveBeenCalledWith({ - sessionId: 'freshopencode-req-1', - sessionType: 'freshopencode', - provider: 'opencode', - }, { + expect(runtimeManager.send).toHaveBeenCalledWith(placeholderLocator, { text: 'Ship it', images: undefined, settings: undefined, }) - expect(runtimeManager.subscribe).toHaveBeenCalledWith( - { sessionId: 'ses_real_1', sessionType: 'freshopencode', provider: 'opencode' }, - expect.any(Function), - ) + expect(runtimeManager.subscribe).toHaveBeenCalledWith(durableLocator, expect.any(Function)) + expect(unsubscribeByKey.get(placeholderKey)).toHaveBeenCalledTimes(1) expect(seenMessages).toContainEqual({ type: 'freshAgent.session.materialized', previousSessionId: 'freshopencode-req-1', @@ -553,6 +555,77 @@ describe('WsHandler fresh-agent routing', () => { } }) + it('forwards provider materialization events as top-level websocket materialization', async () => { + const listeners = new Map void>() + const unsubscribeByKey = new Map>() + const placeholderLocator = { sessionId: 'freshopencode-req-event', sessionType: 'freshopencode', provider: 'opencode' } + const durableLocator = { sessionId: 'ses_event_1', sessionType: 'freshopencode', provider: 'opencode' } + const placeholderKey = JSON.stringify(placeholderLocator) + const runtimeManager = { + create: vi.fn().mockResolvedValue({ + sessionId: 'freshopencode-req-event', + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + sessionRef: { provider: 'opencode', sessionId: 'freshopencode-req-event' }, + }), + subscribe: vi.fn().mockImplementation(async (locator: unknown, listener: (message: unknown) => void) => { + const key = JSON.stringify(locator) + listeners.set(key, listener) + const off = vi.fn(() => { + listeners.delete(key) + }) + unsubscribeByKey.set(key, off) + return off + }), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const ws = await connectAndAuth(server) + const seenMessages: any[] = [] + ws.on('message', (data) => { + seenMessages.push(JSON.parse(data.toString())) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.create', + requestId: 'req-materialize-event', + sessionType: 'freshopencode', + provider: 'opencode', + })) + + await vi.waitFor(() => { + expect(runtimeManager.subscribe).toHaveBeenCalledWith(placeholderLocator, expect.any(Function)) + expect(listeners.has(placeholderKey)).toBe(true) + }) + + listeners.get(placeholderKey)?.({ + type: 'freshAgent.session.materialized', + previousSessionId: 'freshopencode-req-event', + sessionId: 'ses_event_1', + sessionRef: { provider: 'opencode', sessionId: 'ses_event_1' }, + }) + + await vi.waitFor(() => { + expect(runtimeManager.subscribe).toHaveBeenCalledWith(durableLocator, expect.any(Function)) + expect(unsubscribeByKey.get(placeholderKey)).toHaveBeenCalledTimes(1) + expect(listeners.has(placeholderKey)).toBe(false) + expect(seenMessages).toContainEqual({ + type: 'freshAgent.session.materialized', + previousSessionId: 'freshopencode-req-event', + sessionId: 'ses_event_1', + sessionType: 'freshopencode', + provider: 'opencode', + sessionRef: { provider: 'opencode', sessionId: 'ses_event_1' }, + }) + }) + } finally { + handler.close() + registry.shutdown() + await new Promise((resolve) => server.close(() => resolve())) + } + }) + it('routes freshAgent.compact through the runtime manager', async () => { const runtimeManager = { create: vi.fn().mockResolvedValue({ From f05d85414e21115d1a2b364442a61ab0a8d350bd Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 13:34:39 -0700 Subject: [PATCH 2/2] Document gh account preference --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index e24cfd21a..7da20bb0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ Freshell is a self-hosted, browser-accessible terminal multiplexer and session o - Before creating a new worktree, ensure the repo-supported test suite is green on the intended base. If the suite is not green, pause before creating the worktree and notify the user with the failing command and failure summary. - New behavior changes start on a worktree branch from `origin/main` and are submitted as PRs targeting `main`. - Do not create or open a PR until the user explicitly approves PR creation for that branch/change. Preparing a branch, committing locally, and pushing the branch is fine; stop before `gh pr create` or any equivalent PR creation step unless approval is explicit. +- Use `dan@danshapiro.com` when using `gh`. - Everything goes through a PR — never push behavior changes directly to `origin/main`. - Merge PRs once their required checks pass, then bring `origin/main` down to local `main`. Self-merging your own PRs is the norm. The only exception is a PR the user has said needs someone else to approve it first — leave those unmerged. - Many agents may be working in the worktree at the same time. If you see activity from other agents (for example test runs or file changes), respect it.