Skip to content
Closed
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
69 changes: 69 additions & 0 deletions src/renderer/src/features/debug/saveDebugBundle.renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,72 @@ it('saves the screen and tail history main recorded into the bundle', async () =
const state = JSON.parse(saved.find(file => file.name.endsWith('state-snapshot.json'))!.content) as { recentScreen: string }
expect(state.recentScreen).toBe('main recent')
})

// #1336 (codex-headless#70 review a, P1): a fresh session's proxy run lives
// under `shell-<sessionId>`, chosen at process start, but once its first turn
// reveals the providerSessionId the bundle asked only for `resume-<id>`. The
// reader answered `match: 'none'` and the bundle had no proxy section. The
// stub answers exactly as readProxyEventsForBundle does for a segment that
// does not exist (match 'none', nulls) and for one that does.
async function bundleWithProxy(providerSessionId: string | null, existingSegment: string) {
const saved: Array<{ name: string; content: string }> = []
const asked: string[] = []
const originalApi = window.api
window.api = {
...originalApi,
flushPerformance: async () => {},
getPerformanceSnapshot: async () => null,
getScreenDebug: async () => ({ screen: null, samples: [] }),
readProxyEvents: async ({ sessionKey }: { sessionKey: string }) => {
asked.push(sessionKey)
return sessionKey === existingSegment
? { proxyEvents: '{"kind":"request"}\n{"kind":"request-body-latest"}\n', runDir: `/proxy/p/${sessionKey}/run1`, sessionMeta: null, match: 'exact', requestedSessionKey: sessionKey, matchedSessionSegment: sessionKey }
: { proxyEvents: null, runDir: null, sessionMeta: null, match: 'none', requestedSessionKey: sessionKey, matchedSessionSegment: null }
},
saveDebugBundle: async (input: { files: Array<{ name: string; content: string }> }) => {
saved.push(...input.files)
return { bundlePath: '/tmp/bundle' }
},
} as never
try {
await assembleAndSaveDebugBundle({ sessionId: 'pane-1', runtime: emptyRuntime(), kind: 'codex', cwd: '/repo', providerSessionId })
} finally {
window.api = originalApi
}
const proxyFile = saved.find(file => file.name.includes('proxy') && file.content.includes('request-body-latest'))
const manifest = JSON.parse(saved.find(file => file.name.endsWith('manifest.json'))!.content) as Record<string, unknown>
return { asked, proxyFile, manifest }
}

it('finds a fresh session\'s proxy run after its first turn revealed the provider id', async () => {
const { asked, proxyFile, manifest } = await bundleWithProxy('thread-1', 'shell-pane-1')
expect(asked).toEqual(['resume-thread-1', 'shell-pane-1'])
expect(proxyFile).toBeDefined()
// The manifest describes the run that was actually read (#1399 review a).
expect(JSON.stringify(manifest)).toContain('"matchedSessionSegment":"shell-pane-1"')
expect(JSON.stringify(manifest)).toContain('"requestedSessionKey":"shell-pane-1"')
})

it('asks once, and bundles nothing, when neither key has a run', async () => {
// A `match: 'none'` fallback answer must not replace the first miss with
// an empty section (#1399 review a: this branch was untested).
const { asked, proxyFile, manifest } = await bundleWithProxy('thread-1', 'no-such-segment')
expect(asked).toEqual(['resume-thread-1', 'shell-pane-1'])
expect(proxyFile).toBeUndefined()
expect(JSON.stringify(manifest)).toContain('"requestedSessionKey":"resume-thread-1"')
})

it('does not repeat the read for a fresh session with no provider id yet', async () => {
const { asked, proxyFile } = await bundleWithProxy(null, 'shell-pane-1')
expect(asked).toEqual(['shell-pane-1'])
expect(proxyFile).toBeDefined()
// Also when that one key misses: the fallback key is the same key.
const missed = await bundleWithProxy(null, 'no-such-segment')
expect(missed.asked).toEqual(['shell-pane-1'])
})

it('prefers the resumed run when the process was launched to resume', async () => {
const { asked, proxyFile } = await bundleWithProxy('thread-1', 'resume-thread-1')
expect(asked).toEqual(['resume-thread-1'])
expect(proxyFile).toBeDefined()
})
33 changes: 27 additions & 6 deletions src/renderer/src/features/debug/saveDebugBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,12 +504,33 @@ export async function assembleAndSaveDebugBundle(params: {
// every minute-level autosave was one of the multipliers behind the 108 GB
// debug-bundles directory; autosave should preserve orientation, not create
// a second archive of already-persisted wire logs.
const proxySection = cwd && includeProxyPayload
? await window.api.readProxyEvents({
cwd,
sessionKey: proxySessionKey,
}).catch(() => null)
: null
//
// WHY a second, exact key (#1336, codex-headless#70 review a): the proxy
// writers choose the run's session segment ONCE, at process start:
// `resume-<id>` only when the process was launched to resume a known
// conversation, else `shell-<sessionId>` (Codex: codexSession.ts
// allocateProxyEventsFile; Claude's createProxyServer does the same). A
// FRESH session learns its providerSessionId from its first turn, after the
// run dir already exists under `shell-<sessionId>`, and nothing renames it.
// So every manual bundle of a fresh session that had taken a turn asked
// only for `resume-<id>`, got `match: 'none'`, and carried no proxy
// section at all (no events tail, no latest request body).
//
// The fallback key is pane-keyed, so it can only ever find THIS pane's own
// run. The `resume-` key is NOT (#1399 review a): it names a conversation,
// and another pane that resumed the same conversation writes there too. The
// reader's "exact" is a segment-name match, so a pane whose conversation was
// later resumed elsewhere can still bundle that other pane's run. That risk
// predates this fallback (main asked only for `resume-`); the proper fix is
// recording the launch-time key per pane, #1405. `resume-` stays first so a
// resumed pane (whose own run lives there) keeps what it had on main.
const readProxy = (sessionKey: string) => window.api.readProxyEvents({ cwd: cwd!, sessionKey }).catch(() => null)
const shellSessionKey = `shell-${sessionId}`
let proxySection = cwd && includeProxyPayload ? await readProxy(proxySessionKey) : null
if (cwd && includeProxyPayload && proxySessionKey !== shellSessionKey && (!proxySection || proxySection.match === 'none')) {
const fresh = await readProxy(shellSessionKey)
if (fresh && fresh.match !== 'none') proxySection = fresh
}

const files: BundleFile[] = [
{
Expand Down
Loading