mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/codex-subagent-worklog
# Conflicts: # src/renderer/src/components/native-chat/NativeChatMessageList.tsx # src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
This commit is contained in:
@@ -181,11 +181,12 @@ jobs:
|
||||
env:
|
||||
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.deploy-auth.outputs.id_token }}
|
||||
run: |
|
||||
RETRY_ARGS=()
|
||||
if test "${WAVE_INDEX}" != 0; then RETRY_ARGS=(--retry-freshness); fi
|
||||
# Freshness-only failures are publish lag, not health, on every wave
|
||||
# including the first; the CLI still caps the retry at the wave's
|
||||
# evidence-age budget, so this cannot mutate on aged evidence.
|
||||
pnpm incident:relay-preflight -- \
|
||||
--state-file "${OUTPUT_DIRECTORY}/relay-${MONITOR_RUN_ID}-dry-run.state.json" \
|
||||
--wave-index "${WAVE_INDEX}" "${RETRY_ARGS[@]}"
|
||||
--wave-index "${WAVE_INDEX}" --retry-freshness
|
||||
|
||||
- name: Require durable rehome disabled and exact selector
|
||||
env:
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
livePreflightGcloud,
|
||||
runIncidentLivePreflight
|
||||
} from './incident-live-preflight-cli.js'
|
||||
import type { IncidentSample } from './incident-monitor.js'
|
||||
import {
|
||||
INCIDENT_MONITOR_THRESHOLDS,
|
||||
type IncidentSample
|
||||
} from './incident-monitor.js'
|
||||
import type { AdmissionSelector } from './incident-selector.js'
|
||||
|
||||
const directories: string[] = []
|
||||
@@ -313,7 +316,7 @@ describe('relay incident live preflight', () => {
|
||||
it('retries freshness-only failures when explicitly requested', async () => {
|
||||
const stale = sample()
|
||||
stale.sources['cloud-monitoring']!.signals['cloud_sql.cpu']!.observedAt =
|
||||
new Date(now - 180_001).toISOString()
|
||||
new Date(now - (INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs + 1)).toISOString()
|
||||
const missing = sample()
|
||||
delete missing.sources['relay-logs']
|
||||
const collect = vi.fn()
|
||||
@@ -331,11 +334,44 @@ describe('relay incident live preflight', () => {
|
||||
expect(wait).toHaveBeenNthCalledWith(2, 15_000)
|
||||
})
|
||||
|
||||
it('retries a first-wave stale sample and passes on the fresh one', async () => {
|
||||
const stale = sample()
|
||||
stale.sources['cloud-monitoring']!.signals['cloud_sql.cpu']!.observedAt =
|
||||
new Date(now - (INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs + 1)).toISOString()
|
||||
const collect = vi.fn().mockResolvedValueOnce(stale).mockResolvedValueOnce(sample())
|
||||
const wait = vi.fn(async () => undefined)
|
||||
await expect(runIncidentLivePreflight(
|
||||
['--state-file', stateFile(), '--wave-index', '0', '--retry-freshness'],
|
||||
{ now: () => now, collect, wait }
|
||||
)).resolves.toBeUndefined()
|
||||
expect(collect).toHaveBeenCalledTimes(2)
|
||||
expect(wait).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stops retrying when the next wait would exceed the evidence-age bound', async () => {
|
||||
const completedAt = now - 290_000
|
||||
const stale = sample()
|
||||
stale.sources['cloud-monitoring']!.observedAt = new Date(now - (INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs + 1)).toISOString()
|
||||
const collect = vi.fn(async () => stale)
|
||||
const wait = vi.fn(async () => undefined)
|
||||
await expect(runIncidentLivePreflight(
|
||||
['--state-file', stateFile('strict', {
|
||||
startedAt: new Date(completedAt - 17 * 60_000).toISOString(),
|
||||
windowStartedAt: new Date(completedAt - 16 * 60_000).toISOString(),
|
||||
lastSampleAt: new Date(completedAt - 30_000).toISOString(),
|
||||
completedAt: new Date(completedAt).toISOString()
|
||||
}), '--retry-freshness'],
|
||||
{ now: () => now, collect, wait }
|
||||
)).rejects.toThrow('cloud-monitoring/source_stale')
|
||||
expect(collect).toHaveBeenCalledOnce()
|
||||
expect(wait).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not retry a threshold failure', async () => {
|
||||
const unhealthy = sample()
|
||||
unhealthy.sources['cloud-monitoring']!.signals['cloud_sql.cpu']!.value = 0.9
|
||||
unhealthy.sources['cloud-monitoring']!.signals['cloud_sql.cpu']!.observedAt =
|
||||
new Date(now - 180_001).toISOString()
|
||||
new Date(now - (INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs + 1)).toISOString()
|
||||
const collect = vi.fn(async () => unhealthy)
|
||||
const wait = vi.fn(async () => undefined)
|
||||
await expect(runIncidentLivePreflight(
|
||||
@@ -348,7 +384,7 @@ describe('relay incident live preflight', () => {
|
||||
|
||||
it('fails closed after the bounded freshness retry window', async () => {
|
||||
const stale = sample()
|
||||
stale.sources['cloud-monitoring']!.observedAt = new Date(now - 180_001).toISOString()
|
||||
stale.sources['cloud-monitoring']!.observedAt = new Date(now - (INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs + 1)).toISOString()
|
||||
const collect = vi.fn(async () => stale)
|
||||
const wait = vi.fn(async () => undefined)
|
||||
await expect(runIncidentLivePreflight(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { suppliedIdentityToken } from './incident-monitor-cli.js'
|
||||
import { AdmissionSelectorSchema, type AdmissionSelector } from './incident-selector.js'
|
||||
import {
|
||||
evaluateIncidentSample,
|
||||
FRESHNESS_FAILURE_CODES,
|
||||
preDrainDryRunPassed,
|
||||
type IncidentSample
|
||||
} from './incident-monitor.js'
|
||||
@@ -18,12 +19,6 @@ const MONITOR_EVIDENCE_MAX_AGE_MS = 5 * 60_000
|
||||
// Matches the same-cap cell job timeout-minutes; bounds each predecessor wave.
|
||||
const WAVE_PREDECESSOR_TIMEOUT_MS = 75 * 60_000
|
||||
const WAVE_INDEX_PATTERN = /^[0-3]$/
|
||||
const FRESHNESS_FAILURE_CODES = new Set([
|
||||
'signal_missing',
|
||||
'signal_stale',
|
||||
'source_missing',
|
||||
'source_stale'
|
||||
])
|
||||
|
||||
export function livePreflightGcloud(
|
||||
gcloud: ReturnType<typeof createGcloudClient>,
|
||||
@@ -173,7 +168,11 @@ export async function runIncidentLivePreflight(
|
||||
const freshnessOnly = evaluation.failures.every((failure) =>
|
||||
FRESHNESS_FAILURE_CODES.has(failure.code)
|
||||
)
|
||||
if (!freshnessOnly || attempt === attempts) {
|
||||
// Waiting must never carry the mutation past the same evidence-age bound
|
||||
// the entry check enforces, so the wave budget also caps the retry window.
|
||||
const budgetExhausted =
|
||||
now() + FRESHNESS_RETRY_INTERVAL_MS - completedAt > maxEvidenceAgeMs
|
||||
if (!freshnessOnly || attempt === attempts || budgetExhausted) {
|
||||
throw new Error(
|
||||
`relay live preflight failed: ${evaluation.failures
|
||||
.map((failure) => `${failure.source}/${failure.code}`)
|
||||
|
||||
@@ -50,6 +50,8 @@ const StateSchema = z.object({
|
||||
continuityEvents: z.array(z.object({
|
||||
recordedAt: z.string(),
|
||||
windowSequence: z.number().int().nonnegative(),
|
||||
// Pre-2026-09-05 state files predate tolerated freshness gaps.
|
||||
tolerated: z.boolean().default(false),
|
||||
failures: z.array(z.object({
|
||||
code: z.string(),
|
||||
source: z.enum(['active-probe', 'cloud-monitoring', 'relay-logs', 'director-admin']),
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('incident monitor sources', () => {
|
||||
})
|
||||
|
||||
it('zero-fills an expired sparse lock-wait point', async () => {
|
||||
let pointAt = now - INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs
|
||||
let pointAt = now - INCIDENT_MONITOR_THRESHOLDS.cloudLockWaitCarryMs
|
||||
const fetchImpl: typeof fetch = async () => Response.json({
|
||||
timeSeries: [{
|
||||
points: [{
|
||||
@@ -141,7 +141,7 @@ describe('incident monitor sources', () => {
|
||||
|
||||
it('freshens a sparse zero without masking a recent nonzero lock wait', async () => {
|
||||
let value = 0
|
||||
const pointAt = now - INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs
|
||||
const pointAt = now - INCIDENT_MONITOR_THRESHOLDS.cloudLockWaitCarryMs
|
||||
const readAt = now + 11_879
|
||||
const fetchImpl: typeof fetch = async () => Response.json({
|
||||
timeSeries: [{
|
||||
|
||||
@@ -95,7 +95,7 @@ export const GOOGLE_METRICS: GoogleMetricDefinition[] = [
|
||||
'resource.type="cloudsql_database" AND metric.label."wait_event_type"="Lock"',
|
||||
aggregation: 'latest-max',
|
||||
emptyIsZero: true,
|
||||
zeroAfterMs: INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs
|
||||
zeroAfterMs: INCIDENT_MONITOR_THRESHOLDS.cloudLockWaitCarryMs
|
||||
},
|
||||
{
|
||||
signal: 'cloud_sql.deadlocks',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
evaluateIncidentSample,
|
||||
INCIDENT_CHECKPOINT_MINUTES,
|
||||
INCIDENT_FRESHNESS_TOLERANCE_SAMPLES,
|
||||
INCIDENT_MONITOR_THRESHOLDS,
|
||||
INCIDENT_PRE_DRAIN_MAX_LINEAGE_MS,
|
||||
initialIncidentMonitorState,
|
||||
@@ -182,12 +183,49 @@ describe('incident monitor evaluator', () => {
|
||||
code: 'source_missing',
|
||||
source: 'relay-logs'
|
||||
})
|
||||
const stale = healthySample(startedAt - 180_001)
|
||||
const stale = healthySample(
|
||||
startedAt - INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs - 1
|
||||
)
|
||||
const failures = evaluateIncidentSample(stale, startedAt).failures
|
||||
expect(failures.some((failure) => failure.source === 'cloud-monitoring')).toBe(true)
|
||||
expect(failures.some((failure) => failure.source === 'active-probe')).toBe(true)
|
||||
})
|
||||
|
||||
// Why: production run 33944873727 at 2026-09-05T04:46:09Z read
|
||||
// cloud_sql.lock_waits 189 286 ms old and restarted a 15-minute window on
|
||||
// Google's publish lag. Cloud SQL documents 60 s sampling plus up to 165 s of
|
||||
// invisibility, so that age is Google's clock, not our fleet.
|
||||
it('reads a 189-second cloud signal as fresh and holds the other sources at 180 s', () => {
|
||||
const lagged = healthySample()
|
||||
lagged.sources['cloud-monitoring']!.signals['cloud_sql.lock_waits'] =
|
||||
signal(0, startedAt - 189_286)
|
||||
expect(evaluateIncidentSample(lagged, startedAt)).toMatchObject({
|
||||
status: 'green',
|
||||
failures: []
|
||||
})
|
||||
const laggedDirector = healthySample()
|
||||
laggedDirector.sources['director-admin']!.observedAt =
|
||||
new Date(startedAt - 189_286).toISOString()
|
||||
expect(evaluateIncidentSample(laggedDirector, startedAt).failures).toContainEqual(
|
||||
expect.objectContaining({ code: 'source_stale', source: 'director-admin' })
|
||||
)
|
||||
})
|
||||
|
||||
it('still fails a cloud signal past the documented publish lag', () => {
|
||||
const dark = healthySample()
|
||||
dark.sources['cloud-monitoring']!.signals['cloud_sql.lock_waits'] = signal(
|
||||
0,
|
||||
startedAt - INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs - 1
|
||||
)
|
||||
expect(evaluateIncidentSample(dark, startedAt).failures).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'signal_stale',
|
||||
source: 'cloud-monitoring',
|
||||
signal: 'cloud_sql.lock_waits'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('freezes on SQL, director, relay pool, heartbeat, and migration breaches', () => {
|
||||
const sample = healthySample()
|
||||
sample.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81)
|
||||
@@ -592,7 +630,7 @@ describe('incident monitor lifecycle', () => {
|
||||
'restarts a %i-minute continuous window after stale telemetry',
|
||||
async (durationMinutes) => {
|
||||
let now = startedAt
|
||||
let staleInjected = false
|
||||
let staleSamples = INCIDENT_FRESHNESS_TOLERANCE_SAMPLES + 1
|
||||
const checkpoints: Array<[number, number]> = []
|
||||
const state = initialIncidentMonitorState({
|
||||
incidentId: 'incident-1',
|
||||
@@ -612,9 +650,11 @@ describe('incident monitor lifecycle', () => {
|
||||
now += ms
|
||||
},
|
||||
collect: async () => {
|
||||
if (!staleInjected && now === startedAt + 5 * 60_000) {
|
||||
staleInjected = true
|
||||
return healthySample(now - 180_001)
|
||||
if (staleSamples > 0 && now >= startedAt + 5 * 60_000) {
|
||||
staleSamples--
|
||||
return healthySample(
|
||||
now - INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs - 1
|
||||
)
|
||||
}
|
||||
return healthySample(now)
|
||||
},
|
||||
@@ -623,16 +663,20 @@ describe('incident monitor lifecycle', () => {
|
||||
checkpoints.push([summary.windowSequence, summary.checkpointMinute])
|
||||
}
|
||||
})
|
||||
const restartMinute = 5 + INCIDENT_FRESHNESS_TOLERANCE_SAMPLES + 1
|
||||
expect(result.windowSequence).toBe(1)
|
||||
expect(result.windowStartedAt).toBe(
|
||||
new Date(startedAt + 6 * 60_000).toISOString()
|
||||
new Date(startedAt + restartMinute * 60_000).toISOString()
|
||||
)
|
||||
expect(result.completedAt).toBe(
|
||||
new Date(startedAt + (durationMinutes + 6) * 60_000).toISOString()
|
||||
new Date(startedAt + (durationMinutes + restartMinute) * 60_000).toISOString()
|
||||
)
|
||||
expect(result.sampleCount).toBe(durationMinutes + 1)
|
||||
expect(result.continuityEvents).toHaveLength(1)
|
||||
expect(result.continuityEvents[0]!.failures).toEqual(
|
||||
expect(result.continuityEvents.map((event) => event.tolerated)).toEqual([
|
||||
...Array<boolean>(INCIDENT_FRESHNESS_TOLERANCE_SAMPLES).fill(true),
|
||||
false
|
||||
])
|
||||
expect(result.continuityEvents.at(-1)!.failures).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'source_stale' })
|
||||
])
|
||||
@@ -642,6 +686,188 @@ describe('incident monitor lifecycle', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Why: run 33944873727 on 2026-09-05 restarted at 04:46:09Z on a single
|
||||
// 189-second cloud reading and then blew the 25-minute lineage cap, so a
|
||||
// green fleet produced no verdict at all. One unread sample now continues the
|
||||
// window; the sample is still checked against every threshold it can read.
|
||||
it('carries a 15-minute window through a single stale cloud sample', async () => {
|
||||
let now = startedAt
|
||||
const state = initialIncidentMonitorState({
|
||||
incidentId: 'incident-1',
|
||||
environment: 'production',
|
||||
expectedSelector: selector,
|
||||
preDrainDryRun: true,
|
||||
migrationPolicy: 'strict',
|
||||
recoverySourceCellId: null,
|
||||
capacityCellId: null,
|
||||
startedAt: new Date(startedAt).toISOString(),
|
||||
durationMinutes: 15,
|
||||
intervalMs: 60_000
|
||||
})
|
||||
const result = await runIncidentMonitor(state, {
|
||||
now: () => now,
|
||||
wait: async (ms) => {
|
||||
now += ms
|
||||
},
|
||||
collect: async () => {
|
||||
const sample = healthySample(now)
|
||||
if (now === startedAt + 10 * 60_000) {
|
||||
sample.sources['cloud-monitoring']!.signals['cloud_sql.lock_waits'] =
|
||||
signal(0, now - INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs - 1)
|
||||
}
|
||||
return sample
|
||||
},
|
||||
persist: async () => {},
|
||||
checkpoint: async () => {}
|
||||
})
|
||||
expect(result.windowSequence).toBe(0)
|
||||
expect(result.windowStartedAt).toBe(new Date(startedAt).toISOString())
|
||||
expect(result.completedAt).toBe(new Date(startedAt + 15 * 60_000).toISOString())
|
||||
expect(result.sampleCount).toBe(16)
|
||||
expect(result.frozenAt).toBeNull()
|
||||
expect(result.continuityEvents).toEqual([{
|
||||
recordedAt: new Date(startedAt + 10 * 60_000).toISOString(),
|
||||
windowSequence: 0,
|
||||
tolerated: true,
|
||||
failures: [expect.objectContaining({
|
||||
code: 'signal_stale',
|
||||
source: 'cloud-monitoring',
|
||||
signal: 'cloud_sql.lock_waits'
|
||||
})]
|
||||
}])
|
||||
expect(preDrainDryRunPassed(result)).toBe(true)
|
||||
})
|
||||
|
||||
it('gives a signal a fresh budget only after it reads fresh again', async () => {
|
||||
let now = startedAt
|
||||
const staleMinutes = new Set([3, 5, 6, 9, 10])
|
||||
const state = initialIncidentMonitorState({
|
||||
incidentId: 'incident-1',
|
||||
environment: 'production',
|
||||
expectedSelector: selector,
|
||||
preDrainDryRun: true,
|
||||
migrationPolicy: 'strict',
|
||||
recoverySourceCellId: null,
|
||||
capacityCellId: null,
|
||||
startedAt: new Date(startedAt).toISOString(),
|
||||
durationMinutes: 15,
|
||||
intervalMs: 60_000
|
||||
})
|
||||
const result = await runIncidentMonitor(state, {
|
||||
now: () => now,
|
||||
wait: async (ms) => {
|
||||
now += ms
|
||||
},
|
||||
collect: async () => {
|
||||
const sample = healthySample(now)
|
||||
if (staleMinutes.has((now - startedAt) / 60_000)) {
|
||||
sample.sources['cloud-monitoring']!.signals['cloud_sql.lock_waits'] =
|
||||
signal(0, now - INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs - 1)
|
||||
}
|
||||
return sample
|
||||
},
|
||||
persist: async () => {},
|
||||
checkpoint: async () => {}
|
||||
})
|
||||
expect(result.windowSequence).toBe(0)
|
||||
expect(result.continuityEvents).toHaveLength(staleMinutes.size)
|
||||
expect(result.continuityEvents.every((event) => event.tolerated)).toBe(true)
|
||||
expect(preDrainDryRunPassed(result)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not hand a resumed monitor a fresh tolerance budget', async () => {
|
||||
let now = startedAt + 3 * 60_000
|
||||
const resumed = {
|
||||
...initialIncidentMonitorState({
|
||||
incidentId: 'incident-1',
|
||||
environment: 'production',
|
||||
expectedSelector: selector,
|
||||
preDrainDryRun: true,
|
||||
migrationPolicy: 'strict',
|
||||
recoverySourceCellId: null,
|
||||
capacityCellId: null,
|
||||
startedAt: new Date(startedAt).toISOString(),
|
||||
durationMinutes: 15,
|
||||
intervalMs: 60_000
|
||||
}),
|
||||
windowStartedAt: new Date(startedAt).toISOString(),
|
||||
lastSampleAt: new Date(startedAt + 2 * 60_000).toISOString(),
|
||||
sampleCount: 3,
|
||||
totalSampleCount: 3,
|
||||
continuityEvents: Array.from(
|
||||
{ length: INCIDENT_FRESHNESS_TOLERANCE_SAMPLES },
|
||||
(_, index) => ({
|
||||
recordedAt: new Date(startedAt + (index + 1) * 60_000).toISOString(),
|
||||
windowSequence: 0,
|
||||
tolerated: true,
|
||||
failures: [{
|
||||
code: 'signal_stale',
|
||||
source: 'cloud-monitoring' as const,
|
||||
signal: 'cloud_sql.lock_waits'
|
||||
}]
|
||||
})
|
||||
)
|
||||
}
|
||||
const stop = new Error('stop after the resumed sample')
|
||||
await expect(runIncidentMonitor(resumed, {
|
||||
now: () => now,
|
||||
wait: async () => {
|
||||
throw stop
|
||||
},
|
||||
collect: async () => {
|
||||
const sample = healthySample(now)
|
||||
sample.sources['cloud-monitoring']!.signals['cloud_sql.lock_waits'] =
|
||||
signal(0, now - INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs - 1)
|
||||
return sample
|
||||
},
|
||||
persist: async (state) => {
|
||||
expect(state.windowSequence).toBe(1)
|
||||
expect(state.windowStartedAt).toBeNull()
|
||||
expect(state.continuityEvents.at(-1)!.tolerated).toBe(false)
|
||||
},
|
||||
checkpoint: async () => {}
|
||||
})).rejects.toThrow(stop)
|
||||
})
|
||||
|
||||
it('freezes on a threshold breach that arrives with a tolerated stale signal', async () => {
|
||||
let now = startedAt
|
||||
const state = initialIncidentMonitorState({
|
||||
incidentId: 'incident-1',
|
||||
environment: 'production',
|
||||
expectedSelector: selector,
|
||||
preDrainDryRun: true,
|
||||
migrationPolicy: 'strict',
|
||||
recoverySourceCellId: null,
|
||||
capacityCellId: null,
|
||||
startedAt: new Date(startedAt).toISOString(),
|
||||
durationMinutes: 15,
|
||||
intervalMs: 60_000
|
||||
})
|
||||
const result = await runIncidentMonitor(state, {
|
||||
now: () => now,
|
||||
wait: async (ms) => {
|
||||
now += ms
|
||||
},
|
||||
collect: async () => {
|
||||
const sample = healthySample(now)
|
||||
if (now === startedAt + 2 * 60_000) {
|
||||
sample.sources['cloud-monitoring']!.signals['cloud_sql.lock_waits'] =
|
||||
signal(0, now - INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs - 1)
|
||||
sample.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81, now)
|
||||
}
|
||||
return sample
|
||||
},
|
||||
persist: async () => {},
|
||||
checkpoint: async () => {}
|
||||
})
|
||||
expect(result.frozenAt).toBe(new Date(startedAt + 2 * 60_000).toISOString())
|
||||
expect(result.failures).toContainEqual(expect.objectContaining({
|
||||
code: 'threshold_max',
|
||||
signal: 'cloud_sql.cpu'
|
||||
}))
|
||||
expect(preDrainDryRunPassed(result)).toBe(false)
|
||||
})
|
||||
|
||||
it('resets at the next fresh sample after a runner gap', async () => {
|
||||
let now = startedAt + 10 * 60_000
|
||||
const state = {
|
||||
@@ -690,13 +916,21 @@ describe('incident monitor lifecycle', () => {
|
||||
durationMinutes: 15,
|
||||
intervalMs: 60_000
|
||||
})
|
||||
let staleSamples = INCIDENT_FRESHNESS_TOLERANCE_SAMPLES + 1
|
||||
const result = await runIncidentMonitor(state, {
|
||||
now: () => now,
|
||||
wait: async (ms) => {
|
||||
now += ms
|
||||
},
|
||||
collect: async () =>
|
||||
healthySample(now === startedAt + 10 * 60_000 ? now - 180_001 : now),
|
||||
collect: async () => {
|
||||
if (staleSamples > 0 && now >= startedAt + 10 * 60_000) {
|
||||
staleSamples--
|
||||
return healthySample(
|
||||
now - INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs - 1
|
||||
)
|
||||
}
|
||||
return healthySample(now)
|
||||
},
|
||||
persist: async () => {},
|
||||
checkpoint: async () => {}
|
||||
})
|
||||
@@ -706,7 +940,7 @@ describe('incident monitor lifecycle', () => {
|
||||
)
|
||||
expect(result.frozenAt).not.toBeNull()
|
||||
expect(result.windowSequence).toBe(1)
|
||||
expect(result.sampleCount).toBe(15)
|
||||
expect(result.sampleCount).toBe(13)
|
||||
expect(result.failures).toContainEqual({
|
||||
code: 'continuity_deadline_exceeded',
|
||||
source: 'active-probe',
|
||||
|
||||
@@ -6,7 +6,27 @@ import {
|
||||
|
||||
export const INCIDENT_MONITOR_THRESHOLDS = {
|
||||
activeProbeMaxAgeMs: 60_000,
|
||||
cloudDataMaxAgeMs: 180_000,
|
||||
// Why: Cloud Monitoring publishes on Google's clock, not ours. Per the metric
|
||||
// list read 2026-09-05, Cloud Run instance_count / cpu / memory /
|
||||
// max_request_concurrencies / request_count are "Sampled every 60 seconds.
|
||||
// After sampling, data is not visible for up to 120 seconds" (60+120=180 s),
|
||||
// and Cloud SQL cpu / memory / num_backends / backends_in_wait /
|
||||
// deadlock_count say "up to 165 seconds" (60+165=225 s). Window-sum signals
|
||||
// age differently: observedAt is the newest point in the 5-minute query
|
||||
// window, so a label series that stops emitting reads as 300 s old while its
|
||||
// summed value is still complete. 330 s clears the worst of the three (the
|
||||
// 300 s query window) plus ~30 s of collect-to-evaluate latency. The old
|
||||
// 180 s bar restarted healthy 15-minute windows at 181 s, 189 s and 255 s on
|
||||
// 2026-09-04/05, once burning the whole 25-minute lineage with no verdict.
|
||||
cloudDataMaxAgeMs: 330_000,
|
||||
// Why: the director admin API answers live on our own request, so hold its
|
||||
// freshness bar where it sat while it shared cloudDataMaxAgeMs.
|
||||
directorAdminMaxAgeMs: 180_000,
|
||||
// Why: how long a nonzero backends-in-wait point is carried before it reads as
|
||||
// zero. Held at the pre-2026-09-05 cloud bar: carrying it for the full
|
||||
// cloudDataMaxAgeMs would hand the evaluator a point older than its own
|
||||
// freshness bar as soon as collection latency is added.
|
||||
cloudLockWaitCarryMs: 180_000,
|
||||
relayLogMaxAgeMs: 180_000,
|
||||
heartbeatMaxAgeMs: 45_000,
|
||||
endpointLatencyMs: 2_000,
|
||||
@@ -175,6 +195,7 @@ export type IncidentMonitorState = {
|
||||
continuityEvents: {
|
||||
recordedAt: string
|
||||
windowSequence: number
|
||||
tolerated: boolean
|
||||
failures: IncidentFailure[]
|
||||
}[]
|
||||
frozenAt: string | null
|
||||
@@ -307,7 +328,7 @@ const SOURCE_MAX_AGE: Record<IncidentSourceName, number> = {
|
||||
'active-probe': INCIDENT_MONITOR_THRESHOLDS.activeProbeMaxAgeMs,
|
||||
'cloud-monitoring': INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs,
|
||||
'relay-logs': INCIDENT_MONITOR_THRESHOLDS.relayLogMaxAgeMs,
|
||||
'director-admin': INCIDENT_MONITOR_THRESHOLDS.cloudDataMaxAgeMs
|
||||
'director-admin': INCIDENT_MONITOR_THRESHOLDS.directorAdminMaxAgeMs
|
||||
}
|
||||
|
||||
function ageMs(timestamp: string, nowMs: number): number {
|
||||
@@ -608,14 +629,59 @@ function checkpointMinutes(durationMinutes: number): number[] {
|
||||
return INCIDENT_CHECKPOINT_MINUTES.filter((minute) => minute <= durationMinutes)
|
||||
}
|
||||
|
||||
const CONTINUITY_FAILURE_CODES = new Set([
|
||||
'collector_failed',
|
||||
'monitor_gap',
|
||||
// Freshness-only failures: we could not read a signal this sample. Distinct from
|
||||
// collector_failed / monitor_gap, where the whole sample is absent.
|
||||
export const FRESHNESS_FAILURE_CODES = new Set([
|
||||
'signal_missing',
|
||||
'signal_stale',
|
||||
'source_missing',
|
||||
'source_stale'
|
||||
])
|
||||
|
||||
const CONTINUITY_FAILURE_CODES = new Set([
|
||||
'collector_failed',
|
||||
'monitor_gap',
|
||||
...FRESHNESS_FAILURE_CODES
|
||||
])
|
||||
|
||||
// Why: Cloud Monitoring overshoots its own publish bar, and one unread sample is
|
||||
// not evidence of an unhealthy fleet. Under the 25-minute lineage cap a restart
|
||||
// past minute 10 costs the entire verdict, so a healthy fleet produced none on
|
||||
// 2026-09-05. A signal may miss this many consecutive samples before the window
|
||||
// restarts; the sample is still evaluated against every threshold it can read,
|
||||
// and a threshold breach still freezes the run outright.
|
||||
export const INCIDENT_FRESHNESS_TOLERANCE_SAMPLES = 2
|
||||
|
||||
function freshnessKey(failure: IncidentFailure): string {
|
||||
return `${failure.source}/${failure.signal ?? '*'}`
|
||||
}
|
||||
|
||||
// Rebuild the per-signal tolerated streak from the trailing continuity events so a
|
||||
// resumed monitor cannot hand a signal a fresh budget.
|
||||
function resumeFreshnessStreaks(
|
||||
state: IncidentMonitorState
|
||||
): Map<string, number> {
|
||||
const events = state.continuityEvents
|
||||
const streaks = new Map<string, number>()
|
||||
const last = events[events.length - 1]
|
||||
if (!last?.tolerated) return streaks
|
||||
for (const key of new Set(last.failures.map(freshnessKey))) {
|
||||
let streak = 0
|
||||
let laterAt: number | null = null
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
const event = events[index]!
|
||||
const recordedAt = Date.parse(event.recordedAt)
|
||||
if (!event.tolerated) break
|
||||
if (laterAt !== null && laterAt - recordedAt > state.intervalMs * 1.5) break
|
||||
if (!event.failures.some((failure) => freshnessKey(failure) === key)) break
|
||||
streak++
|
||||
laterAt = recordedAt
|
||||
}
|
||||
streaks.set(key, streak)
|
||||
}
|
||||
return streaks
|
||||
}
|
||||
|
||||
function resetContinuousWindow(
|
||||
state: IncidentMonitorState,
|
||||
recordedAt: string,
|
||||
@@ -631,6 +697,7 @@ function resetContinuousWindow(
|
||||
state.continuityEvents.push({
|
||||
recordedAt,
|
||||
windowSequence: state.windowSequence,
|
||||
tolerated: false,
|
||||
failures
|
||||
})
|
||||
}
|
||||
@@ -681,6 +748,7 @@ export async function runIncidentMonitor(
|
||||
await dependencies.persist(state)
|
||||
return state
|
||||
}
|
||||
const freshnessStreaks = resumeFreshnessStreaks(state)
|
||||
while (state.completedAt === null) {
|
||||
if (dependencies.now() > lineageDeadlineMs) {
|
||||
completeContinuityDeadline(state, dependencies.now(), lineageStartMs)
|
||||
@@ -715,9 +783,34 @@ export async function runIncidentMonitor(
|
||||
const thresholdFailures = evaluation.failures.filter((failure) =>
|
||||
!CONTINUITY_FAILURE_CODES.has(failure.code)
|
||||
)
|
||||
if (continuityFailures.length > 0) {
|
||||
const toleratedKeys = new Set(
|
||||
state.windowStartedAt !== null &&
|
||||
continuityFailures.length > 0 &&
|
||||
continuityFailures.every((failure) => FRESHNESS_FAILURE_CODES.has(failure.code))
|
||||
? continuityFailures.map(freshnessKey)
|
||||
: []
|
||||
)
|
||||
for (const key of [...freshnessStreaks.keys()]) {
|
||||
if (!toleratedKeys.has(key)) freshnessStreaks.delete(key)
|
||||
}
|
||||
let tolerated = toleratedKeys.size > 0
|
||||
for (const key of toleratedKeys) {
|
||||
const streak = (freshnessStreaks.get(key) ?? 0) + 1
|
||||
freshnessStreaks.set(key, streak)
|
||||
if (streak > INCIDENT_FRESHNESS_TOLERANCE_SAMPLES) tolerated = false
|
||||
}
|
||||
if (continuityFailures.length > 0 && !tolerated) {
|
||||
freshnessStreaks.clear()
|
||||
resetContinuousWindow(state, evaluation.evaluatedAt, continuityFailures)
|
||||
} else {
|
||||
if (tolerated) {
|
||||
state.continuityEvents.push({
|
||||
recordedAt: evaluation.evaluatedAt,
|
||||
windowSequence: state.windowSequence,
|
||||
tolerated: true,
|
||||
failures: continuityFailures
|
||||
})
|
||||
}
|
||||
if (state.windowStartedAt === null) {
|
||||
state.windowStartedAt = evaluation.evaluatedAt
|
||||
}
|
||||
|
||||
@@ -92,7 +92,11 @@ test('same-cap wrapper is reusable, canary-bound, and sequential', () => {
|
||||
// age checks must scale by wave or cell_2+ can never pass; the bound's
|
||||
// per-wave step is the cell job timeout, so the two must move together.
|
||||
assert.match(job, /--required-migration-policy strict \\\n --wave-index "\$\{WAVE_INDEX\}"/)
|
||||
assert.match(job, /dry-run\.state\.json" \\\n --wave-index "\$\{WAVE_INDEX\}" "\$\{RETRY_ARGS\[@\]\}"/)
|
||||
// Wave 0 must retry freshness-only failures too: one Cloud Monitoring publish
|
||||
// lag at the sample instant is not health evidence, and single-shot wave 0
|
||||
// failed a whole batch on a series that was fresh again a minute later.
|
||||
assert.match(job, /dry-run\.state\.json" \\\n --wave-index "\$\{WAVE_INDEX\}" --retry-freshness/)
|
||||
assert.doesNotMatch(job, /RETRY_ARGS/)
|
||||
assert.match(job, /timeout-minutes: 75/)
|
||||
// Both age gates step by the cell job timeout above; the constant is
|
||||
// duplicated across the two languages, so pin each copy to it.
|
||||
|
||||
@@ -73,6 +73,13 @@ for a committed forward-recovery gate. Durable files default to
|
||||
gap resets the active window at the next fresh sample and preserves the prior
|
||||
window evidence. A threshold freeze never clears automatically.
|
||||
|
||||
A signal that reads missing or stale may miss up to two consecutive samples
|
||||
without restarting the window. The sample still counts and is still checked
|
||||
against every threshold it can read, and each tolerated gap is recorded in
|
||||
`continuityEvents` with `tolerated: true`. A third consecutive miss of the same
|
||||
signal, a failed collector, a runner gap, or any threshold breach restarts or
|
||||
freezes as before.
|
||||
|
||||
A production candidate or multi-target mutation must download the exact
|
||||
dry-run artifact by workflow run ID and attempt. It verifies the artifact
|
||||
hashes and provenance, requires a green completed 15-minute state no older
|
||||
@@ -89,7 +96,8 @@ durably marked consumed before mutation and cannot authorize another run.
|
||||
| Signal | Freeze condition |
|
||||
| --- | ---: |
|
||||
| Active probe age | over 60 seconds |
|
||||
| Cloud/log data age | over 180 seconds |
|
||||
| Cloud Monitoring data age | over 330 seconds |
|
||||
| Relay log and director admin data age | over 180 seconds |
|
||||
| Cell heartbeat age | over 45 seconds |
|
||||
| Endpoint latency | over 2,000 ms |
|
||||
| Cloud SQL CPU | over 80% |
|
||||
@@ -155,6 +163,25 @@ heartbeats, and matching live admission.
|
||||
separate it from today's baseline; the exhausted-retry bar (incident peak
|
||||
467 vs bar 300), director concurrency, and the pool bars carry that role.
|
||||
Re-tighten after the fleet is on the 500 ms lock wait.
|
||||
- Raised the Cloud Monitoring freshness bar from 180 s to 330 s and let a
|
||||
freshness-only failure miss up to two consecutive samples without restarting
|
||||
the window (2026-09-05). Basis: Google's metric list documents Cloud Run
|
||||
`request_count`, `container/instance_count`, `container/cpu/utilizations`,
|
||||
`container/memory/utilizations` and `container/max_request_concurrencies` as
|
||||
"Sampled every 60 seconds. After sampling, data is not visible for up to 120
|
||||
seconds", and Cloud SQL `database/cpu/utilization`,
|
||||
`database/memory/utilization`, `database/postgresql/num_backends`,
|
||||
`database/postgresql/backends_in_wait` and `database/postgresql/deadlock_count`
|
||||
as "up to 165 seconds", so the newest visible point is up to 180 s and 225 s
|
||||
old respectively. Window-sum signals age further: `observedAt` is the newest
|
||||
point in the 5-minute query window, so a label series that stops emitting
|
||||
reads as 300 s old while its summed value is complete. The old bar sat under
|
||||
all three. Production on 2026-09-04/05 restarted healthy 15-minute windows at
|
||||
181 s and 255 s (`auth.errors`, run 33928912676) and at 189 s
|
||||
(`cloud_sql.lock_waits`, run 33944873727), and the last of those then blew the
|
||||
25-minute lineage cap at 1 500 004 ms, so a green fleet produced no verdict.
|
||||
The director admin bar stays at 180 s and the nonzero lock-wait carry window
|
||||
stays at 180 s; both publish on our own cadence.
|
||||
- Recalibrated the exhausted-PostgreSQL-retry freeze from 0 to 300 per five
|
||||
minutes (2026-09-04). Basis: #18521 cut the request-path cell-inventory
|
||||
lock wait from the 1 s pool `lock_timeout` to 500 ms, so contended waiters
|
||||
|
||||
@@ -6,8 +6,13 @@ ARG LIBASOUND_PACKAGE=libasound2t64
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install Electron's link-time libraries without adding a display server or FUSE.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
# Why: archive.ubuntu.com mid-sync returns Hash Sum mismatch / wrong-size indexes and stalls per-package fetches; retry with bounded timeouts and drop half-synced lists between attempts.
|
||||
RUN for attempt in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 update && break; \
|
||||
if [ "$attempt" = 5 ]; then exit 100; fi; \
|
||||
rm -rf /var/lib/apt/lists/*; sleep 20; \
|
||||
done \
|
||||
&& apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
coreutils \
|
||||
|
||||
@@ -5,8 +5,13 @@ ARG LIBASOUND_PACKAGE=libasound2t64
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
# Why: archive.ubuntu.com mid-sync returns Hash Sum mismatch / wrong-size indexes and stalls per-package fetches; retry with bounded timeouts and drop half-synced lists between attempts.
|
||||
RUN for attempt in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 update && break; \
|
||||
if [ "$attempt" = 5 ]; then exit 100; fi; \
|
||||
rm -rf /var/lib/apt/lists/*; sleep 20; \
|
||||
done \
|
||||
&& apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
dbus-x11 \
|
||||
|
||||
@@ -2,8 +2,13 @@ FROM ubuntu@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
# Why: archive.ubuntu.com mid-sync returns Hash Sum mismatch / wrong-size indexes and stalls per-package fetches; retry with bounded timeouts and drop half-synced lists between attempts.
|
||||
RUN for attempt in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 update && break; \
|
||||
if [ "$attempt" = 5 ]; then exit 100; fi; \
|
||||
rm -rf /var/lib/apt/lists/*; sleep 20; \
|
||||
done \
|
||||
&& apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
dbus-x11 \
|
||||
|
||||
@@ -12,14 +12,15 @@ const { join, resolve } = require('node:path')
|
||||
* `_inSocket`, and it wraps a real Windows named-pipe handle from `fs.openSync(term.conin, 'w')`.
|
||||
* Every terminal leaks one File handle for the life of the host process.
|
||||
*
|
||||
* The obvious fix -- and the one the desktop patch ships -- releases it at the TOP of the branch,
|
||||
* before `_getConsoleProcessList()` forks and before the native kill. That is measurably worse than
|
||||
* leaving the leak alone: teardown aborts partway, the forked console-list agent is never reaped,
|
||||
* and both pipe handles stay alive instead of one. This asset releases it at the END of the branch
|
||||
* instead, after the fork and the kill have already happened.
|
||||
* The obvious fix -- and the placement `config/patches/node-pty@1.1.0.patch` uses -- releases it at
|
||||
* the TOP of the branch, before `_getConsoleProcessList()` forks and before the native kill. That is
|
||||
* measurably worse than leaving the leak alone: teardown aborts partway, the forked console-list
|
||||
* agent is never reaped, and both pipe handles stay alive instead of one. This asset releases it at
|
||||
* the END of the branch instead, after the fork and the kill have already happened.
|
||||
*
|
||||
* Measured on a Windows SSH host, 20 spawn/kill cycles, handles bucketed by NT object type
|
||||
* (identical numbers standalone and through a real relay):
|
||||
* (identical numbers standalone and through a real relay). Every row is the NON-DLL branch, which
|
||||
* is the branch a relay runs -- see the divergence note below for why that matters:
|
||||
*
|
||||
* published node-pty File +1/terminal, Process flat
|
||||
* desktop patch placement File +2/terminal, Process +1/terminal <-- 3x WORSE
|
||||
@@ -34,18 +35,64 @@ const { join, resolve } = require('node:path')
|
||||
* Why this ships as a relay asset rather than only in config/patches/node-pty@1.1.0.patch: pnpm
|
||||
* patches do not cross the SSH boundary -- a relay host runs the tree `npm install` put there.
|
||||
*
|
||||
* DELIBERATE DIVERGENCE FROM THE DESKTOP: the desktop patch has the early placement and therefore
|
||||
* the +2 File / +1 Process regression, measured against its exact installed tree. Correcting it
|
||||
* there is a separate change with its own verification, so the two trees differ on this one hunk on
|
||||
* purpose, and the test pins that so a future "sync the patches" does not copy the bug back.
|
||||
* DELIBERATE DIVERGENCE FROM THE DESKTOP, AND WHY IT IS NOT A DESKTOP-TERMINAL BUG: the two hosts
|
||||
* do not run the same branch of `kill()`. node-pty defaults `_useConptyDll` to false
|
||||
* (`windowsPtyAgent.js`). Every desktop site that opens a terminal pane sets it true --
|
||||
* `local-pty-utils.ts` (two) and `native-pty-spawn.ts` -- as does the `windows-conpty-warmup.ts`
|
||||
* warm-up, so all of those take the `else` branch, where UPSTREAM ALREADY destroys the input
|
||||
* socket. The relay passes no such option (`src/relay/pty-handler.ts`), so it takes the
|
||||
* `!useConptyDll` branch -- the one this asset and the desktop patch both edit.
|
||||
*
|
||||
* NOT ADDRESSED, AND A SEPARATE DEFECT THAT IS STILL OPEN: a terminal that exits on its own is
|
||||
* still torn down through `kill()` -- both hosts call `destroy()` on natural exit and
|
||||
* `WindowsTerminal.destroy()` is `kill()` -- but the shell is already gone by then, and the
|
||||
* ordering this patch relies on does not hold. Measured over 20 self-exit cycles with that
|
||||
* `destroy()` issued: published +3 File/+1 Process per terminal, desktop-patched +2/+1, this tree
|
||||
* +2/+1. So this patch does not close it and the desktop patch does not either. It is reachable
|
||||
* for every Windows user, local and relay, on every terminal closed by typing `exit`.
|
||||
* THE DESKTOP IS NOT ENTIRELY OFF THAT BRANCH. Two desktop sites omit the option and so run it
|
||||
* too: the hidden rate-limit probes in `src/main/rate-limits/claude-pty.ts` and
|
||||
* `codex-pty-rate-limit-probe.ts`. Both recur -- their fetchers poll -- and both tear down through
|
||||
* `kill()`, so this hunk is live on the desktop, just never for a pane a user can see. Do not
|
||||
* restate this as "the desktop never executes that branch": that sentence stood here for two
|
||||
* revisions and is false.
|
||||
*
|
||||
* What the numbers above therefore do NOT cover: they were measured on relay-style spawn/kill
|
||||
* cycles. Whether the early placement costs the same +2 File / +1 Process across a probe's
|
||||
* lifecycle is UNMEASURED -- plausible, not established, and worth measuring before anyone quotes
|
||||
* a desktop figure. What IS settled is the claim this comment replaced: that the desktop patch made
|
||||
* every Windows user worse off ON EVERY TERMINAL. Terminals take the DLL branch, and the harness
|
||||
* that produced that claim defaulted into the branch it was not trying to measure.
|
||||
*
|
||||
* The divergence is therefore about which branch each host runs for the workload that matters, not
|
||||
* about a regression in the terminals users open. The test still pins it, because a future "sync
|
||||
* the patches" would put the early placement onto the relay's branch, where it does cost +2 File
|
||||
* and +1 Process per terminal.
|
||||
*
|
||||
* If you extend this enumeration, grep for `node-pty` rather than for a static import: those two
|
||||
* probes were missed three times because they use `await import('node-pty')`.
|
||||
*
|
||||
* THE SELF-EXIT LEAK: FIXED FOR THE DESKTOP BY #18635, STILL LIVE ON A RELAY. A terminal that exits
|
||||
* on its own is also torn down through `kill()` -- both hosts call `destroy()` on natural exit and
|
||||
* `WindowsTerminal.destroy()` is `kill()` -- but the shell is already gone by then, so the ordering
|
||||
* this asset relies on does not hold. Measured over 20 self-exit cycles on the NON-DLL branch:
|
||||
* published +3 File/+1 Process per terminal, desktop patch placement +2/+1, this tree +2/+1. This
|
||||
* asset does not close it.
|
||||
*
|
||||
* #18635 does, in `config/patches/node-pty@1.1.0.patch`: the baton outlives the shell so `PtyKill`
|
||||
* still reaches `ClosePseudoConsole`, plus an unconditional conout dispose on the DLL branch. That
|
||||
* fix does not reach a Windows relay, and no hunk in THIS file can carry it, because it is mostly
|
||||
* NATIVE (`src/win/conpty.cc`) and this asset only rewrites `lib/*.js`. Three delivery paths exist
|
||||
* and none currently covers Windows:
|
||||
*
|
||||
* - the pnpm patch does not cross the SSH boundary -- the remote `npm install` yields upstream's
|
||||
* unpatched node-pty;
|
||||
* - the orcad prebuild matrix has no win32 entry (`MATRIX_SLOTS`,
|
||||
* `config/scripts/build-orcad-prebuilds.mjs`), so no Windows binary is ever compiled from
|
||||
* patched source to ship;
|
||||
* - a relay asset CAN patch native source and rebuild on the host -- that is exactly what
|
||||
* `node-pty-1.1.0-master-cloexec-patch.cjs` does -- but it returns
|
||||
* `skipped:unsupported-platform` for anything but linux/darwin. Extending it to win32 means
|
||||
* requiring an MSVC toolchain on the relay host, a far heavier precondition than on Linux,
|
||||
* where node-gyp already runs at install time.
|
||||
*
|
||||
* So a Windows SSH relay still leaks a pseudoconsole per self-exiting terminal, and closing it is a
|
||||
* DELIVERY problem, not another hunk here. Do not read #18635's flat self-exit relay numbers as
|
||||
* covering deployed relays: they were measured against a locally rebuilt binary, so they describe
|
||||
* the relay CODE PATH on a patched tree, not the tree a relay host actually installs.
|
||||
*/
|
||||
|
||||
const EXPECTED_NODE_PTY_VERSION = '1.1.0'
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
// Counts how many whole-host process-table captures the agent-completion cadence costs.
|
||||
//
|
||||
// Local panes all resolve out of one TTL-deduped snapshot, and the inspection queue collapses
|
||||
// every shared-observation task enqueued in the same tick onto a single capture. So the capture
|
||||
// count is the number of DISTINCT wake instants across panes, not the number of pane wakes.
|
||||
//
|
||||
// This drives the production interval picker (`nextCadenceInspectionDelayMs`) against a baseline
|
||||
// that reproduces the pre-change ±10% jitter, over a simulated wall-clock window.
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import nodeModule from 'node:module'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
if (!process.execArgv.includes('--experimental-transform-types')) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--experimental-transform-types', '--no-warnings', import.meta.filename],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
nodeModule.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
|
||||
const candidate = new URL(`${specifier}.ts`, context.parentURL)
|
||||
if (fs.existsSync(fileURLToPath(candidate))) {
|
||||
return { url: candidate.href, shortCircuit: true }
|
||||
}
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
}
|
||||
})
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '../..')
|
||||
const WINDOW_MS = Number(process.env.ORCA_INSPECTION_BENCH_WINDOW_MS ?? '60000')
|
||||
const PANE_COUNTS = (process.env.ORCA_INSPECTION_BENCH_PANES ?? '1,2,4,8')
|
||||
.split(',')
|
||||
.map((value) => Number(value.trim()))
|
||||
|
||||
if (!Number.isSafeInteger(WINDOW_MS) || WINDOW_MS <= 0) {
|
||||
throw new Error(`ORCA_INSPECTION_BENCH_WINDOW_MS must be a positive integer, got ${WINDOW_MS}`)
|
||||
}
|
||||
for (const paneCount of PANE_COUNTS) {
|
||||
if (!Number.isSafeInteger(paneCount) || paneCount <= 0) {
|
||||
throw new Error(`ORCA_INSPECTION_BENCH_PANES entries must be positive, got ${paneCount}`)
|
||||
}
|
||||
}
|
||||
|
||||
const { nextCadenceInspectionDelayMs } = await import(
|
||||
path.join(ROOT, 'src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts')
|
||||
)
|
||||
const { POLL_TIER_INTERVAL_MS } = await import(
|
||||
path.join(ROOT, 'src/renderer/src/components/terminal-pane/agent-completion-poll-cadence.ts')
|
||||
)
|
||||
const { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } = await import(
|
||||
path.join(ROOT, 'src/shared/process-table-snapshot-reader.ts')
|
||||
)
|
||||
|
||||
// Pre-change: independent ±10% jitter per pane, re-rolled on every reschedule.
|
||||
function baselineDelayMs(baseMs) {
|
||||
return Math.round(baseMs * (1 + (Math.random() * 0.2 - 0.1)))
|
||||
}
|
||||
|
||||
function simulate(paneCount, baseMs, pickDelay) {
|
||||
const startedAt = 1_700_000_000_000
|
||||
const wakes = []
|
||||
for (let pane = 0; pane < paneCount; pane += 1) {
|
||||
// Panes mount at arbitrary moments, which is what spreads them apart in the first place.
|
||||
let clock = startedAt + Math.floor(Math.random() * baseMs)
|
||||
while ((clock += pickDelay(baseMs, clock)) < startedAt + WINDOW_MS) {
|
||||
wakes.push(clock)
|
||||
}
|
||||
}
|
||||
// A wake is served from the snapshot the previous capture produced until that snapshot's TTL
|
||||
// lapses, so the TTL window starts at the capture, not on an epoch grid.
|
||||
let captures = 0
|
||||
let snapshotExpiresAt = -Infinity
|
||||
for (const wakeAt of wakes.sort((left, right) => left - right)) {
|
||||
if (wakeAt >= snapshotExpiresAt) {
|
||||
captures += 1
|
||||
snapshotExpiresAt = wakeAt + PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS
|
||||
}
|
||||
}
|
||||
return captures
|
||||
}
|
||||
|
||||
function medianOf(rounds, run) {
|
||||
const samples = Array.from({ length: rounds }, run).sort((left, right) => left - right)
|
||||
return samples[Math.floor(samples.length / 2)]
|
||||
}
|
||||
|
||||
const baseMs = POLL_TIER_INTERVAL_MS.idle
|
||||
console.log(
|
||||
`Agent-completion cadence — whole-host \`ps\` captures over ${WINDOW_MS / 1000}s at the idle tier (${baseMs}ms)\n`
|
||||
)
|
||||
console.log('| visible panes | before | after | reduction |')
|
||||
console.log('| --- | --- | --- | --- |')
|
||||
for (const paneCount of PANE_COUNTS) {
|
||||
const before = medianOf(21, () => simulate(paneCount, baseMs, baselineDelayMs))
|
||||
const after = medianOf(21, () =>
|
||||
simulate(paneCount, baseMs, (base, now) =>
|
||||
nextCadenceInspectionDelayMs({
|
||||
baseMs: base,
|
||||
hasConsecutiveErrors: false,
|
||||
alignToSharedGrid: true,
|
||||
now
|
||||
})
|
||||
)
|
||||
)
|
||||
// A window shorter than one cadence tier can leave the baseline at zero; reporting a
|
||||
// percentage off that divides by zero and prints a meaningless reduction.
|
||||
const reduction = before > 0 ? `${(((before - after) / before) * 100).toFixed(0)}%` : 'n/a'
|
||||
console.log(`| ${paneCount} | ${before} | ${after} | ${reduction} |`)
|
||||
}
|
||||
|
||||
// Detection latency must not regress: the grid deadline is always within one interval.
|
||||
let worstDelay = 0
|
||||
for (let sample = 0; sample < 100_000; sample += 1) {
|
||||
const now = 1_700_000_000_000 + sample * 7
|
||||
worstDelay = Math.max(
|
||||
worstDelay,
|
||||
nextCadenceInspectionDelayMs({
|
||||
baseMs,
|
||||
hasConsecutiveErrors: false,
|
||||
alignToSharedGrid: true,
|
||||
now
|
||||
})
|
||||
)
|
||||
}
|
||||
if (worstDelay > baseMs) {
|
||||
throw new Error(`grid alignment delayed a poll to ${worstDelay}ms, above the ${baseMs}ms tier`)
|
||||
}
|
||||
console.log(
|
||||
`\nWorst observed wait: ${worstDelay}ms (tier interval ${baseMs}ms) — no inspection is ever delayed.`
|
||||
)
|
||||
@@ -1,11 +1,18 @@
|
||||
// The relay's copy of the ConPTY teardown release, and the guard that keeps it in lockstep with the
|
||||
// desktop's own node-pty patch. pnpm patches do not cross the SSH boundary, so a relay runs the tree
|
||||
// `npm install` put there; the desktop had this fix and the relay did not, and every terminal on a
|
||||
// Windows SSH host leaked one File handle for the life of the relay process.
|
||||
// The relay's copy of the ConPTY teardown release, and the guard that keeps it in lockstep with
|
||||
// `config/patches/node-pty@1.1.0.patch`. pnpm patches do not cross the SSH boundary, so a relay runs
|
||||
// the tree `npm install` put there, and every terminal on a Windows SSH host leaked one File handle
|
||||
// for the life of the relay process.
|
||||
//
|
||||
// The ORDER of the conin release is the fix. Releasing it at the top of the branch -- what the
|
||||
// desktop patch does -- was measured at 3x WORSE than shipping nothing (File +2/terminal and a new
|
||||
// Process +1/terminal); releasing it after the console-list fork and the native kill is flat.
|
||||
// The ORDER of the conin release is the fix. Releasing it at the top of the branch -- the placement
|
||||
// the desktop patch uses -- was measured at 3x WORSE than shipping nothing (File +2/terminal and a
|
||||
// new Process +1/terminal); releasing it after the console-list fork and the native kill is flat.
|
||||
//
|
||||
// Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop
|
||||
// site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where
|
||||
// upstream already destroys the input socket. Two hidden rate-limit probes
|
||||
// (`src/main/rate-limits/claude-pty.ts`, `codex-pty-rate-limit-probe.ts`) do omit the option and so
|
||||
// do run this hunk, but no user-visible pane does. The divergence pinned below is about which
|
||||
// branch each host runs for terminals -- not about a regression in the panes users open.
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
@@ -90,9 +97,11 @@ describe('Windows SSH relay node-pty ConPTY teardown patch', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// The one hunk that must NOT match the desktop, and the reason is measured, not stylistic:
|
||||
// releasing conin before `_getConsoleProcessList()` forks aborts teardown partway.
|
||||
it('releases conin after the console-list fork, not before it like the desktop patch', () => {
|
||||
// The one hunk that must NOT match the desktop patch, and the reason is measured, not stylistic:
|
||||
// on the branch a relay runs, releasing conin before `_getConsoleProcessList()` forks aborts
|
||||
// teardown partway. Desktop terminal panes take the other branch, so no pane is affected either
|
||||
// way; what this guards is a patch sync putting the early placement onto the relay's branch.
|
||||
it('releases conin after the console-list fork, unlike the desktop patch placement', () => {
|
||||
const fixture = writeNodePtyFixture('1.1.0')
|
||||
patchNodePtyWindowsTeardown(fixture.root)
|
||||
const patched = readFileSync(join(fixture.libDir, 'windowsPtyAgent.js'), 'utf8')
|
||||
@@ -108,7 +117,8 @@ describe('Windows SSH relay node-pty ConPTY teardown patch', () => {
|
||||
expect(branch.indexOf('this._inSocket.destroy();')).toBeGreaterThan(
|
||||
branch.indexOf('this._getConsoleProcessList()')
|
||||
)
|
||||
// Pinned so a future "sync the relay asset to config/patches" cannot copy the regression back.
|
||||
// Pinned so a future "sync the relay asset to config/patches" cannot copy the early placement
|
||||
// onto the relay's branch, where it costs +2 File and +1 Process per terminal.
|
||||
expect(patched).not.toBe(readFileSync(desktopPath('windowsPtyAgent.js'), 'utf8'))
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmarks four renderer projections that scaled worse than linearly with user data, each on a
|
||||
// path that reruns per keystroke or per store write.
|
||||
//
|
||||
// Scenarios 1, 3 and 4 time the production export against a hand-written reproduction of the
|
||||
// pre-change shape and assert both agree first. Scenario 2 is MODELLED on both sides: the
|
||||
// projection lives inside the `useTabGroupItemProjections` React hook and cannot be imported
|
||||
// without a renderer, so it reproduces the before/after loops rather than driving production.
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { transformSync } from 'esbuild'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import fs from 'node:fs'
|
||||
import nodeModule from 'node:module'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
if (!process.execArgv.includes('--experimental-transform-types')) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--experimental-transform-types', '--no-warnings', import.meta.filename],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '../..')
|
||||
const RENDERER = path.join(ROOT, 'src/renderer/src')
|
||||
|
||||
nodeModule.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (!context.parentURL) {
|
||||
return nextResolve(specifier, context)
|
||||
}
|
||||
const candidates = specifier.startsWith('@/')
|
||||
? ['.ts', '.tsx', '/index.ts', '/index.tsx', ''].map(
|
||||
(suffix) => path.join(RENDERER, specifier.slice(2)) + suffix
|
||||
)
|
||||
: specifier.startsWith('.') && !/\.[cm]?[jt]sx?$/.test(specifier)
|
||||
? ['.ts', '.tsx'].map((suffix) =>
|
||||
fileURLToPath(new URL(specifier + suffix, context.parentURL))
|
||||
)
|
||||
: []
|
||||
const resolved = candidates.find((file) => fs.existsSync(file) && fs.statSync(file).isFile())
|
||||
return resolved
|
||||
? { url: pathToFileURL(resolved).href, shortCircuit: true }
|
||||
: nextResolve(specifier, context)
|
||||
},
|
||||
// Node strips types from .ts but not .tsx; the sidebar row model transitively imports icons.
|
||||
load(url, context, nextLoad) {
|
||||
if (url.endsWith('.tsx')) {
|
||||
const source = fs.readFileSync(fileURLToPath(url), 'utf8')
|
||||
const { code } = transformSync(source, { loader: 'tsx', format: 'esm', jsx: 'automatic' })
|
||||
return { format: 'module', source: code, shortCircuit: true }
|
||||
}
|
||||
if (url.endsWith('.json') && !url.includes('/node_modules/')) {
|
||||
const source = fs.readFileSync(fileURLToPath(url), 'utf8')
|
||||
return { format: 'module', source: `export default ${source}`, shortCircuit: true }
|
||||
}
|
||||
return nextLoad(url, context)
|
||||
}
|
||||
})
|
||||
|
||||
const importRenderer = (relativePath) =>
|
||||
import(pathToFileURL(path.join(RENDERER, relativePath)).href)
|
||||
|
||||
function envInt(name, fallback) {
|
||||
const value = Number(process.env[name] ?? fallback)
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, got ${value}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const KEYSTROKES = envInt('ORCA_QUADRATIC_BENCH_KEYSTROKES', 12)
|
||||
const WORKTREES = envInt('ORCA_QUADRATIC_BENCH_WORKTREES', 300)
|
||||
const TABS = envInt('ORCA_QUADRATIC_BENCH_TABS', 60)
|
||||
const OPEN_FILES = envInt('ORCA_QUADRATIC_BENCH_OPEN_FILES', 120)
|
||||
const CHANGED_FILES = envInt('ORCA_QUADRATIC_BENCH_CHANGED_FILES', 5000)
|
||||
const SIDEBAR_ROWS = envInt('ORCA_QUADRATIC_BENCH_SIDEBAR_ROWS', 600)
|
||||
const SIDEBAR_REPOS = envInt('ORCA_QUADRATIC_BENCH_SIDEBAR_REPOS', 80)
|
||||
if (SIDEBAR_REPOS > SIDEBAR_ROWS) {
|
||||
throw new Error(
|
||||
'ORCA_QUADRATIC_BENCH_SIDEBAR_REPOS must not exceed ORCA_QUADRATIC_BENCH_SIDEBAR_ROWS'
|
||||
)
|
||||
}
|
||||
|
||||
function timeRounds(run, rounds = 7) {
|
||||
run()
|
||||
const samples = Array.from({ length: rounds }, () => {
|
||||
const start = performance.now()
|
||||
run()
|
||||
return performance.now() - start
|
||||
}).sort((left, right) => left - right)
|
||||
return samples[Math.floor(rounds / 2)]
|
||||
}
|
||||
|
||||
function repeat(times, run) {
|
||||
return () => {
|
||||
let last
|
||||
for (let round = 0; round < times; round += 1) {
|
||||
last = run()
|
||||
}
|
||||
return last
|
||||
}
|
||||
}
|
||||
|
||||
const results = []
|
||||
function compare({ label, scale, drives, before, after }) {
|
||||
if (JSON.stringify(before()) !== JSON.stringify(after())) {
|
||||
throw new Error(`${label}: baseline disagreed with the indexed shape`)
|
||||
}
|
||||
results.push({ label, scale, drives, beforeMs: timeRounds(before), afterMs: timeRounds(after) })
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 1. workspace board search index
|
||||
|
||||
const { buildWorkspaceBoardPaletteDocuments, matchWorkspaceBoardWorktrees } = await importRenderer(
|
||||
'components/sidebar/workspace-kanban-search.ts'
|
||||
)
|
||||
|
||||
const repoMap = new Map([
|
||||
['repo-1', { id: 'repo-1', name: 'orca', path: '/tmp/orca', branch: 'main' }]
|
||||
])
|
||||
const boardWorktrees = Array.from({ length: WORKTREES }, (_, index) => ({
|
||||
id: `repo-1::/tmp/worktree-${index}`,
|
||||
repoId: 'repo-1',
|
||||
path: `/tmp/worktree-${index}`,
|
||||
branch: `feature/search-target-${index}`,
|
||||
title: `Workspace ${index} search target`,
|
||||
isMain: false
|
||||
}))
|
||||
const queries = Array.from({ length: KEYSTROKES }, (_, index) => 'search'.slice(0, (index % 6) + 1))
|
||||
const matchAll = (documents) =>
|
||||
queries.map((query) => [
|
||||
...matchWorkspaceBoardWorktrees({ worktrees: boardWorktrees, query, repoMap, documents })
|
||||
])
|
||||
|
||||
compare({
|
||||
label: 'workspace board filter (per keystroke burst)',
|
||||
scale: `${WORKTREES} worktrees x ${KEYSTROKES} keystrokes`,
|
||||
drives: 'production',
|
||||
// Omitting `documents` is the pre-change shape: the index is rebuilt inside every match.
|
||||
before: () => matchAll(undefined),
|
||||
// The hook memoizes the index on [worktrees, repoMap]; only the match reruns per keystroke.
|
||||
after: () => matchAll(buildWorkspaceBoardPaletteDocuments({ worktrees: boardWorktrees, repoMap }))
|
||||
})
|
||||
|
||||
// ------------------------------------------------- 2. tab-group projections (modelled)
|
||||
|
||||
const groupTabs = Array.from({ length: TABS }, (_, index) => ({
|
||||
id: `tab-${index}`,
|
||||
entityId: `entity-${index}`,
|
||||
contentType: index % 3 === 0 ? 'editor' : 'terminal'
|
||||
}))
|
||||
const openFiles = Array.from({ length: OPEN_FILES }, (_, index) => ({
|
||||
id: `entity-${index}`,
|
||||
path: `/tmp/file-${index}.ts`
|
||||
}))
|
||||
const tabOrder = groupTabs.map((tab) => tab.id)
|
||||
// Production memoizes each index on its own source list, so a unified-tab write reuses it.
|
||||
const openFileById = new Map(openFiles.map((item) => [item.id, item]))
|
||||
const groupTabById = new Map(groupTabs.map((item) => [item.id, item]))
|
||||
|
||||
function tabProjections(findOpenFile, findGroupTab) {
|
||||
const editorItems = groupTabs
|
||||
.filter((item) => item.contentType === 'editor')
|
||||
.map((item) => findOpenFile(item.entityId))
|
||||
.filter((file) => file !== undefined)
|
||||
const order = tabOrder.map((itemId) => findGroupTab(itemId)?.entityId ?? itemId)
|
||||
return [editorItems, order]
|
||||
}
|
||||
|
||||
compare({
|
||||
label: 'tab-group projections (per unified-tab write)',
|
||||
scale: `${TABS} tabs x ${OPEN_FILES} open files`,
|
||||
drives: 'modelled',
|
||||
before: repeat(200, () =>
|
||||
tabProjections(
|
||||
(id) => openFiles.find((candidate) => candidate.id === id),
|
||||
(id) => groupTabs.find((candidate) => candidate.id === id)
|
||||
)
|
||||
),
|
||||
after: repeat(200, () =>
|
||||
tabProjections(
|
||||
(id) => openFileById.get(id),
|
||||
(id) => groupTabById.get(id)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// ------------------------------------------------- 3. source-control tree build
|
||||
|
||||
const { buildSourceControlTree } = await importRenderer(
|
||||
'components/right-sidebar/source-control-tree.ts'
|
||||
)
|
||||
const { normalizeRelativePath } = await importRenderer('lib/path.ts')
|
||||
const { splitPathSegments } = await importRenderer('components/right-sidebar/path-tree.ts')
|
||||
const { compareFileNames } = await import(
|
||||
pathToFileURL(path.join(ROOT, 'src/shared/file-name-sort.ts')).href
|
||||
)
|
||||
|
||||
const changedEntries = Array.from({ length: CHANGED_FILES }, (_, index) => ({
|
||||
path: `src/area-${index % 20}/module-${index % 60}/nested/deep/part-${index % 7}/file-${index}.ts`
|
||||
}))
|
||||
|
||||
// Pre-change `buildSourceControlTree`: identical except each ancestor path is re-joined.
|
||||
function buildSourceControlTreeBefore(area, entries) {
|
||||
const makeDirectory = (dirPath, name, depth) => ({
|
||||
type: 'directory',
|
||||
key: `dir::${area}::${dirPath}`,
|
||||
name,
|
||||
path: dirPath,
|
||||
area,
|
||||
depth,
|
||||
fileCount: 0,
|
||||
children: [],
|
||||
directoryChildren: new Map()
|
||||
})
|
||||
const root = makeDirectory('', '', -1)
|
||||
for (const entry of entries) {
|
||||
const normalizedPath = normalizeRelativePath(entry.path)
|
||||
const segments = splitPathSegments(normalizedPath)
|
||||
if (segments.length === 0) {
|
||||
continue
|
||||
}
|
||||
let parent = root
|
||||
for (let index = 0; index < segments.length - 1; index += 1) {
|
||||
const name = segments[index]
|
||||
const dirPath = segments.slice(0, index + 1).join('/')
|
||||
let dir = parent.directoryChildren.get(name)
|
||||
if (!dir) {
|
||||
dir = makeDirectory(dirPath, name, index)
|
||||
parent.directoryChildren.set(name, dir)
|
||||
parent.children.push(dir)
|
||||
}
|
||||
parent = dir
|
||||
}
|
||||
parent.children.push({
|
||||
type: 'file',
|
||||
key: `${area}::${entry.path}`,
|
||||
name: segments.at(-1),
|
||||
path: normalizedPath,
|
||||
entry,
|
||||
area,
|
||||
depth: segments.length - 1
|
||||
})
|
||||
}
|
||||
const finalize = (node) => {
|
||||
const directories = node.children.filter((child) => child.type === 'directory').map(finalize)
|
||||
const files = node.children.filter((child) => child.type === 'file')
|
||||
directories.sort((a, b) => compareFileNames(a.name, b.name))
|
||||
files.sort((a, b) => compareFileNames(a.entry.path, b.entry.path))
|
||||
const { directoryChildren: _, ...rest } = node
|
||||
return {
|
||||
...rest,
|
||||
fileCount: files.length + directories.reduce((count, dir) => count + dir.fileCount, 0),
|
||||
children: [...directories, ...files]
|
||||
}
|
||||
}
|
||||
return finalize(root).children
|
||||
}
|
||||
|
||||
compare({
|
||||
label: 'source-control tree build (per filter keystroke)',
|
||||
scale: `${CHANGED_FILES} changed files`,
|
||||
drives: 'production',
|
||||
before: () => buildSourceControlTreeBefore('unstaged', changedEntries),
|
||||
after: () => buildSourceControlTree('unstaged', changedEntries)
|
||||
})
|
||||
|
||||
// ------------------------------------------------- 4. sidebar header boundaries
|
||||
|
||||
const { getRepoHeaderSectionEndByRepoId } = await importRenderer(
|
||||
'components/sidebar/worktree-header-section-boundaries.ts'
|
||||
)
|
||||
const { estimateRenderRowSize } = await importRenderer(
|
||||
'components/sidebar/worktree-list/viewport/virtual-rows.ts'
|
||||
)
|
||||
|
||||
const headerRowIndexes = new Set(
|
||||
Array.from({ length: SIDEBAR_REPOS }, (_, repo) =>
|
||||
Math.floor((repo * SIDEBAR_ROWS) / SIDEBAR_REPOS)
|
||||
)
|
||||
)
|
||||
const sidebarRows = Array.from({ length: SIDEBAR_ROWS }, (_, index) =>
|
||||
headerRowIndexes.has(index)
|
||||
? {
|
||||
type: 'header',
|
||||
key: `repo:${index}`,
|
||||
label: '',
|
||||
count: 0,
|
||||
tone: '',
|
||||
repo: { id: `repo-${index}` }
|
||||
}
|
||||
: { type: 'item', rowKey: `wt:${index}`, sectionKey: '', depth: 0, groupDepth: 0 }
|
||||
)
|
||||
const headerRepoIds = sidebarRows.filter((row) => row.type === 'header').map((row) => row.repo.id)
|
||||
const boundaryArgs = {
|
||||
rows: sidebarRows,
|
||||
firstHeaderIndex: 0,
|
||||
// What `getSidebarOrderedRepoHeaderIdsByBucket` yields for repos outside any project group.
|
||||
sidebarRepoHeaderIdsByBucket: new Map([['ungrouped', headerRepoIds]]),
|
||||
repoHeaderBucketByRepoId: new Map(headerRepoIds.map((id) => [id, 'ungrouped']))
|
||||
}
|
||||
|
||||
// Pre-change `getRepoHeaderSectionEndByRepoId`: a findIndex and an indexOf per header row.
|
||||
function getRepoHeaderSectionEndByRepoIdBefore(args) {
|
||||
const rowStarts = []
|
||||
let offset = 0
|
||||
for (let index = 0; index < args.rows.length; index += 1) {
|
||||
rowStarts[index] = offset
|
||||
offset += estimateRenderRowSize(args.rows, index, args.firstHeaderIndex, null)
|
||||
}
|
||||
rowStarts[args.rows.length] = offset
|
||||
const sectionEndByRepoId = new Map()
|
||||
for (let index = 0; index < args.rows.length; index += 1) {
|
||||
const row = args.rows[index]
|
||||
const repoId = row?.type === 'header' ? row.repo?.id : undefined
|
||||
if (!repoId) {
|
||||
continue
|
||||
}
|
||||
const bucketKey = args.repoHeaderBucketByRepoId.get(repoId)
|
||||
const bucketRepoIds = bucketKey ? args.sidebarRepoHeaderIdsByBucket.get(bucketKey) : undefined
|
||||
const bucketIndex = bucketRepoIds?.indexOf(repoId) ?? -1
|
||||
const nextRepoId = bucketIndex >= 0 ? bucketRepoIds?.[bucketIndex + 1] : undefined
|
||||
let endIndex = -1
|
||||
if (nextRepoId) {
|
||||
endIndex = args.rows.findIndex((r) => r.type === 'header' && r.repo?.id === nextRepoId)
|
||||
} else {
|
||||
endIndex = args.rows.length
|
||||
for (let next = index + 1; next < args.rows.length; next += 1) {
|
||||
if (args.rows[next]?.type === 'header' || args.rows[next]?.type === 'host-header') {
|
||||
endIndex = next
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
sectionEndByRepoId.set(
|
||||
repoId,
|
||||
rowStarts[endIndex >= 0 ? endIndex : args.rows.length] ?? rowStarts[args.rows.length] ?? 0
|
||||
)
|
||||
}
|
||||
return sectionEndByRepoId
|
||||
}
|
||||
|
||||
compare({
|
||||
label: 'sidebar header boundaries (per row-model rebuild)',
|
||||
scale: `${SIDEBAR_REPOS} repos x ${SIDEBAR_ROWS} rows`,
|
||||
drives: 'production',
|
||||
before: repeat(50, () => [...getRepoHeaderSectionEndByRepoIdBefore(boundaryArgs)]),
|
||||
after: repeat(50, () => [...getRepoHeaderSectionEndByRepoId(boundaryArgs)])
|
||||
})
|
||||
|
||||
// -------------------------------------------------
|
||||
|
||||
console.log('Renderer quadratic-scan removals\n')
|
||||
console.log('| projection | drives | scale | before | after | |')
|
||||
console.log('| --- | --- | --- | --- | --- | --- |')
|
||||
for (const row of results) {
|
||||
console.log(
|
||||
`| ${row.label} | ${row.drives} | ${row.scale} | ${row.beforeMs.toFixed(2)} ms | ${row.afterMs.toFixed(2)} ms | ${(row.beforeMs / row.afterMs).toFixed(1)}x |`
|
||||
)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ function valueAfter(flag) {
|
||||
|
||||
function buildImage(image) {
|
||||
console.log(`Building ${image.name} fixture...`)
|
||||
docker([
|
||||
const buildArgs = [
|
||||
'build',
|
||||
'--build-arg',
|
||||
`BASE_IMAGE=${image.base}`,
|
||||
@@ -81,7 +81,16 @@ function buildImage(image) {
|
||||
'-t',
|
||||
image.tag,
|
||||
'.'
|
||||
])
|
||||
]
|
||||
// Why: apt fetches from archive.ubuntu.com stall or fail mid-sync; a second build usually lands on a healthy index.
|
||||
try {
|
||||
docker(buildArgs)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nRetrying docker build once...`
|
||||
)
|
||||
docker(buildArgs)
|
||||
}
|
||||
}
|
||||
|
||||
function extractAppImage(image) {
|
||||
|
||||
@@ -43,7 +43,7 @@ const artifactVolume = `orca-headless-serve-shutdown-${suffix}`
|
||||
const sha256 = createHash('sha256').update(readFileSync(appImage)).digest('hex')
|
||||
|
||||
try {
|
||||
docker([
|
||||
const buildArgs = [
|
||||
'build',
|
||||
'--platform',
|
||||
platform,
|
||||
@@ -52,7 +52,15 @@ try {
|
||||
'-t',
|
||||
image,
|
||||
shutdownDockerDirectory
|
||||
])
|
||||
]
|
||||
// Why: apt fetches from archive.ubuntu.com stall or fail mid-sync; a second build usually lands on a healthy index.
|
||||
const firstBuild = docker(buildArgs, { allowFailure: true })
|
||||
if (firstBuild.status !== 0) {
|
||||
process.stderr.write(
|
||||
`${firstBuild.stdout}${firstBuild.stderr}\ndocker build failed with status ${firstBuild.status}; retrying once...\n`
|
||||
)
|
||||
docker(buildArgs)
|
||||
}
|
||||
docker(['volume', 'create', artifactVolume])
|
||||
runDesktopStartupOracle({ image, appImage, platform })
|
||||
docker([
|
||||
|
||||
@@ -173,20 +173,26 @@ function runCase(caseName) {
|
||||
|
||||
function buildImage() {
|
||||
console.log(`Building ${tag}…`)
|
||||
docker(
|
||||
[
|
||||
'build',
|
||||
...dockerPlatformArgs,
|
||||
'--build-arg',
|
||||
`BASE_IMAGE=${base}`,
|
||||
'-f',
|
||||
'config/docker/cli-launch-contract/Dockerfile',
|
||||
'-t',
|
||||
tag,
|
||||
'config/docker/cli-launch-contract'
|
||||
],
|
||||
{ timeoutMs: BUILD_TIMEOUT_MS }
|
||||
)
|
||||
const buildArgs = [
|
||||
'build',
|
||||
...dockerPlatformArgs,
|
||||
'--build-arg',
|
||||
`BASE_IMAGE=${base}`,
|
||||
'-f',
|
||||
'config/docker/cli-launch-contract/Dockerfile',
|
||||
'-t',
|
||||
tag,
|
||||
'config/docker/cli-launch-contract'
|
||||
]
|
||||
// Why: apt fetches from archive.ubuntu.com stall or fail mid-sync; a second build usually lands on a healthy index.
|
||||
try {
|
||||
docker(buildArgs, { timeoutMs: BUILD_TIMEOUT_MS })
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nRetrying docker build once…`
|
||||
)
|
||||
docker(buildArgs, { timeoutMs: BUILD_TIMEOUT_MS })
|
||||
}
|
||||
}
|
||||
|
||||
// Extract unprivileged so chrome-sandbox is not root-owned setuid.
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmarks two CPU costs `setLocalWorkspaceSession` pays on every session write — the write
|
||||
// that fires on something as ordinary as clicking between two terminal split panes.
|
||||
//
|
||||
// 1. capTerminalScrollbackSessionBuffer — UTF-8 budget scan per retained scrollback buffer
|
||||
// 2. remapPaneKeys — pane-key map rebuild that steady state throws away
|
||||
//
|
||||
// The snapshot disk rewrite on the same path is measured separately (#18764).
|
||||
//
|
||||
// Each scenario runs the production export against a baseline that reproduces the pre-change
|
||||
// shape, so the reported speedup cannot drift away from what production actually does.
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import fs from 'node:fs'
|
||||
import nodeModule from 'node:module'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
if (!process.execArgv.includes('--experimental-transform-types')) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--experimental-transform-types', '--no-warnings', import.meta.filename],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
// The app's TS sources import siblings without an extension; Node's ESM resolver needs it.
|
||||
nodeModule.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
|
||||
const candidate = new URL(`${specifier}.ts`, context.parentURL)
|
||||
if (fs.existsSync(fileURLToPath(candidate))) {
|
||||
return { url: candidate.href, shortCircuit: true }
|
||||
}
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
}
|
||||
})
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '../..')
|
||||
const ROUNDS = Number(process.env.ORCA_SESSION_WRITE_BENCH_ROUNDS ?? '9')
|
||||
const LEAVES = Number(process.env.ORCA_SESSION_WRITE_BENCH_LEAVES ?? '8')
|
||||
const PANE_KEYS = Number(process.env.ORCA_SESSION_WRITE_BENCH_PANE_KEYS ?? '2000')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_SESSION_WRITE_BENCH_ROUNDS', ROUNDS],
|
||||
['ORCA_SESSION_WRITE_BENCH_LEAVES', LEAVES],
|
||||
['ORCA_SESSION_WRITE_BENCH_PANE_KEYS', PANE_KEYS]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, got ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
const { capTerminalScrollbackSessionBuffer } = await import(
|
||||
path.join(ROOT, 'src/shared/workspace-session-terminal-buffers.ts')
|
||||
)
|
||||
const { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } = await import(
|
||||
path.join(ROOT, 'src/shared/terminal-scrollback-limits.ts')
|
||||
)
|
||||
const { remapAcknowledgedAgentPaneKeys } = await import(
|
||||
path.join(ROOT, 'src/main/persistence/restoring-sessions/pane-key-remapping.ts')
|
||||
)
|
||||
const { clampUtf8TextTail, measureUtf8ByteLength } = await import(
|
||||
path.join(ROOT, 'src/shared/utf8-byte-limits.ts')
|
||||
)
|
||||
const { isTerminalLeafId, makePaneKey, parsePaneKey } = await import(
|
||||
path.join(ROOT, 'src/shared/stable-pane-id.ts')
|
||||
)
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((left, right) => left - right)
|
||||
return sorted[Math.floor(sorted.length / 2)]
|
||||
}
|
||||
|
||||
function timeRounds(run) {
|
||||
const samples = []
|
||||
run()
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
const start = performance.now()
|
||||
run()
|
||||
samples.push(performance.now() - start)
|
||||
}
|
||||
return median(samples)
|
||||
}
|
||||
|
||||
function report(label, baselineMs, currentMs, extra = '') {
|
||||
const speedup = baselineMs / currentMs
|
||||
console.log(
|
||||
`${label}\n before ${baselineMs.toFixed(3)} ms → after ${currentMs.toFixed(3)} ms (${speedup.toFixed(1)}x)${extra}`
|
||||
)
|
||||
return speedup
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scenario 1
|
||||
|
||||
// Verbatim pre-change capTerminalScrollbackSessionBuffer; measureUtf8ByteLength itself is unchanged.
|
||||
function baselineCapScrollbackBuffer(buffer) {
|
||||
if (
|
||||
buffer.length <= TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT &&
|
||||
!measureUtf8ByteLength(buffer, {
|
||||
stopAfterBytes: TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT
|
||||
}).exceededLimit
|
||||
) {
|
||||
return buffer
|
||||
}
|
||||
return clampUtf8TextTail(buffer, TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT).text
|
||||
}
|
||||
|
||||
// A terminal that has been running a while sits at the cap, which is the case that scanned in full.
|
||||
const scrollbackLine = `${'[0m'}build output line with a path /Users/dev/project/src/index.ts and a status ok\n`
|
||||
let atCapBuffer = ''
|
||||
while (atCapBuffer.length < TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT) {
|
||||
atCapBuffer += scrollbackLine
|
||||
}
|
||||
atCapBuffer = atCapBuffer.slice(0, TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT)
|
||||
|
||||
if (capTerminalScrollbackSessionBuffer(atCapBuffer) !== baselineCapScrollbackBuffer(atCapBuffer)) {
|
||||
throw new Error('scrollback cap disagreed with the baseline implementation')
|
||||
}
|
||||
|
||||
// The session write runs the prune twice, once per retained leaf.
|
||||
const CAP_CALLS_PER_WRITE = LEAVES * 2
|
||||
const capBaselineMs = timeRounds(() => {
|
||||
for (let call = 0; call < CAP_CALLS_PER_WRITE; call += 1) {
|
||||
baselineCapScrollbackBuffer(atCapBuffer)
|
||||
}
|
||||
})
|
||||
const capCurrentMs = timeRounds(() => {
|
||||
for (let call = 0; call < CAP_CALLS_PER_WRITE; call += 1) {
|
||||
capTerminalScrollbackSessionBuffer(atCapBuffer)
|
||||
}
|
||||
})
|
||||
|
||||
console.log(
|
||||
`Session-write hot path — ${LEAVES} retained scrollback leaves, ${PANE_KEYS} accumulated pane keys\n`
|
||||
)
|
||||
report(
|
||||
`1. scrollback UTF-8 budget scan (${CAP_CALLS_PER_WRITE} calls/write @ ${(atCapBuffer.length / 1024).toFixed(0)} KB)`,
|
||||
capBaselineMs,
|
||||
capCurrentMs
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- scenario 2
|
||||
|
||||
const paneKeys = {}
|
||||
const leafIdByInputLeafIdByTabId = new Map()
|
||||
for (let index = 0; index < PANE_KEYS; index += 1) {
|
||||
const tabId = `tab-${index % 64}`
|
||||
const leafId = `${(index % 64).toString(16).padStart(8, '0')}-0000-4000-8000-${index.toString(16).padStart(12, '0')}`
|
||||
paneKeys[makePaneKey(tabId, leafId)] = index
|
||||
let leaves = leafIdByInputLeafIdByTabId.get(tabId)
|
||||
if (!leaves) {
|
||||
leaves = new Map()
|
||||
leafIdByInputLeafIdByTabId.set(tabId, leaves)
|
||||
}
|
||||
// Steady state: a stable UUID leaf maps to itself.
|
||||
leaves.set(leafId, leafId)
|
||||
}
|
||||
|
||||
// Verbatim pre-change remapPaneKeys: parses every key, then rebuilds the object regardless.
|
||||
function baselineRemapPaneKeys(values, remap) {
|
||||
if (!values || Object.keys(values).length === 0) {
|
||||
return { values, changed: false }
|
||||
}
|
||||
let changed = false
|
||||
const next = {}
|
||||
const setValue = (paneKey, value) => {
|
||||
const existing = next[paneKey]
|
||||
next[paneKey] = existing === undefined ? value : Math.max(existing, value)
|
||||
}
|
||||
for (const [paneKey, value] of Object.entries(values)) {
|
||||
if (parsePaneKey(paneKey)) {
|
||||
setValue(paneKey, value)
|
||||
continue
|
||||
}
|
||||
const delimiter = paneKey.indexOf(':')
|
||||
if (delimiter <= 0 || delimiter === paneKey.length - 1) {
|
||||
setValue(paneKey, value)
|
||||
continue
|
||||
}
|
||||
const tabId = paneKey.slice(0, delimiter)
|
||||
const remappedLeafId = remap.get(tabId)?.get(paneKey.slice(delimiter + 1))
|
||||
if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) {
|
||||
setValue(paneKey, value)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
setValue(makePaneKey(tabId, remappedLeafId), value)
|
||||
changed = true
|
||||
} catch {
|
||||
setValue(paneKey, value)
|
||||
}
|
||||
}
|
||||
return { values: next, changed }
|
||||
}
|
||||
|
||||
// The write remaps three of these maps: acknowledgements, activity cutoffs, manual unread.
|
||||
const REMAP_CALLS_PER_WRITE = 3
|
||||
const remapBaselineMs = timeRounds(() => {
|
||||
for (let call = 0; call < REMAP_CALLS_PER_WRITE; call += 1) {
|
||||
baselineRemapPaneKeys(paneKeys, leafIdByInputLeafIdByTabId)
|
||||
}
|
||||
})
|
||||
const remapCurrentMs = timeRounds(() => {
|
||||
for (let call = 0; call < REMAP_CALLS_PER_WRITE; call += 1) {
|
||||
remapAcknowledgedAgentPaneKeys(paneKeys, leafIdByInputLeafIdByTabId)
|
||||
}
|
||||
})
|
||||
const remapResult = remapAcknowledgedAgentPaneKeys(paneKeys, leafIdByInputLeafIdByTabId)
|
||||
if (remapResult.changed || remapResult.acknowledgements !== paneKeys) {
|
||||
throw new Error('steady-state remap should return the input map untouched')
|
||||
}
|
||||
report(
|
||||
`2. pane-key remap (${REMAP_CALLS_PER_WRITE} maps/write @ ${PANE_KEYS} keys)`,
|
||||
remapBaselineMs,
|
||||
remapCurrentMs,
|
||||
' — and 3 discarded objects/write become 0'
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
// Times the partial-escape-tail fold that runs once per PTY chunk for every terminal against a
|
||||
// baseline with the pre-change shape (unconditional concat + per-code-unit walk). Equivalence is
|
||||
// proven over a corpus first, so the reported speedup cannot come from the gate changing the answer.
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import {
|
||||
advancePartialEscapeTail,
|
||||
extractPartialEscapeTail,
|
||||
MAX_PARTIAL_ESCAPE_TAIL_LENGTH
|
||||
} from '../../src/shared/terminal-partial-escape-tail.ts'
|
||||
|
||||
const CHUNK_BYTES = 16 * 1024
|
||||
const CHUNKS = 640
|
||||
const ROUNDS = 7
|
||||
|
||||
function baselineAdvance(pendingTail, chunk) {
|
||||
const tail = extractPartialEscapeTail(pendingTail + chunk)
|
||||
return tail.length > MAX_PARTIAL_ESCAPE_TAIL_LENGTH ? '' : tail
|
||||
}
|
||||
|
||||
const chunkOf = (line) => line.repeat(Math.ceil(CHUNK_BYTES / line.length)).slice(0, CHUNK_BYTES)
|
||||
const escFreeChunk = chunkOf('[build] compiled src/renderer/src/components/thing.tsx in 12ms\n')
|
||||
const colouredChunk = chunkOf(
|
||||
'\x1b[32m[build]\x1b[0m compiled src/renderer/src/components/thing.tsx in 12ms\n'
|
||||
)
|
||||
|
||||
// Every state the scanner can be left in, plus the boundaries the gate must not swallow.
|
||||
const PIECES = [
|
||||
'',
|
||||
'plain output\n',
|
||||
'\x1b[32mgreen\x1b[0m',
|
||||
'\x1b[3',
|
||||
'\x1b]0;title\x07',
|
||||
'\x1b]0;partial',
|
||||
'\x1bP dcs payload',
|
||||
'\x1b',
|
||||
'\x18',
|
||||
'\x1a',
|
||||
'\x1b]8;;https://example.com\x1b\\',
|
||||
'\x1b]8;;https://example.com\x1b',
|
||||
'\x1b(B',
|
||||
'\x1b(',
|
||||
'\x1b[1;2;3',
|
||||
escFreeChunk
|
||||
]
|
||||
let checked = 0
|
||||
for (const pending of PIECES.map((piece) => extractPartialEscapeTail(piece))) {
|
||||
for (const chunk of PIECES) {
|
||||
const expected = baselineAdvance(pending, chunk)
|
||||
const actual = advancePartialEscapeTail(pending, chunk)
|
||||
if (expected !== actual) {
|
||||
throw new Error(
|
||||
`gate changed the tracked tail: ${JSON.stringify({ pending, chunk, expected, actual })}`
|
||||
)
|
||||
}
|
||||
checked += 1
|
||||
}
|
||||
}
|
||||
|
||||
function medianMs(advance, chunk) {
|
||||
// First sample is the warm-up and is discarded.
|
||||
const samples = Array.from({ length: ROUNDS + 1 }, () => {
|
||||
const start = performance.now()
|
||||
let tail = ''
|
||||
for (let index = 0; index < CHUNKS; index += 1) {
|
||||
tail = advance(tail, chunk)
|
||||
}
|
||||
return performance.now() - start
|
||||
})
|
||||
return samples.slice(1).sort((left, right) => left - right)[Math.floor(ROUNDS / 2)]
|
||||
}
|
||||
|
||||
const megabytes = ((CHUNK_BYTES * CHUNKS) / 1024 / 1024).toFixed(1)
|
||||
console.log(
|
||||
`Partial-escape-tail fold: ${CHUNKS} x ${CHUNK_BYTES / 1024} KB chunks (${megabytes} MB), ${checked} equivalence cases verified\n`
|
||||
)
|
||||
console.log('| stream shape | before | after | |')
|
||||
console.log('| --- | --- | --- | --- |')
|
||||
for (const [label, chunk] of [
|
||||
['ESC-free (build logs, `cat`, piped output)', escFreeChunk],
|
||||
['SGR-coloured output (gate does not apply)', colouredChunk]
|
||||
]) {
|
||||
const before = medianMs(baselineAdvance, chunk)
|
||||
const after = medianMs(advancePartialEscapeTail, chunk)
|
||||
console.log(
|
||||
`| ${label} | ${before.toFixed(2)} ms | ${after.toFixed(2)} ms | ${(before / after).toFixed(1)}x |`
|
||||
)
|
||||
}
|
||||
@@ -9,9 +9,7 @@ Browser-use profiles let you run the Orca browser with a specific identity — a
|
||||
1. Open [Settings → Browser → Profiles](/docs/settings).
|
||||
1. Click **Add profile**, give it a name.
|
||||
1. Optionally seed it with cookies, a user-agent, and a viewport size.
|
||||
1. For sites that reject Orca's default Chrome-shaped UA (some Google sign-in flows), create a profile that keeps the **native Electron user agent** instead of spoofing. Default profiles still use the cleaned Chrome UA for broader Cloudflare compatibility.
|
||||
|
||||
You can also create a no-spoof profile from the CLI with `orca tab profile create --no-ua-spoof` when you script browser setup.
|
||||
1. Every profile presents Electron's own user agent. Orca no longer rewrites it to look like Chrome, because Cloudflare Turnstile rejects a Chrome-shaped UA that sends no client hints and accepts a declared Electron client. The only exception is Google's sign-in hosts, where Orca presents a Firefox identity so Google issues cookies bound to the embedded browser. A **native user agent** profile (`orca tab profile create --no-ua-spoof`) also skips that Google exception.
|
||||
|
||||
## Cookie import and Google sign-in
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.47",
|
||||
"version": "0.0.48",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
|
||||
@@ -192,6 +192,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
|
||||
return (
|
||||
<Text
|
||||
key={index}
|
||||
selectable
|
||||
style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]}
|
||||
>
|
||||
{renderInline(block.text, onOpenFile)}
|
||||
@@ -201,7 +202,9 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
|
||||
if (block.type === 'quote') {
|
||||
return (
|
||||
<View key={index} style={styles.quote}>
|
||||
<Text style={styles.quoteText}>{renderInline(block.text, onOpenFile)}</Text>
|
||||
<Text selectable style={styles.quoteText}>
|
||||
{renderInline(block.text, onOpenFile)}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -223,7 +226,9 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
|
||||
return (
|
||||
<View key={index} style={styles.codeBlock}>
|
||||
{block.language ? <Text style={styles.codeLanguage}>{block.language}</Text> : null}
|
||||
<Text style={styles.codeText}>{block.text}</Text>
|
||||
<Text selectable style={styles.codeText}>
|
||||
{block.text}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -251,7 +256,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableRow}>
|
||||
{visibleHeaders.map((header, cellIndex) => (
|
||||
<Text key={cellIndex} style={[styles.tableCell, styles.tableHeader]}>
|
||||
<Text key={cellIndex} selectable style={[styles.tableCell, styles.tableHeader]}>
|
||||
{renderInline(header, onOpenFile)}
|
||||
</Text>
|
||||
))}
|
||||
@@ -259,7 +264,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
|
||||
{visibleRows.map((row, rowIndex) => (
|
||||
<View key={rowIndex} style={styles.tableRow}>
|
||||
{visibleHeaders.map((_, cellIndex) => (
|
||||
<Text key={cellIndex} style={styles.tableCell}>
|
||||
<Text key={cellIndex} selectable style={styles.tableCell}>
|
||||
{renderInline(row[cellIndex] ?? '', onOpenFile)}
|
||||
</Text>
|
||||
))}
|
||||
@@ -290,7 +295,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
|
||||
? '[x]'
|
||||
: '[ ]'}
|
||||
</Text>
|
||||
<Text style={[styles.listText, listScale]}>
|
||||
<Text selectable style={[styles.listText, listScale]}>
|
||||
{renderInline(item.text, onOpenFile)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -6,11 +6,21 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
|
||||
React.createElement('Text', props, children)
|
||||
return {
|
||||
Animated: {
|
||||
Text,
|
||||
Value: class {
|
||||
setValue(): void {}
|
||||
},
|
||||
loop: (animation: unknown) => animation,
|
||||
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
|
||||
timing: () => ({ start: vi.fn(), stop: vi.fn() })
|
||||
},
|
||||
Image: 'Image',
|
||||
Pressable: 'Pressable',
|
||||
Text: ({ children, ...props }: { children?: unknown }) =>
|
||||
React.createElement('Text', props, children),
|
||||
Text,
|
||||
View: ({ children, ...props }: { children?: unknown }) =>
|
||||
React.createElement('View', props, children),
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
|
||||
@@ -21,7 +31,10 @@ vi.mock('lucide-react-native', () => ({
|
||||
ArrowUp: 'ArrowUp',
|
||||
ChevronDown: 'ChevronDown',
|
||||
Copy: 'Copy',
|
||||
SquareChevronRight: 'SquareChevronRight'
|
||||
SquareChevronRight: 'SquareChevronRight',
|
||||
SquareTerminal: 'SquareTerminal',
|
||||
Wrench: 'Wrench',
|
||||
ChevronRight: 'ChevronRight'
|
||||
}))
|
||||
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
|
||||
|
||||
@@ -45,7 +58,18 @@ describe('MobileNativeChatMessage', () => {
|
||||
|
||||
function render(
|
||||
message: NativeChatMessage,
|
||||
props: { toolsExpanded?: boolean } = {}
|
||||
props: {
|
||||
toolsExpanded?: boolean
|
||||
structuredActivityUi?: boolean
|
||||
activeTurnIsWorking?: boolean
|
||||
turnExpanded?: boolean
|
||||
turnStatus?: {
|
||||
startedAt: number | null
|
||||
thinking: boolean
|
||||
workedSeconds: number | null
|
||||
} | null
|
||||
onToggleTurn?: () => void
|
||||
} = {}
|
||||
): ReactTestRenderer {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
|
||||
@@ -152,4 +176,96 @@ describe('MobileNativeChatMessage', () => {
|
||||
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
|
||||
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe('structured activity UI', () => {
|
||||
const runningCall = {
|
||||
type: 'tool-call' as const,
|
||||
name: 'Bash',
|
||||
input: { command: 'npm test' },
|
||||
state: 'running' as const
|
||||
}
|
||||
const settledCall = {
|
||||
type: 'tool-call' as const,
|
||||
name: 'Read',
|
||||
input: { file_path: 'a/b.ts' },
|
||||
state: 'completed' as const
|
||||
}
|
||||
|
||||
it('shows the live tool label with a terminal glyph while a command runs', () => {
|
||||
const tree = render(toolMessage([runningCall]), {
|
||||
structuredActivityUi: true,
|
||||
activeTurnIsWorking: true
|
||||
})
|
||||
expect(textIn(tree.root)).toContain('Running npm test')
|
||||
expect(tree.root.findAllByType('SquareTerminal' as never)).toHaveLength(1)
|
||||
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses the wrench glyph for a non-command tool', () => {
|
||||
const tree = render(
|
||||
toolMessage([
|
||||
{ type: 'tool-call', name: 'Read', input: { file_path: 'a/b.ts' }, state: 'running' }
|
||||
]),
|
||||
{ structuredActivityUi: true, activeTurnIsWorking: true }
|
||||
)
|
||||
expect(textIn(tree.root)).toContain('Running Read a/b.ts')
|
||||
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('falls back to the collapsed count row once the run settles', () => {
|
||||
const tree = render(toolMessage([settledCall]), {
|
||||
structuredActivityUi: true,
|
||||
activeTurnIsWorking: true
|
||||
})
|
||||
expect(textIn(tree.root)).not.toContain('Running Read a/b.ts')
|
||||
expect(textIn(tree.root)).toContain('1×')
|
||||
})
|
||||
|
||||
it("hides a completed turn's activity until the turn caret discloses it", () => {
|
||||
const collapsed = render(toolMessage([settledCall]), {
|
||||
structuredActivityUi: true,
|
||||
activeTurnIsWorking: false
|
||||
})
|
||||
expect(textIn(collapsed.root)).not.toContain('1×')
|
||||
act(() => collapsed.unmount())
|
||||
|
||||
const disclosed = render(toolMessage([settledCall]), {
|
||||
structuredActivityUi: true,
|
||||
activeTurnIsWorking: false,
|
||||
turnExpanded: true
|
||||
})
|
||||
expect(textIn(disclosed.root)).toContain('1×')
|
||||
})
|
||||
|
||||
it('lets the global Tools toggle reveal a hidden settled run', () => {
|
||||
// Otherwise the composer's Tools control is a no-op on every settled turn.
|
||||
const tree = render(toolMessage([settledCall]), {
|
||||
structuredActivityUi: true,
|
||||
activeTurnIsWorking: false,
|
||||
toolsExpanded: true
|
||||
})
|
||||
expect(textIn(tree.root)).toContain('1\u00d7')
|
||||
})
|
||||
|
||||
it('keeps the bridge lane on its always-visible tool run', () => {
|
||||
const tree = render(toolMessage([settledCall]), { activeTurnIsWorking: false })
|
||||
expect(textIn(tree.root)).toContain('1×')
|
||||
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders the turn status row under a user message', () => {
|
||||
const tree = render(userMessage([{ type: 'text', text: 'go' }]), {
|
||||
structuredActivityUi: true,
|
||||
turnStatus: { startedAt: Date.now(), thinking: true, workedSeconds: null }
|
||||
})
|
||||
expect(textIn(tree.root)).toContain('Thinking')
|
||||
})
|
||||
|
||||
it('does not render a turn status row without one', () => {
|
||||
const tree = render(userMessage([{ type: 'text', text: 'go' }]), {
|
||||
structuredActivityUi: true
|
||||
})
|
||||
expect(textIn(tree.root)).toEqual(['go'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,139 +1,20 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { Image, Pressable, Text, View } from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native'
|
||||
import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff'
|
||||
import type { NativeChatDiffLine as DiffLine } from '../../../src/shared/native-chat-diff'
|
||||
import { pairToolBlocks, splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
|
||||
import type { NativeChatToolPair as ToolPair } from '../../../src/shared/native-chat-tool-fold'
|
||||
import {
|
||||
createToolInputDisplay,
|
||||
summarizeToolRun,
|
||||
truncateToolDetail
|
||||
} from '../../../src/shared/native-chat-tool-summary'
|
||||
import { ArrowUp, Copy } from 'lucide-react-native'
|
||||
import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
|
||||
import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity'
|
||||
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
|
||||
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileMarkdown } from '../components/MobileMarkdown'
|
||||
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
|
||||
import { ToolRun } from './MobileNativeChatToolRun'
|
||||
import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
|
||||
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
|
||||
import { nativeChatMessageText } from './mobile-native-chat-message-text'
|
||||
|
||||
const MAX_VISIBLE_TOOL_PAIRS = 6
|
||||
const MAX_TOOL_RUN_DIFF_ROWS = 240
|
||||
|
||||
function DiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element {
|
||||
return (
|
||||
<View style={styles.diff}>
|
||||
{lines.map((line, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
style={[
|
||||
styles.diffLine,
|
||||
line.kind === 'add' && styles.diffAdd,
|
||||
line.kind === 'del' && styles.diffDel,
|
||||
line.kind === 'meta' && styles.diffMeta
|
||||
]}
|
||||
>
|
||||
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}
|
||||
{line.text}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/** A single inline tool line — `▸ ToolName preview` — that expands in place to
|
||||
* show the call's diff/input or the result's body. Mirrors the reference design
|
||||
* where tool calls read as flat lines in the conversation, not boxed blocks. */
|
||||
function ResultBody({
|
||||
output,
|
||||
isError,
|
||||
diff
|
||||
}: {
|
||||
output: string
|
||||
isError?: boolean
|
||||
diff: DiffLine[] | null
|
||||
}): React.JSX.Element {
|
||||
if (diff) {
|
||||
return <DiffView lines={diff} />
|
||||
}
|
||||
return (
|
||||
<View style={[styles.toolResult, isError && styles.toolResultError]}>
|
||||
<Text style={styles.mono}>{truncateToolDetail(output)}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/** One request: a tool call and its result rendered together as a single
|
||||
* expandable line. `defaultExpanded` lets the group toggle open every line. */
|
||||
function ToolLine({
|
||||
pair,
|
||||
defaultExpanded,
|
||||
diffLineLimit,
|
||||
onOpenFile
|
||||
}: {
|
||||
pair: ToolPair
|
||||
defaultExpanded: boolean
|
||||
diffLineLimit: number
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
}): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
const { call, result } = pair
|
||||
const name = call ? call.name : 'Result'
|
||||
const inputDisplay = call ? createToolInputDisplay(call.input) : null
|
||||
const preview = inputDisplay?.label ?? result?.output.split('\n')[0]?.slice(0, 80) ?? ''
|
||||
// Why: collapsed tool rows are the common path; defer bounded diff parsing
|
||||
// and detail formatting until the user asks to reveal the detail.
|
||||
const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null
|
||||
const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null
|
||||
const callDetail = expanded && inputDisplay && !callDiff ? inputDisplay.formatDetail() : undefined
|
||||
const hasDetail = callDiff !== null || result !== undefined || inputDisplay?.hasDetail === true
|
||||
// The group toggle opens every line at once, bypassing the tap guard, so the
|
||||
// panel has to consult it too — else a detail-less row echoes its own label
|
||||
// under itself and no tap can dismiss it.
|
||||
const showDetail = hasDetail && expanded
|
||||
// A tool that targets a file (Read/Edit/Write…) renders its preview as a
|
||||
// tappable link that opens the file, independent of the line's expand tap.
|
||||
const filePath = inputDisplay?.filePath ?? null
|
||||
const openable = filePath !== null && onOpenFile !== undefined
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
style={styles.toolLine}
|
||||
onPress={() => hasDetail && setExpanded((v) => !v)}
|
||||
hitSlop={6}
|
||||
>
|
||||
{showDetail ? (
|
||||
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
) : (
|
||||
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
)}
|
||||
<Text style={styles.toolName}>{name}</Text>
|
||||
{preview ? (
|
||||
<Text
|
||||
style={[styles.toolPreview, openable && styles.toolPreviewLink]}
|
||||
numberOfLines={1}
|
||||
onPress={openable ? () => onOpenFile!(filePath!) : undefined}
|
||||
suppressHighlighting={!openable}
|
||||
>
|
||||
{preview}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{showDetail ? (
|
||||
<View style={styles.toolDetail}>
|
||||
{callDiff ? <DiffView lines={callDiff} /> : null}
|
||||
{callDetail ? <Text style={styles.mono}>{callDetail}</Text> : null}
|
||||
{result ? (
|
||||
<ResultBody output={result.output} isError={result.isError} diff={resultDiff} />
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Prose({
|
||||
block,
|
||||
invert,
|
||||
@@ -150,7 +31,9 @@ function Prose({
|
||||
// markdown renderer's light-on-dark palette.
|
||||
if (invert) {
|
||||
return (
|
||||
<Text style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>{block.text}</Text>
|
||||
<Text selectable style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>
|
||||
{block.text}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return (
|
||||
@@ -180,67 +63,6 @@ function Prose({
|
||||
return null
|
||||
}
|
||||
|
||||
/** A run of a message's tool calls/results, collapsed to a one-line summary that
|
||||
* expands to the individual inline tool lines. `defaultExpanded` lets the global
|
||||
* toolbar toggle drive every run at once while still allowing per-run override. */
|
||||
function ToolRun({
|
||||
blocks,
|
||||
defaultExpanded,
|
||||
trailing,
|
||||
onOpenFile
|
||||
}: {
|
||||
blocks: NativeChatBlock[]
|
||||
defaultExpanded: boolean
|
||||
trailing?: React.ReactNode
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(defaultExpanded)
|
||||
const pairs = pairToolBlocks(blocks, MAX_VISIBLE_TOOL_PAIRS)
|
||||
const diffLineLimit = Math.max(1, Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (pairs.length * 2 || 1)))
|
||||
let callCount = 0
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'tool-call') {
|
||||
callCount++
|
||||
}
|
||||
}
|
||||
callCount ||= pairs.length
|
||||
const summary = summarizeToolRun(blocks)
|
||||
return (
|
||||
<View style={styles.toolRun}>
|
||||
<View style={styles.toolRunHeader}>
|
||||
<Pressable style={styles.toolRunToggle} onPress={() => setOpen((v) => !v)} hitSlop={6}>
|
||||
{open ? (
|
||||
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
) : (
|
||||
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
)}
|
||||
<Text style={styles.toolRunCount}>{callCount}×</Text>
|
||||
<Text style={styles.toolRunLabel} numberOfLines={1}>
|
||||
{summary || `${callCount} tool ${callCount === 1 ? 'call' : 'calls'}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{trailing}
|
||||
</View>
|
||||
{open ? (
|
||||
<View style={styles.toolRunBody}>
|
||||
{pairs.map((pair, i) => (
|
||||
<ToolLine
|
||||
key={i}
|
||||
pair={pair}
|
||||
defaultExpanded={defaultExpanded}
|
||||
diffLineLimit={diffLineLimit}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
))}
|
||||
{callCount > pairs.length ? (
|
||||
<Text style={styles.toolPreview}>… {callCount - pairs.length} more tool calls</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/** Subtle top-right controls for an agent message: copy its prose, or scroll so
|
||||
* this message's top aligns to the top of the viewport. */
|
||||
function AgentControls({
|
||||
@@ -280,7 +102,13 @@ function MobileNativeChatMessageImpl({
|
||||
fontScale = 1,
|
||||
messageIndex,
|
||||
onScrollToMessage,
|
||||
onOpenFile
|
||||
onOpenFile,
|
||||
turnStatus,
|
||||
turnExpanded,
|
||||
turnKey,
|
||||
onToggleTurn,
|
||||
activeTurnIsWorking,
|
||||
structuredActivityUi = false
|
||||
}: {
|
||||
message: NativeChatMessage
|
||||
toolsExpanded?: boolean
|
||||
@@ -291,6 +119,18 @@ function MobileNativeChatMessageImpl({
|
||||
/** Ask the list to align this message's top to the top of the viewport. */
|
||||
onScrollToMessage?: (index: number) => void
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
/** This turn's status row, rendered under a user message (desktop parity). */
|
||||
turnStatus?: NativeChatTurnStatus | null
|
||||
/** Whether the turn caret has disclosed this turn's activity. */
|
||||
turnExpanded?: boolean
|
||||
/** Set only when this row's turn has settled and can disclose its activity. */
|
||||
turnKey?: string
|
||||
/** Stable across renders; the row supplies its own key when tapped. */
|
||||
onToggleTurn?: (turnKey: string) => void
|
||||
/** Session-level working state for this message's turn; gates the live tool row. */
|
||||
activeTurnIsWorking?: boolean
|
||||
/** Structured lane only: live tool progress plus the turn-status disclosure. */
|
||||
structuredActivityUi?: boolean
|
||||
}): React.JSX.Element {
|
||||
const isUser = message.role === 'user'
|
||||
const isReasoning = message.role === 'reasoning'
|
||||
@@ -310,6 +150,20 @@ function MobileNativeChatMessageImpl({
|
||||
// tool calls fold into a collapsible run beneath. The user's own messages get
|
||||
// an inverted (filled accent) bubble so they stand apart from agent prose.
|
||||
const { prose, tools } = splitNativeChatBlocks(message.blocks)
|
||||
const activeCall = structuredActivityUi
|
||||
? selectActiveToolCall(tools, { activeTurnIsWorking })
|
||||
: null
|
||||
// A completed turn's activity belongs behind the turn-status caret. Leaving the
|
||||
// grouped row visible made a failed child command read as a failed response.
|
||||
// The composer's global Tools toggle still overrides this, or it would silently
|
||||
// do nothing on every settled turn.
|
||||
const settledToolsHidden =
|
||||
structuredActivityUi &&
|
||||
activeCall == null &&
|
||||
activeTurnIsWorking === false &&
|
||||
!turnExpanded &&
|
||||
!toolsExpanded
|
||||
const showToolRun = tools.length > 0 && !settledToolsHidden
|
||||
|
||||
const handleCopy = (): void => {
|
||||
const text = nativeChatMessageText(message.blocks)
|
||||
@@ -338,39 +192,52 @@ function MobileNativeChatMessageImpl({
|
||||
) : null
|
||||
|
||||
return (
|
||||
<View style={[styles.row, isUser && styles.rowUser]}>
|
||||
<View
|
||||
style={[
|
||||
styles.content,
|
||||
isUser && styles.userBubble,
|
||||
isReasoning && styles.reasoning,
|
||||
copied && styles.copied
|
||||
]}
|
||||
>
|
||||
{prose.map((block, index) => (
|
||||
<Prose
|
||||
key={index}
|
||||
block={block}
|
||||
invert={isUser}
|
||||
fontScale={fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
))}
|
||||
{tools.length > 0 ? (
|
||||
<ToolRun
|
||||
// Why: a global toggle intentionally resets all per-run/per-line
|
||||
// overrides in one remount, avoiding an effect-driven second render.
|
||||
key={toolsExpanded ? 'expanded' : 'collapsed'}
|
||||
blocks={tools}
|
||||
defaultExpanded={toolsExpanded}
|
||||
trailing={controls}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
) : controls ? (
|
||||
<View style={styles.controlsRow}>{controls}</View>
|
||||
) : null}
|
||||
<>
|
||||
<View style={[styles.row, isUser && styles.rowUser]}>
|
||||
<View
|
||||
style={[
|
||||
styles.content,
|
||||
isUser && styles.userBubble,
|
||||
isReasoning && styles.reasoning,
|
||||
copied && styles.copied
|
||||
]}
|
||||
>
|
||||
{prose.map((block, index) => (
|
||||
<Prose
|
||||
key={index}
|
||||
block={block}
|
||||
invert={isUser}
|
||||
fontScale={fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
))}
|
||||
{showToolRun ? (
|
||||
<ToolRun
|
||||
// Why: a global toggle intentionally resets all per-run/per-line
|
||||
// overrides in one remount, avoiding an effect-driven second render.
|
||||
key={`${toolsExpanded ? 'expanded' : 'collapsed'}:${turnExpanded ? 'turn' : 'flat'}`}
|
||||
blocks={tools}
|
||||
defaultExpanded={turnExpanded || toolsExpanded}
|
||||
expandChildren={turnExpanded ? false : toolsExpanded}
|
||||
activeCall={activeCall}
|
||||
trailing={controls}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
) : controls ? (
|
||||
<View style={styles.controlsRow}>{controls}</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{turnStatus ? (
|
||||
<MobileNativeChatTurnStatus
|
||||
startedAt={turnStatus.startedAt}
|
||||
thinking={turnStatus.thinking}
|
||||
workedSeconds={turnStatus.workedSeconds}
|
||||
expanded={turnExpanded ?? false}
|
||||
onToggleExpanded={turnKey && onToggleTurn ? () => onToggleTurn(turnKey) : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export function MobileNativeChatOverlay({
|
||||
error={session.error}
|
||||
agent={controller.nativeChatAgent}
|
||||
agentWorking={controller.nativeChatAgentWorking}
|
||||
structuredActivityUi={controller.nativeChatStructured}
|
||||
streaming={streaming}
|
||||
onStop={controller.handleNativeChatStop}
|
||||
ask={controller.nativeChatAsk}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
|
||||
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
|
||||
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
|
||||
import type { MobileChatPermission } from './mobile-native-chat-permission'
|
||||
import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
|
||||
import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question'
|
||||
|
||||
/** The one pending agent prompt shown above the composer: a structured
|
||||
* AskUserQuestion wins, then a heuristic permission, then a heuristic question.
|
||||
* The controller owns dismissal (it must survive this subtree unmounting on a
|
||||
* view toggle); `ask` arrives already nulled while dismissed. */
|
||||
export function MobileNativeChatPromptCard({
|
||||
ask,
|
||||
askKey,
|
||||
onDismissAsk,
|
||||
onAnswerAsk,
|
||||
onCancelAsk,
|
||||
permission,
|
||||
onRespondPermission,
|
||||
question,
|
||||
onAnswerQuestion
|
||||
}: {
|
||||
ask?: AskPrompt | null
|
||||
askKey?: string | null
|
||||
onDismissAsk?: () => void
|
||||
onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
|
||||
onCancelAsk?: () => Promise<boolean>
|
||||
permission?: MobileChatPermission | null
|
||||
onRespondPermission?: (send: string) => Promise<boolean>
|
||||
question?: MobileChatQuestion | null
|
||||
onAnswerQuestion?: (text: string) => Promise<boolean>
|
||||
}): React.JSX.Element | null {
|
||||
if (ask) {
|
||||
return (
|
||||
<MobileNativeChatAsk
|
||||
key={askKey ?? 'ask'}
|
||||
prompt={ask}
|
||||
onAnswer={async (selections) => {
|
||||
const accepted = (await onAnswerAsk?.(ask, selections)) ?? false
|
||||
if (accepted) {
|
||||
onDismissAsk?.()
|
||||
}
|
||||
return accepted
|
||||
}}
|
||||
onCancel={async () => {
|
||||
const accepted = (await onCancelAsk?.()) ?? false
|
||||
if (accepted) {
|
||||
onDismissAsk?.()
|
||||
}
|
||||
return accepted
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (permission) {
|
||||
return (
|
||||
<MobileNativeChatPermission
|
||||
key={JSON.stringify(permission)}
|
||||
permission={permission}
|
||||
onRespond={async (send) => (await onRespondPermission?.(send)) ?? false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (question) {
|
||||
return (
|
||||
<MobileNativeChatQuestion
|
||||
key={mobileChatQuestionKey(question)}
|
||||
question={question}
|
||||
onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Animated, Pressable, Text, View } from 'react-native'
|
||||
import { ChevronDown, SquareChevronRight, SquareTerminal, Wrench } from 'lucide-react-native'
|
||||
import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff'
|
||||
import type { NativeChatDiffLine as DiffLine } from '../../../src/shared/native-chat-diff'
|
||||
import { pairToolBlocks } from '../../../src/shared/native-chat-tool-fold'
|
||||
import type { NativeChatToolPair as ToolPair } from '../../../src/shared/native-chat-tool-fold'
|
||||
import {
|
||||
createToolInputDisplay,
|
||||
summarizeToolRun,
|
||||
truncateToolDetail
|
||||
} from '../../../src/shared/native-chat-tool-summary'
|
||||
import {
|
||||
describeActiveToolCall,
|
||||
formatActiveToolLabel,
|
||||
formatToolCallCount,
|
||||
isCommandToolName,
|
||||
selectActiveToolCall
|
||||
} from '../../../src/shared/native-chat-tool-activity'
|
||||
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-native-chat-message-styles'
|
||||
|
||||
const MAX_VISIBLE_TOOL_PAIRS = 6
|
||||
const MAX_TOOL_RUN_DIFF_ROWS = 240
|
||||
|
||||
function DiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element {
|
||||
return (
|
||||
<View style={styles.diff}>
|
||||
{lines.map((line, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
style={[
|
||||
styles.diffLine,
|
||||
line.kind === 'add' && styles.diffAdd,
|
||||
line.kind === 'del' && styles.diffDel,
|
||||
line.kind === 'meta' && styles.diffMeta
|
||||
]}
|
||||
>
|
||||
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}
|
||||
{line.text}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/** A single inline tool line — `▸ ToolName preview` — that expands in place to
|
||||
* show the call's diff/input or the result's body. Mirrors the reference design
|
||||
* where tool calls read as flat lines in the conversation, not boxed blocks. */
|
||||
function ResultBody({
|
||||
output,
|
||||
isError,
|
||||
diff
|
||||
}: {
|
||||
output: string
|
||||
isError?: boolean
|
||||
diff: DiffLine[] | null
|
||||
}): React.JSX.Element {
|
||||
if (diff) {
|
||||
return <DiffView lines={diff} />
|
||||
}
|
||||
return (
|
||||
<View style={[styles.toolResult, isError && styles.toolResultError]}>
|
||||
<Text style={styles.mono}>{truncateToolDetail(output)}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/** One request: a tool call and its result rendered together as a single
|
||||
* expandable line. `defaultExpanded` lets the group toggle open every line. */
|
||||
function ToolLine({
|
||||
pair,
|
||||
defaultExpanded,
|
||||
diffLineLimit,
|
||||
onOpenFile
|
||||
}: {
|
||||
pair: ToolPair
|
||||
defaultExpanded: boolean
|
||||
diffLineLimit: number
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
}): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
const { call, result } = pair
|
||||
const name = call ? call.name : 'Result'
|
||||
const inputDisplay = call ? createToolInputDisplay(call.input) : null
|
||||
const preview = inputDisplay?.label ?? result?.output.split('\n')[0]?.slice(0, 80) ?? ''
|
||||
// Why: collapsed tool rows are the common path; defer bounded diff parsing
|
||||
// and detail formatting until the user asks to reveal the detail.
|
||||
const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null
|
||||
const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null
|
||||
const callDetail = expanded && inputDisplay && !callDiff ? inputDisplay.formatDetail() : undefined
|
||||
const hasDetail = callDiff !== null || result !== undefined || inputDisplay?.hasDetail === true
|
||||
// The group toggle opens every line at once, bypassing the tap guard, so the
|
||||
// panel has to consult it too — else a detail-less row echoes its own label
|
||||
// under itself and no tap can dismiss it.
|
||||
const showDetail = hasDetail && expanded
|
||||
// A tool that targets a file (Read/Edit/Write…) renders its preview as a
|
||||
// tappable link that opens the file, independent of the line's expand tap.
|
||||
const filePath = inputDisplay?.filePath ?? null
|
||||
const openable = filePath !== null && onOpenFile !== undefined
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
style={styles.toolLine}
|
||||
onPress={() => hasDetail && setExpanded((v) => !v)}
|
||||
hitSlop={6}
|
||||
>
|
||||
{showDetail ? (
|
||||
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
) : (
|
||||
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
)}
|
||||
<Text style={styles.toolName}>{name}</Text>
|
||||
{preview ? (
|
||||
<Text
|
||||
style={[styles.toolPreview, openable && styles.toolPreviewLink]}
|
||||
numberOfLines={1}
|
||||
onPress={openable ? () => onOpenFile!(filePath!) : undefined}
|
||||
suppressHighlighting={!openable}
|
||||
>
|
||||
{preview}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{showDetail ? (
|
||||
<View style={styles.toolDetail}>
|
||||
{callDiff ? <DiffView lines={callDiff} /> : null}
|
||||
{callDetail ? <Text style={styles.mono}>{callDetail}</Text> : null}
|
||||
{result ? (
|
||||
<ResultBody output={result.output} isError={result.isError} diff={resultDiff} />
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/** Breathing label for a still-running tool, matching desktop's `animate-pulse`. */
|
||||
function PulsingText({
|
||||
style,
|
||||
numberOfLines,
|
||||
children
|
||||
}: {
|
||||
style?: React.ComponentProps<typeof Animated.Text>['style']
|
||||
numberOfLines?: number
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const pulse = useRef(new Animated.Value(1)).current
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 0.45, duration: 700, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true })
|
||||
])
|
||||
)
|
||||
animation.start()
|
||||
return () => animation.stop()
|
||||
}, [pulse])
|
||||
return (
|
||||
<Animated.Text style={[style, { opacity: pulse }]} numberOfLines={numberOfLines}>
|
||||
{children}
|
||||
</Animated.Text>
|
||||
)
|
||||
}
|
||||
|
||||
/** A run of a message's tool calls/results, collapsed to a one-line summary that
|
||||
* expands to the individual inline tool lines. `defaultExpanded` lets the global
|
||||
* toolbar toggle drive every run at once while still allowing per-run override. */
|
||||
export function ToolRun({
|
||||
blocks,
|
||||
defaultExpanded,
|
||||
expandChildren,
|
||||
activeCall,
|
||||
trailing,
|
||||
onOpenFile
|
||||
}: {
|
||||
blocks: NativeChatBlock[]
|
||||
defaultExpanded: boolean
|
||||
/** Child tool lines stay collapsed when the turn caret drove the run open. */
|
||||
expandChildren: boolean
|
||||
/** The still-running call, when the turn is live (desktop parity). */
|
||||
activeCall: ReturnType<typeof selectActiveToolCall>
|
||||
trailing?: React.ReactNode
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(defaultExpanded)
|
||||
const pairs = pairToolBlocks(blocks, MAX_VISIBLE_TOOL_PAIRS)
|
||||
const diffLineLimit = Math.max(1, Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (pairs.length * 2 || 1)))
|
||||
let callCount = 0
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'tool-call') {
|
||||
callCount++
|
||||
}
|
||||
}
|
||||
callCount ||= pairs.length
|
||||
const summary = summarizeToolRun(blocks)
|
||||
const ActiveToolIcon = activeCall && isCommandToolName(activeCall.name) ? SquareTerminal : Wrench
|
||||
return (
|
||||
<View style={styles.toolRun}>
|
||||
<View style={styles.toolRunHeader}>
|
||||
{activeCall ? (
|
||||
<Pressable
|
||||
style={styles.toolRunActive}
|
||||
onPress={() => setOpen((v) => !v)}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: open }}
|
||||
accessibilityLiveRegion="polite"
|
||||
>
|
||||
<ActiveToolIcon size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
<PulsingText style={styles.toolRunActiveLabel} numberOfLines={1}>
|
||||
{formatActiveToolLabel(describeActiveToolCall(activeCall))}
|
||||
</PulsingText>
|
||||
{open ? <ChevronDown size={15} color={colors.textMuted} strokeWidth={2} /> : null}
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable style={styles.toolRunToggle} onPress={() => setOpen((v) => !v)} hitSlop={6}>
|
||||
{open ? (
|
||||
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
) : (
|
||||
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
)}
|
||||
<Text style={styles.toolRunCount}>{callCount}×</Text>
|
||||
<Text style={styles.toolRunLabel} numberOfLines={1}>
|
||||
{summary || formatToolCallCount(callCount)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{trailing}
|
||||
</View>
|
||||
{open ? (
|
||||
<View style={styles.toolRunBody}>
|
||||
{pairs.map((pair, i) => (
|
||||
<ToolLine
|
||||
key={i}
|
||||
pair={pair}
|
||||
defaultExpanded={expandChildren}
|
||||
diffLineLimit={diffLineLimit}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
))}
|
||||
{callCount > pairs.length ? (
|
||||
<Text style={styles.toolPreview}>… {callCount - pairs.length} more tool calls</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
|
||||
React.createElement('Text', props, children)
|
||||
return {
|
||||
Animated: {
|
||||
Text,
|
||||
Value: class {
|
||||
constructor(private value: number) {}
|
||||
setValue(next: number): void {
|
||||
this.value = next
|
||||
}
|
||||
},
|
||||
loop: (animation: unknown) => animation,
|
||||
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
|
||||
timing: () => ({ start: vi.fn(), stop: vi.fn() })
|
||||
},
|
||||
Pressable: ({ children, ...props }: { children?: unknown }) =>
|
||||
React.createElement('Pressable', props, children),
|
||||
Text,
|
||||
View: ({ children, ...props }: { children?: unknown }) =>
|
||||
React.createElement('View', props, children),
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
|
||||
}
|
||||
})
|
||||
vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' }))
|
||||
|
||||
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
|
||||
|
||||
describe('MobileNativeChatTurnStatus', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-04T00:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function render(props: {
|
||||
startedAt: number | null
|
||||
thinking: boolean
|
||||
workedSeconds?: number | null
|
||||
expanded?: boolean
|
||||
onToggleExpanded?: () => void
|
||||
}): ReactTestRenderer {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileNativeChatTurnStatus, props))
|
||||
})
|
||||
return renderer!
|
||||
}
|
||||
|
||||
const labels = (node: ReactTestInstance): string[] =>
|
||||
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
|
||||
|
||||
it('reads "Thinking" before the turn produces output', () => {
|
||||
const tree = render({ startedAt: Date.now(), thinking: true })
|
||||
expect(labels(tree.root)).toEqual(['Thinking'])
|
||||
})
|
||||
|
||||
it('counts up once the turn is producing output', () => {
|
||||
const startedAt = Date.now()
|
||||
const tree = render({ startedAt, thinking: false })
|
||||
expect(labels(tree.root)).toEqual(['Working for 0s'])
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(12_000)
|
||||
})
|
||||
expect(labels(tree.root)).toEqual(['Working for 12s'])
|
||||
})
|
||||
|
||||
it('settles to a tappable "Worked for" row that toggles the turn', () => {
|
||||
const onToggleExpanded = vi.fn()
|
||||
const tree = render({
|
||||
startedAt: Date.now(),
|
||||
thinking: false,
|
||||
workedSeconds: 184,
|
||||
onToggleExpanded
|
||||
})
|
||||
expect(labels(tree.root)).toEqual(['Worked for 3m 4s'])
|
||||
const button = tree.root.findByType('Pressable' as never)
|
||||
expect(button.props.accessibilityLabel).toBe('Toggle turn details')
|
||||
expect(button.props.accessibilityState).toEqual({ expanded: false })
|
||||
act(() => button.props.onPress())
|
||||
expect(onToggleExpanded).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stays a plain row when the settled turn has nothing to disclose', () => {
|
||||
const tree = render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 })
|
||||
expect(tree.root.findAllByType('Pressable' as never)).toHaveLength(0)
|
||||
expect(labels(tree.root)).toEqual(['Worked for 5s'])
|
||||
})
|
||||
|
||||
it('holds no interval once the turn has settled', () => {
|
||||
render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('announces the live row to assistive tech', () => {
|
||||
const tree = render({ startedAt: Date.now(), thinking: true })
|
||||
const row = tree.root.findByType('View' as never)
|
||||
expect(row.props.accessibilityLiveRegion).toBe('polite')
|
||||
expect(row.props.accessibilityLabel).toBe('Agent is responding')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { ChevronRight } from 'lucide-react-native'
|
||||
import {
|
||||
formatNativeChatTurnStatusLabel,
|
||||
NATIVE_CHAT_TURN_STATUS_COPY,
|
||||
nativeChatElapsedSeconds
|
||||
} from '../../../src/shared/native-chat-turn-status'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
/** Seconds tick only while a turn is actually counting, so a settled transcript
|
||||
* holds no timers. */
|
||||
function useElapsedSeconds(startedAt: number | null, counting: boolean): number {
|
||||
// Preserves the pre-stamp epoch for the frame before the turn's startedAt lands.
|
||||
const [mountedAt] = useState(() => Date.now())
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
useEffect(() => {
|
||||
if (!counting) {
|
||||
return
|
||||
}
|
||||
setNow(Date.now())
|
||||
const timer = setInterval(() => setNow(Date.now()), 1_000)
|
||||
return () => clearInterval(timer)
|
||||
}, [counting])
|
||||
return counting ? nativeChatElapsedSeconds(startedAt, mountedAt, now) : 0
|
||||
}
|
||||
|
||||
/** The per-turn status row — "Thinking", then "Working for 12s" while the turn
|
||||
* runs, settling to a tappable "Worked for 3m 4s" that discloses the turn's
|
||||
* tool activity. Desktop parity: `NativeChatWorkingStatus`. */
|
||||
export function MobileNativeChatTurnStatus({
|
||||
startedAt,
|
||||
thinking,
|
||||
workedSeconds,
|
||||
expanded = false,
|
||||
onToggleExpanded
|
||||
}: {
|
||||
startedAt: number | null
|
||||
thinking: boolean
|
||||
workedSeconds?: number | null
|
||||
expanded?: boolean
|
||||
onToggleExpanded?: () => void
|
||||
}): React.JSX.Element {
|
||||
const counting = !thinking && workedSeconds == null
|
||||
const elapsedSeconds = useElapsedSeconds(startedAt, counting)
|
||||
const label = formatNativeChatTurnStatusLabel({ thinking, workedSeconds, elapsedSeconds })
|
||||
|
||||
const pulse = useRef(new Animated.Value(1)).current
|
||||
useEffect(() => {
|
||||
if (!thinking) {
|
||||
pulse.setValue(1)
|
||||
return
|
||||
}
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 0.45, duration: 700, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true })
|
||||
])
|
||||
)
|
||||
animation.start()
|
||||
return () => animation.stop()
|
||||
}, [pulse, thinking])
|
||||
|
||||
const rowStyle = [styles.row, thinking ? null : styles.rowSettled]
|
||||
|
||||
if (workedSeconds != null && onToggleExpanded) {
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [...rowStyle, pressed && styles.pressed]}
|
||||
onPress={onToggleExpanded}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded }}
|
||||
accessibilityLabel={NATIVE_CHAT_TURN_STATUS_COPY.toggleDetails}
|
||||
>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<View style={expanded ? styles.caretOpen : undefined}>
|
||||
<ChevronRight size={14} color={colors.textMuted} strokeWidth={2} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={rowStyle}
|
||||
accessibilityLiveRegion="polite"
|
||||
accessibilityLabel={NATIVE_CHAT_TURN_STATUS_COPY.responding}
|
||||
>
|
||||
<Animated.Text style={[styles.label, thinking && { opacity: pulse }]}>{label}</Animated.Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
minHeight: 28,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
rowSettled: {
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
label: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
caretOpen: {
|
||||
transform: [{ rotate: '90deg' }]
|
||||
}
|
||||
})
|
||||
@@ -72,6 +72,9 @@ type Overrides = {
|
||||
inputLockReason?: 'disconnected' | 'waiting' | null
|
||||
onSend?: (text: string) => Promise<boolean>
|
||||
pending?: Parameters<typeof MobileNativeChatView>[0]['pending']
|
||||
structuredActivityUi?: boolean
|
||||
agentWorking?: boolean
|
||||
sendSurfaceId?: string
|
||||
}
|
||||
|
||||
function assistantTurn(id: string, text: string): NativeChatMessage {
|
||||
@@ -243,4 +246,111 @@ describe('MobileNativeChatView', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
describe('structured turn status wiring', () => {
|
||||
const userTurn = (id: string, text: string): NativeChatMessage => ({
|
||||
id,
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: 0,
|
||||
source: 'transcript'
|
||||
})
|
||||
|
||||
function rowProps(id: string): Record<string, unknown> {
|
||||
return (renderedRow(id) as { props: Record<string, unknown> }).props
|
||||
}
|
||||
|
||||
function workingIndicators(): ReactTestInstance[] {
|
||||
return renderer!.root.findAll((node) => node.type === 'WorkingIndicator')
|
||||
}
|
||||
|
||||
it('gives the live user turn a status row and drops the three-dot indicator', async () => {
|
||||
const folded = [userTurn('u1', 'go')]
|
||||
await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true })
|
||||
const props = rowProps('u1')
|
||||
expect(props.structuredActivityUi).toBe(true)
|
||||
expect(props.turnStatus).toMatchObject({ thinking: true, workedSeconds: null })
|
||||
expect(props.activeTurnIsWorking).toBe(true)
|
||||
expect(workingIndicators()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the bridge lane on the three-dot indicator with no turn status', async () => {
|
||||
const folded = [userTurn('u1', 'go')]
|
||||
await render({ messages: folded, folded, agentWorking: true })
|
||||
const props = rowProps('u1')
|
||||
expect(props.structuredActivityUi).toBe(false)
|
||||
expect(props.turnStatus).toBeNull()
|
||||
expect(props.activeTurnIsWorking).toBe(false)
|
||||
expect(workingIndicators()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('settles the finished turn to a tappable duration', async () => {
|
||||
const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')]
|
||||
await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true })
|
||||
expect(rowProps('u1').turnStatus).toMatchObject({ thinking: false, workedSeconds: null })
|
||||
await update({ messages: folded, folded, structuredActivityUi: true, agentWorking: false })
|
||||
const settled = rowProps('u1')
|
||||
expect(settled.turnStatus).toMatchObject({ thinking: false })
|
||||
expect((settled.turnStatus as { workedSeconds: number | null }).workedSeconds).toBeTypeOf(
|
||||
'number'
|
||||
)
|
||||
expect(settled.onToggleTurn).toBeTypeOf('function')
|
||||
expect(settled.activeTurnIsWorking).toBe(false)
|
||||
})
|
||||
|
||||
it('hangs no status row on an assistant row', async () => {
|
||||
const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')]
|
||||
await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true })
|
||||
expect(rowProps('a1').turnStatus).toBeNull()
|
||||
// The assistant row still belongs to the live turn, so its tool row stays visible.
|
||||
expect(rowProps('a1').activeTurnIsWorking).toBe(true)
|
||||
})
|
||||
|
||||
it('does not carry a running turn clock across chat surfaces', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(1_000)
|
||||
const firstTab = [userTurn('u1', 'first')]
|
||||
await render({
|
||||
messages: firstTab,
|
||||
folded: firstTab,
|
||||
structuredActivityUi: true,
|
||||
agentWorking: true,
|
||||
sendSurfaceId: 'host\0worktree\0tab-a'
|
||||
})
|
||||
expect(rowProps('u1').turnStatus).toMatchObject({ startedAt: 1_000 })
|
||||
|
||||
vi.setSystemTime(12_000)
|
||||
const secondTab = [userTurn('u2', 'second')]
|
||||
await update({
|
||||
messages: secondTab,
|
||||
folded: secondTab,
|
||||
structuredActivityUi: true,
|
||||
agentWorking: true,
|
||||
sendSurfaceId: 'host\0worktree\0tab-b'
|
||||
})
|
||||
|
||||
expect(rowProps('u2').turnStatus).toMatchObject({ startedAt: 12_000 })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not treat pre-user history as part of the live turn', async () => {
|
||||
const history = [
|
||||
assistantTurn('a0', 'before the first prompt'),
|
||||
userTurn('u1', 'go'),
|
||||
assistantTurn('a1', 'working')
|
||||
]
|
||||
await render({
|
||||
messages: history,
|
||||
folded: history,
|
||||
structuredActivityUi: true,
|
||||
agentWorking: true
|
||||
})
|
||||
|
||||
expect(rowProps('a0').activeTurnIsWorking).toBe(false)
|
||||
expect(rowProps('a1').activeTurnIsWorking).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,16 +21,16 @@ import {
|
||||
type MobileNativeChatPendingItem
|
||||
} from './mobile-native-chat-render-data'
|
||||
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
|
||||
import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure'
|
||||
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
|
||||
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
|
||||
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
|
||||
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
|
||||
import { MobileNativeChatPromptCard } from './MobileNativeChatPromptCard'
|
||||
import type { MobileChatPermission } from './mobile-native-chat-permission'
|
||||
import type { MobileChatQuestion } from './mobile-native-chat-question'
|
||||
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
|
||||
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
|
||||
import type { MobileChatPermission } from './mobile-native-chat-permission'
|
||||
import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
|
||||
import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question'
|
||||
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
|
||||
|
||||
const INPUT_LOCK_SETTLE_MS = 600
|
||||
@@ -49,6 +49,9 @@ type Props = {
|
||||
/** Resolved agent for this chat; names the empty-state copy (desktop parity). */
|
||||
agent?: string | null
|
||||
agentWorking?: boolean
|
||||
/** Structured lane: per-turn "Working for N" status plus live tool progress,
|
||||
* replacing the bridge lane's static three-dot working row (desktop parity). */
|
||||
structuredActivityUi?: boolean
|
||||
/** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */
|
||||
onStop?: () => void
|
||||
/** Live partial assistant text to show as an in-progress bubble, already gated
|
||||
@@ -126,6 +129,7 @@ export function MobileNativeChatView({
|
||||
error,
|
||||
agent,
|
||||
agentWorking,
|
||||
structuredActivityUi = false,
|
||||
onStop,
|
||||
streaming,
|
||||
hasMore,
|
||||
@@ -252,6 +256,15 @@ export function MobileNativeChatView({
|
||||
listRef.current?.scrollToIndex({ index, viewPosition: 0, animated: true })
|
||||
}, [])
|
||||
|
||||
// Per-turn "Thinking / Working for N / Worked for N" rows. The structured lane
|
||||
// owns them; the bridge lane keeps its three-dot indicator.
|
||||
const turns = useMobileNativeChatTurnDisclosure({
|
||||
messages: data,
|
||||
enabled: structuredActivityUi,
|
||||
isWorking: agentWorking === true,
|
||||
scopeKey: sendSurfaceId
|
||||
})
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: NativeChatMessage; index: number }) => (
|
||||
<MobileNativeChatMessage
|
||||
@@ -261,9 +274,12 @@ export function MobileNativeChatView({
|
||||
messageIndex={index}
|
||||
onScrollToMessage={onScrollToMessage}
|
||||
onOpenFile={onOpenFile}
|
||||
structuredActivityUi={structuredActivityUi}
|
||||
onToggleTurn={turns.onToggleTurn}
|
||||
{...turns.resolveRow(index, item)}
|
||||
/>
|
||||
),
|
||||
[toolsExpanded, fontScale, onScrollToMessage, onOpenFile]
|
||||
[toolsExpanded, fontScale, onScrollToMessage, onOpenFile, structuredActivityUi, turns]
|
||||
)
|
||||
|
||||
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
|
||||
@@ -337,6 +353,15 @@ export function MobileNativeChatView({
|
||||
</Pressable>
|
||||
) : null
|
||||
}
|
||||
ListFooterComponent={
|
||||
turns.activeTurnIsUnanchored && turns.active ? (
|
||||
<MobileNativeChatTurnStatus
|
||||
startedAt={turns.active.startedAt}
|
||||
thinking={turns.active.thinking}
|
||||
workedSeconds={turns.active.workedSeconds}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
ListEmptyComponent={
|
||||
emptyState ? (
|
||||
<View style={styles.center}>
|
||||
@@ -360,47 +385,22 @@ export function MobileNativeChatView({
|
||||
) : null}
|
||||
</GestureHandlerRootView>
|
||||
)}
|
||||
{/* Pending agent prompt: a structured AskUserQuestion wins, then a
|
||||
heuristic permission, then a heuristic question. The controller owns
|
||||
dismissal (it must survive this subtree unmounting on a view toggle);
|
||||
`ask` arrives already nulled while dismissed. */}
|
||||
{ask ? (
|
||||
<MobileNativeChatAsk
|
||||
key={askKey ?? 'ask'}
|
||||
prompt={ask}
|
||||
onAnswer={async (selections) => {
|
||||
const accepted = (await onAnswerAsk?.(ask, selections)) ?? false
|
||||
if (accepted) {
|
||||
onDismissAsk?.()
|
||||
}
|
||||
return accepted
|
||||
}}
|
||||
onCancel={async () => {
|
||||
const accepted = (await onCancelAsk?.()) ?? false
|
||||
if (accepted) {
|
||||
onDismissAsk?.()
|
||||
}
|
||||
return accepted
|
||||
}}
|
||||
/>
|
||||
) : permission ? (
|
||||
<MobileNativeChatPermission
|
||||
key={JSON.stringify(permission)}
|
||||
permission={permission}
|
||||
onRespond={async (send) => (await onRespondPermission?.(send)) ?? false}
|
||||
/>
|
||||
) : question ? (
|
||||
<MobileNativeChatQuestion
|
||||
key={mobileChatQuestionKey(question)}
|
||||
question={question}
|
||||
onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false}
|
||||
/>
|
||||
) : null}
|
||||
<MobileNativeChatPromptCard
|
||||
ask={ask}
|
||||
askKey={askKey}
|
||||
onDismissAsk={onDismissAsk}
|
||||
onAnswerAsk={onAnswerAsk}
|
||||
onCancelAsk={onCancelAsk}
|
||||
permission={permission}
|
||||
onRespondPermission={onRespondPermission}
|
||||
question={question}
|
||||
onAnswerQuestion={onAnswerQuestion}
|
||||
/>
|
||||
{/* Chrome row above the composer: the working indicator and the global
|
||||
tool-calls expand/collapse toggle on the left, Stop in the far corner. */}
|
||||
<View style={styles.chromeRow}>
|
||||
<View style={styles.chromeLeft}>
|
||||
{agentWorking ? <MobileAgentWorkingIndicator /> : null}
|
||||
{agentWorking && !structuredActivityUi ? <MobileAgentWorkingIndicator /> : null}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.chromeToggle, pressed && styles.pressed]}
|
||||
onPress={() => setToolsExpanded((v) => !v)}
|
||||
|
||||
@@ -25,6 +25,8 @@ export type MobileNativeChatController = {
|
||||
chatPending: MobileNativeChatPendingMessage[]
|
||||
chatImagePreviewsByMessageId: Record<string, string[]>
|
||||
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
|
||||
/** Structured lane: drives the per-turn status row and live tool progress. */
|
||||
nativeChatStructured: boolean
|
||||
nativeChatAgentWorking: boolean
|
||||
nativeChatStreamingText?: string
|
||||
/** Agent mid-turn, regardless of whether chat is the visible view. */
|
||||
|
||||
@@ -80,6 +80,18 @@ export const styles = StyleSheet.create({
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: MONO_SIZE
|
||||
},
|
||||
toolRunActive: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: 3
|
||||
},
|
||||
toolRunActiveLabel: {
|
||||
flex: 1,
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
toolRunBody: {
|
||||
paddingLeft: spacing.sm,
|
||||
borderLeftWidth: 2,
|
||||
|
||||
@@ -139,4 +139,76 @@ describe('mobile structured Codex launch', () => {
|
||||
kind: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['structured_agent_session_unsupported', 'method_not_found'])(
|
||||
'treats a top-level %s as a definitive refusal',
|
||||
async (code) => {
|
||||
const client = clientReturning(
|
||||
{ ok: true, result: { supported: true } },
|
||||
{ ok: false, error: { code, message: 'structured create unavailable' } }
|
||||
)
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({
|
||||
kind: 'failed',
|
||||
message: 'structured create unavailable'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['agent_session_operation_unknown', 'runtime_error', 'future_unknown_code'])(
|
||||
'keeps a top-level %s outcome unknown',
|
||||
async (code) => {
|
||||
const client = clientReturning(
|
||||
{ ok: true, result: { supported: true } },
|
||||
{ ok: false, error: { code, message: 'create outcome ambiguous' } }
|
||||
)
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({
|
||||
kind: 'unknown',
|
||||
message: 'create outcome ambiguous'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('treats an envelope unsupported refusal as definitive', async () => {
|
||||
const client = clientReturning(
|
||||
{ ok: true, result: { supported: true } },
|
||||
{
|
||||
ok: true,
|
||||
result: {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'structured_agent_session_unsupported',
|
||||
message: 'structured create unavailable'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({
|
||||
kind: 'failed',
|
||||
message: 'structured create unavailable'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['agent_session_operation_unknown', 'agent_session_ownership_unknown', 'future_code'])(
|
||||
'keeps an envelope %s refusal unknown',
|
||||
async (code) => {
|
||||
const client = clientReturning(
|
||||
{ ok: true, result: { supported: true } },
|
||||
{
|
||||
ok: true,
|
||||
result: {
|
||||
ok: false,
|
||||
refusal: { code, message: 'create outcome ambiguous' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({
|
||||
kind: 'unknown',
|
||||
message: 'create outcome ambiguous'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AgentSessionAttachResult,
|
||||
AgentSessionMutationResult
|
||||
} from '../../../src/shared/agent-session-wire'
|
||||
import { isDefinitiveAgentSessionCreateRefusal } from '../../../src/shared/agent-session-definitive-refusal'
|
||||
import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { structuredSessionOperationId } from './mobile-structured-agent-session-rpc'
|
||||
@@ -66,6 +67,13 @@ function unknownCreateResult(error: unknown): MobileStructuredCodexLaunchResult
|
||||
}
|
||||
}
|
||||
|
||||
function classifyCreateRefusal(code: string, message: string): MobileStructuredCodexLaunchResult {
|
||||
if (!isDefinitiveAgentSessionCreateRefusal(code)) {
|
||||
return unknownCreateResult(new Error(message))
|
||||
}
|
||||
return { kind: 'failed', message: message || 'Could not open Codex chat.' }
|
||||
}
|
||||
|
||||
export async function createMobileStructuredCodexSession(
|
||||
client: RpcClient,
|
||||
worktreeId: string
|
||||
@@ -125,10 +133,7 @@ export async function createMobileStructuredCodexSession(
|
||||
) {
|
||||
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
|
||||
}
|
||||
if (response.error.code === 'agent_session_operation_unknown') {
|
||||
return unknownCreateResult(new Error(response.error.message))
|
||||
}
|
||||
return { kind: 'failed', message: response.error.message || 'Could not open Codex chat.' }
|
||||
return classifyCreateRefusal(response.error.code, response.error.message)
|
||||
}
|
||||
const result = response.result as AgentSessionMutationResult<AgentSessionAttachResult>
|
||||
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
|
||||
@@ -142,10 +147,7 @@ export async function createMobileStructuredCodexSession(
|
||||
) {
|
||||
return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
|
||||
}
|
||||
if (result.refusal.code === 'agent_session_operation_unknown') {
|
||||
return unknownCreateResult(new Error(result.refusal.message))
|
||||
}
|
||||
return { kind: 'failed', message: result.refusal.message || 'Could not open Codex chat.' }
|
||||
return classifyCreateRefusal(result.refusal.code, result.refusal.message)
|
||||
}
|
||||
if (
|
||||
!result.value ||
|
||||
|
||||
@@ -10,9 +10,8 @@ import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
|
||||
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
|
||||
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
|
||||
import { mobileNativeChatStreamPreview } from './mobile-native-chat-streaming-gate'
|
||||
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import { useMobileNativeChatSessionOptionController } from './use-mobile-native-chat-session-option-controller'
|
||||
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
|
||||
import { useMobileNativeChatSessionLane } from './use-mobile-native-chat-session-lane'
|
||||
import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge'
|
||||
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
|
||||
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
|
||||
@@ -82,27 +81,19 @@ export function useMobileNativeChatController(args: {
|
||||
nativeChatTranscriptIsLocalReadable
|
||||
})
|
||||
|
||||
const legacyNativeChatSession = useMobileNativeChatSession({
|
||||
client,
|
||||
sourceIdentity,
|
||||
agent: activeChatStructured ? null : (activeChatResolution?.agent ?? null),
|
||||
sessionId: activeChatStructured ? null : activeChatSessionId,
|
||||
transcriptPath: activeChatStructured ? null : (activeChatResolution?.transcriptPath ?? null)
|
||||
})
|
||||
const structuredNativeChat = useMobileStructuredAgentSession({
|
||||
client,
|
||||
sessionId: activeChatStructured ? activeChatSessionId : null,
|
||||
sourceIdentity,
|
||||
enabled: showNativeChat,
|
||||
// Holds are connection-scoped; dropping this on transport loss lets the hook
|
||||
// reacquire the provider without clearing the cached transcript.
|
||||
connected: connState === 'connected',
|
||||
agent: activeChatStructured ? activeChatAgent : null,
|
||||
onSendError
|
||||
})
|
||||
const nativeChatSession = activeChatStructured
|
||||
? structuredNativeChat.session
|
||||
: legacyNativeChatSession
|
||||
const { structuredSession: structuredNativeChat, session: nativeChatSession } =
|
||||
useMobileNativeChatSessionLane({
|
||||
client,
|
||||
structured: activeChatStructured,
|
||||
agent: activeChatAgent,
|
||||
resolvedAgent: activeChatResolution?.agent ?? null,
|
||||
transcriptPath: activeChatResolution?.transcriptPath ?? null,
|
||||
sessionId: activeChatSessionId,
|
||||
sourceIdentity,
|
||||
enabled: showNativeChat,
|
||||
connState,
|
||||
onSendError
|
||||
})
|
||||
const {
|
||||
composerText: chatComposerText,
|
||||
setComposerText: setChatComposerText,
|
||||
@@ -303,6 +294,8 @@ export function useMobileNativeChatController(args: {
|
||||
chatPending,
|
||||
chatImagePreviewsByMessageId,
|
||||
nativeChatSession,
|
||||
/** Structured lane: drives the per-turn status row and live tool progress. */
|
||||
nativeChatStructured: activeChatStructured,
|
||||
nativeChatAgentWorking,
|
||||
nativeChatStreamingText,
|
||||
nativeChatStreamLive,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
|
||||
|
||||
/** Mounts both transcript sources and hands back the one this tab's lane owns.
|
||||
* Both hooks always run (hook order is fixed); the inactive lane is starved of
|
||||
* its identity inputs rather than unmounted, so a lane flip keeps its cache. */
|
||||
export function useMobileNativeChatSessionLane({
|
||||
client,
|
||||
structured,
|
||||
agent,
|
||||
resolvedAgent,
|
||||
transcriptPath,
|
||||
sessionId,
|
||||
sourceIdentity,
|
||||
enabled,
|
||||
connState,
|
||||
onSendError
|
||||
}: {
|
||||
client: RpcClient | null
|
||||
structured: boolean
|
||||
/** Agent id for the structured provider session. */
|
||||
agent: string | null
|
||||
/** Agent resolved from the terminal, for the bridge transcript reader. */
|
||||
resolvedAgent: string | null
|
||||
transcriptPath: string | null
|
||||
sessionId: string | null
|
||||
sourceIdentity: Parameters<typeof useMobileNativeChatSession>[0]['sourceIdentity']
|
||||
enabled: boolean
|
||||
connState: ConnectionState
|
||||
onSendError: (message: string) => void
|
||||
}): {
|
||||
structuredSession: ReturnType<typeof useMobileStructuredAgentSession>
|
||||
session: ReturnType<typeof useMobileNativeChatSession>
|
||||
} {
|
||||
const bridgeSession = useMobileNativeChatSession({
|
||||
client,
|
||||
sourceIdentity,
|
||||
agent: structured ? null : resolvedAgent,
|
||||
sessionId: structured ? null : sessionId,
|
||||
transcriptPath: structured ? null : transcriptPath
|
||||
})
|
||||
const structuredSession = useMobileStructuredAgentSession({
|
||||
client,
|
||||
sessionId: structured ? sessionId : null,
|
||||
sourceIdentity,
|
||||
enabled,
|
||||
// Holds are connection-scoped; dropping this on transport loss lets the hook
|
||||
// reacquire the provider without clearing the cached transcript.
|
||||
connected: connState === 'connected',
|
||||
agent: structured ? agent : null,
|
||||
onSendError
|
||||
})
|
||||
return {
|
||||
structuredSession,
|
||||
session: structured ? structuredSession.session : bridgeSession
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure'
|
||||
|
||||
function userMessage(id: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: id }],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
function Harness({
|
||||
messages,
|
||||
enabled,
|
||||
isWorking = true,
|
||||
scopeKey = 'host\0worktree\0tab-a'
|
||||
}: {
|
||||
messages: readonly NativeChatMessage[]
|
||||
enabled: boolean
|
||||
isWorking?: boolean
|
||||
scopeKey?: string
|
||||
}): React.JSX.Element {
|
||||
const disclosure = useMobileNativeChatTurnDisclosure({
|
||||
messages,
|
||||
enabled,
|
||||
isWorking,
|
||||
scopeKey
|
||||
})
|
||||
return createElement('result', { disclosure })
|
||||
}
|
||||
|
||||
describe('useMobileNativeChatTurnDisclosure', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('does not scan bridge-lane transcripts', () => {
|
||||
const messages: NativeChatMessage[] = [
|
||||
{
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'go' }],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
]
|
||||
const findLastIndex = vi.spyOn(messages, 'findLastIndex')
|
||||
const slice = vi.spyOn(messages, 'slice')
|
||||
const filter = vi.spyOn(messages, 'filter')
|
||||
const map = vi.spyOn(messages, 'map')
|
||||
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { messages, enabled: false }))
|
||||
})
|
||||
|
||||
expect(findLastIndex).not.toHaveBeenCalled()
|
||||
expect(slice).not.toHaveBeenCalled()
|
||||
expect(filter).not.toHaveBeenCalled()
|
||||
expect(map).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a settled turn handler stable for NUL-delimited scope keys', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(1_000)
|
||||
const messages: NativeChatMessage[] = [
|
||||
{
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'go' }],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
]
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { messages, enabled: true }))
|
||||
})
|
||||
vi.setSystemTime(6_000)
|
||||
act(() => {
|
||||
renderer?.update(createElement(Harness, { messages, enabled: true, isWorking: false }))
|
||||
})
|
||||
const first = renderer!.root.findByType('result').props.disclosure.resolveRow(0, messages[0])
|
||||
|
||||
const refreshed = [...messages]
|
||||
act(() => {
|
||||
renderer?.update(
|
||||
createElement(Harness, { messages: refreshed, enabled: true, isWorking: false })
|
||||
)
|
||||
})
|
||||
const second = renderer!.root
|
||||
.findByType('result')
|
||||
.props.disclosure.resolveRow(0, refreshed[0])
|
||||
|
||||
// The row carries the key; the handler itself lives on the hook and stays
|
||||
// stable for the scope, so a re-render never disturbs a row's memo.
|
||||
expect(first.turnKey).toBe('u1')
|
||||
expect(second.turnKey).toBe('u1')
|
||||
const firstHandler = renderer!.root.findByType('result').props.disclosure.onToggleTurn
|
||||
expect(firstHandler).toBeTypeOf('function')
|
||||
act(() => {
|
||||
renderer?.update(
|
||||
createElement(Harness, { messages: [...refreshed], enabled: true, isWorking: false })
|
||||
)
|
||||
})
|
||||
expect(renderer!.root.findByType('result').props.disclosure.onToggleTurn).toBe(firstHandler)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps at most the latest 128 turns expanded', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let messages: NativeChatMessage[] = []
|
||||
for (let index = 0; index < 129; index++) {
|
||||
messages = messages.concat(userMessage(`u${index}`))
|
||||
vi.setSystemTime(index * 2_000)
|
||||
act(() => {
|
||||
if (renderer) {
|
||||
renderer.update(createElement(Harness, { messages, enabled: true }))
|
||||
} else {
|
||||
renderer = create(createElement(Harness, { messages, enabled: true }))
|
||||
}
|
||||
})
|
||||
vi.setSystemTime(index * 2_000 + 1_000)
|
||||
act(() => {
|
||||
renderer?.update(createElement(Harness, { messages, enabled: true, isWorking: false }))
|
||||
})
|
||||
const disclosureNow = renderer!.root.findByType('result').props.disclosure
|
||||
const row = disclosureNow.resolveRow(index, messages[index])
|
||||
act(() => disclosureNow.onToggleTurn(row.turnKey))
|
||||
}
|
||||
|
||||
const disclosure = renderer!.root.findByType('result').props.disclosure
|
||||
const expanded = messages.filter(
|
||||
(message, index) => disclosure.resolveRow(index, message).turnExpanded
|
||||
)
|
||||
expect(expanded).toHaveLength(128)
|
||||
expect(disclosure.resolveRow(0, messages[0]).turnExpanded).toBe(false)
|
||||
expect(disclosure.resolveRow(128, messages[128]).turnExpanded).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
MOBILE_UNANCHORED_TURN_KEY,
|
||||
useMobileNativeChatTurnStatus,
|
||||
type NativeChatTurnStatus
|
||||
} from './use-mobile-native-chat-turn-status'
|
||||
|
||||
const EMPTY_TURN_IDS: ReadonlySet<string> = new Set()
|
||||
const EMPTY_TURN_KEYS: readonly undefined[] = []
|
||||
const MAX_EXPANDED_TURNS = 128
|
||||
|
||||
export type MobileNativeChatTurnRow = {
|
||||
turnStatus: NativeChatTurnStatus | null
|
||||
turnExpanded: boolean
|
||||
/** Set only on a settled turn — the one row that has activity to disclose. */
|
||||
turnKey?: string
|
||||
activeTurnIsWorking: boolean
|
||||
}
|
||||
|
||||
/** Owns the transcript's per-turn status rows and their disclosure state, and
|
||||
* resolves what one list row needs. Bridge-lane chats pass `enabled: false` and
|
||||
* keep their single three-dot working indicator instead. */
|
||||
export function useMobileNativeChatTurnDisclosure({
|
||||
messages,
|
||||
enabled,
|
||||
isWorking,
|
||||
scopeKey
|
||||
}: {
|
||||
messages: readonly NativeChatMessage[]
|
||||
enabled: boolean
|
||||
isWorking: boolean
|
||||
/** Host/worktree/tab identity for timing and disclosure isolation. */
|
||||
scopeKey: string
|
||||
}): {
|
||||
active: NativeChatTurnStatus | null
|
||||
/** True when the live turn has no user message to hang its status row under. */
|
||||
activeTurnIsUnanchored: boolean
|
||||
onToggleTurn: (turnKey: string) => void
|
||||
resolveRow: (index: number, message: NativeChatMessage) => MobileNativeChatTurnRow
|
||||
} {
|
||||
const turnStatuses = useMobileNativeChatTurnStatus({
|
||||
messages,
|
||||
enabled,
|
||||
isWorking,
|
||||
scopeKey
|
||||
})
|
||||
const [expandedTurns, setExpandedTurns] = useState<{
|
||||
scopeKey: string
|
||||
turnIds: ReadonlySet<string>
|
||||
}>(() => ({ scopeKey, turnIds: new Set() }))
|
||||
const expandedTurnIds =
|
||||
expandedTurns.scopeKey === scopeKey ? expandedTurns.turnIds : EMPTY_TURN_IDS
|
||||
const toggleExpandedTurn = useCallback(
|
||||
(turnKey: string) => {
|
||||
setExpandedTurns((current) => {
|
||||
const next = new Set(current.scopeKey === scopeKey ? current.turnIds : [])
|
||||
if (!next.delete(turnKey)) {
|
||||
if (next.size >= MAX_EXPANDED_TURNS) {
|
||||
const oldest = next.values().next().value
|
||||
if (oldest) {
|
||||
next.delete(oldest)
|
||||
}
|
||||
}
|
||||
next.add(turnKey)
|
||||
}
|
||||
return { scopeKey, turnIds: next }
|
||||
})
|
||||
},
|
||||
[scopeKey]
|
||||
)
|
||||
// Resolve each row's turn boundary once — a findLast per row is quadratic on a
|
||||
// long transcript.
|
||||
const turnKeys = useMemo(() => {
|
||||
if (!enabled) {
|
||||
return EMPTY_TURN_KEYS
|
||||
}
|
||||
let turnKey: string | undefined
|
||||
return messages.map((message) => {
|
||||
if (message.role === 'user') {
|
||||
turnKey = message.id
|
||||
}
|
||||
return turnKey
|
||||
})
|
||||
}, [enabled, messages])
|
||||
|
||||
const { active, activeTurnKey, completedByTurn } = turnStatuses
|
||||
const resolveRow = useCallback(
|
||||
(index: number, message: NativeChatMessage): MobileNativeChatTurnRow => {
|
||||
const turnKey = turnKeys[index]
|
||||
const turnStatus =
|
||||
!enabled || message.role !== 'user'
|
||||
? null
|
||||
: turnKey === activeTurnKey
|
||||
? active
|
||||
: turnKey
|
||||
? (completedByTurn[turnKey] ?? null)
|
||||
: null
|
||||
return {
|
||||
turnStatus,
|
||||
turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false,
|
||||
// Why: the key travels and the row calls one stable handler with it. A
|
||||
// closure per row would be a new identity every render of a streaming
|
||||
// transcript, defeating the row's memo; caching one per turn would mean
|
||||
// writing a ref during render, which react-freeze can discard.
|
||||
turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined,
|
||||
// With no user boundary at all, the session's working state stays authoritative.
|
||||
activeTurnIsWorking:
|
||||
enabled &&
|
||||
isWorking &&
|
||||
(turnKey === activeTurnKey ||
|
||||
(turnKey === undefined && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY))
|
||||
}
|
||||
},
|
||||
[turnKeys, enabled, activeTurnKey, active, completedByTurn, expandedTurnIds, isWorking]
|
||||
)
|
||||
|
||||
return {
|
||||
active,
|
||||
/** Stable for a given chat scope, so it never disturbs a row's memo. */
|
||||
onToggleTurn: toggleExpandedTurn,
|
||||
activeTurnIsUnanchored:
|
||||
enabled && active != null && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY,
|
||||
resolveRow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
nativeChatTurnHasResponse,
|
||||
reduceNativeChatTurnTiming,
|
||||
selectNativeChatTurnStatuses,
|
||||
type NativeChatTurnStatus,
|
||||
type NativeChatTurnTimingByTurn
|
||||
} from '../../../src/shared/native-chat-turn-status'
|
||||
|
||||
export type { NativeChatTurnStatus }
|
||||
|
||||
export const MOBILE_UNANCHORED_TURN_KEY = '__unanchored__'
|
||||
const EMPTY_TURN_TIMING_BY_TURN: NativeChatTurnTimingByTurn = Object.freeze({})
|
||||
|
||||
type ScopedTurnTiming = {
|
||||
scopeKey: string
|
||||
timingByTurn: NativeChatTurnTimingByTurn
|
||||
}
|
||||
|
||||
/** Per-turn "Thinking / Working for N / Worked for N" timing, on the same shared
|
||||
* state machine the desktop renderer uses so the two surfaces stamp turns alike. */
|
||||
export function useMobileNativeChatTurnStatus({
|
||||
messages,
|
||||
enabled,
|
||||
isWorking,
|
||||
workingStartedAt,
|
||||
scopeKey
|
||||
}: {
|
||||
messages: readonly NativeChatMessage[]
|
||||
enabled: boolean
|
||||
isWorking: boolean
|
||||
workingStartedAt?: number | null
|
||||
/** Host/worktree/tab identity. Timings never carry across chat surfaces. */
|
||||
scopeKey: string
|
||||
}): {
|
||||
active: NativeChatTurnStatus | null
|
||||
completedByTurn: Readonly<Record<string, NativeChatTurnStatus>>
|
||||
activeTurnKey: string
|
||||
} {
|
||||
const latestUserIndex = enabled
|
||||
? messages.findLastIndex((message) => message.role === 'user')
|
||||
: -1
|
||||
const hasCurrentTurnResponse = enabled && nativeChatTurnHasResponse(messages, latestUserIndex)
|
||||
const latestUserId = latestUserIndex !== -1 ? (messages[latestUserIndex]?.id ?? null) : null
|
||||
const activeTurnKey = latestUserId ?? MOBILE_UNANCHORED_TURN_KEY
|
||||
const [scopedTiming, setScopedTiming] = useState<ScopedTurnTiming>(() => ({
|
||||
scopeKey,
|
||||
timingByTurn: {}
|
||||
}))
|
||||
// Do not expose the previous surface's state during the render before the
|
||||
// timing effect adopts the new scope, or scan it while this UI is disabled.
|
||||
const timingByTurn =
|
||||
enabled && scopedTiming.scopeKey === scopeKey
|
||||
? scopedTiming.timingByTurn
|
||||
: EMPTY_TURN_TIMING_BY_TURN
|
||||
// An accepted send renders as `pending-N` until the transcript echo lands under
|
||||
// its real id. That is one turn under two keys, so the clock must survive the swap.
|
||||
const previousActiveTurn = useRef<{ scopeKey: string; turnKey: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
const validTurnKeys = new Set(
|
||||
messages.filter((message) => message.role === 'user').map((message) => message.id)
|
||||
)
|
||||
const previousActiveTurnKey =
|
||||
previousActiveTurn.current?.scopeKey === scopeKey
|
||||
? previousActiveTurn.current.turnKey
|
||||
: undefined
|
||||
previousActiveTurn.current = { scopeKey, turnKey: activeTurnKey }
|
||||
setScopedTiming((current) => {
|
||||
const currentTiming =
|
||||
current.scopeKey === scopeKey ? current.timingByTurn : EMPTY_TURN_TIMING_BY_TURN
|
||||
const nextTiming = reduceNativeChatTurnTiming(currentTiming, {
|
||||
activeTurnKey,
|
||||
previousActiveTurnKey,
|
||||
validTurnKeys,
|
||||
isWorking,
|
||||
workingStartedAt,
|
||||
now: Date.now()
|
||||
})
|
||||
return current.scopeKey === scopeKey && nextTiming === currentTiming
|
||||
? current
|
||||
: { scopeKey, timingByTurn: nextTiming }
|
||||
})
|
||||
}, [activeTurnKey, enabled, isWorking, messages, scopeKey, workingStartedAt])
|
||||
|
||||
// Why: the selection rebuilds its status objects on every call, and a streaming
|
||||
// turn re-renders ~20x/s. Without this, every settled turn's row gets fresh
|
||||
// props each tick and the memoized message rows all re-render.
|
||||
const turnIsWorking = enabled && isWorking
|
||||
const statuses = useMemo(
|
||||
() =>
|
||||
selectNativeChatTurnStatuses(timingByTurn, {
|
||||
activeTurnKey,
|
||||
isWorking: turnIsWorking,
|
||||
workingStartedAt,
|
||||
hasCurrentTurnResponse
|
||||
}),
|
||||
[timingByTurn, activeTurnKey, turnIsWorking, workingStartedAt, hasCurrentTurnResponse]
|
||||
)
|
||||
return { ...statuses, activeTurnKey }
|
||||
}
|
||||
@@ -137,14 +137,17 @@ describe('mobile + Codex tab creation routing', () => {
|
||||
expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('terminal-tab-1')
|
||||
})
|
||||
|
||||
it('falls back to a terminal when structured creation is refused', async () => {
|
||||
it('falls back to a terminal when structured creation is definitively refused', async () => {
|
||||
const client = clientReturning(
|
||||
{ ok: true, result: { supported: true } },
|
||||
{
|
||||
ok: true,
|
||||
result: {
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_ownership_unknown', message: 'provider unavailable' }
|
||||
refusal: {
|
||||
code: 'structured_agent_session_unsupported',
|
||||
message: 'provider unavailable'
|
||||
}
|
||||
}
|
||||
},
|
||||
terminalCreateResponse()
|
||||
@@ -228,4 +231,34 @@ describe('mobile + Codex tab creation routing', () => {
|
||||
expect(scope.setCreateError).toHaveBeenCalledWith('still unknown')
|
||||
expect(scope.showToast).toHaveBeenCalledWith('still unknown', 1800)
|
||||
})
|
||||
|
||||
it.each(['agent_session_operation_unknown', 'runtime_error', 'future_unknown_code'])(
|
||||
'does not create a legacy sibling after a top-level %s response',
|
||||
async (code) => {
|
||||
const client = clientReturning(
|
||||
{ ok: true, result: { supported: true } },
|
||||
{ ok: false, error: { code, message: 'create outcome ambiguous' } }
|
||||
)
|
||||
const scope = createScope(client)
|
||||
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
|
||||
function Harness() {
|
||||
actions = useMobileSessionTerminalCreateActions(scope as never)
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await act(async () => {
|
||||
await actions?.handleCreateTerminal('codex')
|
||||
})
|
||||
|
||||
const sendRequest = client.sendRequest as unknown as ReturnType<typeof vi.fn>
|
||||
expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([
|
||||
'agentSession.createSupport',
|
||||
'agentSession.create'
|
||||
])
|
||||
expect(scope.setCreateError).toHaveBeenCalledWith('create outcome ambiguous')
|
||||
expect(scope.showToast).toHaveBeenCalledWith('create outcome ambiguous', 1800)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -141,6 +141,10 @@
|
||||
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/main-thread-jank-bench.mjs",
|
||||
"bench:worktree-deletion": "node tests/tools/benchmarks/worktree-deletion-dev-bench.mjs",
|
||||
"bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs",
|
||||
"bench:agent-inspection-cadence": "node config/scripts/agent-inspection-cadence-batching-benchmark.mjs",
|
||||
"bench:renderer-quadratic-scans": "node config/scripts/renderer-quadratic-scan-benchmark.mjs",
|
||||
"bench:session-write-hot-path": "node config/scripts/session-write-hot-path-benchmark.mjs",
|
||||
"bench:terminal-partial-escape-tail": "node config/scripts/terminal-partial-escape-tail-benchmark.mjs",
|
||||
"bench:worktree-refresh-churn": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/worktree-refresh-churn-benchmark.mjs",
|
||||
"bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs",
|
||||
"bench:ai-vault-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-ai-vault-typing-bench.mjs",
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
|
||||
type PermissionQueryResult = EventTarget & {
|
||||
state: string
|
||||
onchange: EventListener | null
|
||||
marker: string
|
||||
}
|
||||
|
||||
type PermissionStatusConstructor = {
|
||||
new (): PermissionQueryResult
|
||||
prototype: PermissionQueryResult
|
||||
}
|
||||
|
||||
type AntiDetectionContext = {
|
||||
Notification: {
|
||||
permission: string
|
||||
requestPermission: (callback?: (permission: string) => void) => Promise<string>
|
||||
}
|
||||
PermissionStatus: PermissionStatusConstructor
|
||||
dispatchPermissionChange: (name: string) => void
|
||||
navigator: {
|
||||
permissions: {
|
||||
query: (descriptor: { name: string }) => Promise<PermissionQueryResult>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(args: {
|
||||
nativeNotificationPermission: string
|
||||
requestedNotificationPermission: string
|
||||
rejectedPermissions?: string[]
|
||||
}): AntiDetectionContext & Record<string, unknown> {
|
||||
class PermissionStatus extends EventTarget {
|
||||
#state = 'denied'
|
||||
#onchange: EventListener | null = null
|
||||
marker = 'real-status'
|
||||
|
||||
get state(): string {
|
||||
return this.#state
|
||||
}
|
||||
|
||||
get onchange(): EventListener | null {
|
||||
return this.#onchange
|
||||
}
|
||||
|
||||
set onchange(listener: EventListener | null) {
|
||||
if (this.#onchange) {
|
||||
super.removeEventListener('change', this.#onchange)
|
||||
}
|
||||
this.#onchange = typeof listener === 'function' ? listener : null
|
||||
if (this.#onchange) {
|
||||
super.addEventListener('change', this.#onchange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const statuses = new Map<string, PermissionStatus[]>()
|
||||
const rejectedPermissions = new Set(args.rejectedPermissions)
|
||||
|
||||
class Permissions {
|
||||
query(descriptor: { name: string }): Promise<PermissionStatus> {
|
||||
if (rejectedPermissions.has(descriptor.name)) {
|
||||
return Promise.reject(new Error('Unsupported permission'))
|
||||
}
|
||||
const status = new PermissionStatus()
|
||||
const permissionStatuses = statuses.get(descriptor.name) ?? []
|
||||
permissionStatuses.push(status)
|
||||
statuses.set(descriptor.name, permissionStatuses)
|
||||
return Promise.resolve(status)
|
||||
}
|
||||
}
|
||||
|
||||
const Notification = {
|
||||
permission: args.nativeNotificationPermission,
|
||||
requestPermission(callback?: (permission: string) => void): Promise<string> {
|
||||
callback?.(args.requestedNotificationPermission)
|
||||
return Promise.resolve(args.requestedNotificationPermission)
|
||||
}
|
||||
}
|
||||
Object.defineProperty(Notification, 'permission', {
|
||||
configurable: true,
|
||||
get: () => args.nativeNotificationPermission
|
||||
})
|
||||
|
||||
return {
|
||||
Date,
|
||||
Event,
|
||||
EventTarget,
|
||||
Object,
|
||||
Promise,
|
||||
Set,
|
||||
performance: { now: () => 0 },
|
||||
// Why: the script's Firefox gate reads navigator.userAgent, so a non-Firefox UA keeps these
|
||||
// tests on the ordinary-page path where the PermissionStatus override applies.
|
||||
window: { chrome: {} },
|
||||
navigator: {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
|
||||
plugins: [],
|
||||
languages: [],
|
||||
permissions: new Permissions()
|
||||
},
|
||||
Permissions,
|
||||
PermissionStatus,
|
||||
Notification,
|
||||
dispatchPermissionChange(name: string): void {
|
||||
for (const status of statuses.get(name) ?? []) {
|
||||
status.dispatchEvent(new Event('change'))
|
||||
}
|
||||
}
|
||||
} as AntiDetectionContext & Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('ANTI_DETECTION_SCRIPT — PermissionStatus', () => {
|
||||
it('keeps an existing notification status current after permission changes', async () => {
|
||||
const context = createContext({
|
||||
nativeNotificationPermission: 'denied',
|
||||
requestedNotificationPermission: 'granted'
|
||||
})
|
||||
|
||||
runInNewContext(ANTI_DETECTION_SCRIPT, context)
|
||||
const status = await context.navigator.permissions.query({ name: 'notifications' })
|
||||
|
||||
expect(context.Notification.permission).toBe('default')
|
||||
expect(status.state).toBe('prompt')
|
||||
|
||||
await expect(context.Notification.requestPermission()).resolves.toBe('granted')
|
||||
|
||||
expect(context.Notification.permission).toBe('granted')
|
||||
expect(status.state).toBe('granted')
|
||||
})
|
||||
|
||||
it('preserves native PermissionStatus identity and methods', async () => {
|
||||
const context = createContext({
|
||||
nativeNotificationPermission: 'denied',
|
||||
requestedNotificationPermission: 'granted'
|
||||
})
|
||||
|
||||
runInNewContext(ANTI_DETECTION_SCRIPT, context)
|
||||
const status = await context.navigator.permissions.query({ name: 'camera' })
|
||||
const expectedSource = Function.prototype.toString.call(
|
||||
context.PermissionStatus.prototype.addEventListener
|
||||
)
|
||||
|
||||
expect(status).toBeInstanceOf(context.PermissionStatus)
|
||||
expect(status.state).toBe('prompt')
|
||||
expect(status.constructor.name).toBe('PermissionStatus')
|
||||
expect(status.marker).toBe('real-status')
|
||||
expect(status.addEventListener.name).toBe('addEventListener')
|
||||
expect(status.addEventListener).toBe(status.addEventListener)
|
||||
expect(Function.prototype.toString.call(status.addEventListener)).toBe(expectedSource)
|
||||
expect(expectedSource).toContain('addEventListener')
|
||||
})
|
||||
|
||||
it('delivers change events through the returned status with the overridden state', async () => {
|
||||
const context = createContext({
|
||||
nativeNotificationPermission: 'denied',
|
||||
requestedNotificationPermission: 'granted'
|
||||
})
|
||||
|
||||
runInNewContext(ANTI_DETECTION_SCRIPT, context)
|
||||
const status = await context.navigator.permissions.query({ name: 'notifications' })
|
||||
const events: { receiver: EventTarget; target: EventTarget | null; state: string }[] = []
|
||||
const recordEvent = function (this: EventTarget, event: Event): void {
|
||||
events.push({
|
||||
receiver: this,
|
||||
target: event.target,
|
||||
state: (event.target as PermissionQueryResult).state
|
||||
})
|
||||
}
|
||||
|
||||
status.addEventListener('change', recordEvent)
|
||||
expect(() => {
|
||||
status.onchange = function (this: EventTarget, event): void {
|
||||
recordEvent.call(this, event)
|
||||
}
|
||||
}).not.toThrow()
|
||||
|
||||
await context.Notification.requestPermission()
|
||||
context.dispatchPermissionChange('notifications')
|
||||
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events).toEqual([
|
||||
{ receiver: status, target: status, state: 'granted' },
|
||||
{ receiver: status, target: status, state: 'granted' }
|
||||
])
|
||||
})
|
||||
|
||||
// Why: 'camera' rather than 'storage-access' — #14685 narrowed the intercepted set to
|
||||
// camera/microphone, so a name outside it falls through to the real query and never reaches
|
||||
// the fallback at all.
|
||||
it('uses a non-enumerable EventTarget fallback when the native query rejects', async () => {
|
||||
const context = createContext({
|
||||
nativeNotificationPermission: 'denied',
|
||||
requestedNotificationPermission: 'granted',
|
||||
rejectedPermissions: ['camera']
|
||||
})
|
||||
|
||||
runInNewContext(ANTI_DETECTION_SCRIPT, context)
|
||||
const status = await context.navigator.permissions.query({ name: 'camera' })
|
||||
|
||||
expect(status).toBeInstanceOf(EventTarget)
|
||||
expect(status).not.toBeInstanceOf(context.PermissionStatus)
|
||||
expect(status.state).toBe('prompt')
|
||||
expect(Object.keys(status)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,157 +0,0 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
import { googleAuthUserAgent } from './browser-google-auth-ua'
|
||||
|
||||
type PermissionQueryResult = {
|
||||
state: string
|
||||
onchange: null
|
||||
}
|
||||
|
||||
type AntiDetectionContext = {
|
||||
Notification: {
|
||||
permission: string
|
||||
requestPermission: (callback?: (permission: string) => void) => Promise<string>
|
||||
}
|
||||
navigator: {
|
||||
userAgent: string
|
||||
permissions: {
|
||||
query: (descriptor: { name: string }) => Promise<PermissionQueryResult>
|
||||
}
|
||||
}
|
||||
window: {
|
||||
chrome?: {
|
||||
runtime?: unknown
|
||||
csi?: () => unknown
|
||||
loadTimes?: () => unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(args: {
|
||||
nativeNotificationPermission: string
|
||||
requestedNotificationPermission: string
|
||||
userAgent?: string
|
||||
}): AntiDetectionContext & Record<string, unknown> {
|
||||
class Permissions {
|
||||
query(): Promise<PermissionQueryResult> {
|
||||
return Promise.resolve({ state: 'denied', onchange: null })
|
||||
}
|
||||
}
|
||||
|
||||
const Notification = {
|
||||
permission: args.nativeNotificationPermission,
|
||||
requestPermission(callback?: (permission: string) => void): Promise<string> {
|
||||
callback?.(args.requestedNotificationPermission)
|
||||
return Promise.resolve(args.requestedNotificationPermission)
|
||||
}
|
||||
}
|
||||
Object.defineProperty(Notification, 'permission', {
|
||||
configurable: true,
|
||||
get: () => args.nativeNotificationPermission
|
||||
})
|
||||
|
||||
return {
|
||||
Date,
|
||||
Object,
|
||||
Promise,
|
||||
Set,
|
||||
performance: { now: () => 0 },
|
||||
// Electron 43 exposes this native object before the anti-detection script runs.
|
||||
window: { chrome: {} },
|
||||
navigator: {
|
||||
userAgent:
|
||||
args.userAgent ??
|
||||
'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
|
||||
plugins: [],
|
||||
languages: [],
|
||||
permissions: new Permissions()
|
||||
},
|
||||
Permissions,
|
||||
Notification
|
||||
} as AntiDetectionContext & Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('ANTI_DETECTION_SCRIPT', () => {
|
||||
it('does not expose Chrome globals under a Firefox identity', () => {
|
||||
const context = createContext({
|
||||
nativeNotificationPermission: 'denied',
|
||||
requestedNotificationPermission: 'denied',
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
|
||||
runInNewContext(ANTI_DETECTION_SCRIPT, context)
|
||||
|
||||
expect(context.window.chrome).toBeUndefined()
|
||||
expect('chrome' in context.window).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps Chrome API stubs aligned with an ordinary Chrome page', () => {
|
||||
const context = createContext({
|
||||
nativeNotificationPermission: 'denied',
|
||||
requestedNotificationPermission: 'denied'
|
||||
})
|
||||
|
||||
runInNewContext(ANTI_DETECTION_SCRIPT, context)
|
||||
|
||||
expect(context.window.chrome?.runtime).toBeUndefined()
|
||||
expect(context.window.chrome?.csi).toBeTypeOf('function')
|
||||
expect(context.window.chrome?.loadTimes).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it.each(['geolocation', 'idle-detection', 'midi', 'storage-access'])(
|
||||
'passes non-intercepted permission queries through to the native state for %s',
|
||||
async (name) => {
|
||||
const context = createContext({
|
||||
nativeNotificationPermission: 'denied',
|
||||
requestedNotificationPermission: 'denied'
|
||||
})
|
||||
|
||||
runInNewContext(ANTI_DETECTION_SCRIPT, context)
|
||||
|
||||
await expect(context.navigator.permissions.query({ name })).resolves.toEqual({
|
||||
state: 'denied',
|
||||
onchange: null
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('reports notification permission as granted after a site permission request succeeds', async () => {
|
||||
const context = createContext({
|
||||
nativeNotificationPermission: 'denied',
|
||||
requestedNotificationPermission: 'granted'
|
||||
})
|
||||
|
||||
runInNewContext(ANTI_DETECTION_SCRIPT, context)
|
||||
|
||||
expect(context.Notification.permission).toBe('default')
|
||||
await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({
|
||||
state: 'prompt',
|
||||
onchange: null
|
||||
})
|
||||
|
||||
await expect(context.Notification.requestPermission()).resolves.toBe('granted')
|
||||
|
||||
expect(context.Notification.permission).toBe('granted')
|
||||
await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({
|
||||
state: 'granted',
|
||||
onchange: null
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves notification permission when Electron already reports a grant', async () => {
|
||||
const context = createContext({
|
||||
nativeNotificationPermission: 'granted',
|
||||
requestedNotificationPermission: 'granted'
|
||||
})
|
||||
|
||||
runInNewContext(ANTI_DETECTION_SCRIPT, context)
|
||||
|
||||
expect(context.Notification.permission).toBe('granted')
|
||||
await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({
|
||||
state: 'granted',
|
||||
onchange: null
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,161 +0,0 @@
|
||||
// Why: Cloudflare Turnstile and similar bot detectors probe multiple browser
|
||||
// APIs beyond navigator.webdriver. This script runs via
|
||||
// Page.addScriptToEvaluateOnNewDocument before any page JS to mask automation
|
||||
// signals that CDP debugger attachment and Electron's webview expose.
|
||||
export const ANTI_DETECTION_SCRIPT = `(function() {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||
// Why: Electron webviews expose an empty plugins array. Real Chrome always
|
||||
// has at least a few default plugins (PDF Viewer, etc.). An empty array is
|
||||
// a strong automation signal.
|
||||
if (navigator.plugins.length === 0) {
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [
|
||||
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
|
||||
{ name: 'Native Client', filename: 'internal-nacl-plugin' }
|
||||
]
|
||||
});
|
||||
}
|
||||
// Why: auth hosts present Firefox, where Electron's native window.chrome is an identity mismatch.
|
||||
if (navigator.userAgent.includes('Firefox/')) {
|
||||
try {
|
||||
delete window.chrome;
|
||||
if ('chrome' in window) {
|
||||
window.chrome = undefined;
|
||||
}
|
||||
} catch {}
|
||||
} else {
|
||||
// Why: Electron webviews may not have the window.chrome object that real
|
||||
// Chrome exposes. Turnstile checks for its presence. The csi() and
|
||||
// loadTimes() stubs satisfy deeper probes of Chrome-specific APIs.
|
||||
if (!window.chrome) {
|
||||
window.chrome = {};
|
||||
}
|
||||
if (!window.chrome.csi) {
|
||||
window.chrome.csi = function() {
|
||||
return {
|
||||
startE: Date.now(),
|
||||
onloadT: Date.now(),
|
||||
pageT: performance.now(),
|
||||
tran: 15
|
||||
};
|
||||
};
|
||||
}
|
||||
if (!window.chrome.loadTimes) {
|
||||
window.chrome.loadTimes = function() {
|
||||
return {
|
||||
commitLoadTime: Date.now() / 1000,
|
||||
connectionInfo: 'h2',
|
||||
finishDocumentLoadTime: Date.now() / 1000,
|
||||
finishLoadTime: Date.now() / 1000,
|
||||
firstPaintAfterLoadTime: 0,
|
||||
firstPaintTime: Date.now() / 1000,
|
||||
navigationType: 'Other',
|
||||
npnNegotiatedProtocol: 'h2',
|
||||
requestTime: Date.now() / 1000 - 0.16,
|
||||
startLoadTime: Date.now() / 1000 - 0.3,
|
||||
wasAlternateProtocolAvailable: false,
|
||||
wasFetchedViaSpdy: true,
|
||||
wasNpnNegotiated: true
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
// Why: Electron's Permission API defaults to 'denied' for most permissions,
|
||||
// but real Chrome returns 'prompt' for ungranted permissions. Returning
|
||||
// 'denied' is a strong bot signal. Override the query result for common
|
||||
// permissions that Turnstile and similar detectors probe.
|
||||
var notificationPermission = 'default';
|
||||
var setNotificationPermission = function(permission) {
|
||||
if (permission === 'granted' || permission === 'denied') {
|
||||
notificationPermission = permission;
|
||||
return permission;
|
||||
}
|
||||
notificationPermission = 'default';
|
||||
return 'default';
|
||||
};
|
||||
var notificationPermissionState = function() {
|
||||
return notificationPermission === 'default' ? 'prompt' : notificationPermission;
|
||||
};
|
||||
try {
|
||||
if (Notification.permission === 'granted') {
|
||||
notificationPermission = 'granted';
|
||||
}
|
||||
} catch {}
|
||||
const promptPerms = new Set([
|
||||
'camera', 'microphone'
|
||||
]);
|
||||
const origQuery = Permissions.prototype.query;
|
||||
// Why: sites must receive the genuine PermissionStatus so native events, brand checks and method
|
||||
// identity survive. Shadow only state, and resolve it lazily so existing statuses stay current.
|
||||
function withOverriddenState(realStatus, stateProvider) {
|
||||
Object.defineProperty(realStatus, 'state', {
|
||||
configurable: true,
|
||||
get: stateProvider
|
||||
});
|
||||
return realStatus;
|
||||
}
|
||||
// Why: some names the real implementation rejects outright; fall back to an EventTarget so
|
||||
// listener registration still works instead of throwing.
|
||||
function fallbackStatus(stateProvider) {
|
||||
const status = new EventTarget();
|
||||
Object.defineProperties(status, {
|
||||
state: { configurable: true, get: stateProvider },
|
||||
onchange: { configurable: true, value: null, writable: true }
|
||||
});
|
||||
return status;
|
||||
}
|
||||
function queryWithState(permissions, desc, stateProvider) {
|
||||
let real;
|
||||
try {
|
||||
real = origQuery.call(permissions, desc);
|
||||
} catch {
|
||||
return Promise.resolve(fallbackStatus(stateProvider));
|
||||
}
|
||||
return Promise.resolve(real).then(
|
||||
(status) => withOverriddenState(status, stateProvider),
|
||||
() => fallbackStatus(stateProvider)
|
||||
);
|
||||
}
|
||||
Permissions.prototype.query = function(desc) {
|
||||
if (desc.name === 'notifications') {
|
||||
return queryWithState(this, desc, notificationPermissionState);
|
||||
}
|
||||
if (promptPerms.has(desc.name)) {
|
||||
return queryWithState(this, desc, () => 'prompt');
|
||||
}
|
||||
return origQuery.call(this, desc);
|
||||
};
|
||||
// Why: Electron may report Notification.permission as 'denied' by default
|
||||
// whereas real Chrome reports 'default' for sites that haven't been granted
|
||||
// or blocked. Turnstile cross-references this with the Permissions API.
|
||||
try {
|
||||
Object.defineProperty(Notification, 'permission', {
|
||||
get: () => notificationPermission
|
||||
});
|
||||
const origRequestPermission = Notification.requestPermission;
|
||||
if (typeof origRequestPermission === 'function') {
|
||||
Notification.requestPermission = function(callback) {
|
||||
var wrappedCallback = typeof callback === 'function'
|
||||
? function(permission) {
|
||||
callback(setNotificationPermission(permission));
|
||||
}
|
||||
: undefined;
|
||||
var result = origRequestPermission.call(Notification, wrappedCallback);
|
||||
if (result && typeof result.then === 'function') {
|
||||
return result.then(function(permission) {
|
||||
return setNotificationPermission(permission);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
// Why: Electron webviews may have an empty languages array. Real Chrome
|
||||
// always has at least one entry. An empty array is an automation signal.
|
||||
if (!navigator.languages || navigator.languages.length === 0) {
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['en-US', 'en']
|
||||
});
|
||||
}
|
||||
})()`
|
||||
@@ -1,12 +1,12 @@
|
||||
// Why: Google binds a signed-in session to the browser identity that created it.
|
||||
// Cookies copied in from another browser (or sent under an Electron/Chrome-shaped
|
||||
// UA that doesn't match a real first-party browser) get flagged by anti-fraud on
|
||||
// accounts.google.com and expire within ~1h. Presenting a Firefox identity scoped
|
||||
// Cookies copied in from another browser (or sent under a UA that doesn't match a
|
||||
// real first-party browser) get flagged by anti-fraud on accounts.google.com and
|
||||
// expire within ~1h. Presenting a Firefox identity scoped
|
||||
// to Google's auth hosts lets the user sign in *inside* the embedded browser, so
|
||||
// Google issues cookies bound to THIS browser that self-refresh — instead of us
|
||||
// transplanting cookies that go stale. Scope is deliberately the auth hosts only:
|
||||
// post-auth app surfaces (mail.google.com, myaccount.google.com, drive, etc.) keep
|
||||
// the profile's real Chrome-shaped identity so nothing else about the session shifts.
|
||||
// the profile's real identity so nothing else about the session shifts.
|
||||
|
||||
// Why: exact hostname match — subdomains such as myaccount.google.com are post-auth
|
||||
// app surfaces, not the sign-in flow, and must retain the profile's real identity.
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
import {
|
||||
createViewportGuestFactory,
|
||||
flushViewportOps,
|
||||
GUEST_CLEAN_UA
|
||||
GUEST_ELECTRON_UA
|
||||
} from './browser-manager-viewport-test-fixtures'
|
||||
|
||||
const {
|
||||
@@ -197,8 +197,9 @@ describe('browserManager', () => {
|
||||
|
||||
// Why: popup child windows get attachGuestPolicies but are never entered into tabIdByWebContentsId,
|
||||
// so a direct lookup of the UA mode misses the native opt-out. That is worse than doing nothing —
|
||||
// native sessions skip setupClientHintsOverride, so the popup would send the raw Electron UA on the
|
||||
// wire while navigator.userAgent claimed Firefox. Google sign-in popups are a first-class surface.
|
||||
// native sessions never install the header-level Firefox switch, so the popup would send the
|
||||
// Electron UA on the wire while navigator.userAgent claimed Firefox. Google sign-in popups are a
|
||||
// first-class surface.
|
||||
it('leaves the UA untouched on auth hosts for a popup owned by a native-UA profile', () => {
|
||||
const ownerGuest = {
|
||||
id: 415,
|
||||
@@ -543,7 +544,7 @@ describe('browserManager', () => {
|
||||
)
|
||||
expect(uaWrites.length).toBeGreaterThan(0)
|
||||
for (const [, params] of uaWrites) {
|
||||
expect((params as { userAgent: string }).userAgent).toBe(GUEST_CLEAN_UA)
|
||||
expect((params as { userAgent: string }).userAgent).toBe(GUEST_ELECTRON_UA)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -637,9 +637,9 @@ describe('browserManager', () => {
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cancels pending anti-detection reattach timers when unregistering a guest', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
// Why: a plain browsing tab must never attach a debugger (Cloudflare treats CDP as a bot signal);
|
||||
// the only debugger wiring it keeps is the detach listener that invalidates the auth-host UA override.
|
||||
it('never attaches a debugger to a browsing guest and drops its detach listener on unregister', () => {
|
||||
const debuggerHandlers = new Map<string, () => void>()
|
||||
const debuggerAttachMock = vi.fn()
|
||||
const guest = {
|
||||
@@ -670,18 +670,17 @@ describe('browserManager', () => {
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-reattach',
|
||||
browserPageId: 'browser-no-debugger',
|
||||
webContentsId: 809,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
debuggerHandlers.get('detach')?.()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
expect(debuggerAttachMock).not.toHaveBeenCalled()
|
||||
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
|
||||
expect(debuggerHandlers.has('detach')).toBe(true)
|
||||
|
||||
browserManager.unregisterGuest('browser-reattach')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(debuggerAttachMock).toHaveBeenCalledTimes(1)
|
||||
browserManager.unregisterGuest('browser-no-debugger')
|
||||
expect(debuggerHandlers.has('detach')).toBe(false)
|
||||
expect(debuggerAttachMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,6 +52,8 @@ type GuestFake = {
|
||||
isAttached: () => boolean
|
||||
attach: ReturnType<typeof vi.fn>
|
||||
sendCommand: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
off: ReturnType<typeof vi.fn>
|
||||
}
|
||||
on: (event: string, listener: (...args: never[]) => void) => void
|
||||
once: (event: string, listener: (...args: never[]) => void) => void
|
||||
@@ -81,7 +83,9 @@ function createGuest(id: number, url: string): GuestFake {
|
||||
debugger: {
|
||||
isAttached: () => true,
|
||||
attach: vi.fn(),
|
||||
sendCommand: vi.fn(async () => undefined)
|
||||
sendCommand: vi.fn(async () => undefined),
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
},
|
||||
on: (event, listener) => {
|
||||
listeners.set(event, [...(listeners.get(event) ?? []), listener])
|
||||
@@ -143,7 +147,7 @@ describe('guest policy profiles', () => {
|
||||
// The presence half of every absence below: a browsing guest observably takes all of it through
|
||||
// the same method, so a profile that fenced nothing — or an attach path that stopped installing
|
||||
// anything at all — cannot pass these by being uniformly empty.
|
||||
it('gives a browsing guest link routing, popups and anti-detection', () => {
|
||||
it('gives a browsing guest link routing, popups and auth-identity detach tracking', () => {
|
||||
const guest = createGuest(300, 'https://example.com/')
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
@@ -151,7 +155,10 @@ describe('guest policy profiles', () => {
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(1)
|
||||
expect(listenerCount(guest, 'frame-created')).toBe(1)
|
||||
expect(listenerCount(guest, 'did-create-window')).toBe(1)
|
||||
expect(guest.debugger.sendCommand).toHaveBeenCalled()
|
||||
expect(guest.debugger.on).toHaveBeenCalledWith('detach', expect.any(Function))
|
||||
// Why: a plain browsing tab must never attach a debugger; Cloudflare treats CDP as a bot signal.
|
||||
expect(guest.debugger.attach).not.toHaveBeenCalled()
|
||||
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
|
||||
expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -161,6 +168,7 @@ describe('guest policy profiles', () => {
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(0)
|
||||
expect(listenerCount(guest, 'frame-created')).toBe(0)
|
||||
expect(listenerCount(guest, 'did-create-window')).toBe(0)
|
||||
expect(guest.debugger.on).not.toHaveBeenCalled()
|
||||
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -35,8 +35,7 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean
|
||||
this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName)
|
||||
}
|
||||
|
||||
// Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes.
|
||||
const disposeAntiDetection = this.injectAntiDetection(guest)
|
||||
const disposeAuthDetachTracking = this.trackDebuggerDetachForAuthUserAgent(guest)
|
||||
// Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty.
|
||||
guest.setBackgroundThrottling(false)
|
||||
const disposePopupPolicy = this.installGuestPopupPolicy(guest, clickedLinkFrameName)
|
||||
@@ -44,14 +43,14 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean
|
||||
|
||||
// Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC.
|
||||
this.policyCleanupByGuestId.set(guest.id, () => {
|
||||
disposeAntiDetection()
|
||||
disposeAuthDetachTracking()
|
||||
disposePopupPolicy()
|
||||
disposeNavigationPolicy()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A workspace document is not the web: no popups, no link routing, no anti-detection, and no
|
||||
* A workspace document is not the web: no popups, no link routing, no auth-identity tracking, and no
|
||||
* navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is
|
||||
* this method's teardown, so a retired preview drops its listeners on the same path.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window'
|
||||
import { cleanElectronUserAgent } from './browser-session-ua'
|
||||
import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
|
||||
import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua'
|
||||
import { buildViewportUserAgentOverride } from './browser-viewport-user-agent'
|
||||
@@ -12,10 +11,10 @@ import { BrowserManagerVisibility } from './browser-manager-visibility'
|
||||
|
||||
export abstract class BrowserManagerNavigation extends BrowserManagerVisibility {
|
||||
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
|
||||
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
|
||||
// not the request header, so the header-level Firefox switch in setupGoogleAuthUserAgentOverride
|
||||
// must be matched here per navigation or the two layers disagree — itself a bot tell.
|
||||
// Restores the session's base identity off the auth hosts. Native-UA profiles opt out
|
||||
// of the whole clean-UA path, so they keep their untouched identity everywhere.
|
||||
// of the Firefox switch, so they keep their untouched identity everywhere.
|
||||
protected applyGoogleAuthUserAgent(
|
||||
guest: Electron.WebContents,
|
||||
url: string,
|
||||
@@ -24,8 +23,8 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility
|
||||
const browserPageId = this.tabIdByWebContentsId.get(guest.id)
|
||||
// Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct
|
||||
// lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA.
|
||||
// That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so
|
||||
// the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox.
|
||||
// That is worse than doing nothing: native sessions never install the header-level Firefox
|
||||
// switch, so the popup would send the Electron UA on the wire while navigator.userAgent claims Firefox.
|
||||
const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id)
|
||||
// Session state is authoritative before renderer registration and after a native profile imports a source UA.
|
||||
const mode =
|
||||
@@ -56,15 +55,14 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility
|
||||
// navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain
|
||||
// cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without
|
||||
// touching the navigation, and it outranks the WebContents UA from then on, so a guest that
|
||||
// switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride
|
||||
// rewrites User-Agent per request for auth-host URLs on its own.
|
||||
// switches to it stays on it. The wire UA never depended on this write:
|
||||
// setupGoogleAuthUserAgentOverride rewrites User-Agent per request for auth-host URLs on its own.
|
||||
if (options.duringRedirect === true || overrideState !== undefined) {
|
||||
if (this.canOverrideUserAgentOverCdp(guest)) {
|
||||
authOverrideIssuedOverCdp = true
|
||||
// Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers
|
||||
// resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off
|
||||
// them, any mobile preset preserved. Writing the session UA directly would put the
|
||||
// unlaundered Electron token back on the wire.
|
||||
// resolve one identity for this URL — Firefox on auth hosts, the session's base identity
|
||||
// off them, any mobile preset preserved.
|
||||
void this.applyAuthUserAgentOverrideOverCdp(
|
||||
guest,
|
||||
(browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ??
|
||||
@@ -188,7 +186,7 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility
|
||||
|
||||
// Why: Emulation.setUserAgentOverride is set once and stands across every later navigation,
|
||||
// outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an
|
||||
// auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the
|
||||
// auth host would otherwise pin navigator.userAgent to the session's preset UA while the
|
||||
// request header says Firefox — the two-layer disagreement this scope exists to remove.
|
||||
protected reapplyViewportUserAgentOverride(
|
||||
guest: Electron.WebContents,
|
||||
@@ -222,7 +220,7 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility
|
||||
// Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not:
|
||||
// applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to
|
||||
// the CDP override, so reading it back here would republish that identity on ordinary hosts.
|
||||
baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent())
|
||||
baseUserAgent: baseUserAgent ?? guest.session.getUserAgent()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
import { BrowserGrabSessionController } from './browser-grab-session-controller'
|
||||
import type { BrowserCertificateTrustController } from './browser-certificate-trust-controller'
|
||||
import {
|
||||
@@ -190,56 +189,19 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt
|
||||
this.settingsResolver = resolver
|
||||
}
|
||||
|
||||
// Why: addScriptToEvaluateOnNewDocument (CDP) is the only reliable pre-page-script hook per nav; executeJavaScript ran on the old page context.
|
||||
protected injectAntiDetection(guest: Electron.WebContents): () => void {
|
||||
let disposed = false
|
||||
let reattachTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const attach = (): void => {
|
||||
if (disposed || guest.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!guest.debugger.isAttached()) {
|
||||
guest.debugger.attach('1.3')
|
||||
}
|
||||
void guest.debugger
|
||||
.sendCommand('Page.enable', {})
|
||||
.then(() =>
|
||||
guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', {
|
||||
source: ANTI_DETECTION_SCRIPT
|
||||
})
|
||||
)
|
||||
.catch(() => {})
|
||||
} catch {
|
||||
/* best-effort — debugger may be unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
// Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides.
|
||||
// Why: a debugger detach clears every CDP override Chromium holds, including the Google auth-host
|
||||
// UA override, so the confirmed-override record must be dropped or the next auth navigation
|
||||
// believes the identity is still installed and skips the write.
|
||||
protected trackDebuggerDetachForAuthUserAgent(guest: Electron.WebContents): () => void {
|
||||
const onDetach = (): void => {
|
||||
this.authUserAgentOverrideStateByGuestId.delete(guest.id)
|
||||
if (!disposed && !guest.isDestroyed() && reattachTimer === null) {
|
||||
reattachTimer = setTimeout(() => {
|
||||
reattachTimer = null
|
||||
attach()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
attach()
|
||||
guest.debugger.on('detach', onDetach)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
/* debugger may be unavailable */
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (reattachTimer !== null) {
|
||||
clearTimeout(reattachTimer)
|
||||
reattachTimer = null
|
||||
}
|
||||
try {
|
||||
guest.debugger.off('detach', onDetach)
|
||||
} catch {
|
||||
|
||||
@@ -117,7 +117,7 @@ export type PopupOwnerContext = {
|
||||
|
||||
/**
|
||||
* What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and
|
||||
* anti-detection all apply. A workspace-document guest renders one granted document and gets none
|
||||
* auth-identity tracking all apply. A workspace-document guest renders one granted document and gets none
|
||||
* of that; `host` is the renderer that minted its grant, and the only sink for what it reports.
|
||||
*/
|
||||
export type BrowserGuestPolicy =
|
||||
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
import {
|
||||
createViewportGuestFactory,
|
||||
flushViewportOps,
|
||||
GUEST_CLEAN_UA,
|
||||
GUEST_ELECTRON_UA
|
||||
} from './browser-manager-viewport-test-fixtures'
|
||||
|
||||
@@ -207,7 +206,7 @@ describe('browserManager', () => {
|
||||
mobile: false
|
||||
})
|
||||
expect(debuggerSendCommand).toHaveBeenLastCalledWith('Emulation.setUserAgentOverride', {
|
||||
userAgent: GUEST_CLEAN_UA
|
||||
userAgent: GUEST_ELECTRON_UA
|
||||
})
|
||||
|
||||
// Navigating to the auth host must move the standing override to the Firefox identity.
|
||||
@@ -218,11 +217,11 @@ describe('browserManager', () => {
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
|
||||
// Leaving the auth host restores the clean Chrome-shaped preset UA.
|
||||
// Leaving the auth host restores the session's own preset UA.
|
||||
debuggerSendCommand.mockClear()
|
||||
willRedirect({ preventDefault: vi.fn() }, 'https://example.com/', false, true)
|
||||
await flushViewportOps()
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
|
||||
})
|
||||
|
||||
// Why: not an ordering race — debugger.sendCommand dispatches in call order over one channel, so
|
||||
@@ -241,9 +240,9 @@ describe('browserManager', () => {
|
||||
}
|
||||
|
||||
// Why mobile: on the desktop branch the break is masked by coincidence — applyGoogleAuthUserAgent
|
||||
// has already switched the WebContents UA to Firefox, and cleanElectronUserAgent passes a Firefox
|
||||
// UA through untouched, so the stale-URL desktop path happens to emit Firefox anyway. The mobile
|
||||
// branch derives a Chrome-shaped iPhone UA from that same base and exposes the real defect.
|
||||
// has already switched the WebContents UA to Firefox, so the stale-URL desktop path happens to
|
||||
// emit Firefox anyway. The mobile branch derives a Chrome-shaped iPhone UA from the session base
|
||||
// and exposes the real defect.
|
||||
it('does not leave the Chrome preset UA standing when a mobile preset lands mid-navigation onto an auth host', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4251, 'https://example.com/')
|
||||
// Hold the preset's first CDP command open so the navigation lands inside its await window.
|
||||
@@ -332,7 +331,7 @@ describe('browserManager', () => {
|
||||
|
||||
// Without the fix the resuming preset re-reads getURL() as the auth host and clobbers the
|
||||
// navigation's correct write, stranding the Firefox UA on a non-auth page.
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
|
||||
})
|
||||
|
||||
it('falls back to the committed URL once a navigation commits or fails', async () => {
|
||||
@@ -378,7 +377,7 @@ describe('browserManager', () => {
|
||||
await flushViewportOps()
|
||||
|
||||
expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA)
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
|
||||
|
||||
// A later preset must also resolve the committed, non-auth URL.
|
||||
debuggerSendCommand.mockClear()
|
||||
@@ -457,7 +456,7 @@ describe('browserManager', () => {
|
||||
expect(guest.setUserAgent).not.toHaveBeenCalled()
|
||||
expect(debuggerSendCommand).not.toHaveBeenCalledWith(
|
||||
'Emulation.setUserAgentOverride',
|
||||
expect.objectContaining({ userAgent: GUEST_CLEAN_UA })
|
||||
expect.objectContaining({ userAgent: GUEST_ELECTRON_UA })
|
||||
)
|
||||
})
|
||||
|
||||
@@ -517,7 +516,7 @@ describe('browserManager', () => {
|
||||
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/redirected', true)
|
||||
await flushViewportOps()
|
||||
expect(guest.setUserAgent).not.toHaveBeenCalled()
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
|
||||
})
|
||||
|
||||
it('preserves the auth identity when a viewport preset is cleared after a redirect', async () => {
|
||||
@@ -592,7 +591,7 @@ describe('browserManager', () => {
|
||||
didStartNavigation(null, 'https://example.com/', false, true)
|
||||
await flushViewportOps()
|
||||
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
|
||||
})
|
||||
|
||||
it('reapplies a preset when navigation starts during its final UA write', async () => {
|
||||
@@ -849,8 +848,7 @@ describe('browserManager', () => {
|
||||
|
||||
expect(debuggerAttach).toHaveBeenCalledWith('1.3')
|
||||
expect(debuggerSendCommand).toHaveBeenCalled()
|
||||
// Why: detaching would clear Page.addScriptToEvaluateOnNewDocument
|
||||
// (anti-detection). Guard regression.
|
||||
// Why: detaching would clear every standing CDP override (viewport, auth UA). Guard regression.
|
||||
expect((guest.debugger as { detach?: unknown }).detach ?? undefined).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { BrowserManagerMocks } from './browser-manager-test-harness'
|
||||
|
||||
export const GUEST_ELECTRON_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36'
|
||||
export const GUEST_CLEAN_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
|
||||
|
||||
// Why: viewport UA writes are queued on the per-tab chain, so draining it takes more than one
|
||||
// microtask hop; loop until the chain is empty rather than guessing a tick count.
|
||||
@@ -53,7 +51,9 @@ export function createViewportGuestFactory(
|
||||
debugger: {
|
||||
isAttached: debuggerIsAttached,
|
||||
attach: debuggerAttach,
|
||||
sendCommand: debuggerSendCommand
|
||||
sendCommand: debuggerSendCommand,
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -30,7 +30,7 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: emulate viewport via CDP; never detach the debugger here or per-guest overrides (addScriptToEvaluateOnNewDocument) are cleared.
|
||||
// Why: emulate viewport via CDP; never detach the debugger here or the agent bridge's per-guest state is cleared.
|
||||
async setViewportOverride(
|
||||
browserTabId: string,
|
||||
override: BrowserViewportOverride | null
|
||||
|
||||
@@ -82,8 +82,7 @@ vi.mock('./browser-media-access', () => ({
|
||||
requestSystemMediaAccess: async () => false
|
||||
}))
|
||||
vi.mock('./browser-session-ua', () => ({
|
||||
cleanElectronUserAgent: (userAgent: string) => userAgent,
|
||||
setupClientHintsOverride: vi.fn()
|
||||
setupGoogleAuthUserAgentOverride: vi.fn()
|
||||
}))
|
||||
vi.mock('./browser-session-user-agent-mode', () => ({
|
||||
setBrowserSessionUserAgentMode: vi.fn()
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from './browser-session-proxy'
|
||||
import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access'
|
||||
import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy'
|
||||
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
|
||||
import { setupGoogleAuthUserAgentOverride } from './browser-session-ua'
|
||||
import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
|
||||
import {
|
||||
allowsBrowserWebAuthnPermission,
|
||||
@@ -92,10 +92,8 @@ export function installBrowserSessionPartitionPolicies(
|
||||
}
|
||||
|
||||
browserManager.installCertificateRequestGuard(sess)
|
||||
if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') {
|
||||
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
|
||||
sess.setUserAgent(cleanUA)
|
||||
setupClientHintsOverride(sess, cleanUA)
|
||||
if (profile.userAgentMode !== 'native') {
|
||||
setupGoogleAuthUserAgentOverride(sess)
|
||||
}
|
||||
if (options?.permissions === 'deny') {
|
||||
sess.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false))
|
||||
@@ -191,11 +189,7 @@ export function applyBrowserSessionUserAgentModes(profiles: BrowserSessionProfil
|
||||
if (profile.userAgentMode === 'native') {
|
||||
continue
|
||||
}
|
||||
|
||||
// Why: the default Electron UA leaks "Electron/X.X.X" + app name, which trips Cloudflare Turnstile.
|
||||
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
|
||||
sess.setUserAgent(cleanUA)
|
||||
setupClientHintsOverride(sess, cleanUA)
|
||||
setupGoogleAuthUserAgentOverride(sess)
|
||||
} catch {
|
||||
/* session not available yet (e.g. unit tests or pre-ready) */
|
||||
}
|
||||
|
||||
@@ -44,8 +44,7 @@ vi.mock('./browser-media-access', () => ({
|
||||
requestSystemMediaAccess: vi.fn(async () => false)
|
||||
}))
|
||||
vi.mock('./browser-session-ua', () => ({
|
||||
cleanElectronUserAgent: vi.fn((ua: string) => ua),
|
||||
setupClientHintsOverride: vi.fn()
|
||||
setupGoogleAuthUserAgentOverride: vi.fn()
|
||||
}))
|
||||
vi.mock('./browser-session-user-agent-mode', () => ({
|
||||
setBrowserSessionUserAgentMode: vi.fn(),
|
||||
|
||||
@@ -27,7 +27,7 @@ function installModuleMocks(
|
||||
copyFailures = new Set<string>()
|
||||
): {
|
||||
sessionFromPartitionMock: ReturnType<typeof vi.fn>
|
||||
setupClientHintsOverrideMock: ReturnType<typeof vi.fn>
|
||||
setupGoogleAuthUserAgentOverrideMock: ReturnType<typeof vi.fn>
|
||||
browserManagerHandleGuestWillDownloadMock: ReturnType<typeof vi.fn>
|
||||
browserManagerNotifyPermissionDeniedMock: ReturnType<typeof vi.fn>
|
||||
requestSystemMediaAccessMock: ReturnType<typeof vi.fn>
|
||||
@@ -36,6 +36,7 @@ function installModuleMocks(
|
||||
partition,
|
||||
setUserAgent: vi.fn(),
|
||||
getUserAgent: vi.fn(() => 'Mozilla/5.0 Electron/31 Orca'),
|
||||
webRequest: { onBeforeSendHeaders: vi.fn() },
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
setDevicePermissionHandler: vi.fn(),
|
||||
@@ -45,7 +46,7 @@ function installModuleMocks(
|
||||
clearStorageData: vi.fn().mockResolvedValue(undefined),
|
||||
clearCache: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
const setupClientHintsOverrideMock = vi.fn()
|
||||
const setupGoogleAuthUserAgentOverrideMock = vi.fn()
|
||||
const browserManagerHandleGuestWillDownloadMock = vi.fn()
|
||||
const browserManagerNotifyPermissionDeniedMock = vi.fn()
|
||||
const requestSystemMediaAccessMock = vi.fn().mockResolvedValue(true)
|
||||
@@ -119,8 +120,7 @@ function installModuleMocks(
|
||||
requestSystemMediaAccess: requestSystemMediaAccessMock
|
||||
}))
|
||||
vi.doMock('./browser-session-ua', () => ({
|
||||
cleanElectronUserAgent: vi.fn((ua: string) => ua.replace(/\s*Electron\/\S+/, '')),
|
||||
setupClientHintsOverride: setupClientHintsOverrideMock
|
||||
setupGoogleAuthUserAgentOverride: setupGoogleAuthUserAgentOverrideMock
|
||||
}))
|
||||
// This suite models replay with an in-memory filesystem. The real file-backed SQLite merge has
|
||||
// dedicated coverage; these fixtures are legacy unmarked images and keep the copy path.
|
||||
@@ -149,7 +149,7 @@ function installModuleMocks(
|
||||
|
||||
return {
|
||||
sessionFromPartitionMock,
|
||||
setupClientHintsOverrideMock,
|
||||
setupGoogleAuthUserAgentOverrideMock,
|
||||
browserManagerHandleGuestWillDownloadMock,
|
||||
browserManagerNotifyPermissionDeniedMock,
|
||||
requestSystemMediaAccessMock
|
||||
@@ -234,21 +234,24 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps UA cleaning as the fallback for profiles without an override', async () => {
|
||||
// Why: the stock Electron UA is what clears Cloudflare; only the Google auth switch installs.
|
||||
it('keeps the stock UA and installs the Google auth switch for profiles without an override', async () => {
|
||||
const fsState = createFsState()
|
||||
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
|
||||
const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } =
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
await browserSessionRegistry.createProfile('isolated', 'Default identity')
|
||||
|
||||
const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value
|
||||
expect(profileSession.setUserAgent).toHaveBeenCalledWith('Mozilla/5.0 Orca')
|
||||
expect(setupClientHintsOverrideMock).toHaveBeenCalledWith(profileSession, 'Mozilla/5.0 Orca')
|
||||
expect(profileSession.setUserAgent).not.toHaveBeenCalled()
|
||||
expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalledWith(profileSession)
|
||||
})
|
||||
|
||||
it('leaves UA and client hints untouched for native-mode profiles', async () => {
|
||||
const fsState = createFsState()
|
||||
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
|
||||
const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } =
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
await browserSessionRegistry.createProfile('isolated', 'Google', { userAgentMode: 'native' })
|
||||
@@ -256,7 +259,7 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value
|
||||
const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode')
|
||||
expect(profileSession.setUserAgent).not.toHaveBeenCalled()
|
||||
expect(setupClientHintsOverrideMock).not.toHaveBeenCalled()
|
||||
expect(setupGoogleAuthUserAgentOverrideMock).not.toHaveBeenCalled()
|
||||
expect(getBrowserSessionUserAgentMode(profileSession as never)).toBe('native')
|
||||
})
|
||||
|
||||
@@ -379,7 +382,7 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
// Why: imports before Aug 2026 persisted a synthesized source-browser UA
|
||||
// (fork imports as a broken Chrome/1.x, Chrome imports as a valid version).
|
||||
// Neither may ever be applied again — the engine-derived UA is the only one.
|
||||
it('ignores legacy persisted UAs, valid or broken, and applies the engine UA', async () => {
|
||||
it('ignores legacy persisted UAs, valid or broken, and keeps the engine UA', async () => {
|
||||
const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111'
|
||||
const brokenUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1.158.1 Safari/537.36'
|
||||
@@ -405,7 +408,8 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
|
||||
const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } =
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
|
||||
@@ -413,16 +417,9 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
const appliedUas = sessionFromPartitionMock.mock.results.flatMap((r) =>
|
||||
r.value.setUserAgent.mock.calls.map((c: unknown[]) => c[0])
|
||||
)
|
||||
expect(appliedUas).not.toContain(brokenUa)
|
||||
expect(appliedUas).not.toContain(validUa)
|
||||
// Why: every non-native profile falls to Orca's own cleaned engine UA.
|
||||
expect(appliedUas.length).toBeGreaterThan(0)
|
||||
expect(appliedUas.every((ua) => ua === 'Mozilla/5.0 Orca')).toBe(true)
|
||||
expect(
|
||||
setupClientHintsOverrideMock.mock.calls.every(
|
||||
(c: unknown[]) => c[1] !== brokenUa && c[1] !== validUa
|
||||
)
|
||||
).toBe(true)
|
||||
// Why: no persisted UA is ever written back; every profile keeps the engine's stock UA.
|
||||
expect(appliedUas).toEqual([])
|
||||
expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never applies a legacy persisted UA to a native-mode profile', async () => {
|
||||
@@ -487,7 +484,8 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
|
||||
const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } =
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
|
||||
@@ -498,7 +496,7 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
expect(importedSessions.length).toBeGreaterThan(0)
|
||||
expect(importedSessions.every((sess) => sess.setUserAgent.mock.calls.length === 0)).toBe(true)
|
||||
expect(
|
||||
setupClientHintsOverrideMock.mock.calls.some(
|
||||
setupGoogleAuthUserAgentOverrideMock.mock.calls.some(
|
||||
([sess]) => (sess as { partition?: string }).partition === importedPartition
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
@@ -33,7 +33,7 @@ vi.mock('./browser-manager', () => ({
|
||||
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
import { googleAuthUserAgent } from './browser-google-auth-ua'
|
||||
import { setupClientHintsOverride } from './browser-session-ua'
|
||||
import { setupGoogleAuthUserAgentOverride } from './browser-session-ua'
|
||||
import { setBrowserNetworkProxySettingsResolver } from './browser-session-proxy'
|
||||
import { handleElectronProxyLogin } from '../network/electron-proxy-credentials'
|
||||
import { applyProxySettingsToSession } from '../network/proxy-settings'
|
||||
@@ -54,6 +54,7 @@ describe('BrowserSessionRegistry', () => {
|
||||
askForMediaAccessMock.mockResolvedValue(true)
|
||||
getMediaAccessStatusMock.mockReturnValue('granted')
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
webRequest: { onBeforeSendHeaders: vi.fn() },
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
setDevicePermissionHandler: vi.fn(),
|
||||
@@ -528,80 +529,46 @@ describe('BrowserSessionRegistry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupClientHintsOverride', () => {
|
||||
it('overrides sec-ch-ua headers for Edge UA', () => {
|
||||
describe('setupGoogleAuthUserAgentOverride', () => {
|
||||
const STOCK_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/147.0.6890.3 Electron/43.0.0 Safari/537.36'
|
||||
|
||||
function install(): (details: unknown, callback: ReturnType<typeof vi.fn>) => void {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
const edgeUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36 Edg/147.0.3210.5'
|
||||
|
||||
setupClientHintsOverride(mockSess, edgeUa)
|
||||
|
||||
setupGoogleAuthUserAgentOverride({ webRequest: { onBeforeSendHeaders } } as never)
|
||||
expect(onBeforeSendHeaders).toHaveBeenCalledWith(
|
||||
{ urls: ['https://*/*'] },
|
||||
expect.any(Function)
|
||||
)
|
||||
return onBeforeSendHeaders.mock.calls[0][1]
|
||||
}
|
||||
|
||||
// Why: the Electron token is what clears Cloudflare Turnstile; a Chrome-shaped UA with no
|
||||
// client hints is what it rejects, so ordinary hosts must see the session's UA untouched.
|
||||
it('leaves the stock Electron UA and its client hints alone off the auth hosts', () => {
|
||||
const listener = install()
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener(
|
||||
{ requestHeaders: { 'sec-ch-ua': 'old', 'sec-ch-ua-full-version-list': 'old' } },
|
||||
{
|
||||
url: 'https://example.com/api',
|
||||
requestHeaders: { 'User-Agent': STOCK_UA, 'sec-ch-ua': 'old', Cookie: 'abc=123' }
|
||||
},
|
||||
callback
|
||||
)
|
||||
const modified = callback.mock.calls[0][0].requestHeaders
|
||||
expect(modified['sec-ch-ua']).toContain('Microsoft Edge')
|
||||
expect(modified['sec-ch-ua']).toContain('"147"')
|
||||
expect(modified['sec-ch-ua-full-version-list']).toContain('147.0.3210.5')
|
||||
})
|
||||
|
||||
it('overrides sec-ch-ua headers for Chrome UA', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
const chromeUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
|
||||
setupClientHintsOverride(mockSess, chromeUa)
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener({ requestHeaders: { 'sec-ch-ua': 'old' } }, callback)
|
||||
const modified = callback.mock.calls[0][0].requestHeaders
|
||||
expect(modified['sec-ch-ua']).toContain('Google Chrome')
|
||||
expect(modified['sec-ch-ua']).not.toContain('Microsoft Edge')
|
||||
})
|
||||
|
||||
it('registers handler even for non-Chrome UA but leaves sec-ch-ua untouched off auth hosts', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
|
||||
// Why: the Google-auth Firefox switch must install regardless of the base UA.
|
||||
setupClientHintsOverride(mockSess, 'Mozilla/5.0 (compatible; MSIE 10.0)')
|
||||
|
||||
expect(onBeforeSendHeaders).toHaveBeenCalledWith(
|
||||
{ urls: ['https://*/*'] },
|
||||
expect.any(Function)
|
||||
)
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener({ url: 'https://example.com/', requestHeaders: { 'sec-ch-ua': 'old' } }, callback)
|
||||
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toBe('old')
|
||||
expect(modified['User-Agent']).toBe(STOCK_UA)
|
||||
expect(modified['sec-ch-ua']).toBe('old')
|
||||
expect(modified.Cookie).toBe('abc=123')
|
||||
})
|
||||
|
||||
it('presents a Firefox UA and strips client hints on Google auth hosts', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
setupClientHintsOverride(
|
||||
mockSess,
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
)
|
||||
|
||||
const listener = install()
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener(
|
||||
{
|
||||
url: 'https://accounts.google.com/v3/signin/identifier',
|
||||
requestHeaders: {
|
||||
'User-Agent': 'Chrome/147',
|
||||
'User-Agent': STOCK_UA,
|
||||
'sec-ch-ua': 'old',
|
||||
'sec-ch-ua-full-version-list': 'old',
|
||||
'sec-ch-ua-platform': '"macOS"'
|
||||
@@ -610,7 +577,7 @@ describe('BrowserSessionRegistry', () => {
|
||||
callback
|
||||
)
|
||||
const modified = callback.mock.calls[0][0].requestHeaders
|
||||
expect(modified['User-Agent']).toMatch(/Firefox\/\d/)
|
||||
expect(modified['User-Agent']).toBe(googleAuthUserAgent())
|
||||
expect(modified['User-Agent']).not.toContain('Chrome')
|
||||
expect(modified['sec-ch-ua']).toBeUndefined()
|
||||
expect(modified['sec-ch-ua-full-version-list']).toBeUndefined()
|
||||
@@ -618,15 +585,8 @@ describe('BrowserSessionRegistry', () => {
|
||||
})
|
||||
|
||||
it('strips client hints on a cross-host request that carries the Firefox auth UA', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
setupClientHintsOverride(
|
||||
mockSess,
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
)
|
||||
|
||||
const listener = install()
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
// Subresource/XHR to a non-auth Google host while the auth document is on
|
||||
// screen: the WebContents Firefox UA leaks onto the request header.
|
||||
listener(
|
||||
@@ -651,99 +611,19 @@ describe('BrowserSessionRegistry', () => {
|
||||
expect(modified['sec-ch-ua-mobile']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the clean Chrome identity on cross-host requests that carry the Chrome UA', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
const chromeUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
setupClientHintsOverride(mockSess, chromeUa)
|
||||
|
||||
it('keeps the session identity on Google app subdomains (not auth hosts)', () => {
|
||||
const listener = install()
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
// Regression guard: non-Google sites (Cloudflare) must keep Chrome hints.
|
||||
listener(
|
||||
{
|
||||
url: 'https://example.com/api',
|
||||
requestHeaders: { 'User-Agent': chromeUa, 'sec-ch-ua': 'old' }
|
||||
},
|
||||
callback
|
||||
)
|
||||
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome')
|
||||
})
|
||||
|
||||
it('does not strip hints for the Firefox UA when googleAuthOverride is disabled', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
const chromeUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
setupClientHintsOverride(mockSess, chromeUa, { googleAuthOverride: false })
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener(
|
||||
{
|
||||
url: 'https://play.google.com/log',
|
||||
requestHeaders: { 'User-Agent': googleAuthUserAgent(), 'sec-ch-ua': 'old' }
|
||||
},
|
||||
callback
|
||||
)
|
||||
// Imported-native profiles never install the Firefox switch, so the strip
|
||||
// branch stays inert and hints are aligned to Chrome instead.
|
||||
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome')
|
||||
})
|
||||
|
||||
it('keeps Chrome client hints on Google app subdomains (not auth hosts)', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
setupClientHintsOverride(
|
||||
mockSess,
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
)
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener(
|
||||
{ url: 'https://myaccount.google.com/', requestHeaders: { 'sec-ch-ua': 'old' } },
|
||||
callback
|
||||
)
|
||||
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome')
|
||||
})
|
||||
|
||||
it('keeps an imported native UA on auth hosts while aligning its Chrome hints', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
const importedUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
setupClientHintsOverride(mockSess, importedUa, { googleAuthOverride: false })
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener(
|
||||
{
|
||||
url: 'https://accounts.google.com/v3/signin/identifier',
|
||||
requestHeaders: { 'User-Agent': importedUa, 'sec-ch-ua': 'old' }
|
||||
url: 'https://myaccount.google.com/',
|
||||
requestHeaders: { 'User-Agent': STOCK_UA, 'sec-ch-ua': 'old' }
|
||||
},
|
||||
callback
|
||||
)
|
||||
const modified = callback.mock.calls[0][0].requestHeaders
|
||||
expect(modified['User-Agent']).toBe(importedUa)
|
||||
expect(modified['sec-ch-ua']).toContain('Google Chrome')
|
||||
})
|
||||
|
||||
it('leaves non-Client-Hints headers unchanged', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
setupClientHintsOverride(mockSess, 'Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36')
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener(
|
||||
{ requestHeaders: { Cookie: 'abc=123', 'sec-ch-ua': 'old', Accept: 'text/html' } },
|
||||
callback
|
||||
)
|
||||
const modified = callback.mock.calls[0][0].requestHeaders
|
||||
expect(modified.Cookie).toBe('abc=123')
|
||||
expect(modified.Accept).toBe('text/html')
|
||||
expect(modified['User-Agent']).toBe(STOCK_UA)
|
||||
expect(modified['sec-ch-ua']).toBe('old')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
|
||||
// Why this runs a real Electron: Cloudflare Turnstile rejects a Chrome-shaped UA that ships no
|
||||
// client hints (error 600010) and clears a declared Electron client. The header layer is the
|
||||
// only place that identity can be proven, and the vm-based unit tests cannot see Chromium's
|
||||
// header emission at all. Every partition must therefore keep the stock Electron UA on the wire
|
||||
// for ordinary hosts and present the Firefox identity on Google's sign-in hosts only.
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of fixtureRoots) {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
// Retry once when Electron startup times out before `ready`; keep later failures fatal.
|
||||
const FIXTURE_LAUNCH_ATTEMPTS = 2
|
||||
|
||||
type CapturedRequest = {
|
||||
url: string
|
||||
userAgent: string | null
|
||||
clientHints: string[]
|
||||
}
|
||||
|
||||
type FixtureResult = {
|
||||
sessionUserAgent: string
|
||||
navigatorUserAgent: string
|
||||
requests: CapturedRequest[]
|
||||
}
|
||||
|
||||
function neverReachedElectronReady(fixtureResult: string): boolean {
|
||||
try {
|
||||
return (JSON.parse(fixtureResult) as { step?: string }).step === 'timed out after starting'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function buildFixtureMain(modulePath: string, resultPath: string): string {
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const { setupGoogleAuthUserAgentOverride } = require(${JSON.stringify(modulePath)})
|
||||
const resultPath = ${JSON.stringify(resultPath)}
|
||||
let currentStep = 'starting'
|
||||
const mark = (step) => {
|
||||
currentStep = step
|
||||
writeFileSync(resultPath, JSON.stringify({ step }))
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const timeout = setTimeout(() => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
|
||||
app.exit(1)
|
||||
}, 15000)
|
||||
await app.whenReady()
|
||||
mark('ready')
|
||||
const partition = 'persist:wire-identity-test'
|
||||
const sess = session.fromPartition(partition)
|
||||
setupGoogleAuthUserAgentOverride(sess)
|
||||
mark('auth switch installed')
|
||||
|
||||
// Why: onSendHeaders reports the headers exactly as they leave the network stack, after the
|
||||
// product's onBeforeSendHeaders listener has rewritten them. The requests must actually be
|
||||
// dispatched for it to fire, so the session is pointed at a proxy that refuses every
|
||||
// connection: nothing reaches the real hosts and every load fails fast.
|
||||
await sess.setProxy({ proxyRules: 'http://127.0.0.1:9', proxyBypassRules: '<-loopback>' })
|
||||
const requests = []
|
||||
sess.webRequest.onSendHeaders({ urls: ['https://*/*'] }, (details) => {
|
||||
const headers = details.requestHeaders || {}
|
||||
const uaKey = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent')
|
||||
requests.push({
|
||||
url: details.url,
|
||||
userAgent: uaKey ? headers[uaKey] : null,
|
||||
clientHints: Object.keys(headers)
|
||||
.filter((key) => key.toLowerCase().startsWith('sec-ch-ua'))
|
||||
.sort()
|
||||
})
|
||||
})
|
||||
|
||||
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
|
||||
mark('window created')
|
||||
for (const url of ['https://example.com/', 'https://accounts.google.com/v3/signin/identifier']) {
|
||||
await window.loadURL(url).catch(() => {})
|
||||
}
|
||||
mark('navigations attempted')
|
||||
const navigatorUserAgent = await window.webContents.executeJavaScript('navigator.userAgent')
|
||||
clearTimeout(timeout)
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
sessionUserAgent: sess.getUserAgent(),
|
||||
navigatorUserAgent,
|
||||
requests
|
||||
}))
|
||||
window.destroy()
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
|
||||
app.exit(1)
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
async function runFixture(): Promise<FixtureResult> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-wire-identity-'))
|
||||
fixtureRoots.push(root)
|
||||
const modulePath = join(root, 'browser-session-ua.cjs')
|
||||
const resultPath = join(root, 'result.json')
|
||||
const fixturePath = join(root, 'main.cjs')
|
||||
await buildVite({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: join(process.cwd(), 'src/main/browser/browser-session-ua.ts'),
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'browser-session-ua.cjs'
|
||||
},
|
||||
outDir: root,
|
||||
target: 'node20',
|
||||
rollupOptions: { external: ['electron', /^node:/] }
|
||||
}
|
||||
})
|
||||
writeFileSync(fixturePath, buildFixtureMain(modulePath, resultPath))
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
rmSync(resultPath, { force: true })
|
||||
// Why a fresh profile per attempt: a launch that never reached `ready` may have left the
|
||||
// Chromium profile mid-initialization, and reusing it would bias the retry.
|
||||
const electronArgs = [fixturePath, `--user-data-dir=${join(root, `profile-${attempt}`)}`]
|
||||
const run = spawnSync(
|
||||
executable,
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs,
|
||||
{ encoding: 'utf8', env, timeout: 60_000 }
|
||||
)
|
||||
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
if (attempt < FIXTURE_LAUNCH_ATTEMPTS && neverReachedElectronReady(fixtureResult)) {
|
||||
continue
|
||||
}
|
||||
expect(run.error).toBeUndefined()
|
||||
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
|
||||
return JSON.parse(fixtureResult) as FixtureResult
|
||||
}
|
||||
}
|
||||
|
||||
describe('browser session wire identity under Electron', () => {
|
||||
it('sends the stock Electron UA to ordinary hosts and Firefox to Google auth hosts', async () => {
|
||||
const result = await runFixture()
|
||||
|
||||
// Presence precondition: the stock identity still carries the Electron token that the old
|
||||
// Chrome-shaped rewrite stripped, so an identity check below cannot pass on an empty UA.
|
||||
expect(result.sessionUserAgent).toMatch(/ Electron\/\d/)
|
||||
|
||||
const ordinary = result.requests.find((request) => request.url === 'https://example.com/')
|
||||
expect(ordinary, JSON.stringify(result.requests)).toBeDefined()
|
||||
expect(ordinary?.userAgent).toBe(result.sessionUserAgent)
|
||||
expect(result.navigatorUserAgent).toBe(result.sessionUserAgent)
|
||||
|
||||
const auth = result.requests.find((request) =>
|
||||
request.url.startsWith('https://accounts.google.com/')
|
||||
)
|
||||
expect(auth, JSON.stringify(result.requests)).toBeDefined()
|
||||
expect(auth?.userAgent).toMatch(/Firefox\/\d/)
|
||||
expect(auth?.userAgent).not.toContain('Chrome')
|
||||
expect(auth?.clientHints).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -8,93 +8,28 @@ import {
|
||||
stripClientHints
|
||||
} from './browser-google-auth-ua'
|
||||
|
||||
// Why: Electron's default UA includes "Electron/X.X.X" and the app name
|
||||
// (e.g. "orca/1.2.3"), which Cloudflare Turnstile and other bot detectors
|
||||
// flag as non-human traffic. Strip those tokens so the webview's UA and
|
||||
// sec-ch-ua Client Hints look like standard Chrome.
|
||||
export function cleanElectronUserAgent(ua: string): string {
|
||||
return (
|
||||
ua
|
||||
.replace(/\s+Electron\/\S+/, '')
|
||||
// Why: \S+ matches any non-whitespace token (e.g. "orca/1.3.8-rc.0")
|
||||
// including pre-release semver strings that [\d.]+ would miss.
|
||||
.replace(/(\)\s+)\S+\s+(Chrome\/)/, '$1$2')
|
||||
)
|
||||
}
|
||||
|
||||
// Why: Electron emits sec-ch-ua brands like "Not A(Brand" without a
|
||||
// "Google Chrome" entry, which disagrees with the Chrome-shaped UA the session
|
||||
// presents. Rewrite the hint headers to the brand set Chrome ships for the same
|
||||
// engine version so the two surfaces tell one story. Also owns the Google
|
||||
// auth-host Firefox switch, which must install even for a non-Chrome-shaped UA.
|
||||
export function setupClientHintsOverride(
|
||||
sess: Session,
|
||||
ua: string,
|
||||
options: { googleAuthOverride?: boolean } = {}
|
||||
): void {
|
||||
// Why: only Chrome-shaped base UAs carry sec-ch-ua hints to rewrite, but the
|
||||
// Google-auth Firefox switch below must install regardless, so keep the hints
|
||||
// optional rather than bailing out of the whole handler.
|
||||
const chromeHints = buildChromeClientHints(ua)
|
||||
// Why: the session keeps Electron's stock UA. Stripping the Electron/app tokens to look like
|
||||
// plain Chrome is what Cloudflare Turnstile rejects (error 600010): a Chrome UA that ships no
|
||||
// client hints reads as a spoof, while a declared Electron client clears the same challenge.
|
||||
// This handler only owns the Google auth-host Firefox switch, which is a proven, host-scoped
|
||||
// exception that must stay consistent across the header and every cross-host subresource.
|
||||
export function setupGoogleAuthUserAgentOverride(sess: Session): void {
|
||||
const firefoxUa = googleAuthUserAgent()
|
||||
|
||||
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
|
||||
const headers = details.requestHeaders
|
||||
if (options.googleAuthOverride !== false && isGoogleAuthUrl(details.url)) {
|
||||
if (isGoogleAuthUrl(details.url)) {
|
||||
// Why: present a Firefox identity on Google's sign-in hosts so the user logs
|
||||
// in inside the app and Google issues self-refreshing bound cookies. Strip
|
||||
// sec-ch-ua* because real Firefox sends none.
|
||||
setUserAgentHeader(headers, firefoxUa)
|
||||
stripClientHints(headers)
|
||||
callback({ requestHeaders: headers })
|
||||
return
|
||||
}
|
||||
if (options.googleAuthOverride !== false && currentUserAgent(headers) === firefoxUa) {
|
||||
// Why: while the auth document is on screen the WebContents UA is Firefox,
|
||||
// so its cross-host subresource/XHR requests (gstatic, play.google.com, the
|
||||
// sign-in challenge endpoints) reach here carrying the Firefox UA yet still
|
||||
// bearing Chromium client hints. Rewriting those to Chrome pairs a Firefox
|
||||
// UA with Chrome hints — a sharper cross-host identity tell than either
|
||||
// alone, which can stall Google's password-submit challenge. Real Firefox
|
||||
// sends no client hints, so strip them to keep one identity for the flow.
|
||||
} else if (currentUserAgent(headers) === firefoxUa) {
|
||||
// Why: while the auth document is on screen the WebContents UA is Firefox, so its
|
||||
// cross-host subresource/XHR requests carry the Firefox UA yet still bear Chromium
|
||||
// client hints — a sharper cross-host identity tell than either alone.
|
||||
stripClientHints(headers)
|
||||
callback({ requestHeaders: headers })
|
||||
return
|
||||
}
|
||||
if (chromeHints) {
|
||||
for (const key of Object.keys(headers)) {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === 'sec-ch-ua') {
|
||||
headers[key] = chromeHints.secChUa
|
||||
} else if (lower === 'sec-ch-ua-full-version-list') {
|
||||
headers[key] = chromeHints.secChUaFull
|
||||
}
|
||||
}
|
||||
}
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
}
|
||||
|
||||
function buildChromeClientHints(ua: string): { secChUa: string; secChUaFull: string } | null {
|
||||
const chromeMatch = ua.match(/Chrome\/([\d.]+)/)
|
||||
if (!chromeMatch) {
|
||||
return null
|
||||
}
|
||||
const fullChromeVersion = chromeMatch[1]
|
||||
const majorVersion = fullChromeVersion.split('.')[0]
|
||||
|
||||
let brand = 'Google Chrome'
|
||||
let brandFullVersion = fullChromeVersion
|
||||
|
||||
const edgeMatch = ua.match(/Edg\/([\d.]+)/)
|
||||
if (edgeMatch) {
|
||||
brand = 'Microsoft Edge'
|
||||
brandFullVersion = edgeMatch[1]
|
||||
}
|
||||
const brandMajor = brandFullVersion.split('.')[0]
|
||||
|
||||
return {
|
||||
secChUa: `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`,
|
||||
secChUaFull: `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export type ViewportUserAgentOverride = {
|
||||
}
|
||||
|
||||
// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real
|
||||
// Chrome major spliced in to keep sec-ch-ua consistent (see setupClientHintsOverride).
|
||||
// Chrome major spliced in so the userAgentMetadata brands below agree with it.
|
||||
function buildMobileUserAgent(chromeMajor: string): string {
|
||||
return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1`
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export function buildViewportUserAgentOverride(args: {
|
||||
return { userAgent: googleAuthUserAgent() }
|
||||
}
|
||||
if (!args.mobile) {
|
||||
// Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session.
|
||||
// Why: desktop presets republish the session's own identity unchanged.
|
||||
return { userAgent: args.baseUserAgent }
|
||||
}
|
||||
const chromeMajor = extractChromeMajor(args.baseUserAgent)
|
||||
|
||||
@@ -48,7 +48,8 @@ function mockSession(): MockSession {
|
||||
setDevicePermissionHandler: vi.fn(),
|
||||
setDisplayMediaRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
setPermissionRequestHandler: vi.fn()
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
webRequest: { onBeforeSendHeaders: vi.fn() }
|
||||
}) as unknown as MockSession
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { WebSocket } from 'ws'
|
||||
import type { WebContents } from 'electron'
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease'
|
||||
import type { CdpClientResponseWriter } from './cdp-client-response-writer'
|
||||
import type { CdpSyntheticSessionRegistry } from './cdp-synthetic-session-registry'
|
||||
@@ -34,14 +33,8 @@ export class CdpDebuggerChannel {
|
||||
}
|
||||
this.attached = true
|
||||
|
||||
// Why: attaching the CDP debugger sets navigator.webdriver = true and
|
||||
// exposes other automation signals that Cloudflare Turnstile checks.
|
||||
// Inject before any page loads so challenges succeed.
|
||||
try {
|
||||
await this.webContents.debugger.sendCommand('Page.enable', {})
|
||||
await this.webContents.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', {
|
||||
source: ANTI_DETECTION_SCRIPT
|
||||
})
|
||||
} catch {
|
||||
/* best-effort — page domain may not be ready yet */
|
||||
}
|
||||
|
||||
@@ -32,9 +32,11 @@ export function createCdpDebuggerMessageListener(
|
||||
| undefined
|
||||
if (p?.sessionId && p.targetInfo?.type === 'iframe' && p.targetInfo.targetId) {
|
||||
state.iframeSessions.set(p.targetInfo.targetId, p.sessionId)
|
||||
// Why: no Runtime.enable here. Cross-origin iframes include challenge widgets
|
||||
// (Cloudflare Turnstile), and the Runtime domain's console/Error.stack serialization
|
||||
// is the CDP tell they detect; nothing reads iframe Runtime events anyway.
|
||||
guest.debugger.sendCommand('DOM.enable', {}, p.sessionId).catch(() => {})
|
||||
guest.debugger.sendCommand('Accessibility.enable', {}, p.sessionId).catch(() => {})
|
||||
guest.debugger.sendCommand('Runtime.enable', {}, p.sessionId).catch(() => {})
|
||||
}
|
||||
}
|
||||
if (method === 'Target.detachedFromTarget') {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { WebContents } from 'electron'
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
import { BrowserError } from './browser-error'
|
||||
import type { CdpTabState } from './cdp-auxiliary-commands'
|
||||
import type { CdpCommandSender } from './snapshot-engine'
|
||||
@@ -62,11 +61,6 @@ export class CdpDebuggerLifecycle {
|
||||
flatten: true
|
||||
})
|
||||
|
||||
// Why: CDP attach exposes automation signals (navigator.webdriver) that Cloudflare checks; override per new document.
|
||||
await sender('Page.addScriptToEvaluateOnNewDocument', {
|
||||
source: ANTI_DETECTION_SCRIPT
|
||||
})
|
||||
|
||||
// Why: only remove this bridge's listeners; screencast/proxy sessions share the debugger and own their teardown.
|
||||
this.removeDebuggerListeners(guest, state)
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 99 }],
|
||||
['DOM.focus', { backendNodeId: 99 }],
|
||||
['Input.insertText', { text: 'hello' }]
|
||||
@@ -80,7 +79,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'],
|
||||
['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'],
|
||||
['Input.insertText', { text: 'frame text' }, 'oopif-session-123']
|
||||
@@ -112,7 +110,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 44 }],
|
||||
['Runtime.callFunctionOn', { functionDeclaration: '() => document.activeElement?.id' }],
|
||||
['Input.insertText', { text: 'after eval' }]
|
||||
@@ -155,7 +152,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 55 }],
|
||||
['Input.insertText', { text: 'fallback' }]
|
||||
])
|
||||
@@ -197,7 +193,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandCalls(mock)).toEqual([
|
||||
['Page.enable', {}],
|
||||
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
|
||||
['DOM.focus', { backendNodeId: 77 }],
|
||||
['DOM.focus', { backendNodeId: 77 }]
|
||||
])
|
||||
@@ -242,7 +237,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
|
||||
expect(insertResponse?.result).toEqual({})
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'DOM.focus',
|
||||
'DOM.focus',
|
||||
'Input.insertText'
|
||||
@@ -270,12 +264,7 @@ describe('CdpWsProxy DOM.focus replay', () => {
|
||||
// Why: both Page.bringToFront and Input.insertText natively call focus(),
|
||||
// independent of the (now-cleared) DOM.focus replay.
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(2)
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'DOM.focus',
|
||||
'Input.insertText'
|
||||
])
|
||||
expect(getSendCommandMethods(mock)).toEqual(['Page.enable', 'DOM.focus', 'Input.insertText'])
|
||||
client.close()
|
||||
})
|
||||
|
||||
@@ -298,7 +287,6 @@ describe('CdpWsProxy DOM.focus replay', () => {
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'DOM.focus',
|
||||
'Page.captureScreenshot',
|
||||
'Input.insertText'
|
||||
@@ -322,12 +310,7 @@ describe('CdpWsProxy DOM.focus replay', () => {
|
||||
|
||||
expect(insertResponse.id).toBe(34)
|
||||
expect(insertResponse.result).toEqual({})
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'DOM.focus',
|
||||
'Input.insertText'
|
||||
])
|
||||
expect(getSendCommandMethods(mock)).toEqual(['Page.enable', 'DOM.focus', 'Input.insertText'])
|
||||
second.close()
|
||||
})
|
||||
|
||||
|
||||
@@ -400,11 +400,7 @@ describe('CdpWsProxy', () => {
|
||||
})
|
||||
|
||||
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Input.insertText'
|
||||
])
|
||||
expect(getSendCommandMethods(mock)).toEqual(['Page.enable', 'Input.insertText'])
|
||||
client.close()
|
||||
})
|
||||
|
||||
@@ -421,7 +417,6 @@ describe('CdpWsProxy', () => {
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Network.enable',
|
||||
'Page.enable',
|
||||
'Page.setLifecycleEventsEnabled',
|
||||
@@ -442,7 +437,6 @@ describe('CdpWsProxy', () => {
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Network.enable',
|
||||
'Page.enable',
|
||||
'Page.setLifecycleEventsEnabled'
|
||||
@@ -462,7 +456,7 @@ describe('CdpWsProxy', () => {
|
||||
sessionId: 'iframe-session-123'
|
||||
})
|
||||
|
||||
expect(getSendCommandCalls(mock).slice(2)).toEqual([
|
||||
expect(getSendCommandCalls(mock).slice(1)).toEqual([
|
||||
['Network.enable', {}, 'iframe-session-123'],
|
||||
['Page.enable', {}, 'iframe-session-123'],
|
||||
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
|
||||
@@ -481,7 +475,7 @@ describe('CdpWsProxy', () => {
|
||||
sessionId: 'iframe-session-123'
|
||||
})
|
||||
|
||||
expect(getSendCommandCalls(mock).slice(2)).toEqual([
|
||||
expect(getSendCommandCalls(mock).slice(1)).toEqual([
|
||||
['Network.enable', {}, 'iframe-session-123'],
|
||||
['Page.enable', {}, 'iframe-session-123'],
|
||||
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
|
||||
@@ -562,11 +556,7 @@ describe('CdpWsProxy', () => {
|
||||
|
||||
expect(response.id).toBe(13)
|
||||
expect(response.result).toEqual({})
|
||||
expect(getSendCommandMethods(mock)).toEqual([
|
||||
'Page.enable',
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
'Runtime.evaluate'
|
||||
])
|
||||
expect(getSendCommandMethods(mock)).toEqual(['Page.enable', 'Runtime.evaluate'])
|
||||
client.close()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Query } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createClaudeControlSurface } from './claude-agent-sdk-control-requests'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('createClaudeControlSurface stopTask', () => {
|
||||
it('bounds a lost reply and permits a later stop request', async () => {
|
||||
vi.useFakeTimers()
|
||||
const stopTask = vi
|
||||
.fn<() => Promise<void>>()
|
||||
.mockImplementationOnce(() => new Promise(() => {}))
|
||||
.mockResolvedValueOnce()
|
||||
const controls = createClaudeControlSurface({ stopTask } as unknown as Query)
|
||||
const timedOut = expect(controls.stopTask('task-1', { timeoutMs: 25 })).rejects.toThrow(
|
||||
'claude stop_task request timed out'
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await timedOut
|
||||
await expect(controls.stopTask('task-2', { timeoutMs: 25 })).resolves.toBeUndefined()
|
||||
expect(stopTask).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -91,6 +91,7 @@ export type ClaudeControlSurface = {
|
||||
settings: Parameters<Query['applyFlagSettings']>[0],
|
||||
options?: ClaudeControlOptions
|
||||
) => Promise<void>
|
||||
stopTask: (taskId: string, options?: ClaudeControlOptions) => Promise<void>
|
||||
supportedModels: (options?: ClaudeControlOptions) => Promise<unknown[]>
|
||||
initializationResult: (options?: ClaudeControlOptions) => Promise<unknown>
|
||||
getSettings: (options?: ClaudeControlOptions) => Promise<unknown>
|
||||
@@ -135,6 +136,10 @@ export function createClaudeControlSurface(query: Query): ClaudeControlSurface {
|
||||
() => query.applyFlagSettings(settings),
|
||||
options?.timeoutMs
|
||||
).then(() => {}),
|
||||
stopTask: (taskId, options) =>
|
||||
runClaudeControl('stop_task', () => query.stopTask(taskId), options?.timeoutMs).then(
|
||||
() => {}
|
||||
),
|
||||
supportedModels: (options) =>
|
||||
runClaudeControl('list_models', () => query.supportedModels(), options?.timeoutMs),
|
||||
initializationResult: (options) =>
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ClaudeBackgroundTaskTracker,
|
||||
classifyClaudeBackgroundTaskKind
|
||||
} from './claude-background-task-tracker'
|
||||
|
||||
function system(subtype: string, fields: Record<string, unknown>): Record<string, unknown> {
|
||||
return { type: 'system', subtype, session_id: 'provider-1', uuid: crypto.randomUUID(), ...fields }
|
||||
}
|
||||
|
||||
function result(): Record<string, unknown> {
|
||||
return { type: 'result', subtype: 'success', session_id: 'provider-1', uuid: crypto.randomUUID() }
|
||||
}
|
||||
|
||||
function aggregate(tasks: unknown[]): Record<string, unknown> {
|
||||
return system('background_tasks_changed', { tasks })
|
||||
}
|
||||
|
||||
describe('ClaudeBackgroundTaskTracker', () => {
|
||||
it('classifies SDK task types without inferring them from descriptions', () => {
|
||||
expect(classifyClaudeBackgroundTaskKind('local_agent')).toBe('agent')
|
||||
expect(classifyClaudeBackgroundTaskKind('local_workflow')).toBe('workflow')
|
||||
expect(classifyClaudeBackgroundTaskKind('local_bash')).toBe('command')
|
||||
expect(classifyClaudeBackgroundTaskKind('monitor')).toBe('monitor')
|
||||
expect(classifyClaudeBackgroundTaskKind('future_task')).toBe('unknown')
|
||||
})
|
||||
|
||||
it('waits for the foreground turn to settle before monitoring a background task', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'task-1',
|
||||
task_type: 'local_agent',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
expect(tracker.state).toBeNull()
|
||||
|
||||
expect(tracker.observe(result())).toBe(true)
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-1', kind: 'agent' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('uses an explicit background update for a foreground task and ignores progress alone', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'task-1',
|
||||
task_type: 'local_bash',
|
||||
is_backgrounded: false
|
||||
})
|
||||
)
|
||||
expect(
|
||||
tracker.observe(system('task_progress', { task_id: 'task-1', description: 'still working' }))
|
||||
).toBe(false)
|
||||
tracker.observe(result())
|
||||
expect(tracker.state).toBeNull()
|
||||
|
||||
tracker.observe(system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } }))
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-1', kind: 'command' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes bounded display details when a running task description changes', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
expect(
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'task-1',
|
||||
task_type: 'local_bash',
|
||||
is_backgrounded: true,
|
||||
description: ' run\n the build '
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-1', kind: 'command', description: 'run the build' }]
|
||||
})
|
||||
|
||||
expect(
|
||||
tracker.observe(
|
||||
system('task_updated', {
|
||||
task_id: 'task-1',
|
||||
patch: { description: 'x'.repeat(600) }
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
expect(tracker.state?.tasks?.[0]?.description).toHaveLength(512)
|
||||
expect(
|
||||
tracker.observe(
|
||||
system('task_updated', {
|
||||
task_id: 'task-1',
|
||||
patch: { description: 'x'.repeat(600) }
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces its roster from aggregate lifecycle frames and preserves stoppable provider ids', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
expect(
|
||||
tracker.observe(
|
||||
aggregate([
|
||||
{ task_id: 'task-agent', task_type: 'local_agent', description: 'agent' },
|
||||
{ task_id: 'task-bash', task_type: 'local_bash', description: 'bash' }
|
||||
])
|
||||
)
|
||||
).toBe(true)
|
||||
expect(tracker.stoppableTaskIds).toEqual(['task-agent', 'task-bash'])
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [
|
||||
{ id: 'task-agent', kind: 'agent', description: 'agent' },
|
||||
{ id: 'task-bash', kind: 'command', description: 'bash' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(
|
||||
tracker.observe(
|
||||
aggregate([{ task_id: 'task-next', task_type: 'local_workflow', description: 'workflow' }])
|
||||
)
|
||||
).toBe(true)
|
||||
expect(tracker.stoppableTaskIds).toEqual(['task-next'])
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-next', kind: 'workflow', description: 'workflow' }]
|
||||
})
|
||||
|
||||
expect(tracker.observe(aggregate([]))).toBe(true)
|
||||
expect(tracker.stoppableTaskIds).toEqual([])
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes ambient aggregate tasks', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe(
|
||||
aggregate([
|
||||
{ task_id: 'ambient', task_type: 'monitor', description: 'watcher', ambient: true },
|
||||
{ task_id: 'visible', task_type: 'local_bash', description: 'command' }
|
||||
])
|
||||
)
|
||||
|
||||
expect(tracker.stoppableTaskIds).toEqual(['visible'])
|
||||
})
|
||||
|
||||
it('does not let late edge frames revive tasks cleared by an aggregate roster', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe(
|
||||
aggregate([{ task_id: 'task-late', task_type: 'local_agent', description: 'agent' }])
|
||||
)
|
||||
tracker.observe(aggregate([]))
|
||||
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'task-late',
|
||||
task_type: 'local_agent',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
tracker.observe(
|
||||
system('task_updated', { task_id: 'task-late', patch: { is_backgrounded: true } })
|
||||
)
|
||||
|
||||
expect(tracker.stoppableTaskIds).toEqual([])
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('lets an authoritative aggregate roster replace earlier terminal-edge evidence', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe(system('task_notification', { task_id: 'task-live', status: 'completed' }))
|
||||
|
||||
tracker.observe(
|
||||
aggregate([{ task_id: 'task-live', task_type: 'local_agent', description: 'agent' }])
|
||||
)
|
||||
|
||||
expect(tracker.stoppableTaskIds).toEqual(['task-live'])
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-live', kind: 'agent', description: 'agent' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps terminal edges authoritative on either side of aggregate replacement', () => {
|
||||
const terminalFirst = new ClaudeBackgroundTaskTracker()
|
||||
terminalFirst.observe(
|
||||
system('task_notification', { task_id: 'task-first', status: 'completed' })
|
||||
)
|
||||
terminalFirst.observe(aggregate([]))
|
||||
terminalFirst.observe(
|
||||
system('task_started', {
|
||||
task_id: 'task-first',
|
||||
task_type: 'local_agent',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
expect(terminalFirst.state).toBeNull()
|
||||
|
||||
const terminalLast = new ClaudeBackgroundTaskTracker()
|
||||
terminalLast.observe(
|
||||
aggregate([{ task_id: 'task-last', task_type: 'local_agent', description: 'agent' }])
|
||||
)
|
||||
terminalLast.observe(system('task_notification', { task_id: 'task-last', status: 'completed' }))
|
||||
terminalLast.observe(
|
||||
system('task_started', {
|
||||
task_id: 'task-last',
|
||||
task_type: 'local_agent',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
expect(terminalLast.state).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps terminal evidence authoritative across duplicates and out-of-order starts', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
const terminal = system('task_notification', { task_id: 'task-late', status: 'completed' })
|
||||
tracker.observe(terminal)
|
||||
tracker.observe(terminal)
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'task-late',
|
||||
task_type: 'local_workflow',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
expect(tracker.state).toBeNull()
|
||||
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'task-live',
|
||||
task_type: 'monitor'
|
||||
})
|
||||
)
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-live', kind: 'monitor' }]
|
||||
})
|
||||
expect(
|
||||
tracker.observe(system('task_updated', { task_id: 'task-live', patch: { status: 'killed' } }))
|
||||
).toBe(true)
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('recognizes task types that are registered only as background work', () => {
|
||||
for (const taskType of ['local_workflow', 'monitor']) {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe(system('task_started', { task_id: taskType, task_type: taskType }))
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: taskType, kind: taskType === 'local_workflow' ? 'workflow' : 'monitor' }]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('admits unknown background updates conservatively and bounds edge-only fallback ids', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe(
|
||||
system('task_updated', { task_id: 'unknown', patch: { is_backgrounded: true } })
|
||||
)
|
||||
expect(tracker.stoppableTaskIds).toEqual(['unknown'])
|
||||
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: `task-${index}`,
|
||||
task_type: 'local_agent',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
}
|
||||
expect(tracker.stoppableTaskIds.length).toBeLessThanOrEqual(256)
|
||||
})
|
||||
|
||||
it('bounds aggregate rosters and resets to the edge-only fallback on clear', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe(
|
||||
aggregate(
|
||||
Array.from({ length: 400 }, (_, index) => ({
|
||||
task_id: `aggregate-${index}`,
|
||||
task_type: 'local_bash',
|
||||
description: 'command'
|
||||
}))
|
||||
)
|
||||
)
|
||||
expect(tracker.stoppableTaskIds).toHaveLength(256)
|
||||
|
||||
tracker.clear()
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'edge-after-reset',
|
||||
task_type: 'local_agent',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
expect(tracker.stoppableTaskIds).toEqual(['edge-after-reset'])
|
||||
})
|
||||
|
||||
it('gates aggregate monitoring behind foreground turn completion', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
tracker.observe(
|
||||
aggregate([{ task_id: 'task-live', task_type: 'local_bash', description: 'command' }])
|
||||
)
|
||||
expect(tracker.state).toBeNull()
|
||||
|
||||
expect(tracker.observe(result())).toBe(true)
|
||||
expect(tracker.state).toEqual({
|
||||
state: 'monitoring',
|
||||
tasks: [{ id: 'task-live', kind: 'command', description: 'command' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores ambient SDK tasks and clears all liveness when the session ends', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'ambient',
|
||||
task_type: 'monitor',
|
||||
is_backgrounded: true,
|
||||
ambient: true
|
||||
})
|
||||
)
|
||||
expect(tracker.state).toBeNull()
|
||||
tracker.observe(
|
||||
system('task_started', {
|
||||
task_id: 'task-live',
|
||||
task_type: 'local_agent',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
expect(tracker.clear()).toBe(true)
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
import type {
|
||||
AgentSessionBackgroundTask,
|
||||
AgentSessionBackgroundTaskState
|
||||
} from '../../shared/agent-session-wire'
|
||||
|
||||
const MAX_TRACKED_TASKS = 256
|
||||
const MAX_TASK_ID_LENGTH = 512
|
||||
const MAX_TASK_DESCRIPTION_LENGTH = 512
|
||||
const TERMINAL_TASK_STATES = new Set(['completed', 'failed', 'killed', 'stopped'])
|
||||
|
||||
export type ClaudeBackgroundTaskKind = AgentSessionBackgroundTask['kind']
|
||||
|
||||
type TrackedTask = {
|
||||
backgrounded: boolean
|
||||
kind: ClaudeBackgroundTaskKind
|
||||
description?: string
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
function taskId(message: Record<string, unknown>): string | null {
|
||||
const value = message.task_id
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= MAX_TASK_ID_LENGTH
|
||||
? value
|
||||
: null
|
||||
}
|
||||
|
||||
function taskDescription(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const trimmed = value.trim().replace(/\s+/g, ' ')
|
||||
return trimmed.length > 0 ? trimmed.slice(0, MAX_TASK_DESCRIPTION_LENGTH) : undefined
|
||||
}
|
||||
|
||||
export function classifyClaudeBackgroundTaskKind(taskType: unknown): ClaudeBackgroundTaskKind {
|
||||
switch (taskType) {
|
||||
case 'local_agent':
|
||||
return 'agent'
|
||||
case 'local_workflow':
|
||||
return 'workflow'
|
||||
case 'local_bash':
|
||||
return 'command'
|
||||
case 'monitor':
|
||||
return 'monitor'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaudeBackgroundTaskTracker {
|
||||
private readonly tasks = new Map<string, TrackedTask>()
|
||||
private readonly terminalTaskIds = new Set<string>()
|
||||
private aggregateRosterObserved = false
|
||||
private foregroundTurnActive = false
|
||||
private monitoring = false
|
||||
private publishedTasksFingerprint = ''
|
||||
|
||||
get state(): AgentSessionBackgroundTaskState | null {
|
||||
if (!this.monitoring) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
state: 'monitoring',
|
||||
tasks: this.backgroundTaskDetails()
|
||||
}
|
||||
}
|
||||
|
||||
get stoppableTaskIds(): string[] {
|
||||
const ids: string[] = []
|
||||
for (const [id, task] of this.tasks) {
|
||||
if (task.backgrounded) {
|
||||
ids.push(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
observe(message: Record<string, unknown>, startsTurn = false): boolean {
|
||||
if (startsTurn) {
|
||||
this.foregroundTurnActive = true
|
||||
}
|
||||
if (message.type === 'result') {
|
||||
this.foregroundTurnActive = false
|
||||
} else if (message.type === 'system') {
|
||||
if (!this.observeSystemFrame(message) && !startsTurn) {
|
||||
return false
|
||||
}
|
||||
} else if (!startsTurn) {
|
||||
return false
|
||||
}
|
||||
return this.refreshMonitoring()
|
||||
}
|
||||
|
||||
clear(): boolean {
|
||||
this.tasks.clear()
|
||||
this.terminalTaskIds.clear()
|
||||
this.aggregateRosterObserved = false
|
||||
this.foregroundTurnActive = false
|
||||
return this.refreshMonitoring()
|
||||
}
|
||||
|
||||
private observeSystemFrame(message: Record<string, unknown>): boolean {
|
||||
if (message.subtype === 'background_tasks_changed') {
|
||||
this.replaceAggregateRoster(message.tasks)
|
||||
return true
|
||||
}
|
||||
const id = taskId(message)
|
||||
if (!id) {
|
||||
return false
|
||||
}
|
||||
if (message.subtype === 'task_notification') {
|
||||
this.finish(id)
|
||||
return true
|
||||
}
|
||||
if (message.subtype === 'task_updated') {
|
||||
const patch = record(message.patch)
|
||||
if (!patch) {
|
||||
return false
|
||||
}
|
||||
if (TERMINAL_TASK_STATES.has(String(patch.status))) {
|
||||
this.finish(id)
|
||||
return true
|
||||
}
|
||||
const existing = this.tasks.get(id)
|
||||
if (
|
||||
(patch.is_backgrounded === true || taskDescription(patch.description)) &&
|
||||
(!this.aggregateRosterObserved || existing)
|
||||
) {
|
||||
this.upsert(id, {
|
||||
backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true,
|
||||
kind: existing?.kind ?? 'unknown',
|
||||
description: taskDescription(patch.description) ?? existing?.description
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (message.subtype !== 'task_started' || this.terminalTaskIds.has(id)) {
|
||||
return false
|
||||
}
|
||||
if (message.ambient === true || message.skip_transcript === true) {
|
||||
this.finish(id)
|
||||
return true
|
||||
}
|
||||
if (this.aggregateRosterObserved && !this.tasks.has(id)) {
|
||||
return false
|
||||
}
|
||||
const kind = classifyClaudeBackgroundTaskKind(message.task_type)
|
||||
this.upsert(id, {
|
||||
backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor',
|
||||
kind,
|
||||
description: taskDescription(message.description)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private replaceAggregateRoster(value: unknown): void {
|
||||
if (!Array.isArray(value)) {
|
||||
return
|
||||
}
|
||||
this.aggregateRosterObserved = true
|
||||
this.tasks.clear()
|
||||
this.terminalTaskIds.clear()
|
||||
for (const valueTask of value) {
|
||||
if (this.tasks.size >= MAX_TRACKED_TASKS) {
|
||||
break
|
||||
}
|
||||
const task = record(valueTask)
|
||||
if (!task || task.ambient === true) {
|
||||
continue
|
||||
}
|
||||
const id = taskId(task)
|
||||
if (!id) {
|
||||
continue
|
||||
}
|
||||
this.tasks.set(id, {
|
||||
backgrounded: true,
|
||||
kind: classifyClaudeBackgroundTaskKind(task.task_type),
|
||||
description: taskDescription(task.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private upsert(id: string, task: TrackedTask): void {
|
||||
const existing = this.tasks.get(id)
|
||||
if (existing) {
|
||||
this.tasks.set(id, {
|
||||
backgrounded: existing.backgrounded || task.backgrounded,
|
||||
kind: existing.kind === 'unknown' ? task.kind : existing.kind,
|
||||
description: task.description ?? existing.description
|
||||
})
|
||||
return
|
||||
}
|
||||
if (this.tasks.size >= MAX_TRACKED_TASKS) {
|
||||
let foregroundId: string | undefined
|
||||
for (const [candidateId, candidate] of this.tasks) {
|
||||
if (!candidate.backgrounded) {
|
||||
foregroundId = candidateId
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!foregroundId) {
|
||||
return
|
||||
}
|
||||
this.tasks.delete(foregroundId)
|
||||
}
|
||||
this.tasks.set(id, task)
|
||||
}
|
||||
|
||||
private finish(id: string): void {
|
||||
this.tasks.delete(id)
|
||||
this.terminalTaskIds.delete(id)
|
||||
this.terminalTaskIds.add(id)
|
||||
if (this.terminalTaskIds.size > MAX_TRACKED_TASKS) {
|
||||
const oldest = this.terminalTaskIds.values().next()
|
||||
if (!oldest.done) {
|
||||
this.terminalTaskIds.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private refreshMonitoring(): boolean {
|
||||
const details = this.foregroundTurnActive ? [] : this.backgroundTaskDetails()
|
||||
const next = details.length > 0
|
||||
const fingerprint = next ? JSON.stringify(details) : ''
|
||||
if (next === this.monitoring && fingerprint === this.publishedTasksFingerprint) {
|
||||
return false
|
||||
}
|
||||
this.monitoring = next
|
||||
this.publishedTasksFingerprint = fingerprint
|
||||
return true
|
||||
}
|
||||
|
||||
private backgroundTaskDetails(): AgentSessionBackgroundTask[] {
|
||||
const details: AgentSessionBackgroundTask[] = []
|
||||
for (const [id, task] of this.tasks) {
|
||||
if (!task.backgrounded) {
|
||||
continue
|
||||
}
|
||||
details.push({
|
||||
id,
|
||||
kind: task.kind,
|
||||
...(task.description ? { description: task.description } : {})
|
||||
})
|
||||
}
|
||||
return details
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export async function releaseClaudeAcquisition(input: {
|
||||
onExitProven?: (sessionId: string, exit: ClaudeSessionExit) => Promise<void>
|
||||
persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle']
|
||||
onEvent?: ClaudeStructuredSessionAdapterDeps['onEvent']
|
||||
onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged']
|
||||
}): Promise<boolean> {
|
||||
const exit = input.exits.get(input.sessionId)
|
||||
if (!exit || input.sessions.has(input.sessionId) || input.acquisitions.get(input.sessionId)) {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { cancelClaudeTurn, answerClaudePrompt } from './claude-structured-control-actions'
|
||||
import {
|
||||
cancelClaudeTurn,
|
||||
answerClaudePrompt,
|
||||
stopClaudeBackgroundTasks
|
||||
} from './claude-structured-control-actions'
|
||||
import { ClaudeControlRequestError } from './claude-stream-json-connection'
|
||||
import { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
|
||||
|
||||
type InterruptResult = Awaited<ReturnType<ClaudeSession['connection']['interrupt']>>
|
||||
|
||||
@@ -111,3 +116,46 @@ describe('answerClaudePrompt', () => {
|
||||
).rejects.toThrow(/no longer waiting/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopClaudeBackgroundTasks', () => {
|
||||
it('stops each live SDK task id and never depends on an active turn id', async () => {
|
||||
const backgroundTasks = new ClaudeBackgroundTaskTracker()
|
||||
backgroundTasks.observe({
|
||||
type: 'system',
|
||||
subtype: 'background_tasks_changed',
|
||||
tasks: [
|
||||
{ task_id: 'task-agent', task_type: 'local_agent', description: 'agent' },
|
||||
{ task_id: 'task-bash', task_type: 'local_bash', description: 'bash' }
|
||||
]
|
||||
})
|
||||
const stopTask = vi.fn(async (_taskId: string, _options?: { timeoutMs?: number }) => {})
|
||||
const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession
|
||||
|
||||
await expect(stopClaudeBackgroundTasks(session, 5_000)).resolves.toEqual({ cancelled: true })
|
||||
expect(stopTask.mock.calls).toEqual([
|
||||
['task-agent', { timeoutMs: 5_000 }],
|
||||
['task-bash', { timeoutMs: 5_000 }]
|
||||
])
|
||||
})
|
||||
|
||||
it('stops issuing requests when ownership changes between tasks', async () => {
|
||||
const backgroundTasks = new ClaudeBackgroundTaskTracker()
|
||||
for (const taskId of ['task-1', 'task-2']) {
|
||||
backgroundTasks.observe({
|
||||
type: 'system',
|
||||
subtype: 'task_started',
|
||||
task_id: taskId,
|
||||
task_type: 'local_agent',
|
||||
is_backgrounded: true
|
||||
})
|
||||
}
|
||||
let current = true
|
||||
const stopTask = vi.fn(async (_taskId: string) => {
|
||||
current = false
|
||||
})
|
||||
const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession
|
||||
|
||||
await stopClaudeBackgroundTasks(session, undefined, () => current)
|
||||
expect(stopTask).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,6 +43,29 @@ export async function cancelClaudeTurn(
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopClaudeBackgroundTasks(
|
||||
session: ClaudeSession,
|
||||
timeoutMs: number | undefined,
|
||||
isCurrent: ClaudeTurnCancellationGuard = () => true
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
const taskIds = session.backgroundTasks.stoppableTaskIds
|
||||
let cancelled = false
|
||||
for (const taskId of taskIds) {
|
||||
if (!isCurrent()) {
|
||||
break
|
||||
}
|
||||
try {
|
||||
await session.connection.stopTask(taskId, { timeoutMs })
|
||||
cancelled = true
|
||||
} catch (error) {
|
||||
if (!(error instanceof ClaudeControlRequestError)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
return { cancelled }
|
||||
}
|
||||
|
||||
export async function answerClaudePrompt(
|
||||
session: ClaudeSession,
|
||||
input: { itemId: string; kind: 'approval' | 'question'; optionId: string }
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AgentJournalMessageItem } from '../../shared/agent-session-journal
|
||||
import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch'
|
||||
import { readClaudeImage } from './claude-structured-dispatch-content'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
|
||||
|
||||
function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession {
|
||||
return {
|
||||
@@ -19,6 +20,7 @@ function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession
|
||||
dispatchWaiters: [],
|
||||
retiredDispatchWaiters: [],
|
||||
replayContentFallbackBlocked: false,
|
||||
backgroundTasks: new ClaudeBackgroundTaskTracker(),
|
||||
dispatchSequence: 0,
|
||||
optionMutationSequence: 0,
|
||||
options: new Map(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setClaudeStructuredOption } from './claude-structured-options'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
|
||||
|
||||
function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession {
|
||||
return {
|
||||
@@ -14,6 +15,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe
|
||||
dispatchWaiters: [],
|
||||
retiredDispatchWaiters: [],
|
||||
replayContentFallbackBlocked: false,
|
||||
backgroundTasks: new ClaudeBackgroundTaskTracker(),
|
||||
dispatchSequence: 0,
|
||||
optionMutationSequence: 0,
|
||||
options: new Map(),
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('Claude structured processless acquisition', () => {
|
||||
setPermissionMode: async () => {},
|
||||
applyFlagSettings: async () => {},
|
||||
send: async () => {},
|
||||
stopTask: async () => {},
|
||||
close
|
||||
}
|
||||
return connection
|
||||
|
||||
@@ -4,7 +4,11 @@ import type {
|
||||
StructuredAgentSessionAdapter
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { answerClaudePrompt, cancelClaudeTurn } from './claude-structured-control-actions'
|
||||
import {
|
||||
answerClaudePrompt,
|
||||
cancelClaudeTurn,
|
||||
stopClaudeBackgroundTasks
|
||||
} from './claude-structured-control-actions'
|
||||
import { dispatchClaudeTurn } from './claude-structured-dispatch'
|
||||
import { releaseClaudeAcquisition } from './claude-structured-acquisition-release'
|
||||
import { acquireClaudeSession } from './claude-structured-session-acquisition'
|
||||
@@ -149,12 +153,21 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
}
|
||||
|
||||
private emit(
|
||||
_session: ClaudeSession | null,
|
||||
session: ClaudeSession | null,
|
||||
_events: StructuredAgentSessionEventSink | undefined,
|
||||
event: ClaudeStructuredSessionEvent
|
||||
): void {
|
||||
_session?.translator?.handle(event)
|
||||
const backgroundTasksChanged =
|
||||
event.type === 'ended'
|
||||
? (session?.backgroundTasks.clear() ?? false)
|
||||
: event.type === 'message'
|
||||
? (session?.backgroundTasks.observe(event.message, event.startsTurn === true) ?? false)
|
||||
: false
|
||||
session?.translator?.handle(event)
|
||||
this.deps.onEvent?.(event)
|
||||
if (backgroundTasksChanged) {
|
||||
this.deps.onBackgroundTasksChanged?.(event.sessionId, session?.backgroundTasks.state ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
bindPromptItemId(
|
||||
@@ -191,6 +204,21 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
)
|
||||
})
|
||||
}
|
||||
stopBackgroundTasks: StructuredAgentSessionAdapter['stopBackgroundTasks'] = (input) => {
|
||||
const session = this.session(input.sessionId)
|
||||
const acquisitionGeneration = session.acquisitionGeneration
|
||||
return stopClaudeBackgroundTasks(session, this.deps.requestTimeoutMs, () =>
|
||||
Boolean(
|
||||
this.sessions.get(input.sessionId) === session &&
|
||||
session.fence === input.fence &&
|
||||
session.acquisitionGeneration === acquisitionGeneration &&
|
||||
session.backgroundTasks.state
|
||||
)
|
||||
)
|
||||
}
|
||||
backgroundTaskState: NonNullable<StructuredAgentSessionAdapter['backgroundTaskState']> = (
|
||||
sessionId
|
||||
) => this.sessions.get(sessionId)?.backgroundTasks.state
|
||||
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) =>
|
||||
answerClaudePrompt(this.session(input.sessionId), input)
|
||||
setOption: StructuredAgentSessionAdapter['setOption'] = (input) =>
|
||||
@@ -210,6 +238,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
exits: this.exits,
|
||||
onExitProven: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit),
|
||||
...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}),
|
||||
...(this.deps.onBackgroundTasksChanged
|
||||
? { onBackgroundTasksChanged: this.deps.onBackgroundTasksChanged }
|
||||
: {}),
|
||||
...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {})
|
||||
})
|
||||
|
||||
@@ -223,6 +254,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
acquisitions: this.acquisitions,
|
||||
...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}),
|
||||
...(this.deps.readTranscriptLeaf ? { readTranscriptLeaf: this.deps.readTranscriptLeaf } : {}),
|
||||
...(this.deps.onBackgroundTasksChanged
|
||||
? { onBackgroundTasksChanged: this.deps.onBackgroundTasksChanged }
|
||||
: {}),
|
||||
...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@ import type {
|
||||
ClaudeStructuredSessionAdapterDeps,
|
||||
ClaudeStructuredSessionEvent
|
||||
} from './claude-structured-session-adapter'
|
||||
import { adapterFor, fakeClaude, identityFor } from './claude-structured-session-test-support'
|
||||
import {
|
||||
PROVIDER_SESSION_ID,
|
||||
adapterFor,
|
||||
fakeClaude,
|
||||
identityFor
|
||||
} from './claude-structured-session-test-support'
|
||||
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
|
||||
|
||||
describe('Claude published session close lifecycle', () => {
|
||||
it('ends the session even when the durable handle write rejects', async () => {
|
||||
@@ -15,7 +21,17 @@ describe('Claude published session close lifecycle', () => {
|
||||
.fn<NonNullable<ClaudeStructuredSessionAdapterDeps['persistHandle']>>()
|
||||
.mockRejectedValueOnce(persistenceError)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const adapter = adapterFor(claude, {}, events, [], undefined, undefined, persistHandle)
|
||||
const backgroundStates: (AgentSessionBackgroundTaskState | null)[] = []
|
||||
const adapter = adapterFor(
|
||||
claude,
|
||||
{},
|
||||
events,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
persistHandle,
|
||||
(_sessionId, state) => backgroundStates.push(state)
|
||||
)
|
||||
const journalSink: StructuredAgentSessionEventSink = {
|
||||
appendItem: () => {},
|
||||
appendTombstone: () => {},
|
||||
@@ -27,6 +43,18 @@ describe('Claude published session close lifecycle', () => {
|
||||
spawnToken: 'spawn-9',
|
||||
events: journalSink
|
||||
})
|
||||
claude.connections[0]!.handlers.onMessage?.({
|
||||
type: 'system',
|
||||
subtype: 'task_started',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
uuid: 'task-start',
|
||||
task_id: 'background-1',
|
||||
task_type: 'local_agent',
|
||||
is_backgrounded: true
|
||||
})
|
||||
expect(backgroundStates).toEqual([
|
||||
{ state: 'monitoring', tasks: [{ id: 'background-1', kind: 'agent' }] }
|
||||
])
|
||||
const session = (
|
||||
adapter as unknown as {
|
||||
sessions: Map<string, { translator: { dispose: () => void } | null }>
|
||||
@@ -39,6 +67,10 @@ describe('Claude published session close lifecycle', () => {
|
||||
expect(events.filter((event) => event.type === 'ended')).toHaveLength(1)
|
||||
expect(events.filter((event) => event.type === 'handle')).toHaveLength(0)
|
||||
expect(disposeTranslator).toHaveBeenCalledOnce()
|
||||
expect(backgroundStates).toEqual([
|
||||
{ state: 'monitoring', tasks: [{ id: 'background-1', kind: 'agent' }] },
|
||||
null
|
||||
])
|
||||
|
||||
await expect(adapter.closeSession('session-1')).resolves.toBe(true)
|
||||
expect(persistHandle).toHaveBeenCalledTimes(2)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AgentSessionPreSpawnError
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
|
||||
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
|
||||
import { closeProcessRegistry } from '../../shared/child-process/close-process-registry'
|
||||
import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof'
|
||||
|
||||
@@ -52,6 +53,10 @@ type CloseClaudePublishedSessionInput = {
|
||||
fence: number
|
||||
}) => Promise<void>
|
||||
onEvent?: (event: ClaudeStructuredSessionEvent) => void
|
||||
onBackgroundTasksChanged?: (
|
||||
sessionId: string,
|
||||
state: AgentSessionBackgroundTaskState | null
|
||||
) => void
|
||||
readTranscriptLeaf?: (input: {
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
@@ -72,6 +77,9 @@ async function finalizeClaudePublishedSession(
|
||||
if ((await session.connection.close()) !== true) {
|
||||
return false
|
||||
}
|
||||
if (session.backgroundTasks.clear()) {
|
||||
input.onBackgroundTasksChanged?.(input.sessionId, null)
|
||||
}
|
||||
try {
|
||||
const transcriptLeaf = input.readTranscriptLeaf
|
||||
? await readClaudeTranscriptLeafWithReproof({
|
||||
@@ -194,6 +202,10 @@ export function closeClaudePublishedSessionForDeps(
|
||||
fence: number
|
||||
}) => Promise<void>
|
||||
onEvent?: (event: ClaudeStructuredSessionEvent) => void
|
||||
onBackgroundTasksChanged?: (
|
||||
sessionId: string,
|
||||
state: AgentSessionBackgroundTaskState | null
|
||||
) => void
|
||||
readTranscriptLeaf?: (input: {
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
@@ -215,6 +227,10 @@ export async function closeClaudeSession(input: {
|
||||
fence: number
|
||||
}) => Promise<void>
|
||||
onEvent?: (event: ClaudeStructuredSessionEvent) => void
|
||||
onBackgroundTasksChanged?: (
|
||||
sessionId: string,
|
||||
state: AgentSessionBackgroundTaskState | null
|
||||
) => void
|
||||
readTranscriptLeaf?: (input: {
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
|
||||
@@ -4,6 +4,7 @@ import { claudeProviderHandleLink } from './claude-structured-owner-identity'
|
||||
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import type { ClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
import type { ClaudeSession } from './claude-structured-session-state'
|
||||
import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
|
||||
|
||||
export function createClaudeSessionPublication(input: {
|
||||
connection: ClaudeSession['connection']
|
||||
@@ -50,6 +51,7 @@ export function createClaudeSessionPublication(input: {
|
||||
dispatchWaiters: [],
|
||||
retiredDispatchWaiters: [],
|
||||
replayContentFallbackBlocked: false,
|
||||
backgroundTasks: new ClaudeBackgroundTaskTracker(),
|
||||
dispatchSequence: 0,
|
||||
optionMutationSequence: 0,
|
||||
options: new Map(input.options),
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { ClaudeJournalTranslator } from './claude-structured-journal-transl
|
||||
import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
|
||||
import type { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker'
|
||||
|
||||
export type ClaudeAuthDiagnostic = {
|
||||
apiKeySourceConfigured: boolean
|
||||
@@ -54,6 +56,10 @@ export type ClaudeStructuredSessionAdapterDeps = {
|
||||
identity: AgentSessionJournalIdentity
|
||||
}) => Promise<ClaudeStructuredLaunch>
|
||||
onEvent?: (event: ClaudeStructuredSessionEvent) => void
|
||||
onBackgroundTasksChanged?: (
|
||||
sessionId: string,
|
||||
state: AgentSessionBackgroundTaskState | null
|
||||
) => void
|
||||
openConnection?: typeof openClaudeStreamJsonConnection
|
||||
readProcessStartTime?: (pid: number) => Promise<number | null>
|
||||
mintLinkId?: () => string
|
||||
@@ -119,6 +125,7 @@ export type ClaudeSession = {
|
||||
capabilities: readonly string[]
|
||||
/** Provider uuid of the most recently admitted turn, if one is active. */
|
||||
activeTurnId?: string
|
||||
backgroundTasks: ClaudeBackgroundTaskTracker
|
||||
/** Monotonic fence advanced when a dispatch starts, including unresolved dispatches. */
|
||||
dispatchSequence: number
|
||||
/** Dispatch sequence that admitted activeTurnId. */
|
||||
|
||||
@@ -155,6 +155,10 @@ export function fakeClaude(
|
||||
connection.calls.push({ subtype: 'cancel_async_message', params: { uuid } })
|
||||
routed('cancel_async_message', { uuid })
|
||||
},
|
||||
stopTask: async (taskId) => {
|
||||
connection.calls.push({ subtype: 'stop_task', params: { taskId } })
|
||||
routed('stop_task', { taskId })
|
||||
},
|
||||
send: async (message) => {
|
||||
connection.sent.push(message)
|
||||
if (message.type === 'user' && options.replayUuid !== null) {
|
||||
@@ -191,7 +195,8 @@ export function adapterFor(
|
||||
persistedHandles: unknown[] = [],
|
||||
initTimeoutMs?: number,
|
||||
readTranscriptLeaf?: ClaudeStructuredSessionAdapterDeps['readTranscriptLeaf'],
|
||||
persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle']
|
||||
persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'],
|
||||
onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged']
|
||||
): ClaudeStructuredSessionAdapter {
|
||||
return new ClaudeStructuredSessionAdapter({
|
||||
resolveLaunch: async () => ({
|
||||
@@ -215,6 +220,7 @@ export function adapterFor(
|
||||
(async (handle) => {
|
||||
persistedHandles.push(handle)
|
||||
}),
|
||||
...(onBackgroundTasksChanged ? { onBackgroundTasksChanged } : {}),
|
||||
...(readTranscriptLeaf ? { readTranscriptLeaf } : {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,9 +10,17 @@ type Clock = () => number
|
||||
const monotonicNow = (): number => performance.now()
|
||||
let now: Clock = monotonicNow
|
||||
let systemSessionEndedAt: number | null = null
|
||||
let systemSessionEnded = false
|
||||
|
||||
export function markSystemSessionEnding(): void {
|
||||
systemSessionEndedAt = now()
|
||||
systemSessionEnded = true
|
||||
}
|
||||
|
||||
// Why latched, unlike the 5s crash-suppression window below: a native dialog or a recovery verdict is never
|
||||
// right once the OS is tearing the session down, however long the process outlives the signal.
|
||||
export function isSystemSessionEnding(): boolean {
|
||||
return systemSessionEnded
|
||||
}
|
||||
|
||||
function isRecentSystemSessionEnd(): boolean {
|
||||
@@ -55,4 +63,5 @@ export function resolveExpectedTeardownScope({
|
||||
export function resetExpectedTeardownStateForTest(clock: Clock = monotonicNow): void {
|
||||
now = clock
|
||||
systemSessionEndedAt = null
|
||||
systemSessionEnded = false
|
||||
}
|
||||
|
||||
@@ -18,5 +18,7 @@ export type InspectProcessRequest = Omit<GetForegroundProcessRequest, 'type'> &
|
||||
type: 'inspectProcess'
|
||||
payload: GetForegroundProcessRequest['payload'] & {
|
||||
expectedIncarnationId?: string
|
||||
/** Optional; a daemon that predates it answers with the full capture as it always did. */
|
||||
steadyState?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, PROTOCOL_VERSION } from './types'
|
||||
|
||||
type ClientInternals = {
|
||||
client: { request: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
function createAdapter(
|
||||
protocolVersion: number,
|
||||
request: ReturnType<typeof vi.fn>
|
||||
): DaemonPtyAdapter {
|
||||
const adapter = new DaemonPtyAdapter({
|
||||
socketPath: '/tmp/orca-steady-state-compat.sock',
|
||||
tokenPath: '/tmp/orca-steady-state-compat.token',
|
||||
protocolVersion
|
||||
})
|
||||
;(adapter as unknown as ClientInternals).client = { request, disconnect: vi.fn() }
|
||||
return adapter
|
||||
}
|
||||
|
||||
describe('steadyState across daemon versions', () => {
|
||||
it('sends steadyState as an additive optional field on the existing inspectProcess request', async () => {
|
||||
const request = vi.fn(async () => ({ foregroundProcess: 'claude', hasChildProcesses: true }))
|
||||
const adapter = createAdapter(PROTOCOL_VERSION, request)
|
||||
await adapter.inspectProcess('sess-a', { steadyState: true })
|
||||
expect(request).toHaveBeenCalledWith('inspectProcess', {
|
||||
sessionId: 'sess-a',
|
||||
steadyState: true
|
||||
})
|
||||
adapter.dispose()
|
||||
})
|
||||
|
||||
it('omits the field entirely when not requested, so the wire is byte-identical to before', async () => {
|
||||
const request = vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false }))
|
||||
const adapter = createAdapter(PROTOCOL_VERSION, request)
|
||||
await adapter.inspectProcess('sess-a', { expectedIncarnationId: 'inc-1', steadyState: false })
|
||||
expect(request).toHaveBeenCalledWith('inspectProcess', {
|
||||
sessionId: 'sess-a',
|
||||
expectedIncarnationId: 'inc-1'
|
||||
})
|
||||
adapter.dispose()
|
||||
})
|
||||
|
||||
it('an old daemon that ignores steadyState still answers with the full-capture shape, and the client accepts it', async () => {
|
||||
// A pre-field daemon returns exactly what it always did: name + evidence, never a cheap answer.
|
||||
const oldDaemonAnswer = {
|
||||
foregroundProcess: 'claude',
|
||||
hasChildProcesses: true,
|
||||
foregroundProcessEvidence: {
|
||||
verdict: 'live',
|
||||
processName: 'claude',
|
||||
authorityGeneration: 'gen',
|
||||
observationEpoch: 1,
|
||||
capturedAgeMs: 0,
|
||||
ptyId: 'sess-a',
|
||||
ptyIncarnationId: 'inc-1'
|
||||
}
|
||||
}
|
||||
const request = vi.fn(async () => oldDaemonAnswer)
|
||||
const adapter = createAdapter(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, request)
|
||||
await expect(adapter.inspectProcess('sess-a', { steadyState: true })).resolves.toEqual(
|
||||
oldDaemonAnswer
|
||||
)
|
||||
adapter.dispose()
|
||||
})
|
||||
|
||||
it('a pre-inspection daemon never sees the field: the client composes from getForegroundProcess as before', async () => {
|
||||
const request = vi.fn(async () => ({ foregroundProcess: 'codex' }))
|
||||
const adapter = createAdapter(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1, request)
|
||||
await expect(adapter.inspectProcess('sess-a', { steadyState: true })).resolves.toEqual({
|
||||
foregroundProcess: 'codex',
|
||||
hasChildProcesses: true
|
||||
})
|
||||
expect(request).toHaveBeenCalledWith('getForegroundProcess', { sessionId: 'sess-a' })
|
||||
adapter.dispose()
|
||||
})
|
||||
})
|
||||
@@ -25,7 +25,7 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot
|
||||
|
||||
async inspectProcess(
|
||||
id: string,
|
||||
options?: { expectedIncarnationId?: string }
|
||||
options?: { expectedIncarnationId?: string; steadyState?: boolean }
|
||||
): Promise<PtyProcessInspection> {
|
||||
if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) {
|
||||
return clientOnlyUnverifiableInspection('old_host')
|
||||
@@ -47,7 +47,9 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot
|
||||
sessionId: id,
|
||||
...(options?.expectedIncarnationId
|
||||
? { expectedIncarnationId: options.expectedIncarnationId }
|
||||
: {})
|
||||
: {}),
|
||||
// Additive: an older daemon ignores it and pays for the full capture.
|
||||
...(options?.steadyState === true ? { steadyState: true } : {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
|
||||
async inspectProcess(
|
||||
id: string,
|
||||
options?: { expectedIncarnationId?: string }
|
||||
options?: { expectedIncarnationId?: string; steadyState?: boolean }
|
||||
): Promise<PtyProcessInspection> {
|
||||
return this.adapterForInspection(id).inspectProcess(id, options)
|
||||
}
|
||||
|
||||
@@ -12,11 +12,8 @@ import { DaemonPtySpawnResult } from './daemon-pty-spawn-result'
|
||||
import type { DaemonPtySpawnContext } from './daemon-pty-spawn-request'
|
||||
import type { ColdRestoreInfo } from './history-reader'
|
||||
import { mintPtySessionId } from './pty-session-id'
|
||||
import {
|
||||
shellPathSupportsPtyStartupBarrier,
|
||||
shellReadyMarkerComesFromLineEditor,
|
||||
resolvePtyShellPath
|
||||
} from './shell-ready'
|
||||
import { shellPathSupportsPtyStartupBarrier, resolvePtyShellPath } from './shell-ready'
|
||||
import { shellReadyMarkerComesFromLineEditor } from '../../shared/shell-ready-marker-timing'
|
||||
import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments'
|
||||
import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types'
|
||||
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
|
||||
|
||||
@@ -105,12 +105,17 @@ export class DaemonRequestRouter {
|
||||
return {
|
||||
foregroundProcess: this.options.host.getForegroundProcess(request.payload.sessionId)
|
||||
}
|
||||
case 'inspectProcess':
|
||||
return request.payload.expectedIncarnationId
|
||||
? this.options.host.inspectProcess(request.payload.sessionId, {
|
||||
expectedIncarnationId: request.payload.expectedIncarnationId
|
||||
})
|
||||
case 'inspectProcess': {
|
||||
const options = {
|
||||
...(request.payload.expectedIncarnationId
|
||||
? { expectedIncarnationId: request.payload.expectedIncarnationId }
|
||||
: {}),
|
||||
...(request.payload.steadyState === true ? { steadyState: true } : {})
|
||||
}
|
||||
return Object.keys(options).length > 0
|
||||
? this.options.host.inspectProcess(request.payload.sessionId, options)
|
||||
: this.options.host.inspectProcess(request.payload.sessionId)
|
||||
}
|
||||
case 'confirmForegroundProcess':
|
||||
return {
|
||||
foregroundProcess: await this.options.host.confirmForegroundProcess(
|
||||
|
||||
@@ -36,7 +36,9 @@ type CachedAgentForeground = { processName: string; pid: number | null; refreshe
|
||||
export type PtyForegroundProcessTracker = {
|
||||
recordOutput(data: string): void
|
||||
markDead(): void
|
||||
getForegroundProcess(): string | null
|
||||
/** `rawFallback`: node-pty's own name only, with no identity cache and no background
|
||||
* process-table refresh -- the cheap-tier tick must not fork a full `ps` as a side effect. */
|
||||
getForegroundProcess(options?: { rawFallback?: boolean }): string | null
|
||||
confirmForegroundProcess(): Promise<string | null>
|
||||
confirmShellForeground(): Promise<boolean>
|
||||
}
|
||||
@@ -213,10 +215,13 @@ export function createPtyForegroundProcessTracker(args: {
|
||||
cachedAgentForeground = null
|
||||
startupAgentForeground = null
|
||||
},
|
||||
getForegroundProcess: () => {
|
||||
getForegroundProcess: (options) => {
|
||||
if (args.isDead()) {
|
||||
return null
|
||||
}
|
||||
if (options?.rawFallback === true) {
|
||||
return getFallbackProcess()
|
||||
}
|
||||
try {
|
||||
const fallbackProcess = getFallbackProcess()
|
||||
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
|
||||
|
||||
@@ -35,11 +35,7 @@ import {
|
||||
} from '../../../shared/agent-process-recognition'
|
||||
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query'
|
||||
import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell'
|
||||
import {
|
||||
getShellLaunchConfig,
|
||||
resolvePtyShellPath,
|
||||
shellReadyMarkerComesFromLineEditor
|
||||
} from '../shell-ready'
|
||||
import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready'
|
||||
import { resolveWslSessionContext } from '../wsl-session-context'
|
||||
import { finalizeDaemonPtyEnvironment, rescrubDaemonPtyEnvironment } from './spawn-environment'
|
||||
import type { PtySubprocessOptions } from '../pty-subprocess'
|
||||
@@ -196,10 +192,10 @@ export function createPtyShellLaunchPlan(
|
||||
const waitsForShellReady =
|
||||
Boolean(opts.command) &&
|
||||
(startupAgentRecognition?.agent !== 'codex' ||
|
||||
shellReadyMarkerComesFromLineEditor(shellPath) ||
|
||||
shouldUseShellReadyStartupDelivery({
|
||||
command: opts.command,
|
||||
startupCommandDelivery: opts.startupCommandDelivery
|
||||
startupCommandDelivery: opts.startupCommandDelivery,
|
||||
shellPath
|
||||
}))
|
||||
delete env.ORCA_SHELL_FEATURES
|
||||
const shellLaunch = getShellLaunchConfig(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
import { Session } from './session'
|
||||
@@ -10,6 +10,20 @@ const describePosix = process.platform === 'win32' ? describe.skip : describe
|
||||
const hasZsh = process.platform !== 'win32' && spawnSync('/bin/zsh', ['--version']).status === 0
|
||||
const hasBash = process.platform !== 'win32' && spawnSync('/bin/bash', ['--version']).status === 0
|
||||
const COMMAND_OUTPUT = 'ORCA_STARTUP_COMMAND_RAN'
|
||||
// A second Bash install with its own canonical path -- the shape a login profile
|
||||
// switches to (`exec /opt/homebrew/bin/bash`) and the one #18768 stalled on. A
|
||||
// symlink cannot stand in: both sides are realpath'd before they are compared.
|
||||
const alternateBashPath = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash'].find(
|
||||
(candidate) =>
|
||||
hasBash && existsSync(candidate) && realpathSync(candidate) !== realpathSync('/bin/bash')
|
||||
)
|
||||
if (process.platform !== 'win32' && !alternateBashPath) {
|
||||
// Why announced: usrmerge hosts resolve /usr/bin/bash back to /bin/bash, so these
|
||||
// two skip on most Linux CI. A silent skip reads as coverage that does not exist.
|
||||
console.warn(
|
||||
'[repro-13767] no second Bash install with a distinct realpath; skipping the alternate-install recovery tests'
|
||||
)
|
||||
}
|
||||
const READ_STARTED_FILE = '.orca-read-started'
|
||||
|
||||
type ShellFixture = {
|
||||
@@ -122,7 +136,8 @@ type RunningFixture = {
|
||||
async function startFixture(
|
||||
fixture: ShellFixture,
|
||||
startupContent: string,
|
||||
extraFiles: Record<string, string> = {}
|
||||
extraFiles: Record<string, string> = {},
|
||||
pathEnv: string = process.env.PATH ?? '/usr/bin:/bin'
|
||||
): Promise<RunningFixture> {
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'orca-shell-ready-exec-'))
|
||||
const previousHome = process.env.HOME
|
||||
@@ -150,7 +165,7 @@ async function startFixture(
|
||||
shellOverride: fixture.shellPath,
|
||||
env: {
|
||||
HOME: tempHome,
|
||||
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
||||
PATH: pathEnv,
|
||||
SHELL: fixture.shellPath,
|
||||
TERM: 'xterm-256color'
|
||||
},
|
||||
@@ -416,4 +431,49 @@ fi
|
||||
},
|
||||
10_000
|
||||
)
|
||||
|
||||
const bashFixture = FIXTURES[2] as ShellFixture
|
||||
const alternateBashTest = alternateBashPath ? it : it.skip
|
||||
const alternateBashProfile = `if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then
|
||||
export ORCA_EXEC_REPRO_DONE=1
|
||||
exec ${alternateBashPath ?? '/bin/bash'} --noprofile --norc -l -i
|
||||
fi
|
||||
`
|
||||
|
||||
alternateBashTest(
|
||||
'releases at the prompt of a second Bash install the pane PATH resolves',
|
||||
async () => {
|
||||
const running = await startFixture(
|
||||
bashFixture,
|
||||
alternateBashProfile,
|
||||
{},
|
||||
`${dirname(alternateBashPath ?? '/bin/bash')}:/usr/bin:/bin`
|
||||
)
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT))
|
||||
expect(running.session.shellState).toBe('ready')
|
||||
expect(count(running.output(), COMMAND_OUTPUT)).toBe(1)
|
||||
expect(running.output()).not.toContain('orca-shell-start')
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
},
|
||||
10_000
|
||||
)
|
||||
|
||||
alternateBashTest(
|
||||
'does not trust a Bash install that the pane PATH cannot reach',
|
||||
async () => {
|
||||
const running = await startFixture(bashFixture, alternateBashProfile, {}, '/usr/bin:/bin')
|
||||
try {
|
||||
await waitForOutput(running.subscribe, () => running.output().includes('$'))
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
expect(running.session.shellState).toBe('pending')
|
||||
expect(running.output()).not.toContain(COMMAND_OUTPUT)
|
||||
} finally {
|
||||
await running.cleanup()
|
||||
}
|
||||
},
|
||||
10_000
|
||||
)
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user