diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 53501add289..4f667bf2778 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -235,11 +235,14 @@ jobs: fi done echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" - # Why the main repo's tags: package.json on a branch is as stale as the - # main it forked from, and stable patches never merge back into it. - published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ - --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ - --json tagName --jq '.[].tagName' || true)" + # Why git tags, not GitHub releases: unpublishing a buggy cut deletes the + # GitHub release and leaves the tag, which still owns that number. + # Releases-only let adhoc sit on a number already taken, so the updater + # would not install it. Empty on failure — the script then falls back + # to package.json. + published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \ + "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \ + --jq '.[].ref | sub("^refs/tags/"; "")' || true)" ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \ node config/scripts/adhoc-build-version.mjs \ >"$RUNNER_TEMP/adhoc-identity.txt" diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index a758d8db3a1..41b87526fea 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -90,7 +90,7 @@ jobs: uses: actions/checkout@v6 with: ref: main - # Version helpers only read HEAD; published versions come from the release API. + # Version helpers only read HEAD; published versions come from git tags. fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the daily repo through a minted App token passed by env. @@ -209,17 +209,21 @@ jobs: # number free", where a stranded draft still holds one. names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \ --jq '.[].name // empty')" - # Why the main repo's tags decide the base version rather than - # package.json: main's version only moves on `release:` commits, and - # stable patches are cut from release branches that never merge back, so - # package.json can sit several patches behind what users are running. A + # Why git tags, not GitHub releases: unpublishing a buggy cut deletes the + # GitHub release and leaves the tag. That dragged hourlies backwards so + # electron-updater stopped offering them; dailies would do the same. A # separate token because GH_TOKEN above is the App's, scoped to the # daily repo. Empty on failure — the script then falls back to # package.json, which is stale but never wrong enough to fail a build. - published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ - --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ - --json tagName --jq '.[].tagName' || true)" - echo "Highest published tag seen: $(head -1 <<<"$published")" + main_tags="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \ + "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \ + --jq '.[].ref | sub("^refs/tags/"; "")' || true)" + # Already-shipped channel tags are a second floor so unpublishing a + # buggy main release cannot drag this series below a daily already out. + channel_tags="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName \ + --jq '.[].tagName' || true)" + published="$main_tags"$'\n'"$channel_tags" + echo "Published version sources: $(grep -c . <<<"$main_tags" || true) main tags, $(grep -c . <<<"$channel_tags" || true) channel tags" ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \ node config/scripts/daily-build-version.mjs \ >"$RUNNER_TEMP/daily-identity.txt" diff --git a/.github/workflows/git-command-termination-runtime.yml b/.github/workflows/git-command-termination-runtime.yml new file mode 100644 index 00000000000..1602bae956a --- /dev/null +++ b/.github/workflows/git-command-termination-runtime.yml @@ -0,0 +1,22 @@ +name: Git command termination runtime +on: + pull_request: + paths: + - 'src/main/git/command-runner/spawned-command-tree-kill*' + - '.github/workflows/git-command-termination-runtime.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + windows-exit: + runs-on: windows-latest + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Verify exited native child does not trigger taskkill + run: node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/git/command-runner/spawned-command-tree-kill.test.ts diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index b4bdd803a6a..1aed485a666 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -137,7 +137,7 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.head_sha }} - # Version helpers only read HEAD; published versions come from the release API. + # Version helpers only read HEAD; published versions come from git tags. fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the hourly repo through a minted App token passed by env. @@ -213,17 +213,25 @@ jobs: # number free", where a stranded draft still holds one. names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \ --jq '.[].name // empty')" - # Why the main repo's tags decide the base version rather than - # package.json: main's version only moves on `release:` commits, and - # stable patches are cut from release branches that never merge back, so - # package.json can sit several patches behind what users are running. A - # separate token because GH_TOKEN above is the App's, scoped to the - # hourly repo. Empty on failure — the script then falls back to - # package.json, which is stale but never wrong enough to fail a build. - published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ - --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ - --json tagName --jq '.[].tagName' || true)" - echo "Highest published tag seen: $(head -1 <<<"$published")" + # Why git tags, not GitHub releases: unpublishing a buggy cut deletes the + # GitHub release and leaves the tag. On 2026-09-14 we deleted v1.4.202's + # release for a bug; hourlies had already climbed to 1.4.203, then + # `gh release list` fell back to v1.4.201 and the next hourlies shipped + # as 1.4.202-hourly — which electron-updater will not install over + # 1.4.203-hourly or over the still-tagged 1.4.202. A separate token + # because GH_TOKEN above is the App's, scoped to the hourly repo. Empty + # on failure — the script then falls back to package.json, which is + # stale but never wrong enough to fail a build. + main_tags="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \ + "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \ + --jq '.[].ref | sub("^refs/tags/"; "")' || true)" + # Already-shipped channel tags are a second floor: even if main's tag + # list is empty this run, a 1.4.203-hourly already out must not be + # followed by a 1.4.202-hourly. + channel_tags="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName \ + --jq '.[].tagName' || true)" + published="$main_tags"$'\n'"$channel_tags" + echo "Published version sources: $(grep -c . <<<"$main_tags" || true) main tags, $(grep -c . <<<"$channel_tags" || true) channel tags" ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \ node config/scripts/hourly-build-version.mjs \ >"$RUNNER_TEMP/hourly-identity.txt" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b21feae3230..490eda88c33 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -56,6 +56,7 @@ jobs: --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ + --exclude=src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ --exclude=src/main/shell-startup-feature-channel.test.ts \ --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ diff --git a/cloud/apps/relay/src/database-postgres-timeout.test.ts b/cloud/apps/relay/src/database-postgres-timeout.test.ts index f678fecc4bb..df300383c1b 100644 --- a/cloud/apps/relay/src/database-postgres-timeout.test.ts +++ b/cloud/apps/relay/src/database-postgres-timeout.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js' const fakes = vi.hoisted(() => ({ configs: [] as Array>, @@ -119,11 +120,16 @@ describe('PostgreSQL relay deadlines', () => { }) expect(ddl.length).toBeGreaterThan(0) + expect(ddl).toContain(POSTGRES_STATEMENT_STATS_MIGRATION) // Statements can open with a leading `--` rationale comment. const body = (statement: string): string => statement.replace(/^(?:\s*--[^\n]*\n)*\s*/, '') expect( - ddl.every((statement) => /^(?:CREATE|ALTER TABLE)\b/i.test(body(statement))) + ddl.every( + (statement) => + statement === POSTGRES_STATEMENT_STATS_MIGRATION || + /^(?:CREATE|ALTER TABLE)\b/i.test(body(statement)) + ) ).toBe(true) // The backfill is DML, so it stays on the deadline-bearing serving pool. expect(ddl.some((statement) => statement.includes('INSERT INTO'))).toBe(false) diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index 41ead67ea60..84069646b33 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -10,6 +10,8 @@ import { type PostgresPoolPressureCounts } from './postgres-pool-pressure.js' import { applyPostgresSchema } from './postgres-schema-startup.js' +import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js' +import { reportPostgresQueryFailure } from './postgres-query-failure.js' import { CellInventoryHoldSamples, emptyCellInventoryHoldCounts, @@ -619,6 +621,7 @@ CREATE INDEX IF NOT EXISTS relay_audit_events_at ON relay_audit_events(at); // auto-named; the replacement is named, so both statements are no-ops on a // database the current schema created and neither can drop the other. export const POSTGRES_SCHEMA_MIGRATIONS = [ + POSTGRES_STATEMENT_STATS_MIGRATION, `ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS last_considered_at BIGINT NOT NULL DEFAULT 0`, `ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS cohort_bucket BIGINT NOT NULL DEFAULT 0`, `ALTER TABLE relay_region_rehome_attempts @@ -908,12 +911,25 @@ class PostgresDatabase implements RelayDatabase { } async query(sql: string, params: unknown[] = []): Promise { - const client = await this.pressure.connect() + const startedAt = performance.now() + let phase: 'acquire' | 'execute' = 'acquire' + let client: pg.PoolClient | undefined try { + client = await this.pressure.connect() + phase = 'execute' const result = await client.query(postgresSql(sql), params) return returnsRows(sql) ? (result.rows as SqlRow[]) : [{ changes: result.rowCount ?? 0 }] + } catch (error) { + reportPostgresQueryFailure({ + error, + phase, + sql, + elapsedMs: performance.now() - startedAt, + pool: this.pool + }) + throw error } finally { - client.release() + client?.release() } } diff --git a/cloud/apps/relay/src/postgres-query-failure-postgres.test.ts b/cloud/apps/relay/src/postgres-query-failure-postgres.test.ts new file mode 100644 index 00000000000..df082de289b --- /dev/null +++ b/cloud/apps/relay/src/postgres-query-failure-postgres.test.ts @@ -0,0 +1,70 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { openRelayDatabase, type RelayDatabase } from './database.js' + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip + +describePostgres('real PostgreSQL query failure phases', () => { + let database: RelayDatabase + + beforeAll(async () => { + database = await openRelayDatabase({ + databaseUrl, + dataDir: '', + poolMax: 1, + statementTimeoutMs: 50 + }) + }) + afterAll(async () => { + await database.close() + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('distinguishes a server statement timeout and leaves the pool usable', async () => { + const log = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await expect(database.query('SELECT pg_sleep(0.2)')).rejects.toMatchObject({ code: '57014' }) + expect(JSON.parse(log.mock.calls[0]![0] as string)).toMatchObject({ + event: 'orca_relay_postgres_query_failed', + phase: 'execute', + code: '57014', + connectionTimeout: false + }) + expect(await database.query('SELECT 1 AS ok')).toEqual([{ ok: 1 }]) + }) + + it('distinguishes queue acquisition timeout without running the statement', async () => { + const log = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + let acquired!: () => void + const ready = new Promise((resolve) => { + acquired = resolve + }) + let release!: () => void + const wait = new Promise((resolve) => { + release = resolve + }) + const holder = database.transaction(async () => { + acquired() + await wait + }) + await ready + try { + await expect(database.query('SELECT pg_sleep(0.2)')).rejects.toThrow( + 'timeout exceeded when trying to connect' + ) + expect(JSON.parse(log.mock.calls[0]![0] as string)).toMatchObject({ + event: 'orca_relay_postgres_query_failed', + phase: 'acquire', + code: 'unknown', + connectionTimeout: true, + poolTotal: 1, + poolIdle: 0 + }) + } finally { + release() + await holder + } + expect(await database.query('SELECT 1 AS ok')).toEqual([{ ok: 1 }]) + }) +}) diff --git a/cloud/apps/relay/src/postgres-query-failure.test.ts b/cloud/apps/relay/src/postgres-query-failure.test.ts new file mode 100644 index 00000000000..7b42b6f5cb1 --- /dev/null +++ b/cloud/apps/relay/src/postgres-query-failure.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const fakes = vi.hoisted(() => ({ + connectError: undefined as unknown, + query: vi.fn(async (_sql: string, _params?: unknown[]) => ({ rows: [], rowCount: 0 })), + release: vi.fn() +})) + +vi.mock('pg', () => ({ + default: { + Pool: class { + totalCount = 10 + idleCount = 0 + waitingCount = 7 + on = vi.fn() + async connect() { + if (fakes.connectError) throw fakes.connectError + return { query: fakes.query, release: fakes.release } + } + async end() {} + } + } +})) + +import { openRelayDatabase, type RelayDatabase } from './database.js' + +describe('PostgreSQL query failure diagnostics', () => { + let database: RelayDatabase + const sql = 'WITH assignment_state AS MATERIALIZED (SELECT $1) SELECT * FROM assignment_state' + + beforeEach(async () => { + fakes.connectError = undefined + fakes.query.mockReset().mockResolvedValue({ rows: [], rowCount: 0 }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + database = await openRelayDatabase({ databaseUrl: 'postgres://unused', dataDir: '' }) + fakes.query.mockClear() + fakes.release.mockClear() + vi.mocked(console.warn).mockClear() + }) + + afterEach(async () => { + await database.close() + vi.restoreAllMocks() + }) + + it('identifies acquisition failure without issuing SQL or changing the error', async () => { + const error = new Error('timeout exceeded when trying to connect: private detail') + fakes.connectError = error + await expect(database.query(sql, ['private-token'])).rejects.toBe(error) + expect(fakes.query).not.toHaveBeenCalled() + expect(fakes.release).not.toHaveBeenCalled() + expect(JSON.parse(vi.mocked(console.warn).mock.calls[0]![0] as string)).toEqual({ + event: 'orca_relay_postgres_query_failed', + phase: 'acquire', + operation: 'control-renewal', + code: 'unknown', + connectionTimeout: true, + elapsedMs: expect.any(Number), + poolTotal: 10, + poolIdle: 0, + poolWaiting: 7 + }) + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain('private') + }) + + it.each(['57014', '55P03', 'ECONNRESET'])( + 'identifies execute failure %s and releases its client', + async (code) => { + const error = Object.assign(new Error('private-token'), { code, detail: sql }) + fakes.query.mockRejectedValueOnce(error) + await expect(database.query(sql, ['private-token'])).rejects.toBe(error) + expect(fakes.query).toHaveBeenCalledOnce() + expect(fakes.release).toHaveBeenCalledOnce() + expect(JSON.parse(vi.mocked(console.warn).mock.calls[0]![0] as string)).toMatchObject({ + phase: 'execute', + operation: 'control-renewal', + code, + connectionTimeout: false + }) + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain('private-token') + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain(sql) + } + ) + + it('does not emit an arbitrary error code, message, query, or parameter', async () => { + const error = { code: 'private-code', message: 'private-message' } + fakes.query.mockRejectedValueOnce(error) + await expect(database.query('SELECT private_column', ['private-param'])).rejects.toBe(error) + const log = vi.mocked(console.warn).mock.calls[0]![0] as string + expect(JSON.parse(log)).toMatchObject({ operation: 'other', code: 'unknown' }) + expect(log).not.toContain('private') + }) + + it('keeps the original error and releases the client if logging fails', async () => { + const error = new Error('database failure') + fakes.query.mockRejectedValueOnce(error) + vi.mocked(console.warn).mockImplementationOnce(() => { + throw new Error('logger failure') + }) + await expect(database.query(sql)).rejects.toBe(error) + expect(fakes.release).toHaveBeenCalledOnce() + }) + + it('does not log successful queries', async () => { + await database.query(sql) + expect(console.warn).not.toHaveBeenCalled() + expect(fakes.release).toHaveBeenCalledOnce() + }) +}) diff --git a/cloud/apps/relay/src/postgres-query-failure.ts b/cloud/apps/relay/src/postgres-query-failure.ts new file mode 100644 index 00000000000..26b536c5134 --- /dev/null +++ b/cloud/apps/relay/src/postgres-query-failure.ts @@ -0,0 +1,55 @@ +type QueryFailurePhase = 'acquire' | 'execute' + +const ERROR_CODES = new Set([ + '57014', + '55P03', + '40P01', + '40001', + '53300', + '57P01', + '57P02', + '57P03', + '08000', + '08001', + '08003', + '08006', + 'ECONNRESET', + 'ECONNREFUSED', + 'ETIMEDOUT', + 'EPIPE' +]) + +export function reportPostgresQueryFailure(input: { + error: unknown + phase: QueryFailurePhase + sql: string + elapsedMs: number + pool: { totalCount: number; idleCount: number; waitingCount: number } +}): void { + // Emit only bounded categories: error messages and SQL can contain credentials or identities. + try { + const error = input.error as { code?: unknown; message?: unknown } | null + const code = + typeof error?.code === 'string' && ERROR_CODES.has(error.code) ? error.code : 'unknown' + const connectionTimeout = + typeof error?.message === 'string' && + error.message.includes('timeout exceeded when trying to connect') + console.warn( + JSON.stringify({ + event: 'orca_relay_postgres_query_failed', + phase: input.phase, + operation: /^\s*WITH\s+assignment_state\s+AS\s+MATERIALIZED\b/i.test(input.sql) + ? 'control-renewal' + : 'other', + code, + connectionTimeout, + elapsedMs: Math.max(0, Math.round(input.elapsedMs)), + poolTotal: input.pool.totalCount, + poolIdle: input.pool.idleCount, + poolWaiting: input.pool.waitingCount + }) + ) + } catch { + // Diagnostics must not replace the original database failure. + } +} diff --git a/cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts b/cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts new file mode 100644 index 00000000000..251e3670759 --- /dev/null +++ b/cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts @@ -0,0 +1,121 @@ +import { randomUUID } from 'node:crypto' +import pg from 'pg' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { openRelayDatabase } from './database.js' +import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js' + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip + +describePostgres('optional PostgreSQL statement statistics', () => { + let admin: pg.Client + let preloaded: boolean + const databases: string[] = [] + const roles: string[] = [] + + beforeAll(async () => { + admin = new pg.Client({ connectionString: databaseUrl }) + await admin.connect() + const result = await admin.query<{ loaded: boolean }>( + `SELECT 'pg_stat_statements' = ANY(string_to_array( + replace(current_setting('shared_preload_libraries'), ' ', ''), ',' + )) AS loaded` + ) + preloaded = result.rows[0]!.loaded + }) + + afterAll(async () => { + for (const database of databases) await admin.query(`DROP DATABASE IF EXISTS ${database}`) + for (const role of roles) await admin.query(`DROP ROLE IF EXISTS ${role}`) + await admin.end() + }) + + async function freshDatabase(): Promise { + const name = `relay_stats_${randomUUID().replaceAll('-', '')}` + await admin.query(`CREATE DATABASE ${name}`) + databases.push(name) + const url = new URL(databaseUrl!) + url.pathname = `/${name}` + return url.toString() + } + + async function connect(url: string): Promise { + const client = new pg.Client({ connectionString: url, statement_timeout: 2_000 }) + await client.connect() + return client + } + + async function installed(client: pg.Client): Promise { + const result = await client.query<{ present: boolean }>( + `SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') AS present` + ) + return result.rows[0]!.present + } + + it('exposes an existing collector idempotently, and skips servers without one', async () => { + const url = await freshDatabase() + const database = await openRelayDatabase({ databaseUrl: url, dataDir: '' }) + await database.close() + const client = await connect(url) + try { + expect(await installed(client)).toBe(preloaded) + if (preloaded) { + const before = await client.query('SELECT stats_reset FROM public.pg_stat_statements_info') + await client.query(POSTGRES_STATEMENT_STATS_MIGRATION) + const after = await client.query('SELECT stats_reset FROM public.pg_stat_statements_info') + expect(after.rows).toEqual(before.rows) + await client.query('SELECT calls, wal_bytes, shared_blks_dirtied FROM public.pg_stat_statements LIMIT 1') + } else { + await client.query(POSTGRES_STATEMENT_STATS_MIGRATION) + expect(await installed(client)).toBe(false) + } + } finally { + await client.end() + } + }) + + it.each([false, true])('tolerates missing extension privileges (read settings: %s)', async (readSettings) => { + const client = await connect(await freshDatabase()) + const role = `relay_stats_role_${randomUUID().replaceAll('-', '')}` + await admin.query(`CREATE ROLE ${role}`) + roles.push(role) + if (readSettings) await admin.query(`GRANT pg_read_all_settings TO ${role}`) + try { + await client.query(`SET ROLE ${role}`) + await client.query(POSTGRES_STATEMENT_STATS_MIGRATION) + expect(await installed(client)).toBe(false) + expect((await client.query<{ value: number }>('SELECT 42 AS value')).rows[0]!.value).toBe(42) + } finally { + await client.end() + } + }) + + it('serializes concurrent catalog creation across directors', async () => { + const url = await freshDatabase() + const clients = await Promise.all(Array.from({ length: 5 }, async () => await connect(url))) + try { + await Promise.all(clients.map(async (client) => await client.query(POSTGRES_STATEMENT_STATS_MIGRATION))) + expect(await installed(clients[0]!)).toBe(preloaded) + } finally { + await Promise.all(clients.map(async (client) => await client.end())) + } + }) + + it('yields to an in-progress installer instead of blocking startup', async () => { + const url = await freshDatabase() + const owner = await connect(url) + const contender = await connect(url) + try { + await owner.query('BEGIN') + await owner.query(`SELECT pg_advisory_xact_lock(hashtext('orca-relay'), hashtext('statement-stats'))`) + await contender.query(POSTGRES_STATEMENT_STATS_MIGRATION) + expect(await installed(contender)).toBe(false) + await owner.query('COMMIT') + await contender.query(POSTGRES_STATEMENT_STATS_MIGRATION) + expect(await installed(contender)).toBe(preloaded) + } finally { + await owner.end() + await contender.end() + } + }) +}) diff --git a/cloud/apps/relay/src/postgres-statement-stats.ts b/cloud/apps/relay/src/postgres-statement-stats.ts new file mode 100644 index 00000000000..61a2fee3b75 --- /dev/null +++ b/cloud/apps/relay/src/postgres-statement-stats.ts @@ -0,0 +1,28 @@ +// Expose an already-running collector; never preload a module or require elevated runtime privileges. +export const POSTGRES_STATEMENT_STATS_MIGRATION = ` +DO $relay_statement_stats$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_settings + WHERE name = 'shared_preload_libraries' + AND 'pg_stat_statements' = ANY(string_to_array(replace(setting, ' ', ''), ',')) + ) OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements' + ) OR NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_available_extensions WHERE name = 'pg_stat_statements' + ) THEN + RETURN; + END IF; + + IF NOT pg_try_advisory_xact_lock(hashtext('orca-relay'), hashtext('statement-stats')) THEN + RETURN; + END IF; + + BEGIN + CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA public; + EXCEPTION WHEN insufficient_privilege THEN + RAISE WARNING 'orca_relay_statement_stats_unavailable: insufficient privilege'; + END; +END +$relay_statement_stats$; +` diff --git a/cloud/docs/orca-relay-operations.md b/cloud/docs/orca-relay-operations.md index 4515048b29b..ea5717daa87 100644 --- a/cloud/docs/orca-relay-operations.md +++ b/cloud/docs/orca-relay-operations.md @@ -2,6 +2,21 @@ This runbook applies to the stable Cloud Run director and the production-shaped GCE cells in both environments. It does not authorize a full Terraform apply: staging and production contain unrelated drift, so inspect a saved targeted plan and its destroy count before every apply. +## PostgreSQL statement statistics + +Relay schema startup exposes `pg_stat_statements` when the server already preloads +that collector and the schema identity can install its extension. Servers without +the collector or the required privileges continue normally. Installation does not +change preload settings, reset collected counters, or require a database restart; +concurrent startups yield to one installer. An existing extension is left in place. + +For SQL incidents, inspect bounded aggregates of `calls`, `total_exec_time`, +`shared_blks_read`, `shared_blks_dirtied`, and `wal_bytes`, scoped to the relay +database and identified query IDs. Compare counter deltas over the same interval +as fleet runtime metrics; retain the statistics reset timestamp. Do not export +query text, identities, credentials, or invoke `pg_stat_statements_reset()` during +an investigation. Treat an unavailable view as missing evidence, not zero work. + The relay is automatically active for entitled signed-in desktops. There is no rollout flag, cohort, or user toggle. The emergency product kill switch is the auth plane refusing relay-token exchange; use cell drains only to move or terminate existing data-plane work. ## Safety rules diff --git a/cloud/docs/relay-database-failure-diagnostics.md b/cloud/docs/relay-database-failure-diagnostics.md new file mode 100644 index 00000000000..26d3d847a17 --- /dev/null +++ b/cloud/docs/relay-database-failure-diagnostics.md @@ -0,0 +1,29 @@ +# Relay database failure phases + +`orca_relay_postgres_query_failed` separates failure to acquire a pooled connection +(`phase=acquire`) from failure after acquisition (`phase=execute`). It covers +`PostgresDatabase.query`, including the single-statement control-renewal CTE. +Statements inside explicit transactions use a different query path and are not +covered. These events are diagnostic evidence, not a replacement for total SQL +failure counters. + +The event contains only an allowlisted error code, a connection-timeout boolean, +the operation category (`control-renewal` or `other`), total elapsed milliseconds, +and pool total/idle/waiting counts at failure. Total elapsed time includes acquisition. +An acquisition timeout can mean either waiting in the queue or establishing a new +connection; use the pool counts and independent server activity to distinguish them. +Unknown error codes stay `unknown`. Query text, parameters, error messages, and +identifiers are never emitted. Successful queries emit no additional event. + +Use structured GCE logs with `jsonPayload.event="orca_relay_postgres_query_failed"`. +Compare counts by phase, operation, and code with the same cell's renewal outcomes +and pool pressure, and with independent PostgreSQL wait samples. Establishing the +failure phase does not by itself establish why the pool backed up. + +For production observation, use an immutable image through the same-cap workflow +on one cell, with fresh monitor evidence and the exact predecessor digest. Verify +the serving digest and health, then inspect these events during a naturally +occurring failure. Do not deliberately induce a production database failure. +Rollback uses the same workflow and predecessor image; no schema or database +configuration changes are involved. Do not change rehome limits, timeouts, pool +sizes, or renewal scheduling merely to collect this evidence. diff --git a/config/scripts/dev-channel-base-version.mjs b/config/scripts/dev-channel-base-version.mjs index 62a28c6a374..074af9ec3d9 100644 --- a/config/scripts/dev-channel-base-version.mjs +++ b/config/scripts/dev-channel-base-version.mjs @@ -25,8 +25,11 @@ function compareTriples(a, b) { * 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and * 1.4.167 all shipped — so hourlies built from that main claimed 1.4.165 while * carrying code newer than 1.4.167, and sorted *below* the stable their user was - * already running. Published tags are the only honest answer to "what number is - * taken"; package.json is a floor, not a source of truth. + * already running. Git tags (not GitHub releases) are the honest answer to "what + * number is taken": unpublishing a buggy cut deletes the GitHub release and + * leaves the tag, which still owns that number. Channel tags (`1.4.203-hourly.*`) + * are a second floor so that unpublish cannot drag the series backwards. + * package.json is a floor, not a source of truth. */ export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) { const fromPackage = parseVersionTriple(packageVersion) diff --git a/config/scripts/dev-channel-base-version.test.mjs b/config/scripts/dev-channel-base-version.test.mjs index d2631eff0e9..00c13f6817a 100644 --- a/config/scripts/dev-channel-base-version.test.mjs +++ b/config/scripts/dev-channel-base-version.test.mjs @@ -39,6 +39,27 @@ describe('dev channel base version', () => { ) }) + // Why tags rather than GitHub releases: unpublishing a buggy cut deletes the + // GitHub release and leaves the tag. Releases-only then treated 1.4.202 as + // free, so hourlies sat on 1.4.202-hourly and sorted below that tagged stable. + it('climbs past a tagged stable that has no GitHub release', () => { + expect(resolveDevChannelBaseVersion('1.4.197', ['v1.4.201', 'v1.4.202'])).toBe('1.4.203') + }) + + // 2026-09-14: v1.4.202's GitHub release was deleted for a bug after hourlies + // had already shipped as 1.4.203. Without the channel tags as a floor, the + // next hourlies would have been 1.4.202-hourly, which electron-updater will + // not install over 1.4.203-hourly. + it('does not drop below an already-published channel version', () => { + expect( + resolveDevChannelBaseVersion('1.4.197', [ + 'v1.4.201', + 'v1.4.202-hourly.202609141912', + 'v1.4.203-hourly.202609140417' + ]) + ).toBe('1.4.203') + }) + it('treats package.json as a floor when it leads the tags', () => { expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0') }) diff --git a/config/scripts/hourly-build-version.test.mjs b/config/scripts/hourly-build-version.test.mjs index 08fc9d28b81..7438b16acbd 100644 --- a/config/scripts/hourly-build-version.test.mjs +++ b/config/scripts/hourly-build-version.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createHourlyBuildVersion, formatHourlyReleaseName, + getHourlyBuildIdentity, nextHourlyBuildNumber } from './hourly-build-version.mjs' import { compareAppVersions } from '../../src/shared/app-version' @@ -120,3 +121,26 @@ describe('nextHourlyBuildNumber', () => { expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1) }) }) + +describe('getHourlyBuildIdentity', () => { + // 2026-09-14: v1.4.202's GitHub release was deleted for a bug after hourlies + // had climbed to 1.4.203. Passing the leftover tag and the already-shipped + // hourly keeps the next build on 1.4.203 so electron-updater will still + // install it. + it('stays on the already-shipped hourly base after a buggy main release is unpublished', () => { + const identity = getHourlyBuildIdentity(new Date('2026-09-14T20:00:00Z'), { + publishedVersions: [ + 'v1.4.201', + 'v1.4.202', + 'v1.4.202-hourly.202609141912', + 'v1.4.203-hourly.202609140417' + ], + releaseNames: [ + '1.4.202 • 14 • Sep 14, 12:12PM • 875b86d', + '1.4.203 • 04 • Sep 13, 9:17PM • 2ce252f' + ] + }) + expect(identity.version).toBe('1.4.203-hourly.202609142000') + expect(identity.buildNumber).toBe(5) + }) +}) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index ed4e1b1f1c8..d18837a1573 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -15,6 +15,7 @@ const shellContractFiles = [ 'src/main/daemon/shell-ready.test.ts', 'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts', 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', + 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', 'src/main/shell-startup-feature-channel.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', diff --git a/config/scripts/workflow-ref-mirror-case-safety.test.mjs b/config/scripts/workflow-ref-mirror-case-safety.test.mjs index 497a58653a0..8ebb2fced9d 100644 --- a/config/scripts/workflow-ref-mirror-case-safety.test.mjs +++ b/config/scripts/workflow-ref-mirror-case-safety.test.mjs @@ -30,6 +30,25 @@ describe('ref-mirroring vet steps', () => { ).toBe(true) }) + // Why matching-refs rather than `gh release list` on the main repo: a tagged + // stable still owns its number after its GitHub release is unpublished for a + // bug, and that unpublish must not drag the channel backwards. + it.each(['daily', 'hourly', 'adhoc'])( + '%s versions from git tags, not main GitHub releases', + (channel) => { + const step = readWorkflow(`.github/workflows/${channel}-mac-build.yml`).jobs[ + `build-${channel}-mac` + ].steps.find((candidate) => candidate.name === `Compute ${channel} version`) + expect(step.run).toContain('git/matching-refs/tags/v') + expect(step.run).not.toMatch( + /gh release list[\s\S]*--repo "\$GITHUB_REPOSITORY"[\s\S]*--json tagName/ + ) + if (channel !== 'adhoc') { + expect(step.run).toContain('channel_tags=') + } + } + ) + it('retains release-cut history for version reservation and retry ancestry', () => { const checkout = readWorkflow('.github/workflows/release-cut.yml').jobs.cut.steps.find( (step) => step.uses === 'actions/checkout@v6' diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 7a0586b8b1e..bd7a18f8488 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 53m + + downloads: 54m @@ -15,7 +15,7 @@ downloads downloads - 53m - 53m + 54m + 54m diff --git a/docs/reference/malformed-worktree-registration-removal.md b/docs/reference/malformed-worktree-registration-removal.md new file mode 100644 index 00000000000..34a32e58caf --- /dev/null +++ b/docs/reference/malformed-worktree-registration-removal.md @@ -0,0 +1,43 @@ +# Malformed worktree registration removal + +Git can report a linked worktree at `/.git` when its administrative +`gitdir` backlink incorrectly ends in `.git/.git`. That reproduces #17316's +validation error. The reproduction establishes the malformed registration, not +which program created it; current OMP uses ordinary `git worktree add`. + +Orca's desktop and runtime removal entry points use registration-only recovery +when Git positively marks the row prunable, the row has a named local branch and +HEAD, it is neither main nor locked, and the execution filesystem confirms the +selected `.git` path is a regular file. Missing or unknown evidence does not +permit this recovery. A symlink or directory is not a regular-file proof. + +Recovery reuses `git worktree prune` followed by a strict worktree listing that +must confirm the selected registration is gone. It does not delete the selected +file, infer a parent path for deletion, or delete the branch. Archive hooks and +checkout teardown are skipped because the selected row is not a checkout. + +Two consequences are intentional: + +- Git's prune also clears other stale, unlocked registrations in the repository; + it is not a path-scoped command. Live and locked registrations remain Git's + responsibility, and Orca verifies that the requested registration disappeared. +- The surviving checkout's `.git` file points at removed administrative metadata. + Files and its named branch are preserved; recovery removes the broken navigation + entry and does not repair or claim to restore that checkout. + +Native and WSL checks use the existing execution-filesystem accessor. WSL prune +and verification use the same selected distro. Paired runtimes run the recovery +on their owning host. Direct SSH does not enter this local recovery: its current +provider has no registration-only removal operation, and a failed remote removal +never authorizes a local fallback. + +The Git commands already exist in the 2.25-compatible cleanup path. On an older +Git that cannot positively attest this file-shaped registration as prunable, Orca +refuses this recovery. Deferred deletion independently rejects non-directory and +symlink targets, so force cannot move a `.git` file into deletion trash. + +Regression coverage is in `worktree-prunable-git-file.test.ts`, +`worktrees-removal-recovery.test.ts`, and +`worktree-deferred-removal-real-git.test.ts`. The latter reproduces the exact +malformation against the installed Git binary in a disposable repository and +checks surviving file contents, branch HEAD, and removed registration. diff --git a/docs/reference/omp-history-titles.md b/docs/reference/omp-history-titles.md new file mode 100644 index 00000000000..f238574ef2a --- /dev/null +++ b/docs/reference/omp-history-titles.md @@ -0,0 +1,34 @@ +# OMP history titles + +The message-graph scanner uses persisted OMP names ahead of the first user prompt: +`session.title`, version-1 `title` slots, `title_change.title`, and legacy +`session_info.name`. Empty or unsupported metadata leaves the previous name or +prompt fallback intact. Non-OMP graph parsing keeps its existing title policy. + +Explicit user names outrank automatic names. Within the same source, timestamps +prevent the current first-line title slot from being replaced by older rename +entries later in the file. Newer appended renames still update the row. Legacy +records without timestamps retain file-order handling. + +The graph fold stores title authority alongside the existing accumulator. Clones +retain it without sharing mutable accumulator or preview state, while preserving +the existing identity and message-consumer contracts. Cached append parsing uses +the normal durable offset; no extra scan, process, poll or watcher is introduced. + +The parser is shared by local and remote content readers and uses transcript data +from the execution host. It performs no client-side path lookup and changes no +wire shape. Folder workspaces require no git metadata. + +Run actual persistence and cache validation with a read-only OMP checkout: + +```sh +ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-history-title-smoke.mjs /path/to/oh-my-pi +``` + +The smoke persists a first prompt, performs a real OMP user rename, and verifies +both cold and incrementally cached scans. It checks one full parse, one append +parse and identical-object reuse on an unchanged scan. All home/config/data roots +are disposable; no model requests are made. + +This is the OMP subset of the history-name behavior proposed in PR #15696 by +Brennan Benson. Pi naming and title changes in the terminal are separate concerns. diff --git a/mobile/README.md b/mobile/README.md index 64f1081b73c..e78c2c410bf 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -184,6 +184,7 @@ Connect from the app using endpoint `ws://localhost:6768` and token `mock-device ### Environment variables - `MOCK_NATIVE_CHAT=1` — serve the native-chat scenario (one live agent tab, empty transcript, image upload) instead of the default terminal fixtures. +- `MOCK_CHAT_AGENT=omp` — with `MOCK_NATIVE_CHAT=1`, present an OMP tab and four decoded transcript messages, including a tool call and result, instead of the default Claude scenario. It deliberately omits `transcriptPath` to exercise legacy-hook readability discovery; current OMP hooks may report a path. - `MOCK_SERVER_KEY_FILE` — persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair. ### Scenario control files diff --git a/mobile/app.json b/mobile/app.json index 6121923f775..cfe66109e22 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.48", + "version": "0.0.50", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", diff --git a/mobile/scripts/mock-server-native-chat-scenario.ts b/mobile/scripts/mock-server-native-chat-scenario.ts index d2f65e0b635..b9d18348f7b 100644 --- a/mobile/scripts/mock-server-native-chat-scenario.ts +++ b/mobile/scripts/mock-server-native-chat-scenario.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { WebSocket } from 'ws' import type { AgentStatusEntry } from '../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../src/shared/native-chat-types' import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionTerminalClientTab @@ -25,6 +26,10 @@ const TAB_ID = 'chat-tab-1' const SESSION_ID = 'mock-chat-session' const TRANSCRIPT_PATH = join(tmpdir(), 'mock-transcript.jsonl') const MOCK_IMAGE_PATH = join(tmpdir(), 'mock-image.png') +// Exercise legacy OMP hooks without a transcript path; current hooks may include one. +const CHAT_AGENT = process.env.MOCK_CHAT_AGENT === 'omp' ? 'omp' : 'claude' +const CHAT_TITLE = CHAT_AGENT === 'omp' ? 'OMP' : 'Claude Code' +const TRANSCRIPT_START = Date.now() - 1000 * 60 * 5 function readControl(file: string): string { try { @@ -41,28 +46,27 @@ const agentStatus: AgentStatusEntry = { prompt: '', updatedAt: Date.now(), stateStartedAt: Date.now(), - agentType: 'claude', + agentType: CHAT_AGENT, paneKey: `${TAB_ID}:leaf-1`, terminalHandle: TERMINAL_HANDLE, stateHistory: [], - providerSession: { - key: 'session_id', - id: SESSION_ID, - transcriptPath: TRANSCRIPT_PATH - } + providerSession: + CHAT_AGENT === 'omp' + ? { key: 'session_id', id: SESSION_ID } + : { key: 'session_id', id: SESSION_ID, transcriptPath: TRANSCRIPT_PATH } } function buildTab(): RuntimeMobileSessionTerminalClientTab { return { type: 'terminal', id: TAB_ID, - title: 'Claude Code', + title: CHAT_TITLE, parentTabId: TAB_ID, leafId: 'leaf-1', ptyId: 'pty-1', status: 'ready', terminal: TERMINAL_HANDLE, - launchAgent: 'claude', + launchAgent: CHAT_AGENT, agentStatus, viewMode: 'chat', isActive: true @@ -104,6 +108,51 @@ function worktreeOf(request: RpcRequest): string { return typeof raw === 'string' ? raw : 'id:mock-worktree' } +// Why: shapes mirror what the runtime's omp decoder emits for a real session +// (thinking→text on the assistant turn, toolCall blocks, toolResult turns), so +// the phone exercises the same render path a live omp pane would. +function mockTranscript(): NativeChatMessage[] { + if (CHAT_AGENT !== 'omp') { + return [] + } + const t = TRANSCRIPT_START + return [ + { + id: 'omp-1', + role: 'user', + blocks: [{ type: 'text', text: 'why is my deploy failing?' }], + timestamp: t, + source: 'transcript' + }, + { + id: 'omp-2', + role: 'assistant', + blocks: [ + { type: 'text', text: 'Let me check the deploy logs first.' }, + { type: 'tool-call', name: 'bash', input: { command: 'kubectl get pods' } } + ], + timestamp: t + 1000, + source: 'transcript' + }, + { + id: 'omp-3', + role: 'tool', + blocks: [{ type: 'tool-result', output: 'api-7f9c 0/1 CrashLoopBackOff' }], + timestamp: t + 2000, + source: 'transcript' + }, + { + id: 'omp-4', + role: 'assistant', + blocks: [ + { type: 'text', text: 'The API pod is crash-looping. Check its logs with kubectl logs.' } + ], + timestamp: t + 3000, + source: 'transcript' + } + ] +} + // Why: unsubscribe correlates by worktree, not request id, and a socket that // navigates A->B->A would otherwise stack one push loop per subscribe. const tabsPushLoops = new Map>>() @@ -144,7 +193,7 @@ type Respond = (response: RpcResponse) => void type Success = (id: string, result: unknown, streaming?: boolean) => RpcResponse type Failure = (id: string, code: string, message: string) => RpcResponse -/** Mock backend for the native-chat surface: session tabs, an empty transcript +/** Mock backend for the native-chat surface: session tabs, a fixture transcript * snapshot, terminal send, and image upload. Opt-in via MOCK_NATIVE_CHAT=1 * because it replaces the default terminal fixtures. No transcript or terminal * output frames are pushed. Returns false for methods it does not own. */ @@ -194,7 +243,7 @@ export function handleMockNativeChatRequest( const entry = (handle: string) => ({ handle, worktreeId, - title: 'Claude Code', + title: CHAT_TITLE, isActive: true, hasRunningProcess: true }) @@ -209,11 +258,13 @@ export function handleMockNativeChatRequest( } case 'nativeChat.subscribe': - respond(success(request.id, { type: 'snapshot', messages: [], hasMore: false }, true)) + respond( + success(request.id, { type: 'snapshot', messages: mockTranscript(), hasMore: false }, true) + ) return true case 'nativeChat.readSession': - respond(success(request.id, { messages: [], hasMore: false })) + respond(success(request.id, { messages: mockTranscript(), hasMore: false })) return true case 'terminal.subscribe': { diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 389beb8eaad..a72b91ff29c 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -83,6 +83,7 @@ export function MobileNativeChatOverlay({ onDismissAsk={controller.dismissNativeChatAsk} onAnswerAsk={controller.handleNativeChatAnswerAsk} onCancelAsk={controller.handleNativeChatCancelAsk} + onCancelPrompt={controller.handleNativeChatCancelPrompt} question={controller.nativeChatQuestion} onAnswerQuestion={controller.handleNativeChatQuestionAnswer} permission={controller.nativeChatPermission} diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts index b39188b381f..88de20a7ca5 100644 --- a/mobile/src/session/MobileNativeChatPermission.test.ts +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -10,7 +10,7 @@ vi.mock('react-native', () => ({ View: 'View' })) -vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion' })) +vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion', X: 'X' })) describe('MobileNativeChatPermission', () => { let renderer: ReactTestRenderer | null = null @@ -42,4 +42,24 @@ describe('MobileNativeChatPermission', () => { expect(onRespond).toHaveBeenCalledOnce() await act(async () => resolveResponse(true)) }) + + it('passes the rendered prompt identity to cancel', async () => { + const onCancel = vi.fn(async () => true) + await act(async () => { + renderer = create( + createElement(MobileNativeChatPermission, { + permission: { + title: 'Approve?', + prompt: { itemId: 'approval-1', expectedRevision: 4 }, + options: [{ label: 'Allow', send: '1' }] + }, + onRespond: vi.fn(async () => true), + onCancel + }) + ) + }) + const cancel = renderer.root.findByProps({ accessibilityLabel: 'Cancel' }) + await act(async () => cancel.props.onPress()) + expect(onCancel).toHaveBeenCalledWith({ itemId: 'approval-1', expectedRevision: 4 }) + }) }) diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx index ad26d578d93..47ad7a52022 100644 --- a/mobile/src/session/MobileNativeChatPermission.tsx +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -1,6 +1,6 @@ import { memo, useRef, useState } from 'react' import { Pressable, StyleSheet, Text, View } from 'react-native' -import { ShieldQuestion } from 'lucide-react-native' +import { ShieldQuestion, X } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import type { MobileChatPermission } from './mobile-native-chat-permission' @@ -9,10 +9,12 @@ import type { MobileChatPermission } from './mobile-native-chat-permission' // accent button so the affirmative choice reads as distinct from the rest. function MobileNativeChatPermissionImpl({ permission, - onRespond + onRespond, + onCancel }: { permission: MobileChatPermission onRespond: (send: string) => Promise + onCancel?: (prompt?: NonNullable) => Promise }): React.JSX.Element { const [submitting, setSubmitting] = useState(false) const submittingRef = useRef(false) @@ -33,6 +35,17 @@ function MobileNativeChatPermissionImpl({ {permission.title} + {onCancel ? ( + void onCancel(permission.prompt)} + disabled={submitting} + > + + + ) : null} {permission.detail ? {permission.detail} : null} @@ -80,10 +93,17 @@ const styles = StyleSheet.create({ gap: spacing.sm }, title: { + flex: 1, color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, + cancel: { + width: 28, + height: 28, + alignItems: 'center', + justifyContent: 'center' + }, detail: { color: colors.textSecondary, fontSize: typography.metaSize, diff --git a/mobile/src/session/MobileNativeChatPromptCard.tsx b/mobile/src/session/MobileNativeChatPromptCard.tsx index 470ba2ee8b6..31a007801dc 100644 --- a/mobile/src/session/MobileNativeChatPromptCard.tsx +++ b/mobile/src/session/MobileNativeChatPromptCard.tsx @@ -15,6 +15,7 @@ export function MobileNativeChatPromptCard({ onDismissAsk, onAnswerAsk, onCancelAsk, + onCancelPrompt, permission, onRespondPermission, question, @@ -25,6 +26,7 @@ export function MobileNativeChatPromptCard({ onDismissAsk?: () => void onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise onCancelAsk?: () => Promise + onCancelPrompt?: (prompt?: NonNullable) => Promise permission?: MobileChatPermission | null onRespondPermission?: (send: string) => Promise question?: MobileChatQuestion | null @@ -58,6 +60,7 @@ export function MobileNativeChatPromptCard({ key={JSON.stringify(permission)} permission={permission} onRespond={async (send) => (await onRespondPermission?.(send)) ?? false} + onCancel={onCancelPrompt} /> ) } @@ -67,6 +70,7 @@ export function MobileNativeChatPromptCard({ key={mobileChatQuestionKey(question)} question={question} onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false} + onCancel={onCancelPrompt} /> ) } diff --git a/mobile/src/session/MobileNativeChatQuestion.test.tsx b/mobile/src/session/MobileNativeChatQuestion.test.tsx index be9777a0b69..96cae6556a4 100644 --- a/mobile/src/session/MobileNativeChatQuestion.test.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.test.tsx @@ -14,7 +14,8 @@ vi.mock('react-native', () => ({ vi.mock('lucide-react-native', () => ({ ArrowUp: 'ArrowUp', Check: 'Check', - CircleHelp: 'CircleHelp' + CircleHelp: 'CircleHelp', + X: 'X' })) describe('MobileNativeChatQuestion', () => { @@ -103,4 +104,27 @@ describe('MobileNativeChatQuestion', () => { expect(onAnswer).toHaveBeenCalledWith('east-token, other-token:ap-south') }) + + it('passes the rendered prompt identity to cancel', async () => { + const onCancel = vi.fn(async () => true) + await act(async () => { + renderer = create( + createElement(MobileNativeChatQuestion, { + question: { + question: 'Pick one', + prompt: { itemId: 'question-1', expectedRevision: 7 }, + options: ['Choice'], + multiSelect: false, + allowOther: false, + optionTokens: ['choice-token'] + }, + onAnswer: vi.fn(async () => true), + onCancel + }) + ) + }) + const cancel = renderer.root.findByProps({ accessibilityLabel: 'Cancel' }) + await act(async () => cancel.props.onPress()) + expect(onCancel).toHaveBeenCalledWith({ itemId: 'question-1', expectedRevision: 7 }) + }) }) diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx index 9eae7210bc8..1cb530c7682 100644 --- a/mobile/src/session/MobileNativeChatQuestion.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef, useState } from 'react' import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' -import { ArrowUp, Check, CircleHelp } from 'lucide-react-native' +import { ArrowUp, Check, CircleHelp, X } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { formatQuestionAnswerByIndexes, @@ -12,13 +12,18 @@ import { type Props = { question: MobileChatQuestion onAnswer: (text: string) => Promise + onCancel?: (prompt?: NonNullable) => Promise } /** Renders an agent's choice prompt as a tappable card. Single-select answers * on tap; multi-select toggles then Submits; an always-present text entry lets * the user answer freely (the escape hatch) when the heuristic misreads the * options or none apply. */ -export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.JSX.Element { +export function MobileNativeChatQuestion({ + question, + onAnswer, + onCancel +}: Props): React.JSX.Element { const [selectedOptionIndexes, setSelectedOptionIndexes] = useState([]) const [freeText, setFreeText] = useState('') const [sending, setSending] = useState(false) @@ -102,6 +107,17 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J {question.question} + {onCancel ? ( + void onCancel(question.prompt)} + disabled={sending} + > + + + ) : null} {hasOptions ? ( @@ -214,6 +230,12 @@ const styles = StyleSheet.create({ fontWeight: '600', lineHeight: typography.bodySize + 7 }, + cancel: { + width: 28, + height: 28, + alignItems: 'center', + justifyContent: 'center' + }, options: { gap: spacing.xs }, diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 28d2f871cdf..49f20fa5d5d 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -122,6 +122,8 @@ type Props = { * into selector keystrokes (Claude) or pasted label text (other agents). */ onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise onCancelAsk?: () => Promise + /** Cancel a structured approval/question with exact item identity when supported. */ + onCancelPrompt?: (prompt?: { itemId: string; expectedRevision: number }) => Promise question?: MobileChatQuestion | null onAnswerQuestion?: (text: string) => Promise permission?: MobileChatPermission | null @@ -178,6 +180,7 @@ export function MobileNativeChatView({ onDismissAsk, onAnswerAsk, onCancelAsk, + onCancelPrompt, question, onAnswerQuestion, permission, @@ -371,6 +374,7 @@ export function MobileNativeChatView({ onDismissAsk={onDismissAsk} onAnswerAsk={onAnswerAsk} onCancelAsk={onCancelAsk} + onCancelPrompt={onCancelPrompt} permission={permission} onRespondPermission={onRespondPermission} question={question} diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 58f5bb05741..78c5ebeb1cf 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -57,6 +57,10 @@ export type MobileNativeChatController = { selections: AskAnswerSelection[] ) => Promise handleNativeChatCancelAsk: () => Promise + handleNativeChatCancelPrompt?: (prompt?: { + itemId: string + expectedRevision: number + }) => Promise handleNativeChatRespondPermission: (text: string) => Promise handleNativeChatStop: () => void nativeChatFilePaths: string[] diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts index fe3f9a0d89b..53799718eb4 100644 --- a/mobile/src/session/mobile-native-chat-permission.ts +++ b/mobile/src/session/mobile-native-chat-permission.ts @@ -11,6 +11,8 @@ export type MobileChatPermission = { title: string detail?: string + /** Structured prompt identity, present only when the host can cancel it exactly. */ + prompt?: { itemId: string; expectedRevision: number } options: Array<{ label: string; send: string }> } diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts index 59ba3d72aba..66ebb75919f 100644 --- a/mobile/src/session/mobile-native-chat-question.ts +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -5,6 +5,8 @@ export type MobileChatQuestion = { question: string + /** Structured prompt identity, present only for durable host prompts. */ + prompt?: { itemId: string; expectedRevision: number } options: string[] multiSelect: boolean /** Structured questions hide the free-text row when the provider does not accept it. */ diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 7d0c5c6de46..7b3b91af7a6 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -62,12 +62,12 @@ const HOST_COMPONENT_NAMES = new Set([ 'View' ]) -const HEAD_MAIN_HOOK_SHA256 = '11cd92aec686a6e47b23114ec31da86152a850b064821578b165fabfbce53b27' -const HEAD_HOOK_BINDING_SHA256 = 'f8bce7101a26b4d794bb58dee54702424a4965cc81dec5c758ca56cd5a6f4ce8' +const HEAD_MAIN_HOOK_SHA256 = 'c7a1bbc0588a5d27797bbab13168e76eb20200288921fdc3347632c2b4afd0ae' +const HEAD_HOOK_BINDING_SHA256 = '06edf1a4314eba41b1d3e1cb67b0cfab2a936aef7d127c5dc48e789c9adc6c8f' const HEAD_CALLBACK_IDENTITY_SHA256 = '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb' const HEAD_CALLBACK_BODY_SHA256 = '85c4f4605e66c45e2b6bc7de739cb3493d9e2d0db9c9242c379db8ed34a8cefe' -const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13' +const HEAD_EFFECT_SHA256 = '73d80845e0a4b6363cfb4bb55551af97965b1f676b97adf0b2a8504219b9a501' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' const HEAD_NESTED_FUNCTION_SHA256 = '97ce5457d8059974f500022a4382ff687074e26843d6c1525be938d6c0537928' @@ -87,7 +87,7 @@ const HEAD_STYLE_REFERENCE_SHA256 = const HEAD_IDENTITY_FIELD_SHA256 = '91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6' const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512' -const HEAD_CAPABILITY_SHA256 = 'ca219f7909a091717110b823d5b94a20770ad3ae51894e0fa765e8628309392d' +const HEAD_CAPABILITY_SHA256 = '67c3154b71b542bb63a4365d3ea75aef19ef133c02f509318619618221786fab' type Definition = { declaration: ts.FunctionDeclaration; sourceFile: ts.SourceFile } type HookFacts = { @@ -472,7 +472,7 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(268) + expect(main.hooks).toHaveLength(269) expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) expect(main.callbacks).toHaveLength(77) @@ -511,7 +511,7 @@ describe('mobile session route extraction parity', () => { expect(hash(compatibility.identityFields)).toBe(HEAD_IDENTITY_FIELD_SHA256) expect(compatibility.navigation).toHaveLength(6) expect(hash(compatibility.navigation)).toBe(HEAD_NAVIGATION_SHA256) - expect(compatibility.capabilities).toHaveLength(5) + expect(compatibility.capabilities).toHaveLength(6) expect(hash(compatibility.capabilities)).toBe(HEAD_CAPABILITY_SHA256) }) diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts index 61425597721..82d619e49e6 100644 --- a/mobile/src/session/mobile-structured-agent-prompts.ts +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -134,6 +134,7 @@ export function projectStructuredPermission( } return { title: prompt.body.title, + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision }, ...(prompt.body.detail ? { detail: prompt.body.detail } : {}), options: prompt.body.options.map((option) => ({ label: option.label, @@ -158,12 +159,14 @@ export function projectStructuredQuestion( return projectGroupedQuestion( prompt.body.questions, groupedDraft, - groupedQuestionPromptKey(prompt.itemId, prompt.revision) + groupedQuestionPromptKey(prompt.itemId, prompt.revision), + { itemId: prompt.itemId, expectedRevision: prompt.revision } ) } const optionDescriptions = prompt.body.options.map((option) => option.description) return { question: prompt.body.question, + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision }, options: prompt.body.options.map((option) => option.label), ...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}), multiSelect: false, diff --git a/mobile/src/session/mobile-structured-agent-session-cancel.ts b/mobile/src/session/mobile-structured-agent-session-cancel.ts new file mode 100644 index 00000000000..9c67e7480d0 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-cancel.ts @@ -0,0 +1,78 @@ +import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import type { RpcClient } from '../transport/rpc-client' +import { + requestStructuredAgentSessionMutation, + retainStructuredSessionOperationId, + type StructuredAgentSessionMutationCallResult +} from './mobile-structured-agent-session-rpc' + +type PromptIdentity = { itemId: string; expectedRevision: number } + +export function pendingStructuredPromptIdentity( + items: readonly AgentJournalRenderItem[] +): PromptIdentity | undefined { + const prompt = items.find((item) => + item.body.kind === 'approval' || item.body.kind === 'question' + ? item.body.resolution.state === 'pending' + : false + ) + return prompt ? { itemId: prompt.itemId, expectedRevision: prompt.revision } : undefined +} + +export async function requestMobileStructuredAgentSessionCancel(args: { + client: RpcClient | null + sessionId: string | null + enabled: boolean + stateRef: { readonly current: StructuredAgentSessionState } + sessionKey: string + operationIds: Map + promptCancelSupported: boolean | null + prompt?: PromptIdentity + onSendError: (message: string) => void +}): Promise { + const { client, enabled, onSendError, operationIds, sessionId, sessionKey, stateRef } = args + const current = stateRef.current + const turnId = activeStructuredAgentSessionTurnId(current.items) + if (!client || !sessionId || !enabled || current.fence === null || !turnId) { + onSendError('Stop not sent') + return false + } + // Check the capability before fields enter either the fingerprint or operation key. + const fields = { + turnId, + ...(args.prompt && args.promptCancelSupported === true ? { prompt: args.prompt } : {}) + } + const key = `${sessionKey}:agentSession.cancel:${JSON.stringify(fields)}` + const clientOperationId = retainStructuredSessionOperationId( + operationIds, + key, + operationIds.get(key) + ) + const result: StructuredAgentSessionMutationCallResult = + await requestStructuredAgentSessionMutation({ + client, + method: 'agentSession.cancel', + fingerprintMethod: 'agentSession.cancel', + sessionId, + expectedRuntimeFence: current.fence, + fields, + clientOperationId + }) + if (result.status !== 'unknown') { + operationIds.delete(key) + } + if (result.status === 'accepted') { + return true + } + if (result.status === 'unknown') { + onSendError('Stop unconfirmed — check chat before retrying') + } else if (result.status === 'refused') { + onSendError(result.message) + } else if (result.status === 'failed') { + onSendError(result.message === 'Request not sent' ? 'Stop not sent' : result.message) + } + return false +} diff --git a/mobile/src/session/mobile-structured-grouped-question.ts b/mobile/src/session/mobile-structured-grouped-question.ts index 17a716cd032..031cdf3f10f 100644 --- a/mobile/src/session/mobile-structured-grouped-question.ts +++ b/mobile/src/session/mobile-structured-grouped-question.ts @@ -105,7 +105,8 @@ function answersFor( export function projectGroupedQuestion( questions: readonly AgentJournalQuestion[], draft: GroupedQuestionDraft | null, - promptKey: string + promptKey: string, + promptIdentity?: { itemId: string; expectedRevision: number } ): MobileChatQuestion | null { const answered = answersFor(draft, promptKey).length const question = questions[answered] @@ -117,6 +118,7 @@ export function projectGroupedQuestion( return { question: questions.length > 1 ? `${heading} (${answered + 1} of ${questions.length})` : heading, + ...(promptIdentity ? { prompt: promptIdentity } : {}), options: question.options.map((option) => option.label), ...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}), multiSelect: question.multiSelect, diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index c90033e2404..00eee9651a0 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -17,6 +17,7 @@ const viewMode = { isTabChatView: (_tabId: string) => true } const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false } const structuredSendWithOutcome = vi.fn() const structuredCancel = vi.fn() +const structuredCancelPrompt = vi.fn(async () => true) const structuredRespondPermission = vi.fn(async () => true) const structuredRespondQuestion = vi.fn(async () => true) const structuredSetOption = vi.fn(async () => true) @@ -90,6 +91,7 @@ vi.mock('./use-mobile-structured-agent-session', () => ({ ...structuredActivity, sendWithOutcome: structuredSendWithOutcome, cancel: structuredCancel, + cancelPrompt: structuredCancelPrompt, permission: structuredPermission, question: structuredQuestion, optionSnapshot: structuredOptionSnapshot, @@ -227,6 +229,10 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { controller = null }) + it('leaves structured prompt cancellation unavailable on the legacy bridge lane', () => { + expect(controller?.handleNativeChatCancelPrompt).toBeUndefined() + }) + it('clears an orphaned image paste before a question-card answer (#10228)', async () => { // The chat overlay wires the question card straight to this send, bypassing // the image hook that used to own the only heal. diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 987546a4f48..b8b99742dd8 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -35,6 +35,8 @@ export function useMobileNativeChatController(args: { nativeChatInputLeaseReady: boolean /** Live socket state; the lease collapses on disconnect but one render later. */ connState: ConnectionState + /** Host capability fact from the shared runtime status probe. */ + agentSessionPromptCancelSupported?: boolean | null onSendError: (message: string) => void /** Retires a held failure banner. Any accepted chat write clears it — a delivered * answer or permission reply must not sit under a stale "not sent". */ @@ -51,6 +53,7 @@ export function useMobileNativeChatController(args: { nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, connState, + agentSessionPromptCancelSupported = null, onSendError, onSendResolved } = args @@ -90,6 +93,7 @@ export function useMobileNativeChatController(args: { callerIdentity: deviceTokenRef.current ?? '', enabled: showNativeChat, connState, + promptCancelSupported: agentSessionPromptCancelSupported, onSendError }) const { @@ -258,6 +262,10 @@ export function useMobileNativeChatController(args: { ? structuredNativeChat.respondPermission : legacyHandleNativeChatRespondPermission const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved) + const structuredCancelPrompt = useNativeChatAcceptedAction( + activeChatStructured ? structuredNativeChat.cancelPrompt : async () => false, + onSendResolved + ) return { isTabChatView, @@ -292,6 +300,9 @@ export function useMobileNativeChatController(args: { dismissNativeChatAsk, handleNativeChatAnswerAsk: answerAsk, handleNativeChatCancelAsk: cancelAsk, + // Heuristic/legacy cards have no durable prompt identity, so keep their + // cancel affordance absent instead of exposing a dead action. + handleNativeChatCancelPrompt: activeChatStructured ? structuredCancelPrompt : undefined, handleNativeChatRespondPermission: respond, handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop, nativeChatFilePaths, diff --git a/mobile/src/session/use-mobile-native-chat-session-lane.ts b/mobile/src/session/use-mobile-native-chat-session-lane.ts index d337d8ba929..bfeb1b06945 100644 --- a/mobile/src/session/use-mobile-native-chat-session-lane.ts +++ b/mobile/src/session/use-mobile-native-chat-session-lane.ts @@ -15,6 +15,7 @@ export function useMobileNativeChatSessionLane({ sessionId, sourceIdentity, callerIdentity, + promptCancelSupported, enabled, connState, onSendError @@ -29,6 +30,7 @@ export function useMobileNativeChatSessionLane({ sessionId: string | null sourceIdentity: Parameters[0]['sourceIdentity'] callerIdentity: string + promptCancelSupported?: boolean | null enabled: boolean connState: ConnectionState onSendError: (message: string) => void @@ -48,6 +50,7 @@ export function useMobileNativeChatSessionLane({ sessionId: structured ? sessionId : null, sourceIdentity, callerIdentity, + promptCancelSupported, enabled, // Holds are connection-scoped; dropping this on transport loss lets the hook // reacquire the provider without clearing the cached transcript. diff --git a/mobile/src/session/use-mobile-session-feedback-capabilities.ts b/mobile/src/session/use-mobile-session-feedback-capabilities.ts index 8c619fb1f7f..f0231189309 100644 --- a/mobile/src/session/use-mobile-session-feedback-capabilities.ts +++ b/mobile/src/session/use-mobile-session-feedback-capabilities.ts @@ -32,6 +32,11 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina null ) const [quickCommandsSupported, setQuickCommandsSupported] = useState(null) + // Prompt cancellation is negotiated with the same host capability probe as + // the other session surfaces; consumers never maintain a second status cache. + const [agentSessionPromptCancelSupported, setAgentSessionPromptCancelSupported] = useState< + boolean | null + >(null) // Why: stable callbacks (handleFileTap) read the live value via this ref, since // the capability probe resolves after the callbacks are created. const browserScreencastSupportedRef = useRef(browserScreencastSupported) @@ -115,6 +120,8 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina setAgentSessionHistorySupported, quickCommandsSupported, setQuickCommandsSupported, + agentSessionPromptCancelSupported, + setAgentSessionPromptCancelSupported, browserScreencastSupportedRef, reconciledCreateWarningState, createWarning, diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index 6046cba1059..535b4034ec8 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -27,6 +27,7 @@ export function useMobileSessionNativeChatDictation( worktreeId, client, connState, + agentSessionPromptCancelSupported, setInput, liveInputTerminalHandles, activeHandle, @@ -72,6 +73,7 @@ export function useMobileSessionNativeChatDictation( nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, connState, + agentSessionPromptCancelSupported, onSendError: nativeChatSendError.show, onSendResolved: nativeChatSendError.clear }) diff --git a/mobile/src/session/use-mobile-session-tab-reconciliation.ts b/mobile/src/session/use-mobile-session-tab-reconciliation.ts index be4641dd297..da7a48e035c 100644 --- a/mobile/src/session/use-mobile-session-tab-reconciliation.ts +++ b/mobile/src/session/use-mobile-session-tab-reconciliation.ts @@ -2,7 +2,10 @@ import { useEffect, useRef, useCallback, useMemo, useState } from 'react' import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' import { supportsMobileQuickCommands } from '../terminal/quick-commands' import { MOBILE_AI_VAULT_CAPABILITY } from '../agent-history/agent-history-capability' -import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, + TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' import { runAcceptedMobileSessionTabsEffects } from './mobile-session-tabs-accepted-effects' import type { SessionTabsStreamSource } from './mobile-session-tabs-stream-health' import { useMobileSessionTabsFetchReporting } from './use-mobile-session-tabs-fetch-reporting' @@ -31,6 +34,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc switchSessionTabRef, setBrowserScreencastSupported, setAgentSessionHistorySupported, + setAgentSessionPromptCancelSupported, setQuickCommandsSupported, nativeChatStream, fetchTerminals, @@ -148,6 +152,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc if (!client || connState !== 'connected') { setBrowserScreencastSupported(null) setAgentSessionHistorySupported(null) + setAgentSessionPromptCancelSupported(null) setQuickCommandsSupported(null) setShowQuickCommands(false) hostQueryReplyInputSupportedRef.current = false @@ -157,6 +162,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc // host; clear the prior capability before exposing host-specific actions. setBrowserScreencastSupported(null) setAgentSessionHistorySupported(null) + setAgentSessionPromptCancelSupported(null) setQuickCommandsSupported(null) setShowQuickCommands(false) hostQueryReplyInputSupportedRef.current = false @@ -165,6 +171,9 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc return startRuntimeCapabilityProbe(client, (capabilities) => { setBrowserScreencastSupported(capabilities.includes('browser.screencast.v1')) setAgentSessionHistorySupported(capabilities.includes(MOBILE_AI_VAULT_CAPABILITY)) + setAgentSessionPromptCancelSupported( + capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY) + ) setQuickCommandsSupported(supportsMobileQuickCommands(capabilities)) // Why: hosts without this capability strip inputKind from terminal.send, // so a forwarded xterm reply would become floor-stealing shell input. diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx new file mode 100644 index 00000000000..48b44e78ff5 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -0,0 +1,221 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import type { RpcClient } from '../transport/rpc-client' + +const mocks = vi.hoisted(() => ({ sendRequest: vi.fn() })) +vi.mock('./use-mobile-structured-agent-state', () => ({ + useMobileStructuredAgentState: () => ({ + state, + stateRef, + loadingOlder: false, + loadEarlier: vi.fn() + }) +})) +vi.mock('./use-mobile-structured-agent-options', () => ({ + useMobileStructuredAgentOptions: () => ({ + conversationCommands: [], + invokeStructuredOption: vi.fn(), + optionSnapshot: [], + optionSurface: { getSnapshot: () => [], subscribe: () => () => {} }, + pendingOptionId: null, + setStructuredOption: vi.fn() + }) +})) +vi.mock('./use-mobile-structured-prompt-responses', () => ({ + useMobileStructuredPromptResponses: () => ({ + groupedDraft: null, + respondPermission: vi.fn(), + respondQuestion: vi.fn() + }) +})) +vi.mock('./use-mobile-structured-send-operation-reconciliation', () => ({ + useMobileStructuredSendOperationReconciliation: vi.fn() +})) + +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +const pendingApproval = (): AgentJournalRenderItem => ({ + itemId: 'approval-1', + revision: 4, + sequence: 2, + observedAt: 2, + body: { + kind: 'approval', + title: 'Allow Bash?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } +}) + +const runningTurn = (): AgentJournalRenderItem => ({ + itemId: 'turn-status', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + text: 'Waiting', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } +}) + +const pendingQuestion = (): AgentJournalRenderItem => ({ + itemId: 'question-1', + revision: 7, + sequence: 2, + observedAt: 2, + body: { + kind: 'question', + question: 'Pick a destination', + options: [{ id: 'local', label: 'Local' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } +}) + +let state: StructuredAgentSessionState +const stateRef = { + get current(): StructuredAgentSessionState { + return state + } +} +const client: RpcClient = { + sendRequest: mocks.sendRequest, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} +} + +function Harness({ promptCancelSupported }: { promptCancelSupported: boolean }): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'codex', + promptCancelSupported, + onSendError: vi.fn() + }) + return null +} + +let hook: ReturnType +let renderer: ReactTestRenderer | null = null + +describe('mobile structured prompt cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + state = { + epoch: 'epoch-1', + cursor: { epoch: 'epoch-1', sequence: 2 }, + fence: 3, + items: [runningTurn(), pendingApproval()], + submissions: [], + retainedItemLimit: 1024, + hasOlder: false, + status: 'ready', + handoff: null + } + mocks.sendRequest.mockResolvedValue({ + ok: true, + result: { + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 3 }, + value: { turnId: 'turn-1', cancelled: true } + } + }) + renderer = null + }) + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('sends the clicked prompt identity on capable hosts', async () => { + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + await act(async () => { + expect(await hook.cancelPrompt()).toBe(true) + }) + expect(mocks.sendRequest).toHaveBeenCalledWith( + 'agentSession.cancel', + expect.objectContaining({ + turnId: 'turn-1', + prompt: { itemId: 'approval-1', expectedRevision: 4 } + }), + expect.any(Object) + ) + }) + + it('downgrades to turn-only cancellation on an old host', async () => { + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: false })) + }) + await act(async () => { + expect(await hook.cancelPrompt()).toBe(true) + }) + const call = mocks.sendRequest.mock.calls.find(([method]) => method === 'agentSession.cancel') + expect(call?.[1]).toMatchObject({ turnId: 'turn-1' }) + expect(call?.[1]).not.toHaveProperty('prompt') + }) + + it('cancels a question card with its item identity', async () => { + state = { ...state, items: [runningTurn(), pendingQuestion()] } + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + await act(async () => { + expect(await hook.cancelPrompt({ itemId: 'question-1', expectedRevision: 7 })).toBe(true) + }) + expect(mocks.sendRequest).toHaveBeenCalledWith( + 'agentSession.cancel', + expect.objectContaining({ + turnId: 'turn-1', + prompt: { itemId: 'question-1', expectedRevision: 7 } + }), + expect.any(Object) + ) + }) + + it('uses the rendered prompt identity when the journal changes before tap', async () => { + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + const renderedIdentity = { itemId: 'approval-1', expectedRevision: 4 } + state = { + ...state, + items: [runningTurn(), { ...pendingApproval(), itemId: 'approval-new', revision: 9 }] + } + // The hook API accepts the identity captured by the card; the state is intentionally newer. + await act(async () => { + expect(await hook.cancelPrompt(renderedIdentity)).toBe(true) + }) + expect(mocks.sendRequest).toHaveBeenCalledWith( + 'agentSession.cancel', + expect.objectContaining({ prompt: renderedIdentity }), + expect.any(Object) + ) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 301c9b7b0ca..2a24d38a38e 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' -import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire' import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment @@ -37,6 +36,10 @@ import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-o import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' import { useMobileStructuredSendOperationReconciliation } from './use-mobile-structured-send-operation-reconciliation' +import { + pendingStructuredPromptIdentity, + requestMobileStructuredAgentSessionCancel +} from './mobile-structured-agent-session-cancel' type StructuredMobileAttachment = StructuredAgentSessionAttachment & { id?: string @@ -61,6 +64,7 @@ type StructuredMobileSession = ReturnType Promise respondQuestion: (answer: string) => Promise + cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise } export function useMobileStructuredAgentSession(args: { @@ -73,6 +77,8 @@ export function useMobileStructuredAgentSession(args: { enabled: boolean /** Live transport only; gates the connection-scoped hold, nothing else. */ connected: boolean + /** Capability fact from the shared runtime status probe; null follows legacy cancellation. */ + promptCancelSupported?: boolean | null agent: string | null onSendError: (message: string) => void }): StructuredMobileSession { @@ -84,14 +90,13 @@ export function useMobileStructuredAgentSession(args: { sessionId, sourceIdentity = '', enabled, - onSendError + onSendError, + promptCancelSupported = null } = args const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId]) const operationIdsRef = useRef(new Map()) const commandPendingRef = useRef(false) useEffect(() => () => operationIdsRef.current.clear(), []) - const retainOperationId = (key: string, operationId?: string): string => - retainStructuredOpId(operationIdsRef.current, key, operationId) const stateArgs = { client, sessionId, sessionKey, enabled, connected } const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState(stateArgs) useMobileStructuredSendOperationReconciliation(state.submissions) @@ -108,7 +113,11 @@ export function useMobileStructuredAgentSession(args: { } const targetFence = current.fence const key = `${sessionKey}:${fingerprintMethod}:${JSON.stringify(fields)}` - const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key)) + const clientOperationId = retainStructuredOpId( + operationIdsRef.current, + key, + operationIdsRef.current.get(key) + ) const result = await requestStructuredAgentSessionMutation({ client, method, @@ -127,9 +136,6 @@ export function useMobileStructuredAgentSession(args: { } } if (result.status === 'unknown') { - // Prompt/option plans cannot repeat a harmful effect under a fresh id; - // issue a fresh id so a retry can be admitted after the user checks the - // stream. Sends keep theirs — see `mobile-structured-send-delivery.ts`. operationIdsRef.current.delete(key) return result } @@ -230,7 +236,6 @@ export function useMobileStructuredAgentSession(args: { setStructuredOption ] ) - const { groupedDraft, respondPermission, respondQuestion } = useMobileStructuredPromptResponses({ stateRef, sessionKey, @@ -238,37 +243,21 @@ export function useMobileStructuredAgentSession(args: { onSendError }) - const cancel = useCallback(() => { - const current = stateRef.current - const turnId = activeStructuredAgentSessionTurnId(current.items) - if (!client || !sessionId || !enabled || current.fence === null || !turnId) { - onSendError('Stop not sent') - return - } - const fields = { turnId } - const key = `${sessionKey}:agentSession.cancel:${JSON.stringify(fields)}` - const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key)) - void requestStructuredAgentSessionMutation({ - client, - method: 'agentSession.cancel', - fingerprintMethod: 'agentSession.cancel', - sessionId, - expectedRuntimeFence: current.fence, - fields, - clientOperationId - }).then((result) => { - if (result.status !== 'unknown') { - operationIdsRef.current.delete(key) - } - if (result.status === 'unknown') { - onSendError('Stop unconfirmed — check chat before retrying') - } else if (result.status === 'refused') { - onSendError(result.message) - } else if (result.status === 'failed') { - onSendError(result.message === 'Request not sent' ? 'Stop not sent' : result.message) - } - }) - }, [client, enabled, onSendError, sessionId, sessionKey]) + const requestCancel = useCallback( + (prompt?: { itemId: string; expectedRevision: number }): Promise => + requestMobileStructuredAgentSessionCancel({ + client, + enabled, + onSendError, + operationIds: operationIdsRef.current, + prompt, + promptCancelSupported, + sessionId, + sessionKey, + stateRef + }), + [client, enabled, onSendError, promptCancelSupported, sessionId, sessionKey, stateRef] + ) const messages = useMemo( () => projectStructuredAgentSessionMessages(state.items, [], state.submissions), @@ -279,8 +268,6 @@ export function useMobileStructuredAgentSession(args: { const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state.items) - // Stable while the readings hold, so a streaming turn does not re-render the - // whole chat surface on every journal batch. const turnIndicator = useMemo(() => ({ thinking, activityText }), [thinking, activityText]) const status = state.status === 'idle' ? 'idle' : state.status const approvalPrompt = useMemo( @@ -291,7 +278,6 @@ export function useMobileStructuredAgentSession(args: { () => state.items.find(pendingStructuredQuestion) ?? null, [state.items] ) - return { ...options, session: { @@ -303,7 +289,6 @@ export function useMobileStructuredAgentSession(args: { loadingEarlier: loadingOlder, loadEarlier }, - // A dispatch the provider has not answered yet is already work — see the desktop hook. isWorking: turnId !== null || hasUnansweredStructuredAgentSessionDispatch(state.submissions, state.fence), @@ -311,7 +296,11 @@ export function useMobileStructuredAgentSession(args: { turnIndicator, ...turnTiming, sendWithOutcome, - cancel, + cancel: () => { + void requestCancel() + }, + cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => + requestCancel(prompt ?? pendingStructuredPromptIdentity(stateRef.current.items)), permission: projectStructuredPermission(approvalPrompt), question: projectStructuredQuestion(questionPrompt, groupedDraft), respondPermission, diff --git a/package.json b/package.json index e48079f8a1c..a9f12759f74 100644 --- a/package.json +++ b/package.json @@ -205,24 +205,24 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@tiptap/extension-code-block": "^3.22.5", - "@tiptap/extension-code-block-lowlight": "^3.22.5", - "@tiptap/extension-details": "^3.22.5", - "@tiptap/extension-image": "^3.22.5", - "@tiptap/extension-link": "^3.22.5", - "@tiptap/extension-list": "^3.22.5", - "@tiptap/extension-mathematics": "3.22.5", - "@tiptap/extension-placeholder": "^3.22.5", - "@tiptap/extension-table": "3.22.4", - "@tiptap/extension-table-cell": "3.22.4", - "@tiptap/extension-table-header": "3.22.4", - "@tiptap/extension-table-row": "3.22.4", - "@tiptap/extension-task-item": "^3.22.5", - "@tiptap/extension-task-list": "^3.22.5", - "@tiptap/markdown": "^3.22.5", - "@tiptap/pm": "^3.22.5", - "@tiptap/react": "^3.22.5", - "@tiptap/starter-kit": "^3.22.5", + "@tiptap/extension-code-block": "3.31.3", + "@tiptap/extension-code-block-lowlight": "3.31.3", + "@tiptap/extension-details": "3.31.3", + "@tiptap/extension-image": "3.31.3", + "@tiptap/extension-link": "3.31.3", + "@tiptap/extension-list": "3.31.3", + "@tiptap/extension-mathematics": "3.31.3", + "@tiptap/extension-placeholder": "3.31.3", + "@tiptap/extension-table": "3.31.3", + "@tiptap/extension-table-cell": "3.31.3", + "@tiptap/extension-table-header": "3.31.3", + "@tiptap/extension-table-row": "3.31.3", + "@tiptap/extension-task-item": "3.31.3", + "@tiptap/extension-task-list": "3.31.3", + "@tiptap/markdown": "3.31.3", + "@tiptap/pm": "3.31.3", + "@tiptap/react": "3.31.3", + "@tiptap/starter-kit": "3.31.3", "@types/node": "^25.6.0", "@types/proper-lockfile": "^4.1.4", "@types/qrcode": "^1.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d93fec995ef..b2f6425aafd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,59 +238,59 @@ importers: specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) '@tiptap/extension-code-block': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-code-block-lowlight': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(highlight.js@11.11.1)(lowlight@3.3.0) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.1)(lowlight@3.3.0) '@tiptap/extension-details': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-text-style@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)))(@tiptap/pm@3.22.5) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)))(@tiptap/pm@3.31.3) '@tiptap/extension-image': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-link': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-list': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-mathematics': - specifier: 3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(katex@0.16.45) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(katex@0.16.45) '@tiptap/extension-placeholder': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-table': - specifier: 3.22.4 - version: 3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-table-cell': - specifier: 3.22.4 - version: 3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-table-header': - specifier: 3.22.4 - version: 3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-table-row': - specifier: 3.22.4 - version: 3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-task-item': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-task-list': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/markdown': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/pm': - specifier: ^3.22.5 - version: 3.22.5 + specifier: 3.31.3 + version: 3.31.3 '@tiptap/react': - specifier: ^3.22.5 - version: 3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 3.31.3 + version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tiptap/starter-kit': - specifier: ^3.22.5 - version: 3.22.5 + specifier: 3.31.3 + version: 3.31.3 '@types/node': specifier: ^25.6.0 version: 25.9.5 @@ -2910,229 +2910,230 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tiptap/core@3.22.5': - resolution: {integrity: sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==} + '@tiptap/core@3.31.3': + resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==} peerDependencies: - '@tiptap/pm': 3.22.5 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-blockquote@3.22.5': - resolution: {integrity: sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==} + '@tiptap/extension-blockquote@3.31.3': + resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.22.5': - resolution: {integrity: sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==} + '@tiptap/extension-bold@3.31.3': + resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-bubble-menu@3.22.5': - resolution: {integrity: sha512-yrNlFQQJY5MmhBpmD8tnmaSmyUQrEvgyPKa3bzVeWEhDSG1CW4A0ZSMx3hrA9yFO0HWfw3IJmvSCycEZQBalpQ==} + '@tiptap/extension-bubble-menu@3.31.3': + resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bullet-list@3.22.5': - resolution: {integrity: sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==} + '@tiptap/extension-bullet-list@3.31.3': + resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==} peerDependencies: - '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-code-block-lowlight@3.22.5': - resolution: {integrity: sha512-lT0SxhjkDL1tKSeVDduV+SJ6kHdNFcbYBaUAwTufRtDt8FIYcSX6tWj5cPEXOFrC0PlJu7ybCnTEbXBdFP8Bnw==} + '@tiptap/extension-code-block-lowlight@3.31.3': + resolution: {integrity: sha512-DN21CYEL4bm01vyB/wiyDPKrpiyalI5okTWx90jRuTqRzbnkPlnng1Vdo9L220w3GXct5Exe6iI01E7F19bC5A==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/extension-code-block': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/extension-code-block': 3.31.3 + '@tiptap/pm': 3.31.3 highlight.js: ^11 lowlight: ^2 || ^3 - '@tiptap/extension-code-block@3.22.5': - resolution: {integrity: sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==} + '@tiptap/extension-code-block@3.31.3': + resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.22.5': - resolution: {integrity: sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==} + '@tiptap/extension-code@3.31.3': + resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-details@3.22.5': - resolution: {integrity: sha512-+vg7wSO9DL8veAzC4jlHu4lQ4qL2iRxj/ONTfP+jnffN0TzrjAdkMLCUEX059gYyks22AXgoI5vEzWs0K7yeuw==} + '@tiptap/extension-details@3.31.3': + resolution: {integrity: sha512-J5kdZy31wb97iKUR4sDsX0sEyRPlTijKKRAnVbInY2aNdFuNBTBdMPmblEJztxfMN+TmDrU2kCVv72J9B1SrJg==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/extension-text-style': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/extension-text-style': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-document@3.22.5': - resolution: {integrity: sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==} + '@tiptap/extension-document@3.31.3': + resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-dropcursor@3.22.5': - resolution: {integrity: sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==} + '@tiptap/extension-dropcursor@3.31.3': + resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==} peerDependencies: - '@tiptap/extensions': 3.22.5 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-floating-menu@3.22.5': - resolution: {integrity: sha512-dhem4sTPhyQgQ+pFp2Oud4k4FSQz9PVMgeQAC9288SmGwxBkJNveDAw6sKTMrumqDvwkJrtslXIupq9TZYQnzg==} + '@tiptap/extension-floating-menu@3.31.3': + resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-gapcursor@3.22.5': - resolution: {integrity: sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==} + '@tiptap/extension-gapcursor@3.31.3': + resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==} peerDependencies: - '@tiptap/extensions': 3.22.5 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-hard-break@3.22.5': - resolution: {integrity: sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==} + '@tiptap/extension-hard-break@3.31.3': + resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-heading@3.22.5': - resolution: {integrity: sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==} + '@tiptap/extension-heading@3.31.3': + resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-horizontal-rule@3.22.5': - resolution: {integrity: sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==} + '@tiptap/extension-horizontal-rule@3.31.3': + resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-image@3.22.5': - resolution: {integrity: sha512-ezMzA6w6UsPesQp6fxTQojI/IkGJLmkwR/VGTimva7sudP3HdSW8k3SGBkjfvp0L2xqUrC/l4nWOchu01A/xtQ==} + '@tiptap/extension-image@3.31.3': + resolution: {integrity: sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-italic@3.22.5': - resolution: {integrity: sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==} + '@tiptap/extension-italic@3.31.3': + resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-link@3.22.5': - resolution: {integrity: sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==} + '@tiptap/extension-link@3.31.3': + resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-list-item@3.22.5': - resolution: {integrity: sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==} + '@tiptap/extension-list-item@3.31.3': + resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==} peerDependencies: - '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list-keymap@3.22.5': - resolution: {integrity: sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==} + '@tiptap/extension-list-keymap@3.31.3': + resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==} peerDependencies: - '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list@3.22.5': - resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==} + '@tiptap/extension-list@3.31.3': + resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-mathematics@3.22.5': - resolution: {integrity: sha512-ld2xoFHKyl4Qs+rgu3wn1UZBTsgDApEz2PD17E/XWlVXHO4KoiBOcLhrc5L9SL+aKOBXXmC/Ex1d+0hCptsBbg==} + '@tiptap/extension-mathematics@3.31.3': + resolution: {integrity: sha512-R19lI2hLSXkQ+aqKvvyrDdI+HnmFtmfTVotrQ5W06i8WxVqRSq5UpsPIRCoeUeBL8uCvKinfmSVJyX2V80Bayg==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 - katex: ^0.16.4 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + katex: ^0.16.4 || ^0.17.0 || ^0.18.0 - '@tiptap/extension-ordered-list@3.22.5': - resolution: {integrity: sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==} + '@tiptap/extension-ordered-list@3.31.3': + resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==} peerDependencies: - '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-paragraph@3.22.5': - resolution: {integrity: sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==} + '@tiptap/extension-paragraph@3.31.3': + resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-placeholder@3.22.5': - resolution: {integrity: sha512-MZAohQ3FCS763BkhGXgaWRya6WruZjwRwEAkXP8vkxbERzl2OJRjniS4uXCWzAlRb3ttE103SnY7LMdM8FvsXw==} + '@tiptap/extension-placeholder@3.31.3': + resolution: {integrity: sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==} peerDependencies: - '@tiptap/extensions': 3.22.5 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-strike@3.22.5': - resolution: {integrity: sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==} + '@tiptap/extension-strike@3.31.3': + resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-table-cell@3.22.4': - resolution: {integrity: sha512-uvFegCc1UQYK2nfIV2sIHg+hzLIMroJJm00XomzBgC1w/eSO7Ui8APiDh/baBcTPpCSU3SLiQLTgx7AU7oE3pg==} + '@tiptap/extension-table-cell@3.31.3': + resolution: {integrity: sha512-5nueKR/p/IX6B4etWqHjyRsjNfDf6dJZaRgfw1/1l90CJe2h3tTZk4JbUKWN3Mo4FzZuYKzmU3fxcZ8pSVOeJQ==} peerDependencies: - '@tiptap/extension-table': 3.22.4 + '@tiptap/extension-table': 3.31.3 - '@tiptap/extension-table-header@3.22.4': - resolution: {integrity: sha512-V4kLLWeRdc/I+IXiXZZhLAjsaHHiJWuLXTuOtZRDrCxQUiFLi4AgNg1DPQ09JAANkEWDhXq3x6BoUXaFwumbEw==} + '@tiptap/extension-table-header@3.31.3': + resolution: {integrity: sha512-sstVtNQiBYX4P16vlhwBYPsthxDnodHWQfGq0EUQM40Z7H796Lgh9t1qYtu8yARQuHKzvINRwJCSy6DZ8YTDtQ==} peerDependencies: - '@tiptap/extension-table': 3.22.4 + '@tiptap/extension-table': 3.31.3 - '@tiptap/extension-table-row@3.22.4': - resolution: {integrity: sha512-9tdS6jgS6DqUu5TpEmNrRoo/DL5Xam0PyrQaUEXUC+ssci+bMRCJ8PAWMcunNsI9NKf/Tb3wYrv6hGFChaT9uA==} + '@tiptap/extension-table-row@3.31.3': + resolution: {integrity: sha512-up6tDK+hYVTFDeJ3XqKO0WJqBzyXNfGxMoimo5h83jrq9dcNh0oPCOzglZ7rdo8GH9I3VJ2fNKxxU2Eg2HcMoQ==} peerDependencies: - '@tiptap/extension-table': 3.22.4 + '@tiptap/extension-table': 3.31.3 - '@tiptap/extension-table@3.22.4': - resolution: {integrity: sha512-kjvLv3Z4JI+1tLDqZKa+bKU8VcxY+ZOyMCKWQA7wYmy8nKWkLJ60W+xy8AcXXpHB2goCIgSFLhsTyswx0GXH4w==} + '@tiptap/extension-table@3.31.3': + resolution: {integrity: sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==} peerDependencies: - '@tiptap/core': 3.22.4 - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-task-item@3.22.5': - resolution: {integrity: sha512-OVJKiq67lU+RiC6slIhhgTJBlP/Vads6MZ7Ld5wxzCtWMdGKDuzQ1dgF7vrMEs7mhSeSH3phNcIdQ5ypYftZ9w==} + '@tiptap/extension-task-item@3.31.3': + resolution: {integrity: sha512-gCWvvXsCzi9tVFXPqKlkbU81XxtIdfjGy7FM9QFRKiWAvq5uv+Q5nvwrCyMVyP4bA7zRGWFaPzwjL68djgzoBw==} peerDependencies: - '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-task-list@3.22.5': - resolution: {integrity: sha512-SfZeJSALtFODs0i3fml1TSi4vQ4Uopu0p/LndK+mX5FGNBtNmWiy7Wr5cH03ANfzj8c2EzfGIyH+F2/V0HLK9g==} + '@tiptap/extension-task-list@3.31.3': + resolution: {integrity: sha512-3WgVzmfEnDbmxbjUBlvTiKpT53KSBnnYhXUVQxM0nz1vpoWG8QifhsnCVsNI6TZmhgR0x/jY+UW9Hc/WLF8DkQ==} peerDependencies: - '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-text-style@3.22.5': - resolution: {integrity: sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==} + '@tiptap/extension-text-style@3.31.3': + resolution: {integrity: sha512-wgjWWrjZwRZHiaDTQPX2am1y/4ePgRgGWF/2MOfSb7g4d5229p4aVtbT1JT7wu9z8OC482pEqcTlhdvgftvW7A==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-text@3.22.5': - resolution: {integrity: sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==} + '@tiptap/extension-text@3.31.3': + resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-underline@3.22.5': - resolution: {integrity: sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==} + '@tiptap/extension-underline@3.31.3': + resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extensions@3.22.5': - resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==} + '@tiptap/extensions@3.31.3': + resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/markdown@3.22.5': - resolution: {integrity: sha512-lLuAySaY5EYNYLe7e4507B9yQMAEDJdOKy0g85UNFV8giorYLQx56aV2O94Qb9gv3egs5inkwVRNfeJzOWAwig==} + '@tiptap/markdown@3.31.3': + resolution: {integrity: sha512-rBbYSxasseUoaBo15C8Yoaqafo7mJJzxwAwV9Z1OpLBvOroW9nQ0EbOdqaSYRw3/d9pP71B/3LNSzKyfyy+dpQ==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/pm@3.22.5': - resolution: {integrity: sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==} + '@tiptap/pm@3.31.3': + resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==} - '@tiptap/react@3.22.5': - resolution: {integrity: sha512-36WHEs+vPmB//V1ff7Ujcnpz7Ey5g8lhpI/0+hoanSbdiPMTQ7qZVWwMovIkMKDlqWVp2fxBgeYM1861jyFzTw==} + '@tiptap/react@3.31.3': + resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tiptap/starter-kit@3.22.5': - resolution: {integrity: sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==} + '@tiptap/starter-kit@3.31.3': + resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==} '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -5207,8 +5208,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - linkifyjs@4.3.2: - resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + linkifyjs@4.3.3: + resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} lint-staged@16.4.0: resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} @@ -5939,11 +5940,14 @@ packages: prosemirror-history@1.5.0: resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + prosemirror-keymap@1.2.3: resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} - prosemirror-model@1.25.4: - resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + prosemirror-model@1.25.11: + resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} prosemirror-schema-list@1.5.1: resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} @@ -5957,8 +5961,8 @@ packages: prosemirror-transform@1.12.0: resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} - prosemirror-view@1.41.8: - resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} + prosemirror-view@1.42.3: + resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} @@ -9192,200 +9196,202 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tiptap/core@3.22.5(@tiptap/pm@3.22.5)': + '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/pm': 3.22.5 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-blockquote@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-bubble-menu@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-bullet-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-code-block-lowlight@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(highlight.js@11.11.1)(lowlight@3.3.0)': + '@tiptap/extension-code-block-lowlight@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.1)(lowlight@3.3.0)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-code-block': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 highlight.js: 11.11.1 lowlight: 3.3.0 - '@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-details@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-text-style@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)))(@tiptap/pm@3.22.5)': + '@tiptap/extension-details@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-text-style': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-text-style': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-document@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-dropcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-floating-menu@3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-gapcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-hard-break@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-heading@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-horizontal-rule@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-image@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-image@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-italic@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-link@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 - linkifyjs: 4.3.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + linkifyjs: 4.3.3 - '@tiptap/extension-list-item@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list-keymap@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-mathematics@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(katex@0.16.45)': + '@tiptap/extension-mathematics@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(katex@0.16.45)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 katex: 0.16.45 - '@tiptap/extension-ordered-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-paragraph@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-placeholder@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-placeholder@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-table-cell@3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-table-cell@3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-table': 3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-table-header@3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-table-header@3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-table': 3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-table-row@3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-table-row@3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-table': 3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-task-item@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-task-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-task-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-task-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-text-style@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-text@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-underline@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/markdown@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/markdown@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 marked: 17.0.6 - '@tiptap/pm@3.22.5': + '@tiptap/pm@3.31.3': dependencies: prosemirror-changeset: 2.4.1 prosemirror-commands: 1.7.1 prosemirror-dropcursor: 1.8.2 prosemirror-gapcursor: 1.4.1 prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.8 + prosemirror-view: 1.42.3 - '@tiptap/react@3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@tiptap/react@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) '@types/use-sync-external-store': 0.0.6 @@ -9394,37 +9400,37 @@ snapshots: react-dom: 19.2.8(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-floating-menu': 3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) transitivePeerDependencies: - '@floating-ui/dom' - '@tiptap/starter-kit@3.22.5': + '@tiptap/starter-kit@3.31.3': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-blockquote': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-bold': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-bullet-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) - '@tiptap/extension-code': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-code-block': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-document': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-dropcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) - '@tiptap/extension-gapcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) - '@tiptap/extension-hard-break': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-heading': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-horizontal-rule': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-italic': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-link': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-list-item': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) - '@tiptap/extension-list-keymap': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) - '@tiptap/extension-ordered-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) - '@tiptap/extension-paragraph': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-strike': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-text': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-underline': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 '@ts-morph/common@0.27.0': dependencies: @@ -11621,7 +11627,7 @@ snapshots: lines-and-columns@1.2.4: {} - linkifyjs@4.3.2: {} + linkifyjs@4.3.3: {} lint-staged@16.4.0(patch_hash=7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673): dependencies: @@ -12641,7 +12647,7 @@ snapshots: prosemirror-commands@1.7.1: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 @@ -12649,58 +12655,63 @@ snapshots: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.8 + prosemirror-view: 1.42.3 prosemirror-gapcursor@1.4.1: dependencies: prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.8 + prosemirror-view: 1.42.3 prosemirror-history@1.5.0: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.8 + prosemirror-view: 1.42.3 rope-sequence: 1.3.4 + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-keymap@1.2.3: dependencies: prosemirror-state: 1.4.4 w3c-keyname: 2.2.8 - prosemirror-model@1.25.4: + prosemirror-model@1.25.11: dependencies: orderedmap: 2.1.1 prosemirror-schema-list@1.5.1: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 prosemirror-state@1.4.4: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.8 + prosemirror-view: 1.42.3 prosemirror-tables@1.8.5: dependencies: prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.8 + prosemirror-view: 1.42.3 prosemirror-transform@1.12.0: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 - prosemirror-view@1.41.8: + prosemirror-view@1.42.3: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index b79ed543494..536831230b4 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -39,8 +39,8 @@ __orca_restore_agent_teams_path # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 3d3403ad099..f86bd569381 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -77,8 +77,8 @@ __orca_deferred_init() { # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index dc14486cdb7..a11d3e6183e 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -42,8 +42,8 @@ __orca_restore_agent_teams_path # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 10e9e144fc0..35a262b5e02 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -77,8 +77,8 @@ __orca_deferred_init() { # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt index de9c8f95248..61bdd01dd50 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt @@ -31,8 +31,8 @@ fi # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 394bc4a6d10..ff90115d30d 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -51,8 +51,8 @@ __orca_deferred_init() { # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/ai-vault-search/session-search-store.ts b/src/main/ai-vault-search/session-search-store.ts index 71215bb7174..435fa16ce8a 100644 --- a/src/main/ai-vault-search/session-search-store.ts +++ b/src/main/ai-vault-search/session-search-store.ts @@ -199,30 +199,32 @@ export class SessionSearchStore { files(): SessionSearchFileRow[] { return ( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The files schema and SELECT aliases define this row; REAL casts return numeric IDs or null. - this.db - .prepare( - // Numeric stat IDs may exceed SQLite's safe INTEGER-to-number read range. - `SELECT path, CAST(dev AS REAL) AS dev, CAST(ino AS REAL) AS ino, + ( + this.db + .prepare( + // Numeric stat IDs may exceed SQLite's safe INTEGER-to-number read range. + `SELECT path, CAST(dev AS REAL) AS dev, CAST(ino AS REAL) AS ino, mtime_ms AS mtimeMs, size_bytes AS sizeBytes, state, fail_count AS failCount, failed_mtime_ms AS failedMtimeMs FROM files` - ) - .all() as (Omit & { - dev: number | null - ino: number | null - })[] - ).map((row) => ({ - path: row.path, - identity: - typeof row.dev === 'number' && typeof row.ino === 'number' - ? { dev: row.dev, ino: row.ino } - : null, - mtimeMs: row.mtimeMs, - sizeBytes: row.sizeBytes, - state: row.state, - failCount: row.failCount, - failedMtimeMs: row.failedMtimeMs - })) + ) + .all() as (Omit & { + dev: number | null + ino: number | null + })[] + ).map((row) => ({ + path: row.path, + identity: + typeof row.dev === 'number' && typeof row.ino === 'number' + ? { dev: row.dev, ino: row.ino } + : null, + mtimeMs: row.mtimeMs, + sizeBytes: row.sizeBytes, + state: row.state, + failCount: row.failCount, + failedMtimeMs: row.failedMtimeMs + })) + ) } /** diff --git a/src/main/ai-vault/remote-session-content-lines.ts b/src/main/ai-vault/remote-session-content-lines.ts index d62e8988018..16786704847 100644 --- a/src/main/ai-vault/remote-session-content-lines.ts +++ b/src/main/ai-vault/remote-session-content-lines.ts @@ -4,6 +4,8 @@ import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' export type RemoteSessionContent = string | AsyncIterable +const MAX_REMOTE_SESSION_RECORD_BYTES = 10 * 1024 * 1024 + const REMOTE_CONTENT_YIELD_LINE_COUNT = 200 const REMOTE_CONTENT_YIELD_CHAR_COUNT = 256 * 1024 @@ -80,7 +82,7 @@ export async function* streamedSessionContentLines( ): AsyncGenerator { let count = 0 let chars = 0 - for await (const record of splitTranscriptStreamLines(bytes)) { + for await (const record of splitTranscriptStreamLines(bytes, MAX_REMOTE_SESSION_RECORD_BYTES)) { throwIfAiVaultScanCancelled(signal) const line = record.line.endsWith('\r') && (record.terminated || signal) diff --git a/src/main/ai-vault/remote-session-large-transcripts.test.ts b/src/main/ai-vault/remote-session-large-transcripts.test.ts index 62d8b8c5ed1..df05700c2cf 100644 --- a/src/main/ai-vault/remote-session-large-transcripts.test.ts +++ b/src/main/ai-vault/remote-session-large-transcripts.test.ts @@ -17,6 +17,35 @@ const jsonl = (rows: unknown[]) => `${rows.map((row) => JSON.stringify(row)).joi const filler = jsonl([{ type: 'irrelevant_event', payload: 'x'.repeat(1024) }]).repeat(11000) describe('large remote history through real relay filesystem', () => { + it('reports an oversized record without losing healthy sessions or publishing a partial session', async () => { + const home = await mkdtemp(join(tmpdir(), 'orca-history-record-limit-')) + try { + const directory = join(home, '.codex', 'sessions') + await mkdir(directory, { recursive: true }) + const metadata = (id: string) => + jsonl([{ type: 'session_meta', payload: { id, cwd: '/repo' } }]) + const badPath = join(directory, 'bad.jsonl') + await writeFile(badPath, metadata('bad') + 'x'.repeat(11 * 1024 * 1024)) + await writeFile(join(directory, 'good.jsonl'), metadata('good')) + const result = await scanRemoteAiVaultSessions({ + provider: createRelayAiVaultFilesystemProvider(), + executionHostId: 'ssh:record-limit', + remoteHome: home, + hostPlatform: platform, + unlimited: true + }) + expect(result.sessions.map((session) => session.sessionId)).toEqual(['good']) + expect(result.issues).toEqual([ + expect.objectContaining({ + path: badPath, + message: 'Session transcript record exceeds 10485760 byte limit' + }) + ]) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + it('lists a large Codex rollout with middle messages and usage intact', async () => { const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-')) try { diff --git a/src/main/ai-vault/remote-session-stream-lifecycle.test.ts b/src/main/ai-vault/remote-session-stream-lifecycle.test.ts index 46668e3574e..2b8e95ea2a4 100644 --- a/src/main/ai-vault/remote-session-stream-lifecycle.test.ts +++ b/src/main/ai-vault/remote-session-stream-lifecycle.test.ts @@ -4,6 +4,26 @@ import { readStreamedSessionDocument } from './session-document-stream' import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' describe('stream lifetime and retained document work', () => { + it('aborts a newline-free record at the byte ceiling and closes the source', async () => { + let closed = false + let reads = 0 + async function* bytes() { + const chunk = Buffer.alloc(1024 * 1024, 'x') + try { + for (; reads < 100;) { + reads++ + yield chunk + } + } finally { + closed = true + } + } + const lines = streamedSessionContentLines(bytes()) + await expect(lines.next()).rejects.toThrow('record exceeds 10485760 byte limit') + expect(reads).toBe(11) + expect(closed).toBe(true) + }) + it('releases the source when a line consumer finishes early', async () => { let closed = false async function* bytes() { diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 3ca4e754106..353decfcd46 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -1,3 +1,4 @@ +import { foldOmpTranscriptTitle, type OmpTranscriptTitle } from './session-scanner-omp-title' import { remoteSessionContentLines, type RemoteSessionContent @@ -15,7 +16,8 @@ import type { } from './session-scanner-types' import type { TranscriptMessageSink } from './session-transcript-consumers' import { - accumulatorFoldResumeState, + accumulatorSessionIdentity, + cloneSessionAccumulator, addPreviewContent, addPreviewMessage, createAccumulator, @@ -212,12 +214,24 @@ export async function parseMessageGraphSessionContent( }) } -function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: string): void { +type MessageGraphParseState = { + accumulator: SessionAccumulator + ompTitle: OmpTranscriptTitle | null +} + +function consumeMessageGraphRecordLine(state: MessageGraphParseState, line: string): void { + const { accumulator } = state const record = parseJsonObject(line) if (!record) { return } updateTimeline(accumulator, extractString(record.timestamp)) + if (accumulator.agent === 'omp') { + state.ompTitle = foldOmpTranscriptTitle(state.ompTitle, record) + if (state.ompTitle) { + accumulator.title = state.ompTitle.title + } + } if (record.type === 'session') { const sessionId = extractString(record.id) if (sessionId) { @@ -241,7 +255,11 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st if (role === 'user' || role === 'assistant') { accumulator.messageCount++ if (role === 'user') { - accumulator.title ??= extractMessageText(message) + if (accumulator.agent === 'omp') { + accumulator.fallbackTitle ??= extractMessageText(message) + } else { + accumulator.title ??= extractMessageText(message) + } } else { accumulator.model = extractString(message?.model) ?? accumulator.model accumulator.totalTokens += tokenTotal(message?.usage) @@ -255,16 +273,38 @@ export function createMessageGraphSessionResumeState( file: FileWithMtime, messages?: TranscriptMessageSink ): ResumableSessionParseState { - const state = accumulatorFoldResumeState( - createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path), messages }), - consumeMessageGraphRecordLine - ) + const state = createMessageGraphResumeState({ + accumulator: createAccumulator({ + agent, + file, + sessionId: sessionIdFromFileName(file.path), + messages + }), + ompTitle: null + }) // Why: only OMP materializes task-subagent transcripts beside its sessions // (in the same-named artifact dir); the row UI shows the count without // expanding details. Pi/OpenClaw/Prime Agent have no such layout — skip the readdir. return agent === 'omp' ? withOmpSubagentTranscriptCount(state, file.path) : state } +function createMessageGraphResumeState(state: MessageGraphParseState): ResumableSessionParseState { + return { + consumeLine: (line) => consumeMessageGraphRecordLine(state, line), + identity: () => accumulatorSessionIdentity(state.accumulator), + clone: () => + createMessageGraphResumeState({ + accumulator: cloneSessionAccumulator(state.accumulator), + ompTitle: state.ompTitle + }), + touchFile: (file) => { + state.accumulator.modifiedAt = file.modifiedAt + }, + finalize: (platform, options) => + finalizeSession(cloneSessionAccumulator(state.accumulator), platform, options) + } +} + async function parseMessageGraphSessionLines(args: { agent: MessageGraphAgent file: FileWithMtime diff --git a/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts index 16511e14239..0162f536315 100644 --- a/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts +++ b/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts @@ -78,4 +78,42 @@ describe('listOmpSubagentSessions', () => { listOmpSubagentSessions({ parentFilePath: parentPath, platform: 'darwin' }) ).resolves.toEqual({ sessions: [], issues: [] }) }) + it('traverses each saved generation through its own transcript without flattening descendants', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'orca-omp-nested-list-')) + tempRoots.push(workspace) + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + const childPath = join(workspace, SESSION_STEM, 'Worker.jsonl') + const grandchildPath = join(workspace, SESSION_STEM, 'Worker', 'Research.jsonl') + await mkdir(join(workspace, SESSION_STEM, 'Worker'), { recursive: true }) + await writeFile( + parentPath, + childTranscript(PARENT_SESSION_ID, '2026-05-01T10:00:00Z', 'Coordinate') + ) + await writeFile( + childPath, + childTranscript('worker-id', '2026-05-01T10:01:00Z', 'Delegate research') + ) + await writeFile( + grandchildPath, + childTranscript('research-id', '2026-05-01T10:02:00Z', 'Investigate') + ) + const children = await listOmpSubagentSessions({ parentFilePath: parentPath }) + expect(children.issues).toEqual([]) + expect(children.sessions).toHaveLength(1) + expect(children.sessions[0]).toMatchObject({ + filePath: childPath, + sessionId: 'worker-id', + subagentTranscriptCount: 1 + }) + const grandchildren = await listOmpSubagentSessions({ + parentFilePath: children.sessions[0].filePath + }) + expect(grandchildren.issues).toEqual([]) + expect(grandchildren.sessions).toHaveLength(1) + expect(grandchildren.sessions[0]).toMatchObject({ + filePath: grandchildPath, + sessionId: 'research-id', + subagentTranscriptCount: 0 + }) + }) }) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-listing.ts b/src/main/ai-vault/session-scanner-omp-subagent-listing.ts index 3ecfade1813..d98c53b78b0 100644 --- a/src/main/ai-vault/session-scanner-omp-subagent-listing.ts +++ b/src/main/ai-vault/session-scanner-omp-subagent-listing.ts @@ -89,10 +89,7 @@ async function parseOmpSubagentTranscript(args: { const filePath = join(args.artifactDir, args.name) try { const fileStat = await wslGatedStat(filePath, OMP_SUBAGENT_FS_PRIORITY) - // The shared OMP parser decorates every parse with an artifact-dir count, so - // a child row carries its own grandchild count. It is accurate but has no - // renderer — subagent rows don't expand — and this lister is local-only, so - // the remote partition never reaches it. + // Each child carries its own count for on-demand nested expansion. const session = await parseMessageGraphSessionFile( 'omp', { path: filePath, mtimeMs: fileStat.mtimeMs, modifiedAt: fileStat.mtime.toISOString() }, diff --git a/src/main/ai-vault/session-scanner-omp-title.test.ts b/src/main/ai-vault/session-scanner-omp-title.test.ts new file mode 100644 index 00000000000..3e2d6a966cf --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-title.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { + createMessageGraphSessionResumeState, + parseMessageGraphSessionContent +} from './session-scanner-graph-parsers' + +const file = { path: '/tmp/omp-title.jsonl', mtimeMs: 1, modifiedAt: '2026-09-14T00:00:00.000Z' } +const prompt = { type: 'message', message: { role: 'user', content: 'First prompt' } } +const header = { type: 'session', id: 'session-id', cwd: '/folder workspace' } +const line = (record: unknown) => JSON.stringify(record) +async function parse(records: unknown[], agent: 'omp' | 'pi' = 'omp') { + return parseMessageGraphSessionContent( + agent, + file, + [header, ...records].map(line).join('\n'), + 'darwin' + ) +} + +describe('OMP stored history names', () => { + it.each([ + { type: 'session', title: 'Harness name', titleSource: 'user' }, + { + type: 'title', + v: 1, + title: 'Harness name', + source: 'user', + updatedAt: '2026-09-14T01:00:00Z', + pad: '' + }, + { type: 'title_change', title: 'Harness name', source: 'user' }, + { type: 'session_info', name: 'Harness name' } + ])('uses persisted %j ahead of the first prompt', async (record) => { + expect((await parse([prompt, record]))?.title).toBe('Harness name') + }) + + it('preserves a user name through stale header and later automatic records', async () => { + expect( + ( + await parse([ + { + type: 'title', + v: 1, + title: 'User name', + source: 'user', + updatedAt: '2026-09-14T02:00:00Z', + pad: '' + }, + { ...header, title: 'Old header' }, + prompt, + { + type: 'title_change', + title: 'Auto name', + source: 'auto', + timestamp: '2026-09-14T03:00:00Z' + } + ]) + )?.title + ).toBe('User name') + }) + + it('keeps the current slot ahead of older rename entries, allowing a newer rename', async () => { + const records = [ + { + type: 'title', + v: 1, + title: 'Current slot', + source: 'user', + updatedAt: '2026-09-14T02:00:00Z', + pad: '' + }, + prompt, + { + type: 'title_change', + title: 'Old rename', + source: 'user', + timestamp: '2026-09-14T01:00:00Z' + } + ] + expect((await parse(records))?.title).toBe('Current slot') + expect( + ( + await parse([ + ...records, + { + type: 'title_change', + title: 'New rename', + source: 'user', + timestamp: '2026-09-14T03:00:00Z' + } + ]) + )?.title + ).toBe('New rename') + }) + + it('preserves fallback behavior for missing, empty or unsupported title records', async () => { + expect( + ( + await parse([ + prompt, + { type: 'title_change', title: ' ', source: 'user' }, + { type: 'title_change', title: 'Unknown', source: 'model' }, + { type: 'session_info', title: 'Wrong field' } + ]) + )?.title + ).toBe('First prompt') + expect( + (await parse([prompt, { type: 'title_change', title: 'OMP only', source: 'user' }], 'pi')) + ?.title + ).toBe('First prompt') + }) + + it('clones title authority for append parsing without mutating previous snapshots', async () => { + const state = createMessageGraphSessionResumeState('omp', file) + for (const record of [ + header, + prompt, + { type: 'title_change', title: 'User name', source: 'user' } + ]) { + state.consumeLine(line(record)) + } + const previous = await state.finalize('darwin') + const next = state.clone() + next.consumeLine(line({ type: 'title_change', title: 'Auto name', source: 'auto' })) + expect((await next.finalize('darwin'))?.title).toBe('User name') + next.consumeLine(line({ type: 'title_change', title: 'New name', source: 'user' })) + expect((await next.finalize('darwin'))?.title).toBe('New name') + expect(previous?.title).toBe('User name') + expect(state.identity?.()?.title).toBe('User name') + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-title.ts b/src/main/ai-vault/session-scanner-omp-title.ts new file mode 100644 index 00000000000..e734350caad --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-title.ts @@ -0,0 +1,49 @@ +import { extractString, normalizeTitleText, timestampMs } from './session-scanner-values' + +export type OmpTranscriptTitle = { + title: string + source: 'user' | 'auto' + updatedAt: number | null +} + +/** Fold persisted title metadata; a current slot can precede older rename entries. */ +export function foldOmpTranscriptTitle( + current: OmpTranscriptTitle | null, + record: Record +): OmpTranscriptTitle | null { + const legacy = record.type === 'session_info' + if ( + !legacy && + record.type !== 'session' && + record.type !== 'title_change' && + record.type !== 'title' + ) { + return current + } + if (record.type === 'title' && record.v !== 1) { + return current + } + const title = normalizeTitleText(extractString(legacy ? record.name : record.title) ?? '') + if (!title) { + return current + } + const rawSource = legacy ? 'user' : (record.source ?? record.titleSource) + if (rawSource !== undefined && rawSource !== 'user' && rawSource !== 'auto') { + return current + } + const source = rawSource === 'user' ? 'user' : 'auto' + if (current?.source === 'user' && source !== 'user') { + return current + } + const timestamp = timestampMs(record.type === 'title' ? record.updatedAt : record.timestamp) + const updatedAt = Number.isFinite(timestamp) ? timestamp : null + if ( + current?.source === source && + current.updatedAt !== null && + updatedAt !== null && + updatedAt < current.updatedAt + ) { + return current + } + return { title, source, updatedAt } +} diff --git a/src/main/claude/claude-prompt-registry.ts b/src/main/claude/claude-prompt-registry.ts new file mode 100644 index 00000000000..6411a19e215 --- /dev/null +++ b/src/main/claude/claude-prompt-registry.ts @@ -0,0 +1,224 @@ +import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' + +/** Settles the SDK's `canUseTool` promise; `null` writes no provider response. */ +export type ClaudePromptSettle = (response: PermissionResult | null) => void + +export type ClaudePendingPrompt = { + requestId: string + promptKey: string + toolUseId: string + toolName: string + kind: 'approval' | 'question' + input: Record + suggestions: PermissionUpdate[] + questionIds: readonly string[] + answers: Map + settle: ClaudePromptSettle + turnId?: string | null +} + +export type ClaudePromptRegistration = { + requestId: string + toolName: string + toolUseId: string + input: Record + suggestions: PermissionUpdate[] + settle: ClaudePromptSettle + turnId?: string | null +} + +type PromptBinding = { + address: string + questionId?: string + turnId: string | null +} + +export type ClaudePromptClaim = { + readonly itemId: string + readonly found: { prompt: ClaudePendingPrompt; questionId?: string } +} + +type ClaudePromptCancellationObservation = { + promise: Promise + resolve: () => void +} + +export function isClaudePromptRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function readClaudePromptString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +export function claudePromptQuestions(input: Record): Record[] { + return Array.isArray(input.questions) ? input.questions.filter(isClaudePromptRecord) : [] +} + +function questionId(question: Record, index: number): string { + return ( + readClaudePromptString(question.question) ?? + readClaudePromptString(question.header) ?? + `question-${index + 1}` + ) +} + +/** Session-local callback ownership; none of this state is reconstructed from the transcript. */ +export class ClaudePromptRegistry { + private readonly prompts = new Map() + private readonly journalBindings = new Map() + private readonly claims = new Map() + private readonly cancellationObservations = new WeakMap< + ClaudePendingPrompt, + ClaudePromptCancellationObservation + >() + + register(registration: ClaudePromptRegistration): ClaudePendingPrompt | null { + const toolUseId = readClaudePromptString(registration.toolUseId) + const toolName = readClaudePromptString(registration.toolName) + const input = isClaudePromptRecord(registration.input) ? registration.input : null + if (!toolUseId || !toolName || !input) { + return null + } + const questions = toolName === 'AskUserQuestion' ? claudePromptQuestions(input) : [] + const prompt: ClaudePendingPrompt = { + requestId: registration.requestId, + promptKey: registration.requestId, + toolUseId, + toolName, + kind: questions.length > 0 ? 'question' : 'approval', + input, + suggestions: Array.isArray(registration.suggestions) ? registration.suggestions : [], + questionIds: questions.map(questionId), + answers: new Map(), + settle: registration.settle, + turnId: registration.turnId ?? null + } + this.prompts.set(prompt.promptKey, prompt) + return prompt + } + + /** True only if the prompt was still pending; lets abort and answer settle once. */ + forgetIfPending(prompt: ClaudePendingPrompt): boolean { + if (!this.prompts.has(prompt.promptKey)) { + return false + } + const observation = this.cancellationObservations.get(prompt) + this.forget(prompt) + observation?.resolve() + return true + } + + bindJournalItemId( + journalItemId: string, + promptKey: string, + questionIdForItem?: string, + turnId: string | null = null + ): void { + const prompt = this.prompts.get(promptKey) + this.journalBindings.set(journalItemId, { + address: promptKey, + ...(questionIdForItem ? { questionId: questionIdForItem } : {}), + turnId: turnId ?? prompt?.turnId ?? null + }) + } + + find(itemId: string): { prompt: ClaudePendingPrompt; questionId?: string } | null { + const binding = this.journalBindings.get(itemId) + const prompt = this.prompts.get(binding?.address ?? itemId) + return prompt + ? { prompt, ...(binding?.questionId ? { questionId: binding.questionId } : {}) } + : null + } + + claim(itemId: string, kind?: 'approval' | 'question'): ClaudePromptClaim | null { + const found = this.find(itemId) + if (!found || this.claims.has(found.prompt) || (kind && found.prompt.kind !== kind)) { + return null + } + const claim = { itemId, found } + this.claims.set(found.prompt, claim) + return claim + } + + claimBound(itemId: string, turnId: string): ClaudePromptClaim | null { + const binding = this.journalBindings.get(itemId) + const prompt = binding ? this.prompts.get(binding.address) : undefined + if (!binding || !prompt || binding.turnId !== turnId || this.claims.has(prompt)) { + return null + } + const found = { prompt, ...(binding.questionId ? { questionId: binding.questionId } : {}) } + const claim = { itemId, found } + this.claims.set(prompt, claim) + return claim + } + + ownsClaim(claim: ClaudePromptClaim): boolean { + return ( + this.claims.get(claim.found.prompt) === claim && + this.find(claim.itemId)?.prompt === claim.found.prompt + ) + } + + ownsBoundClaim(claim: ClaudePromptClaim, itemId: string, turnId: string): boolean { + const binding = this.journalBindings.get(itemId) + return ( + claim.itemId === itemId && + this.claims.get(claim.found.prompt) === claim && + binding?.address === claim.found.prompt.promptKey && + binding.turnId === turnId && + this.prompts.get(binding.address) === claim.found.prompt + ) + } + + releaseClaim(claim: ClaudePromptClaim): void { + if (this.claims.get(claim.found.prompt) === claim) { + this.claims.delete(claim.found.prompt) + } + } + + observeCancellation(claim: ClaudePromptClaim): Promise | null { + if (!this.ownsClaim(claim)) { + return null + } + let observation = this.cancellationObservations.get(claim.found.prompt) + if (!observation) { + let resolve = (): void => {} + const promise = new Promise((settled) => { + resolve = settled + }) + observation = { promise, resolve } + this.cancellationObservations.set(claim.found.prompt, observation) + } + return observation.promise + } + + cancel(requestId: string): ClaudePendingPrompt | null { + const prompt = this.prompts.get(requestId) ?? null + if (prompt) { + this.forget(prompt) + } + return prompt + } + + forget(prompt: ClaudePendingPrompt): void { + this.claims.delete(prompt) + this.prompts.delete(prompt.promptKey) + for (const [itemId, binding] of this.journalBindings) { + if (binding.address === prompt.promptKey) { + this.journalBindings.delete(itemId) + } + } + } + + clear(): ClaudePendingPrompt[] { + const pending = [...this.prompts.values()] + this.prompts.clear() + this.journalBindings.clear() + this.claims.clear() + for (const prompt of pending) { + this.cancellationObservations.get(prompt)?.resolve() + } + return pending + } +} diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 168ce558f53..e9afa9bac25 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -4,10 +4,12 @@ import { answerClaudePrompt, stopClaudeBackgroundTasks } from './claude-structured-control-actions' +import { dispatchClaudeTurn } from './claude-structured-dispatch' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import type { ClaudeSession } from './claude-structured-session-state' +import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' type InterruptResult = Awaited> @@ -23,11 +25,11 @@ function sessionWith(input: { } { const interrupt = vi.fn(input.interrupt) const cancelAsyncMessage = vi.fn(input.cancelAsyncMessage ?? (async () => {})) - const session = { - capabilities: input.capabilities ?? [], - prompts: input.prompts ?? new ClaudePromptRegistry(), - connection: { interrupt, cancelAsyncMessage } - } as unknown as ClaudeSession + const session = sessionFor() + session.capabilities = input.capabilities ?? [] + session.prompts = input.prompts ?? new ClaudePromptRegistry() + session.connection.interrupt = interrupt + session.connection.cancelAsyncMessage = cancelAsyncMessage return { session, interrupt, cancelAsyncMessage } } @@ -54,15 +56,69 @@ describe('cancelClaudeTurn', () => { expect(cancelAsyncMessage.mock.calls.map((call) => call[0])).toEqual(['queued-1', 'queued-2']) }) - it('sends cancel_queued and never sweeps when the CLI advertises the capability', async () => { + it('settles every cancelled queued waiter when the CLI advertises the capability', async () => { + const cancelled = Array.from({ length: 64 }, (_, index) => `queued-${index}`) const { session, interrupt, cancelAsyncMessage } = sessionWith({ capabilities: ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1'], - interrupt: async () => ({ still_queued: [], cancelled: ['queued-1'] }) + interrupt: async () => ({ still_queued: [], cancelled }) }) + const resolutions = cancelled.map(() => vi.fn()) + session.dispatchWaiters = cancelled.map((sentUuid, index): ClaudeDispatchWaiter => ({ + acceptsResult: false, + clientMessageId: `client-${index}`, + sentUuid, + dispatchSequence: index + 1, + replayContentKey: `content-${index}`, + resolve: resolutions[index]! + })) + const settled = vi.fn() - await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true }) + await expect(cancelClaudeTurn(session, 5_000, () => true, settled)).resolves.toEqual({ + cancelled: true + }) expect(interrupt).toHaveBeenCalledWith({ cancelQueued: true, timeoutMs: 5_000 }) expect(cancelAsyncMessage).not.toHaveBeenCalled() + expect(session.dispatchWaiters).toEqual([]) + expect(resolutions.every((resolve) => resolve.mock.calls[0]?.[0] === null)).toBe(true) + expect(settled).toHaveBeenCalledTimes(64) + expect(settled).toHaveBeenNthCalledWith(1, { + clientMessageId: 'client-0', + state: 'rejected', + reason: 'provider_cancelled_before_start' + }) + }) + + it('rejects an ambiguously written dispatch when a later interrupt confirms it was cancelled', async () => { + let cancelledUuid = '' + const { session } = sessionWith({ + capabilities: ['interrupt_cancel_queued_v1'], + interrupt: async () => ({ still_queued: [], cancelled: [cancelledUuid] }) + }) + session.connection.send = vi.fn(async () => { + throw new Error('connection lost after write') + }) + const settled = vi.fn() + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-ambiguous', + body: userMessage([{ type: 'text', text: 'queued' }]) + }) + ).resolves.toMatchObject({ state: 'unknown' }) + expect(session.dispatchWaiters).toEqual([]) + expect(session.retiredDispatchWaiters).toHaveLength(1) + cancelledUuid = session.retiredDispatchWaiters[0]!.sentUuid + + await expect(cancelClaudeTurn(session, 5_000, () => true, settled)).resolves.toEqual({ + cancelled: true + }) + expect(session.retiredDispatchWaiters).toEqual([]) + expect(settled).toHaveBeenCalledOnce() + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-ambiguous', + state: 'rejected', + reason: 'provider_cancelled_before_start' + }) }) it('reports a not-running interrupt as not cancelled without throwing', async () => { @@ -87,6 +143,40 @@ describe('cancelClaudeTurn', () => { }) describe('answerClaudePrompt', () => { + it('resolves cancellation observation when teardown clears the prompt registry', async () => { + const prompts = new ClaudePromptRegistry() + const settle = vi.fn() + const prompt = prompts.register({ + requestId: 'perm-clear', + toolName: 'Bash', + toolUseId: 'tool-clear', + input: { command: 'ls' }, + suggestions: [], + settle + })! + prompts.bindJournalItemId('journal-clear', prompt.promptKey) + const claim = prompts.claim('journal-clear', 'approval') + if (!claim) { + throw new Error('expected prompt claim') + } + const observed = prompts.observeCancellation(claim) + if (!observed) { + throw new Error('expected cancellation observation') + } + let observedCancellation = false + void observed.then(() => { + observedCancellation = true + }) + + expect(prompts.clear()).toEqual([prompt]) + await Promise.resolve() + + expect(observedCancellation).toBe(true) + expect(prompts.find('journal-clear')).toBeNull() + expect(prompts.ownsClaim(claim)).toBe(false) + expect(settle).not.toHaveBeenCalled() + }) + it('settles the pending prompt callback and forgets it', async () => { const prompts = new ClaudePromptRegistry() const settle = vi.fn() @@ -100,20 +190,34 @@ describe('answerClaudePrompt', () => { })! prompts.bindJournalItemId('journal-1', prompt.promptKey) const { session } = sessionWith({ interrupt: async () => undefined, prompts }) + const resolvePrompt = vi.fn() + session.translator = { + handle: vi.fn(), + journalPrompts: { + cancel: vi.fn(() => ({ accepted: true as const })), + resolve: resolvePrompt + }, + flush: vi.fn(), + pendingStreamedBlocks: 0, + dispose: vi.fn() + } - await answerClaudePrompt(session, { itemId: 'journal-1', kind: 'approval', optionId: 'allow' }) + const claim = prompts.claim('journal-1', 'approval') + if (!claim) { + throw new Error('expected prompt claim') + } + await answerClaudePrompt(session, claim, 'allow') expect(settle).toHaveBeenCalledWith( expect.objectContaining({ behavior: 'allow', toolUseID: 'tool-1' }) ) expect(prompts.find('journal-1')).toBeNull() + expect(resolvePrompt).toHaveBeenCalledWith(prompt.promptKey) }) - it('refuses an answer for a prompt Claude is no longer waiting on', async () => { - const { session } = sessionWith({ interrupt: async () => undefined }) - await expect( - answerClaudePrompt(session, { itemId: 'missing', kind: 'approval', optionId: 'allow' }) - ).rejects.toThrow(/no longer waiting/) + it('refuses to claim a prompt Claude is no longer waiting on', () => { + const prompts = new ClaudePromptRegistry() + expect(prompts.claim('missing', 'approval')).toBeNull() }) }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 8b3bb94c7b5..961b9aee7ba 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -1,9 +1,17 @@ -import { applyClaudePromptAnswer } from './claude-structured-prompt-replies' +import { applyClaudePromptAnswer, type ClaudePromptClaim } from './claude-structured-prompt-replies' import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { + settleCancelledClaudeDispatchWaiters, + type ClaudeLateDispatchSettlement +} from './claude-structured-dispatch' import type { ClaudeSession } from './claude-structured-session-state' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' +export function supportsClaudeQueuedInterruptCancellation(session: ClaudeSession): boolean { + return session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY) +} + export type ClaudeTurnCancellationGuard = () => boolean /** @@ -16,20 +24,23 @@ export type ClaudeTurnCancellationGuard = () => boolean export async function cancelClaudeTurn( session: ClaudeSession, timeoutMs: number | undefined, - isCurrent: ClaudeTurnCancellationGuard = () => true + isCurrent: ClaudeTurnCancellationGuard = () => true, + onDispatchSettledLate?: ClaudeLateDispatchSettlement ): Promise<{ cancelled: boolean }> { // The SDK interrupt is session-scoped. Re-check the caller's turn/fence // immediately before issuing it so a delayed request cannot stop a later turn. if (!isCurrent()) { return { cancelled: false } } - const cancelQueued = session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY) + const cancelQueued = supportsClaudeQueuedInterruptCancellation(session) try { const receipt = await session.connection.interrupt({ ...(cancelQueued ? { cancelQueued: true } : {}), timeoutMs }) - if (!cancelQueued) { + if (cancelQueued) { + settleCancelledClaudeDispatchWaiters(session, receipt?.cancelled ?? [], onDispatchSettledLate) + } else { for (const uuid of receipt?.still_queued ?? []) { await session.connection.cancelAsyncMessage(uuid, { timeoutMs }).catch(() => {}) } @@ -71,16 +82,18 @@ export async function stopClaudeBackgroundTasks( export async function answerClaudePrompt( session: ClaudeSession, - input: { itemId: string; kind: 'approval' | 'question'; optionId: string } + claim: ClaudePromptClaim, + optionId: string ): Promise { - const found = session.prompts.find(input.itemId) - if (!found || found.prompt.kind !== input.kind) { - throw new Error(`claude is no longer waiting on ${input.itemId}`) + if (!session.prompts.ownsClaim(claim)) { + throw new Error(`claude is no longer waiting on ${claim.itemId}`) } - const response = applyClaudePromptAnswer(found, input.optionId) + const response = applyClaudePromptAnswer(claim.found, optionId) if (response === null) { + session.prompts.releaseClaim(claim) return } - session.prompts.forget(found.prompt) - found.prompt.settle(response) + session.prompts.forget(claim.found.prompt) + claim.found.prompt.settle(response) + session.translator?.journalPrompts.resolve(claim.found.prompt.promptKey) } diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 45dc3ebc0e3..84253b765f6 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -1,14 +1,15 @@ import { randomUUID } from 'node:crypto' -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem -} from '../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeHasReplayContent, readClaudeMessageEnvelope } from './claude-structured-item-translation' -import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' +import type { + ClaudeDispatchWaiter, + ClaudeLateDispatchOutcome, + ClaudeSession +} from './claude-structured-session-state' import { readClaudeFrameString } from './claude-structured-init-proof' import { claudeDispatchContentKey, @@ -17,6 +18,7 @@ import { } from './claude-structured-dispatch-content' import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { + DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_QUEUE_FULL, dispatchWriteFailureReason } from '../../shared/structured-agent-session-dispatch-rejection' @@ -25,11 +27,8 @@ import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-m const MAX_RETIRED_DISPATCH_WAITERS = 64 const MAX_ACTIVE_DISPATCH_WAITERS = 64 -/** Directly settles provider-proven delivery; the durable replay row independently reconciles it. */ -export type ClaudeLateDispatchSettlement = (input: { - clientMessageId: string - providerIdentity: AgentJournalItemIdentity -}) => void +/** Settles a provider-proven late outcome; replay rows independently reconcile acceptance. */ +export type ClaudeLateDispatchSettlement = (input: ClaudeLateDispatchOutcome) => void export function resolveClaudeReplayWaiter( session: ClaudeSession, @@ -228,6 +227,34 @@ function forgetWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): voi } } +export function settleCancelledClaudeDispatchWaiters( + session: ClaudeSession, + cancelledUuids: readonly string[], + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const cancelled = new Set(cancelledUuids) + const activeWaiters = session.dispatchWaiters.filter((waiter) => cancelled.has(waiter.sentUuid)) + const retiredWaiters = session.retiredDispatchWaiters.filter((waiter) => + cancelled.has(waiter.sentUuid) + ) + for (const waiter of activeWaiters) { + forgetWaiter(session, waiter) + waiter.resolve(null) + } + for (const waiter of retiredWaiters) { + forgetRetiredWaiter(session, waiter) + } + for (const waiter of [...activeWaiters, ...retiredWaiters]) { + if (waiter.clientMessageId) { + onSettledLate?.({ + clientMessageId: waiter.clientMessageId, + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + } + } +} + function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void { forgetWaiter(session, waiter) if (!waiter.retired) { diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts index 343e76d4ea5..27a90181aec 100644 --- a/src/main/claude/claude-structured-inbound-control.ts +++ b/src/main/claude/claude-structured-inbound-control.ts @@ -25,6 +25,7 @@ export type ClaudePermissionCallbackDeps = { sessionId: string prompts: ClaudePromptRegistry emit: (event: ClaudeStructuredSessionEvent) => void + currentTurnId?: () => string | null } function denySafeResult(toolUseId: string | undefined): PermissionResult { @@ -36,7 +37,7 @@ function denySafeResult(toolUseId: string | undefined): PermissionResult { } /** - * Build the SDK permission callbacks from the durable prompt registry. + * Build the SDK permission callbacks from the session-local prompt registry. * * A decodable `can_use_tool` becomes a durable prompt whose `settle` resolves this callback; * a malformed one is denied without registering. The SDK's abort signal fires on @@ -57,7 +58,8 @@ export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDep toolUseId: options.toolUseID, input, suggestions: options.suggestions ?? [], - settle: resolve as (response: Record | null) => void + settle: resolve, + turnId: deps.currentTurnId?.() ?? null }) if (!prompt) { resolve(denySafeResult(options.toolUseID)) diff --git a/src/main/claude/claude-structured-journal-prompt-retry.test.ts b/src/main/claude/claude-structured-journal-prompt-retry.test.ts new file mode 100644 index 00000000000..491152668ed --- /dev/null +++ b/src/main/claude/claude-structured-journal-prompt-retry.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function approval(promptKey: string): ClaudePendingPrompt { + return { + requestId: promptKey, + promptKey, + toolUseId: 'tool-retry', + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + suggestions: [], + questionIds: [], + answers: new Map(), + settle: () => {} + } +} + +function transientBackpressureSink( + refusedAt: 'append' | 'publish', + persistent = false +): { + sink: StructuredAgentSessionEventSink + durableApproval: () => AgentJournalItemBody | undefined + appendAttempts: () => number + publishAttempts: () => number + appliedSettlements: Set + release: () => void +} { + const staged = new Map() + const durable = new Map() + const appliedSettlements = new Set() + let lifecycleAppendAttempts = 0 + let lifecyclePublishAttempts = 0 + let released = false + const persist = (): void => { + durable.clear() + for (const [key, body] of staged) { + durable.set(key, body) + } + } + const applyItem = (identity: AgentJournalItemIdentity, body: AgentJournalItemBody): void => { + staged.set(agentJournalItemKey(identity), body) + } + return { + sink: { + appendItem: applyItem, + appendTombstone: (identity) => staged.delete(agentJournalItemKey(identity)), + publish: persist, + tryAppendLifecycleBatch: (settlementId, mutations) => { + lifecycleAppendAttempts += 1 + if (refusedAt === 'append' && (persistent ? !released : lifecycleAppendAttempts === 1)) { + return { accepted: false, reason: 'backpressure' } + } + if (!appliedSettlements.has(settlementId)) { + for (const mutation of mutations) { + if (mutation.kind === 'item') { + applyItem(mutation.identity, mutation.body) + } else { + staged.delete(agentJournalItemKey(mutation.identity)) + } + } + appliedSettlements.add(settlementId) + } + return { accepted: true } + }, + tryPublish: () => { + lifecyclePublishAttempts += 1 + if (refusedAt === 'publish' && (persistent ? !released : lifecyclePublishAttempts === 1)) { + return { accepted: false, reason: 'backpressure' } + } + persist() + return { accepted: true } + } + }, + durableApproval: () => [...durable.values()].find((body) => body.kind === 'approval'), + appendAttempts: () => lifecycleAppendAttempts, + publishAttempts: () => lifecyclePublishAttempts, + appliedSettlements, + release: () => { + released = true + } + } +} + +function rootResult() { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'result', + subtype: 'success', + uuid: 'result-success', + session_id: 'claude-session', + parent_tool_use_id: null, + is_error: false, + duration_ms: 1 + } + } +} + +function streamDelta(index: number) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'stream_event', + uuid: `stream-${index}`, + session_id: 'claude-session', + parent_tool_use_id: null, + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'x' } + } + } + } +} + +describe('Claude journal prompt cancellation retry', () => { + it.each(['append', 'publish'] as const)( + 'retries after transient lifecycle %s backpressure', + (refusedAt) => { + const state = transientBackpressureSink(refusedAt) + const translator = createClaudeJournalTranslator({ sink: state.sink }) + const prompt = approval('permission-retry') + + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt }) + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: prompt.promptKey + }) + expect(state.durableApproval()).toMatchObject({ resolution: { state: 'pending' } }) + + translator.handle(rootResult()) + expect(state.durableApproval()).toMatchObject({ resolution: { state: 'cancelled' } }) + expect(state.appendAttempts()).toBe(2) + expect(state.publishAttempts()).toBe(refusedAt === 'publish' ? 2 : 1) + expect(state.appliedSettlements).toEqual(new Set(['prompt-cancelled:permission-retry'])) + + translator.handle(rootResult()) + expect(state.appendAttempts()).toBe(2) + expect(state.publishAttempts()).toBe(refusedAt === 'publish' ? 2 : 1) + } + ) + + it('keeps streaming frames off retry work and recovers at the next root result', () => { + const state = transientBackpressureSink('append', true) + const translator = createClaudeJournalTranslator({ sink: state.sink }) + const prompt = approval('permission-streaming') + + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt }) + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: prompt.promptKey + }) + expect(state.appendAttempts()).toBe(1) + + for (let index = 0; index < 100; index += 1) { + translator.handle(streamDelta(index)) + } + expect(state.appendAttempts()).toBe(1) + + state.release() + translator.handle(rootResult()) + expect(state.appendAttempts()).toBe(2) + expect(state.durableApproval()).toMatchObject({ resolution: { state: 'cancelled' } }) + }) +}) diff --git a/src/main/claude/claude-structured-journal-prompts.ts b/src/main/claude/claude-structured-journal-prompts.ts new file mode 100644 index 00000000000..3c660223257 --- /dev/null +++ b/src/main/claude/claude-structured-journal-prompts.ts @@ -0,0 +1,191 @@ +import type { + AgentJournalApprovalItem, + AgentJournalItemIdentity, + AgentJournalQuestionItem +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + claudeApprovalItem, + claudePromptIdentity, + claudeQuestionItems, + type ClaudeQuestionItem +} from './claude-structured-prompt-items' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +const ADMITTED = { accepted: true } as const + +type ClaudeJournalPrompt = { + identity: AgentJournalItemIdentity + body: AgentJournalApprovalItem | AgentJournalQuestionItem +} + +type ClaudeJournalPromptEntry = { + items: ClaudeJournalPrompt[] + cancellationPending: boolean +} + +function cancelledPromptBody( + body: AgentJournalApprovalItem | AgentJournalQuestionItem +): AgentJournalApprovalItem | AgentJournalQuestionItem { + const cancelled = cancelledJournalPromptBody(body) + if (!cancelled) { + throw new Error('Claude prompt body is not cancellable') + } + return cancelled +} + +export class ClaudeJournalPrompts { + private readonly items = new Map() + private pendingCancellationTotal = 0 + + get size(): number { + return this.items.size + } + + get pendingCancellationCount(): number { + return this.pendingCancellationTotal + } + + constructor( + private readonly deps: { + sink: StructuredAgentSessionEventSink + bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void + questionItems?: (input: { + sessionId: string + prompt: Extract['prompt'] + }) => ClaudeQuestionItem[] + } + ) {} + + handle(event: Extract): void { + const items: ClaudeJournalPrompt[] = [] + if (event.prompt.kind === 'question') { + for (const question of (this.deps.questionItems ?? claudeQuestionItems)({ + sessionId: event.sessionId, + prompt: event.prompt + })) { + items.push(question) + this.deps.sink.appendItem(question.identity, question.body) + this.deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey) + } + } else { + const identity = claudePromptIdentity({ + sessionId: event.sessionId, + promptKey: event.prompt.promptKey + }) + const body = claudeApprovalItem(event.prompt) + items.push({ identity, body }) + this.deps.sink.appendItem(identity, body) + this.deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) + } + this.deletePrompt(event.prompt.promptKey) + this.items.set(event.prompt.promptKey, { items, cancellationPending: false }) + this.deps.sink.publish() + } + + private admitCancellation(promptKey: string): StructuredAgentSessionSinkAdmission { + const items = this.items.get(promptKey)?.items ?? [] + if (items.length === 0) { + return ADMITTED + } + const mutations = items.map(({ identity, body }) => ({ + kind: 'item' as const, + identity, + body: cancelledPromptBody(body) + })) + let admission: StructuredAgentSessionSinkAdmission + if (this.deps.sink.tryAppendLifecycleBatch) { + admission = this.deps.sink.tryAppendLifecycleBatch( + `prompt-cancelled:${encodeURIComponent(promptKey)}`, + mutations, + { lifecycle: true } + ) + } else if (this.deps.sink.appendLifecycleBatch) { + admission = + this.deps.sink.appendLifecycleBatch( + `prompt-cancelled:${encodeURIComponent(promptKey)}`, + mutations, + { lifecycle: true } + ) ?? ADMITTED + } else if (items.length === 1) { + const item = items[0] + if (!item) { + return ADMITTED + } + const body = cancelledPromptBody(item.body) + admission = this.deps.sink.tryAppendItem + ? this.deps.sink.tryAppendItem(item.identity, body, { lifecycle: true }) + : (this.deps.sink.appendItem(item.identity, body, { lifecycle: true }), ADMITTED) + } else { + return { accepted: false, reason: 'failed' } + } + if (!admission.accepted) { + return admission + } + const published = this.deps.sink.tryPublish + ? this.deps.sink.tryPublish({ lifecycle: true }) + : (this.deps.sink.publish({ lifecycle: true }), ADMITTED) + if (published.accepted) { + this.deletePrompt(promptKey) + } + return published + } + + private deletePrompt(promptKey: string): void { + const entry = this.items.get(promptKey) + if (entry?.cancellationPending) { + this.pendingCancellationTotal -= 1 + } + this.items.delete(promptKey) + } + + private setCancellationPending(entry: ClaudeJournalPromptEntry, pending: boolean): void { + if (entry.cancellationPending === pending) { + return + } + entry.cancellationPending = pending + this.pendingCancellationTotal += pending ? 1 : -1 + } + + cancel(promptKey: string): StructuredAgentSessionSinkAdmission { + const admission = this.admitCancellation(promptKey) + const entry = this.items.get(promptKey) + if (entry) { + this.setCancellationPending(entry, !admission.accepted && admission.reason === 'backpressure') + } + return admission + } + + retryPendingCancellations(): void { + if (this.pendingCancellationTotal === 0) { + return + } + for (const [promptKey, entry] of this.items) { + if (!entry.cancellationPending) { + continue + } + const admission = this.admitCancellation(promptKey) + if (!admission.accepted && admission.reason === 'backpressure') { + return + } + const retained = this.items.get(promptKey) + if (retained) { + this.setCancellationPending(retained, false) + } + } + } + + resolve(promptKey: string): void { + this.deletePrompt(promptKey) + } + + clear(): void { + this.items.clear() + this.pendingCancellationTotal = 0 + } +} diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 65be68bb05f..d9b8736c955 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -309,6 +309,53 @@ describe('Claude structured journal translation', () => { expect(providerFrameKinds(items)).toEqual([]) }) + it('restores a cancelled prompt as terminal history after reopening the journal', async () => { + const journal = await openAgentSessionJournal({ + identity: JOURNAL_IDENTITY, + journalDir: journalRoot, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: vi.fn() }) + const translator = createClaudeJournalTranslator({ sink: deferred.sink }) + const approval = prompt({ + requestId: 'permission-1', + promptKey: 'permission-1', + toolUseId: 'tool-1', + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + questionIds: [] + }) + + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: approval }) + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: approval.promptKey + }) + await expect(deferred.drained()).resolves.toEqual({ ok: true }) + deferred.close() + await journal.close() + + const reopened = await openAgentSessionJournal({ + identity: JOURNAL_IDENTITY, + journalDir: journalRoot, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-2' + }) + expect(reopened.snapshot().items).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ + kind: 'approval', + resolution: expect.objectContaining({ state: 'cancelled' }) + }) + }) + ]) + await reopened.close() + }) + it('settles result frames, empty thinking and string user replays without painting a row', () => { const state = sinkState() const translator = createClaudeJournalTranslator({ sink: state.sink }) @@ -799,7 +846,11 @@ describe('Claude structured journal translation', () => { sessionId: 'orca-session', promptKey: 'questions-1' }) - expect(state.tombstones).toHaveLength(1) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'question', + resolution: { state: 'cancelled' } + }) + expect(state.tombstones).toHaveLength(0) }) }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 702ff5f6b26..216e760315c 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -1,4 +1,3 @@ -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' @@ -21,7 +20,6 @@ import { readClaudeMessageEnvelope, type ClaudeToolUse } from './claude-structured-item-translation' -import { journalClaudePrompt } from './claude-prompt-journaling' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity' import { @@ -48,6 +46,7 @@ import { type ClaudeCurrentTurn, type ClaudeTurnEnd } from './claude-turn-lifecycle-item' +import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' export type ClaudeJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink @@ -59,6 +58,7 @@ export type ClaudeJournalTranslatorDeps = { export type ClaudeJournalTranslator = { handle: (event: ClaudeStructuredSessionEvent) => void + journalPrompts: Pick flush: () => void /** Streamed blocks still awaiting a final frame. A settled turn leaves none. */ readonly pendingStreamedBlocks: number @@ -84,7 +84,7 @@ export function createClaudeJournalTranslator( deps: ClaudeJournalTranslatorDeps ): ClaudeJournalTranslator { const tools = new Map() - const promptItems = new Map() + const prompts = new ClaudeJournalPrompts(deps) const streamedBlocks = createClaudeStreamedBlockRegistry() let currentTurn: ClaudeCurrentTurn | null = null /** Provider output may not reopen a turn after the session ended or a turn @@ -256,6 +256,7 @@ export function createClaudeJournalTranslator( return { handle: (event) => { if (event.type === 'ended') { + prompts.retryPendingCancellations() streamedText.flush() // No event will ever settle a child once the provider is gone. subagents.settleSession() @@ -278,13 +279,10 @@ export function createClaudeJournalTranslator( } streamedText.flush() if (event.type === 'prompt') { - journalClaudePrompt({ ...deps, promptItems }, event) + prompts.handle(event) } else if (event.type === 'prompt-cancelled') { - for (const identity of promptItems.get(event.promptKey) ?? []) { - deps.sink.appendTombstone(identity) - } - promptItems.delete(event.promptKey) - deps.sink.publish() + prompts.retryPendingCancellations() + prompts.cancel(event.promptKey) } else if (event.type === 'message' && event.message.type === 'result') { // Every turn this translator opens is root by construction, so a nested // result settles the child that produced it and never the turn. The @@ -292,6 +290,7 @@ export function createClaudeJournalTranslator( // it ends no turn. const settlesTurn = isRootClaudeFrame(event.message) if (settlesTurn) { + prompts.retryPendingCancellations() // The turn is over however it ended, so a foreground child still // reported as working will never be settled by an event. // A turn that failed, or that the user stopped, is not resumed by @@ -335,6 +334,7 @@ export function createClaudeJournalTranslator( publishActivity(event.kind, event.payload) } }, + journalPrompts: prompts, flush: streamedText.flush, get pendingStreamedBlocks() { return streamedText.pending @@ -342,7 +342,7 @@ export function createClaudeJournalTranslator( dispose: () => { streamedText.dispose() tools.clear() - promptItems.clear() + prompts.clear() streamedBlocks.clear() subagents.dispose() } diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts index 79916d6a507..eec4ee74c9c 100644 --- a/src/main/claude/claude-structured-prompt-items.test.ts +++ b/src/main/claude/claude-structured-prompt-items.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { encodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' +import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' +import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES } from '../native-chat/agent-session-journal/journal-row-schema' import { claudeQuestionItems } from './claude-structured-prompt-items' import { applyClaudePromptAnswer, @@ -9,6 +11,46 @@ import { } from './claude-structured-prompt-replies' describe('Claude structured question addressing', () => { + it('bounds a valid grouped question before cancellation enters a lifecycle batch', () => { + const oversized = 'large prompt text '.repeat(40_000) + const questions = Array.from({ length: 4 }, (_, questionIndex) => ({ + question: `${questionIndex}:${oversized}`, + header: oversized, + options: Array.from({ length: 4 }, (_, optionIndex) => ({ + label: `${optionIndex}:${oversized}`, + description: oversized + })) + })) + const prompt: ClaudePendingPrompt = { + requestId: 'oversized-question', + promptKey: 'oversized-question', + toolUseId: 'tool-oversized', + toolName: 'AskUserQuestion', + kind: 'question', + input: { questions }, + suggestions: [], + questionIds: questions.map((question) => question.question), + answers: new Map(), + settle: () => {} + } + + const body = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]?.body + if (!body) { + throw new Error('expected grouped question body') + } + const cancelled = cancelledJournalPromptBody(body) + if (!cancelled) { + throw new Error('expected cancellable grouped question body') + } + + expect(body.questions).toHaveLength(4) + expect(body.questions?.[0]?.question).toContain('[Orca: output truncated') + expect(body.questions?.[0]?.options[0]?.description).toContain('[Orca: output truncated') + expect(Buffer.byteLength(JSON.stringify(cancelled), 'utf8') + 4_096).toBeLessThan( + MAX_JOURNAL_LIFECYCLE_BATCH_BYTES + ) + }) + it('keeps wire IDs bounded while returning the original question and choice', () => { const questionId = 'Which option? '.repeat(100) const label = 'A detailed choice '.repeat(100) diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts index 3bdf8ab6091..25b307bd809 100644 --- a/src/main/claude/claude-structured-prompt-items.ts +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -9,6 +9,7 @@ import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' +import { boundJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' import { claudeRecord, claudeText } from './claude-structured-item-translation' import { CLAUDE_APPROVAL_DECISIONS, @@ -119,7 +120,7 @@ export function claudeQuestionItems(input: { sessionId: input.sessionId, promptKey: input.prompt.promptKey }), - body: { + body: boundJournalPromptBody({ kind: 'question', question: legacyCompatible ? first.question @@ -128,7 +129,7 @@ export function claudeQuestionItems(input: { ...(legacyCompatible ? { freeTextQuestionId: first.freeTextQuestionId } : {}), questions, resolution: { ...PENDING } - } + }) } ] } diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts new file mode 100644 index 00000000000..7a06a58e881 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -0,0 +1,766 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { + StructuredAgentSessionAppendOptions, + StructuredAgentSessionEventSink +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' +import { claudeQuestionItems } from './claude-structured-prompt-items' +import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + PROVIDER_SESSION_ID, + USER_MESSAGE, + acquired, + adapterFor, + fakeClaude, + identityFor, + invokeCanUseTool +} from './claude-structured-session-test-support' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {} + const promise = new Promise((finish) => { + resolve = finish + }) + return { promise, resolve } +} + +function lifecycleRecorder(acceptPromptCancellation = true): { + sink: StructuredAgentSessionEventSink + bodies: Map + tombstones: Set + order: string[] +} { + const bodies = new Map() + const tombstones = new Set() + const order: string[] = [] + const appendTombstone = ( + identity: Parameters[0], + options?: StructuredAgentSessionAppendOptions + ): void => { + const key = agentJournalItemKey(identity) + bodies.delete(key) + tombstones.add(key) + if (options?.lifecycle === true) { + order.push('prompt-lifecycle') + } + } + const appendItem = ( + identity: Parameters[0], + body: Parameters[1], + options?: StructuredAgentSessionAppendOptions + ): void => { + bodies.set(agentJournalItemKey(identity), body) + if (options?.lifecycle === true) { + order.push('prompt-lifecycle') + } + } + const sink: StructuredAgentSessionEventSink = { + appendItem, + appendTombstone, + tryAppendTombstone: (identity, options) => { + if (!acceptPromptCancellation) { + return { accepted: false, reason: 'backpressure' } + } + appendTombstone(identity, options) + return { accepted: true } + }, + tryAppendLifecycleBatch: (_settlementId, mutations, options) => { + if (!acceptPromptCancellation) { + return { accepted: false, reason: 'backpressure' } + } + for (const mutation of mutations) { + if (mutation.kind === 'tombstone') { + appendTombstone(mutation.identity, options) + } else { + appendItem(mutation.identity, mutation.body, options) + } + } + return { accepted: true } + }, + publish: (_options?: StructuredAgentSessionAppendOptions) => {}, + tryPublish: () => ({ accepted: true }) + } + return { sink, bodies, tombstones, order } +} + +async function startTurn( + adapter: Awaited>, + turnId = 'turn-1' +): Promise { + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: `client-${turnId}`, + body: USER_MESSAGE, + fence: 7 + }) +} + +describe('Claude live prompt ownership', () => { + it('lets an answer hold the callback claim through its journal commit', async () => { + const claude = fakeClaude({ replayUuid: 'turn-1' }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' } + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + const commitGate = deferred() + const commitStarted = vi.fn() + + const answer = adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 7, + commit: async () => { + expect(answered.settled()).toBe(false) + commitStarted() + await commitGate.promise + } + }) + await vi.waitFor(() => expect(commitStarted).toHaveBeenCalledOnce()) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(claude.connections[0]?.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + + commitGate.resolve() + await answer + await expect(answered.promise).resolves.toMatchObject({ + behavior: 'allow', + toolUseID: 'tool-1' + }) + }) + + it('lets prompt cancellation win and waits for SDK abort cleanup', async () => { + const interruptGate = deferred() + const controller = new AbortController() + const claude = fakeClaude({ + replayUuid: 'turn-1', + routes: { interrupt: () => interruptGate.promise } + }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + + let cancellationSettled = false + const cancellation = adapter + .cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + .finally(() => { + cancellationSettled = true + }) + await vi.waitFor(() => expect(claude.connections[0]?.calls.at(-1)?.subtype).toBe('interrupt')) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + + interruptGate.resolve() + await Promise.resolve() + expect(cancellationSettled).toBe(false) + expect(answered.settled()).toBe(false) + controller.abort() + await expect(cancellation).resolves.toEqual({ cancelled: true }) + await expect(answered.promise).resolves.toBeNull() + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(controller.signal.aborted).toBe(true) + expect(commit).not.toHaveBeenCalled() + }) + + it('cancels an owned prompt after another dispatch queues behind its turn', async () => { + const controller = new AbortController() + let queuedUuid = '' + const claude = fakeClaude({ + replayUuids: ['turn-1', null], + capabilities: ['interrupt_cancel_queued_v1'], + routes: { + interrupt: () => { + controller.abort() + return { still_queued: [], cancelled: [queuedUuid] } + } + } + }) + const lateSettlements: unknown[] = [] + const adapter = await acquired(claude, {}, [], (settlement) => lateSettlements.push(settlement)) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-queued', 'tool-queued', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-queued') + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'queued-message', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + const sentUuid = connection.sent.at(-1)?.uuid + if (typeof sentUuid !== 'string') { + throw new Error('expected queued dispatch uuid') + } + queuedUuid = sentUuid + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: true }) + await expect(answered.promise).resolves.toBeNull() + expect(connection.calls).toContainEqual({ + subtype: 'interrupt', + params: { cancelQueued: true } + }) + expect(lateSettlements).toContainEqual({ + sessionId: 'session-1', + clientMessageId: 'queued-message', + state: 'rejected', + reason: 'provider_cancelled_before_start' + }) + }) + + it('does not interrupt a queued turn when the CLI cannot cancel queued messages', async () => { + const claude = fakeClaude({ replayUuids: ['turn-1', null] }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const controller = new AbortController() + const answered = invokeCanUseTool(connection, 'Bash', 'permission-legacy', 'tool-legacy', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-legacy') + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'queued-message', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + controller.abort() + await expect(answered.promise).resolves.toBeNull() + }) + + it('does not interrupt a newer active turn through a stale prompt callback', async () => { + const claude = fakeClaude({ replayUuids: ['turn-1', 'turn-2'] }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const controller = new AbortController() + const answered = invokeCanUseTool(connection, 'Bash', 'permission-stale', 'tool-stale', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-stale') + await startTurn(adapter, 'turn-2') + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + expect(answered.settled()).toBe(false) + controller.abort() + await expect(answered.promise).resolves.toBeNull() + }) + + it('drops resolved prompt bodies instead of retaining them for the session lifetime', () => { + const prompts = new ClaudeJournalPrompts({ sink: lifecycleRecorder().sink }) + + for (let index = 0; index < 128; index += 1) { + const promptKey = `resolved-${index}` + prompts.handle({ + type: 'prompt', + sessionId: 'session-1', + prompt: { + requestId: promptKey, + promptKey, + toolUseId: `tool-${index}`, + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + suggestions: [], + questionIds: [], + answers: new Map(), + settle: vi.fn() + } + }) + prompts.resolve(promptKey) + } + + expect(prompts.size).toBe(0) + }) + + it('releases the callback claim after a failed interrupt', async () => { + const claude = fakeClaude({ + replayUuid: 'turn-1', + routes: { + interrupt: () => { + throw new ClaudeControlRequestError('interrupt', 'not running') + } + } + }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' } + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 7, + commit: async () => undefined + }) + await expect(answered.promise).resolves.toMatchObject({ + behavior: 'allow', + toolUseID: 'tool-1' + }) + }) + + it('enqueues terminal prompt state before a confirmed cancellation resolves', async () => { + const controller = new AbortController() + const claude = fakeClaude({ + replayUuid: 'turn-1', + routes: { interrupt: () => controller.abort() } + }) + const recorded = lifecycleRecorder() + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + + const cancellation = adapter + .cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + .then((result) => { + recorded.order.push('resolved') + return result + }) + + await expect(cancellation).resolves.toEqual({ cancelled: true }) + await expect(answered.promise).resolves.toBeNull() + if (!promptItemId) { + throw new Error('expected a recorded prompt item') + } + expect(recorded.order).toEqual(['prompt-lifecycle', 'resolved']) + expect( + [...recorded.bodies.values()].some( + (body) => + (body.kind === 'approval' || body.kind === 'question') && + body.resolution.state === 'pending' + ) + ).toBe(false) + expect(recorded.bodies.get(promptItemId)).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect( + [...recorded.bodies.values()].some( + (body) => readAgentJournalTurn(body)?.state === 'interrupted' + ) + ).toBe(false) + + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'error_during_execution', + uuid: 'result-1', + session_id: PROVIDER_SESSION_ID, + is_error: true, + terminal_reason: 'aborted_tools', + errors: [], + duration_ms: 654 + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 654 + }) + + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'success', + uuid: 'result-duplicate', + session_id: PROVIDER_SESSION_ID, + is_error: false, + terminal_reason: 'completed', + duration_ms: 999 + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 654 + }) + + expect(controller.signal.aborted).toBe(true) + expect(recorded.tombstones).toHaveLength(0) + }) + + it('does not synthesize terminal lifecycle for ordinary Stop', async () => { + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(fakeClaude({ replayUuid: 'turn-1' }), {}, events) + await startTurn(adapter) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + expect(events.some((event) => event.type === 'prompt-cancelled')).toBe(false) + expect( + events.some((event) => event.type === 'message' && event.message.type === 'result') + ).toBe(false) + }) + + it('does not report success or release the claim when prompt lifecycle admission fails', async () => { + const controller = new AbortController() + const claude = fakeClaude({ + replayUuid: 'turn-1', + routes: { interrupt: () => controller.abort() } + }) + const recorded = lifecycleRecorder(false) + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' }, + signal: controller.signal + }) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Claude prompt') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + ).rejects.toThrow(/lifecycle was not admitted/) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: promptItemId, + kind: 'approval', + optionId: 'allow', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + }) + + it('checks the bound item, turn, fence, and current acquisition without callback revival', async () => { + const claude = fakeClaude({ replayUuid: 'turn-1' }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' } + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + + for (const input of [ + { turnId: 'turn-1', fence: 7, itemId: 'other-item' }, + { turnId: 'turn-2', fence: 7, itemId: 'journal-prompt' }, + { turnId: 'turn-1', fence: 6, itemId: 'journal-prompt' } + ]) { + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: input.turnId, + fence: input.fence, + prompt: { itemId: input.itemId } + }) + ).resolves.toEqual({ cancelled: false }) + } + expect(claude.connections[0]?.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + + await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + await expect(answered.promise).resolves.toBeNull() + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 8, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 8, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + expect(claude.connections[1]?.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + }) + + it('rejects a grouped prompt batch without partially revising its first row', () => { + const tombstones: string[] = [] + const appendTombstone = vi.fn( + (identity: Parameters[0]) => { + tombstones.push(agentJournalItemKey(identity)) + } + ) + let rowAdmission = 0 + const tryAppendTombstone = vi.fn( + (identity: Parameters[0]) => { + rowAdmission += 1 + if (rowAdmission === 2) { + return { accepted: false as const, reason: 'backpressure' as const } + } + appendTombstone(identity) + return { accepted: true as const } + } + ) + const tryAppendLifecycleBatch = vi.fn( + ( + _settlementId: string, + mutations: Parameters< + NonNullable + >[1] + ) => { + expect(mutations[1]).toMatchObject({ + kind: 'item', + body: { resolution: { state: 'cancelled' } } + }) + return { accepted: false as const, reason: 'backpressure' as const } + } + ) + const prompts = new ClaudeJournalPrompts({ + sink: { + appendItem: () => {}, + appendTombstone, + tryAppendTombstone, + tryAppendLifecycleBatch, + publish: () => {} + }, + questionItems: (input) => { + const item = claudeQuestionItems(input)[0] + return item + ? [ + { + ...item, + identity: { provider: 'orca', clientMessageId: 'group:first' } + }, + { + ...item, + identity: { provider: 'orca', clientMessageId: 'group:second' } + } + ] + : [] + } + }) + const prompt: ClaudePendingPrompt = { + requestId: 'grouped-request', + promptKey: 'grouped-request', + toolUseId: 'tool-grouped', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { question: 'First?', options: [{ label: 'Yes' }] }, + { question: 'Second?', options: [{ label: 'No' }] } + ] + }, + suggestions: [], + questionIds: ['First?', 'Second?'], + answers: new Map(), + settle: vi.fn() + } + prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt }) + + expect(prompts.cancel(prompt.promptKey)).toEqual({ + accepted: false, + reason: 'backpressure' + }) + expect(tryAppendLifecycleBatch).toHaveBeenCalledOnce() + expect(tryAppendTombstone).not.toHaveBeenCalled() + expect(tombstones).toEqual([]) + }) + + it('keeps every backpressured prompt cancellation retry in its owned entry', () => { + let backpressured = true + let lifecycleAttempts = 0 + const prompts = new ClaudeJournalPrompts({ + sink: { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendLifecycleBatch: () => { + lifecycleAttempts += 1 + return backpressured ? { accepted: false, reason: 'backpressure' } : { accepted: true } + } + } + }) + const registerCancellation = (index: number): void => { + const promptKey = `permission-${index}` + const prompt: ClaudePendingPrompt = { + requestId: promptKey, + promptKey, + toolUseId: `tool-${index}`, + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + suggestions: [], + questionIds: [], + answers: new Map(), + settle: vi.fn() + } + prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt }) + prompts.cancel(promptKey) + } + + registerCancellation(0) + prompts.cancel('permission-0') + expect(prompts.pendingCancellationCount).toBe(1) + for (let index = 1; index < 65; index += 1) { + registerCancellation(index) + } + expect(prompts.pendingCancellationCount).toBe(65) + + backpressured = false + const attemptsBeforeRecovery = lifecycleAttempts + prompts.retryPendingCancellations() + expect(lifecycleAttempts - attemptsBeforeRecovery).toBe(65) + expect(prompts.pendingCancellationCount).toBe(0) + expect(prompts.size).toBe(0) + const attemptsAfterRecovery = lifecycleAttempts + prompts.retryPendingCancellations() + expect(lifecycleAttempts).toBe(attemptsAfterRecovery) + + backpressured = true + registerCancellation(65) + expect(prompts.pendingCancellationCount).toBe(1) + prompts.resolve('permission-65') + expect(prompts.pendingCancellationCount).toBe(0) + registerCancellation(66) + prompts.clear() + expect(prompts.pendingCancellationCount).toBe(0) + expect(prompts.size).toBe(0) + }) +}) diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts new file mode 100644 index 00000000000..1dd73f23552 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -0,0 +1,143 @@ +import { + AgentSessionPromptUnavailableError, + type StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' +import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' +import { + answerClaudePrompt, + cancelClaudeTurn, + supportsClaudeQueuedInterruptCancellation +} from './claude-structured-control-actions' +import type { ClaudeLateDispatchSettlement } from './claude-structured-dispatch' +import type { ClaudeSession } from './claude-structured-session-state' + +type CancelInput = Parameters[0] +type AnswerInput = Parameters[0] + +export function admitClaudePromptCancellation(session: ClaudeSession, promptKey: string): boolean { + const admission = session.translator?.journalPrompts.cancel(promptKey) + return admission?.accepted ?? true +} + +function waitForClaudePromptCancellation( + observed: Promise, + timeoutMs = CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS +): Promise { + let timer: ReturnType | null = null + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('Claude prompt cancellation abort was not observed')), + timeoutMs + ) + timer.unref?.() + }) + return Promise.race([observed, deadline]).finally(() => { + if (timer) { + clearTimeout(timer) + } + }) +} + +function requireSession(sessions: Map, sessionId: string): ClaudeSession { + const session = sessions.get(sessionId) + if (!session) { + throw new Error(`no live claude stream-json session for ${sessionId}`) + } + return session +} + +export async function cancelClaudeStructuredTurn(input: { + request: CancelInput + sessions: Map + compactions: StructuredSessionCompaction + timeoutMs?: number + admitPromptCancellation: (session: ClaudeSession, promptKey: string) => boolean + onDispatchSettledLate?: ClaudeLateDispatchSettlement +}): Promise<{ cancelled: boolean }> { + const { request, sessions, compactions, timeoutMs } = input + const session = requireSession(sessions, request.sessionId) + const acquisitionGeneration = session.acquisitionGeneration + const prompt = request.prompt + if (prompt && session.fence !== request.fence) { + return { cancelled: false } + } + const claim = prompt ? session.prompts.claimBound(prompt.itemId, request.turnId) : null + if (prompt && !claim) { + return { cancelled: false } + } + const cancellationObserved = claim ? session.prompts.observeCancellation(claim) : null + if (claim && !cancellationObserved) { + session.prompts.releaseClaim(claim) + return { cancelled: false } + } + const isCurrent = (): boolean => + sessions.get(request.sessionId) === session && + session.fence === request.fence && + session.acquisitionGeneration === acquisitionGeneration && + (claim && prompt + ? session.activeTurnId === request.turnId && + session.prompts.ownsBoundClaim(claim, prompt.itemId, request.turnId) && + (session.activeTurnSequence === session.dispatchSequence || + supportsClaudeQueuedInterruptCancellation(session)) + : compactions.ownsTurn(request.sessionId, request.turnId) || + (session.activeTurnId === undefined + ? session.dispatchSequence === 0 + : session.activeTurnId === request.turnId && + session.activeTurnSequence === session.dispatchSequence)) + let interruptConfirmed = false + try { + const result = await cancelClaudeTurn( + session, + timeoutMs, + isCurrent, + input.onDispatchSettledLate + ) + if (result.cancelled && claim && cancellationObserved) { + interruptConfirmed = true + await waitForClaudePromptCancellation(cancellationObserved, timeoutMs) + if (!input.admitPromptCancellation(session, claim.found.prompt.promptKey)) { + throw new Error(`Claude prompt cancellation lifecycle was not admitted for ${claim.itemId}`) + } + } else if (claim) { + session.prompts.releaseClaim(claim) + } + return result + } catch (error) { + if (claim && !interruptConfirmed) { + session.prompts.releaseClaim(claim) + } + throw error + } +} + +export async function answerClaudeStructuredPrompt(input: { + request: AnswerInput + sessions: Map +}): Promise { + const { request, sessions } = input + const session = sessions.get(request.sessionId) + if (!session || session.fence !== request.fence) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + const acquisitionGeneration = session.acquisitionGeneration + const claim = session.prompts.claim(request.itemId, request.kind) + if (!claim) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + try { + await request.commit() + if ( + sessions.get(request.sessionId) !== session || + session.fence !== request.fence || + session.acquisitionGeneration !== acquisitionGeneration || + !session.prompts.ownsClaim(claim) + ) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + await answerClaudePrompt(session, claim, request.optionId) + } catch (error) { + session.prompts.releaseClaim(claim) + throw error + } +} diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts index deec74b7308..5a6bc19b9a8 100644 --- a/src/main/claude/claude-structured-prompt-replies.ts +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -1,48 +1,24 @@ +import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' import { decodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' +import { + claudePromptQuestions, + isClaudePromptRecord, + readClaudePromptString, + type ClaudePendingPrompt +} from './claude-prompt-registry' +export { + ClaudePromptRegistry, + type ClaudePendingPrompt, + type ClaudePromptClaim, + type ClaudePromptRegistration, + type ClaudePromptSettle +} from './claude-prompt-registry' export const CLAUDE_APPROVAL_DECISIONS = ['allow', 'allowForSession', 'deny', 'cancel'] as const export type ClaudeApprovalDecision = (typeof CLAUDE_APPROVAL_DECISIONS)[number] -/** Settles the SDK's `canUseTool` promise; `null` is the SDK's "no response written" sentinel. */ -export type ClaudePromptSettle = (response: Record | null) => void - -export type ClaudePendingPrompt = { - requestId: string - promptKey: string - toolUseId: string - toolName: string - kind: 'approval' | 'question' - input: Record - suggestions: unknown[] - questionIds: readonly string[] - answers: Map - settle: ClaudePromptSettle -} - -export type ClaudePromptRegistration = { - requestId: string - toolName: string - toolUseId: string - input: Record - suggestions: unknown[] - settle: ClaudePromptSettle -} - -type PromptBinding = { - address: string - questionId?: string -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function readString(value: unknown): string | null { - return typeof value === 'string' && value.trim().length > 0 ? value : null -} - -function questionsFrom(input: Record): Record[] { - return Array.isArray(input.questions) ? input.questions.filter(isRecord) : [] +function isClaudeApprovalDecision(optionId: string): optionId is ClaudeApprovalDecision { + return CLAUDE_APPROVAL_DECISIONS.some((decision) => decision === optionId) } function questionIdFromAddress(prompt: ClaudePendingPrompt, address: string): string | null { @@ -62,10 +38,10 @@ function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionI } const choice = /^choice-([1-9]\d*)$/.exec(decoded.answer) const optionIndex = choice ? Number(choice[1]) - 1 : -1 - const question = questionsFrom(prompt.input)[questionIndex] + const question = claudePromptQuestions(prompt.input)[questionIndex] const options = Array.isArray(question?.options) ? question.options : [] const option = options[optionIndex] - const label = isRecord(option) ? readString(option.label) : null + const label = isClaudePromptRecord(option) ? readClaudePromptString(option.label) : null if (decoded.questionId === `q${questionIndex + 1}` && label) { return label } @@ -73,17 +49,14 @@ function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionI return decoded.answer } const legacyChoice = options.some( - (candidate) => isRecord(candidate) && readString(candidate.label) === decoded.answer + (candidate) => + isClaudePromptRecord(candidate) && readClaudePromptString(candidate.label) === decoded.answer ) return decoded.questionId === questionId && (legacyChoice || decoded.answer.trim().length > 0) ? decoded.answer : optionId } -function questionId(question: Record, index: number): string { - return readString(question.question) ?? readString(question.header) ?? `question-${index + 1}` -} - export function encodeClaudeQuestionOptionId(questionId: string, answer: string): string { return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` } @@ -105,88 +78,11 @@ export function decodeClaudeQuestionOptionId( } } -export class ClaudePromptRegistry { - private readonly prompts = new Map() - private readonly journalBindings = new Map() - - register(registration: ClaudePromptRegistration): ClaudePendingPrompt | null { - const toolUseId = readString(registration.toolUseId) - const toolName = readString(registration.toolName) - const input = isRecord(registration.input) ? registration.input : null - if (!toolUseId || !toolName || !input) { - return null - } - const questions = toolName === 'AskUserQuestion' ? questionsFrom(input) : [] - const prompt: ClaudePendingPrompt = { - requestId: registration.requestId, - promptKey: registration.requestId, - toolUseId, - toolName, - kind: questions.length > 0 ? 'question' : 'approval', - input, - suggestions: Array.isArray(registration.suggestions) ? registration.suggestions : [], - questionIds: questions.map(questionId), - answers: new Map(), - settle: registration.settle - } - this.prompts.set(prompt.promptKey, prompt) - return prompt - } - - /** True only if the prompt was still pending; lets an abort and an answer race settle once. */ - forgetIfPending(prompt: ClaudePendingPrompt): boolean { - if (!this.prompts.has(prompt.promptKey)) { - return false - } - this.forget(prompt) - return true - } - - bindJournalItemId(journalItemId: string, promptKey: string, questionIdForItem?: string): void { - this.journalBindings.set(journalItemId, { - address: promptKey, - ...(questionIdForItem ? { questionId: questionIdForItem } : {}) - }) - } - - find(itemId: string): { prompt: ClaudePendingPrompt; questionId?: string } | null { - const binding = this.journalBindings.get(itemId) - const prompt = this.prompts.get(binding?.address ?? itemId) - return prompt - ? { prompt, ...(binding?.questionId ? { questionId: binding.questionId } : {}) } - : null - } - - cancel(requestId: string): ClaudePendingPrompt | null { - const prompt = this.prompts.get(requestId) ?? null - if (prompt) { - this.forget(prompt) - } - return prompt - } - - forget(prompt: ClaudePendingPrompt): void { - this.prompts.delete(prompt.promptKey) - for (const [itemId, binding] of this.journalBindings) { - if (binding.address === prompt.promptKey) { - this.journalBindings.delete(itemId) - } - } - } - - clear(): ClaudePendingPrompt[] { - const pending = [...this.prompts.values()] - this.prompts.clear() - this.journalBindings.clear() - return pending - } -} - -function approvalResponse(prompt: ClaudePendingPrompt, optionId: string): Record { - if (!(CLAUDE_APPROVAL_DECISIONS as readonly string[]).includes(optionId)) { +function approvalResponse(prompt: ClaudePendingPrompt, optionId: string): PermissionResult { + if (!isClaudeApprovalDecision(optionId)) { throw new Error(`${optionId} is not a Claude approval decision`) } - const decision = optionId as ClaudeApprovalDecision + const decision = optionId if (decision === 'allow' || decision === 'allowForSession') { return { behavior: 'allow', @@ -209,7 +105,7 @@ function questionResponse( prompt: ClaudePendingPrompt, optionId: string, boundQuestionId?: string -): Record | null { +): PermissionResult | null { const decoded = decodeClaudeQuestionOptionId(optionId) const decodedQuestionId = decoded ? (questionIdFromAddress(prompt, decoded.questionId) ?? @@ -229,7 +125,11 @@ function questionResponse( } const answers: Record = {} for (const id of prompt.questionIds) { - answers[id] = prompt.answers.get(id) as string + const answer = prompt.answers.get(id) + if (answer === undefined) { + return null + } + answers[id] = answer } return { behavior: 'allow', @@ -241,21 +141,21 @@ function questionResponse( function groupedQuestionResponse( prompt: ClaudePendingPrompt, optionId: string -): Record | null { +): PermissionResult | null { const grouped = decodeAgentSessionQuestionAnswers(optionId) if (!grouped) { return null } - const questions = questionsFrom(prompt.input) + const questions = claudePromptQuestions(prompt.input) if (grouped.length !== prompt.questionIds.length) { throw new Error(`Grouped answer does not match Claude prompt ${prompt.promptKey}`) } const answers: Record = {} for (let index = 0; index < questions.length; index += 1) { - const question = questions[index]! + const question = questions[index] const providerQuestionId = prompt.questionIds[index] const answer = grouped.find((entry) => entry.questionId === `q${index + 1}`) - if (!providerQuestionId || !answer) { + if (!question || !providerQuestionId || !answer) { throw new Error(`Grouped answer does not name question ${index + 1}`) } const selected = answer.optionIds.map((selectedId) => @@ -286,7 +186,7 @@ function groupedQuestionResponse( export function applyClaudePromptAnswer( found: { prompt: ClaudePendingPrompt; questionId?: string }, optionId: string -): Record | null { +): PermissionResult | null { if (found.prompt.kind === 'approval') { return approvalResponse(found.prompt, optionId) } diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index ec7fc85dadb..8870a0e1daa 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -142,6 +142,7 @@ export async function acquireClaudeSession({ const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ sessionId, prompts, + currentTurnId: () => liveSession?.activeTurnId ?? null, emit: (event) => callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) }) diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 20be62f7997..5593b07d329 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -676,7 +676,8 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { itemId: 'journal-approval', kind: 'approval', optionId: 'allowForSession', - fence: 7 + fence: 7, + commit: async () => undefined }) // The answer resolves the SDK's own callback promise; the SDK writes the wire response. await expect(answered.promise).resolves.toEqual({ @@ -712,7 +713,8 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { itemId: 'journal-q1', kind: 'question', optionId: encodeClaudeQuestionOptionId('Library?', 'Luxon'), - fence: 7 + fence: 7, + commit: async () => undefined }) await tick() expect(answered.settled()).toBe(false) @@ -721,7 +723,8 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { itemId: 'journal-q2', kind: 'question', optionId: encodeClaudeQuestionOptionId('Ship now?', 'Yes'), - fence: 7 + fence: 7, + commit: async () => undefined }) await expect(answered.promise).resolves.toMatchObject({ behavior: 'allow', @@ -752,7 +755,8 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { itemId: 'journal-9', kind: 'approval', optionId: 'allow', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow(/no longer waiting/) }) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 44d73c8bd0f..f62541b50e9 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -4,11 +4,7 @@ import type { StructuredAgentSessionAcquireInput, StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { - answerClaudePrompt, - cancelClaudeTurn, - stopClaudeBackgroundTasks -} from './claude-structured-control-actions' +import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' @@ -33,6 +29,11 @@ import { import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' +import { + admitClaudePromptCancellation, + answerClaudeStructuredPrompt, + cancelClaudeStructuredTurn +} from './claude-structured-prompt-ownership' export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' export type { @@ -226,7 +227,13 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda promptKey: string, questionId?: string ): void { - this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey, questionId) + const session = this.sessions.get(sessionId) + session?.prompts.bindJournalItemId( + journalItemId, + promptKey, + questionId, + session.activeTurnId ?? null + ) } dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => @@ -235,25 +242,17 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda compact: NonNullable = (input) => compactClaudeSession(this.session(input.sessionId), this.compactions, input) - cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => { - const session = this.session(input.sessionId) - const acquisitionGeneration = session.acquisitionGeneration - return cancelClaudeTurn(session, this.deps.requestTimeoutMs, () => { - // Keep every ownership check adjacent to the provider interrupt. The - // session map check fences a replaced child; the turn check fences a - // delayed cancel after a newer turn was admitted on the same child. - return ( - this.sessions.get(input.sessionId) === session && - session.fence === input.fence && - session.acquisitionGeneration === acquisitionGeneration && - (this.compactions.ownsTurn(input.sessionId, input.turnId) || - (session.activeTurnId === undefined - ? session.dispatchSequence === 0 - : session.activeTurnId === input.turnId && - session.activeTurnSequence === session.dispatchSequence)) - ) + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (request) => + cancelClaudeStructuredTurn({ + request, + sessions: this.sessions, + compactions: this.compactions, + admitPromptCancellation: (session, promptKey) => + admitClaudePromptCancellation(session, promptKey), + onDispatchSettledLate: (settlement) => + this.deps.onDispatchSettledLate?.({ sessionId: request.sessionId, ...settlement }), + ...(this.deps.requestTimeoutMs === undefined ? {} : { timeoutMs: this.deps.requestTimeoutMs }) }) - } stopBackgroundTasks: StructuredAgentSessionAdapter['stopBackgroundTasks'] = (input) => { const session = this.session(input.sessionId) const acquisitionGeneration = session.acquisitionGeneration @@ -278,8 +277,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } readCommands: NonNullable = (sessionId) => this.sessions.get(sessionId)?.commands.commands - answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => - answerClaudePrompt(this.session(input.sessionId), input) + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => + answerClaudeStructuredPrompt({ request, sessions: this.sessions }) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => setClaudeStructuredOption(this.session(input.sessionId), input, this.deps.requestTimeoutMs) readOptions = (input: { sessionId: string; fence: number }) => diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 1fbdcca42c2..30e830af64e 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -62,17 +62,20 @@ export type ClaudeStructuredSessionEvent = observedAt?: number } +export type ClaudeLateDispatchOutcome = + | { + clientMessageId: string + providerIdentity: AgentJournalItemIdentity + } + | { clientMessageId: string; state: 'rejected'; reason: string } + export type ClaudeStructuredSessionAdapterDeps = { resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void - /** Direct settlement path for a provider replay; its durable item row also reconciles delivery. */ - onDispatchSettledLate?: (input: { - sessionId: string - clientMessageId: string - providerIdentity: AgentJournalItemIdentity - }) => void + /** Direct settlement path for provider-proven late dispatch outcomes. */ + onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void onBackgroundTasksChanged?: ( sessionId: string, state: AgentSessionBackgroundTaskState | null diff --git a/src/main/codex/codex-prompt-registry-bounds.ts b/src/main/codex/codex-prompt-registry-bounds.ts index f57fbb1e8d1..9c97bf63cd9 100644 --- a/src/main/codex/codex-prompt-registry-bounds.ts +++ b/src/main/codex/codex-prompt-registry-bounds.ts @@ -2,6 +2,7 @@ import { boundPayload, digestPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' +import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' export const CODEX_JOURNAL_PROMPT_ID_COMPONENT_MAX_BYTES = 256 export const CODEX_JOURNAL_PROMPT_OPTION_ID_MAX_BYTES = 1024 @@ -13,7 +14,7 @@ export const CODEX_PROMPT_MAX_ANSWER_BYTES = 64 * 1024 export const MAX_CODEX_PROMPT_REGISTRY_ENTRIES = 128 export const MAX_CODEX_PROMPT_JOURNAL_BINDINGS = 256 export const MAX_CODEX_PROMPT_REGISTRY_BYTES = 4 * 1024 * 1024 -const CODEX_PROMPT_TURN_ID_RESERVED_BYTES = 512 +const CODEX_PROMPT_TURN_ID_RESERVED_BYTES = AGENT_SESSION_ID_MAX_LENGTH * 3 type CodexPromptRegistryEntryBounds = { threadId: string @@ -49,7 +50,7 @@ export function codexPromptTurnIdentity(turnId: string): { turnId: string | null turnIdDigest?: string } { - return Buffer.byteLength(turnId, 'utf8') <= CODEX_PROMPT_TURN_ID_RESERVED_BYTES + return turnId.length <= AGENT_SESSION_ID_MAX_LENGTH ? { turnId } : { turnId: null, turnIdDigest: digestPayload(turnId) } } diff --git a/src/main/codex/codex-prompt-registry.ts b/src/main/codex/codex-prompt-registry.ts new file mode 100644 index 00000000000..c6d0d7f4bef --- /dev/null +++ b/src/main/codex/codex-prompt-registry.ts @@ -0,0 +1,278 @@ +import { + MAX_CODEX_PROMPT_JOURNAL_BINDINGS, + MAX_CODEX_PROMPT_REGISTRY_BYTES, + MAX_CODEX_PROMPT_REGISTRY_ENTRIES, + codexJournalPromptIdPart, + codexPromptMatchesTurn, + codexPromptRegistryEntryBytes, + codexPromptTurnIdentity, + readQuestionIds, + readQuestionOptionAnswers +} from './codex-prompt-registry-bounds' + +export const CODEX_COMMAND_APPROVAL_METHOD = 'item/commandExecution/requestApproval' +export const CODEX_FILE_CHANGE_APPROVAL_METHOD = 'item/fileChange/requestApproval' +export const CODEX_USER_INPUT_METHOD = 'item/tool/requestUserInput' + +export type CodexPendingPrompt = { + requestId: number | string + method: string + threadId: string + turnId: string | null + /** Oversized compatibility turn ids stay comparable without escaping the registry byte cap. */ + turnIdDigest?: string + codexItemId: string + /** One tool item can ask more than once, so approvalId wins over itemId when present. */ + promptKey: string + questionIds: readonly string[] + questionIdAliases: ReadonlyMap + optionAnswers: ReadonlyMap + answers: Map +} + +export type CodexPromptClaim = { + readonly itemId: string + readonly prompt: CodexPendingPrompt +} + +function readString(params: unknown, key: string): string | null { + if (typeof params !== 'object' || params === null) { + return null + } + const value = Reflect.get(params, key) + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function isCodexPromptMethod(method: string): boolean { + return ( + method === CODEX_COMMAND_APPROVAL_METHOD || + method === CODEX_FILE_CHANGE_APPROVAL_METHOD || + method === CODEX_USER_INPUT_METHOD + ) +} + +/** Session-local callback ownership; none of this state is reconstructed from the journal. */ +export class CodexPromptRegistry { + private readonly byAddress = new Map() + private readonly journalItemIds = new Map() + private readonly boundPrompts = new Map() + private readonly claims = new Map() + + get sizes(): { prompts: number; journalBindings: number } { + return { prompts: this.byAddress.size, journalBindings: this.journalItemIds.size } + } + + get bytes(): number { + return this.retainedPromptBytes() + } + + register(request: { + id: number | string + method: string + params: unknown + }): CodexPendingPrompt | null { + const codexItemId = readString(request.params, 'itemId') + const threadId = readString(request.params, 'threadId') + if (!isCodexPromptMethod(request.method) || !codexItemId || !threadId) { + return null + } + const questionIds = + request.method === CODEX_USER_INPUT_METHOD ? readQuestionIds(request.params) : [] + if (questionIds === null) { + return null + } + const optionAnswers = + request.method === CODEX_USER_INPUT_METHOD + ? readQuestionOptionAnswers(request.params) + : new Map() + if (optionAnswers === null) { + return null + } + const turnId = readString(request.params, 'turnId') + const turnIdentity = turnId ? codexPromptTurnIdentity(turnId) : { turnId: null } + if (turnId && turnIdentity.turnId === null) { + return null + } + const prompt: CodexPendingPrompt = { + requestId: request.id, + method: request.method, + threadId, + ...turnIdentity, + codexItemId, + promptKey: readString(request.params, 'approvalId') ?? codexItemId, + questionIds, + questionIdAliases: + request.method === CODEX_USER_INPUT_METHOD + ? new Map(questionIds.map((id) => [codexJournalPromptIdPart(id), id])) + : new Map(), + optionAnswers, + answers: new Map() + } + const promptBytes = codexPromptRegistryEntryBytes(prompt) + if (promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES) { + return null + } + while ( + this.retainedPromptBytes() + promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES && + this.byAddress.size > 0 + ) { + const oldest = this.byAddress.values().next().value + if (!oldest) { + break + } + this.byAddress.delete(this.address(oldest.threadId, oldest.promptKey)) + } + if (this.retainedPromptBytes() + promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES) { + return null + } + const address = this.address(prompt.threadId, prompt.promptKey) + this.byAddress.delete(address) + this.byAddress.set(address, prompt) + this.trim() + return prompt + } + + bindJournalItemId( + journalItemId: string, + threadId: string, + promptKey: string, + turnId?: string | null + ): void { + if (this.journalItemIds.has(journalItemId)) { + this.boundPrompts.delete(journalItemId) + } + this.journalItemIds.delete(journalItemId) + const address = this.address(threadId, promptKey) + const prompt = this.byAddress.get(address) + if (!prompt) { + return + } + if (prompt.turnId === null && prompt.turnIdDigest === undefined && turnId) { + Object.assign(prompt, codexPromptTurnIdentity(turnId)) + } + this.journalItemIds.set(journalItemId, address) + this.boundPrompts.set(journalItemId, prompt) + this.trim() + } + + find(journalItemId: string): CodexPendingPrompt | null { + const address = this.journalItemIds.get(journalItemId) + if (address) { + return this.boundPrompts.get(journalItemId) ?? this.byAddress.get(address) ?? null + } + const matches = [...this.byAddress.values()].filter( + (prompt) => prompt.promptKey === journalItemId + ) + return matches.length === 1 ? (matches[0] ?? null) : null + } + + claim(journalItemId: string, kind?: 'approval' | 'question'): CodexPromptClaim | null { + const prompt = this.find(journalItemId) + if (!prompt || this.claims.has(prompt) || (kind && this.kind(prompt) !== kind)) { + return null + } + const claim = { itemId: journalItemId, prompt } + this.claims.set(prompt, claim) + return claim + } + + claimBound(journalItemId: string): CodexPromptClaim | null { + const prompt = this.boundPrompts.get(journalItemId) + if (!prompt || this.claims.has(prompt)) { + return null + } + const claim = { itemId: journalItemId, prompt } + this.claims.set(prompt, claim) + return claim + } + + ownsClaim(claim: CodexPromptClaim): boolean { + return this.claims.get(claim.prompt) === claim && this.find(claim.itemId) === claim.prompt + } + + ownsBoundClaim( + claim: CodexPromptClaim, + journalItemId: string, + threadId: string, + turnId: string + ): boolean { + return ( + claim.itemId === journalItemId && + this.claims.get(claim.prompt) === claim && + this.journalItemIds.get(journalItemId) === + this.address(claim.prompt.threadId, claim.prompt.promptKey) && + this.boundPrompts.get(journalItemId) === claim.prompt && + claim.prompt.threadId === threadId && + codexPromptMatchesTurn(claim.prompt, turnId) + ) + } + + releaseClaim(claim: CodexPromptClaim): void { + if (this.claims.get(claim.prompt) === claim) { + this.claims.delete(claim.prompt) + } + } + + forget(prompt: CodexPendingPrompt): void { + this.claims.delete(prompt) + const address = this.address(prompt.threadId, prompt.promptKey) + if (this.byAddress.get(address) === prompt) { + this.byAddress.delete(address) + } + for (const [journalItemId, boundPrompt] of this.boundPrompts) { + if (boundPrompt === prompt) { + this.journalItemIds.delete(journalItemId) + this.boundPrompts.delete(journalItemId) + } + } + } + + clearTurn(threadId: string, turnId: string): void { + const prompts = new Set( + [...this.byAddress.values(), ...this.boundPrompts.values()].filter( + (prompt) => prompt.threadId === threadId && codexPromptMatchesTurn(prompt, turnId) + ) + ) + for (const prompt of prompts) { + this.forget(prompt) + } + } + + clear(): void { + this.byAddress.clear() + this.journalItemIds.clear() + this.boundPrompts.clear() + this.claims.clear() + } + + private address(threadId: string, promptKey: string): string { + return `${encodeURIComponent(threadId)}:${encodeURIComponent(promptKey)}` + } + + private kind(prompt: CodexPendingPrompt): 'approval' | 'question' { + return prompt.method === CODEX_USER_INPUT_METHOD ? 'question' : 'approval' + } + + private retainedPromptBytes(): number { + const prompts = new Set([...this.byAddress.values(), ...this.boundPrompts.values()]) + return [...prompts].reduce((total, prompt) => total + codexPromptRegistryEntryBytes(prompt), 0) + } + + private trim(): void { + while (this.byAddress.size > MAX_CODEX_PROMPT_REGISTRY_ENTRIES) { + const oldest = this.byAddress.values().next().value + if (!oldest) { + break + } + this.byAddress.delete(this.address(oldest.threadId, oldest.promptKey)) + } + while (this.journalItemIds.size > MAX_CODEX_PROMPT_JOURNAL_BINDINGS) { + const oldest = this.journalItemIds.keys().next().value + if (!oldest) { + break + } + this.journalItemIds.delete(oldest) + this.boundPrompts.delete(oldest) + } + } +} diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts index 29039ffca3c..fbf50badaef 100644 --- a/src/main/codex/codex-requested-close-turn-timing.test.ts +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -1,8 +1,10 @@ +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' import { closeCodexPublishedSession } from './codex-structured-session-close' import type { CodexSession } from './codex-structured-session-state' @@ -48,17 +50,30 @@ describe('requested-close durable turn timing', () => { observedAt: 1_000 }) ).toEqual({ accepted: true }) - const session = { - connection: { close: vi.fn(async () => true) }, + const session: CodexSession = { + connection: { + pid: 4321, + closed: false, + request: async () => ({}), + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + }, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, requestedClose: false, fence: 7, acquisitionGeneration: 'generation-1', threadId: 'thread-1', - prompts: { clear: vi.fn() }, + historyPath: null, + prompts: new CodexPromptRegistry(), + options: new Map(), + reportedOptions: {}, + fastModeTierByModel: new Map(), + dispatchEchoes: createCodexDispatchEchoes(), translator - } as unknown as CodexSession + } const sessions = new Map([['session-1', session]]) const onEvent = vi.fn() diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts new file mode 100644 index 00000000000..3eece95c3fe --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import { MAX_CODEX_PENDING_DISPATCH_ECHOES } from './codex-structured-dispatch-echo' +import { + acquiredCodexAdapter, + echoUserMessage, + fakeCodexAppServer, + startTurn, + CODEX_TEST_THREAD_ID, + CODEX_TEST_USER_MESSAGE, + type LateSettlement +} from './codex-structured-dispatch-test-support' + +function send( + adapter: Awaited>, + clientMessageId: string +): Promise { + return adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body: CODEX_TEST_USER_MESSAGE, + fence: 7 + }) +} + +describe('codex dispatch admission', () => { + it('admits a send queued behind a running turn and settles it when Codex echoes it', async () => { + // Measured on codex-cli 0.153.4: a `turn/start` issued while a turn runs is + // COALESCED into it -- same turn id back, no second `turn/started`, and the + // user message echoed only once the running turn reaches it. + const codex = fakeCodexAppServer({ + 'turn/start': () => ({ turn: { id: 'turn-1', status: 'inProgress' } }) + }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + + const outcome = await send(adapter, 'client-2') + + // No doubt: elapsed time is not evidence, so nothing invites a Retry. + expect(outcome).toEqual({ state: 'admitted' }) + expect(settlements).toEqual([]) + + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u2', clientId: 'client-2' }) + + // Ordinal 1, not 0: the queued send is the SECOND user message of the turn + // it was coalesced into, which is the key a history replay computes for it. + expect(settlements).toEqual([ + { + sessionId: 'session-1', + clientMessageId: 'client-2', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 1 + } + } + ]) + }) + + it('correlates each send by client message id, not queue order', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + + await send(adapter, 'client-1') + await send(adapter, 'client-2') + + // The echoes arrive in the opposite order to the sends. + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u2', clientId: 'client-2' }) + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + + // Ordinals follow the ECHO order, and each one lands on the send whose + // `clientId` it carried -- not on the send that was queued in that slot. + expect(settlements).toEqual([ + { + sessionId: 'session-1', + clientMessageId: 'client-2', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 0 + } + }, + { + sessionId: 'session-1', + clientMessageId: 'client-1', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 1 + } + } + ]) + }) + + it('settles nothing for a user message this session never sent', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + await send(adapter, 'client-1') + + // A message another client sent on the same thread, and one Codex did not + // correlate at all. + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-x', clientId: 'someone-else' }) + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-y' }) + + expect(settlements).toEqual([]) + }) + + it('rejects only when Codex answered and declined, and arms nothing for it', async () => { + const { CodexAppServerRequestError } = await import('./codex-app-server-connection') + const codex = fakeCodexAppServer({ + 'turn/start': () => { + throw new CodexAppServerRequestError('turn/start', -32602, 'thread not found') + } + }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + + expect(await send(adapter, 'client-1')).toEqual({ + state: 'rejected', + reason: 'thread not found' + }) + + // A refused write is disarmed, so a later echo of that id settles nothing. + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + expect(settlements).toEqual([]) + }) + + it('retains correlation when a request fails after its write may have landed', async () => { + const codex = fakeCodexAppServer({ + 'turn/start': () => { + throw new Error('request timed out after write') + } + }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + + await expect(send(adapter, 'client-1')).rejects.toThrow('request timed out after write') + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + + expect(settlements).toEqual([ + { + sessionId: 'session-1', + clientMessageId: 'client-1', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 0 + } + } + ]) + }) + + it('refuses overflow without discarding an older accepted send', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + + for (let index = 0; index < MAX_CODEX_PENDING_DISPATCH_ECHOES; index += 1) { + expect(await send(adapter, `client-${index}`)).toEqual({ state: 'admitted' }) + } + expect(await send(adapter, 'client-overflow')).toEqual({ + state: 'rejected', + reason: 'codex structured dispatch queue is full' + }) + + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u0', clientId: 'client-0' }) + expect(settlements.map(({ clientMessageId }) => clientMessageId)).toEqual(['client-0']) + }) + + it('leaves no waiter behind when the session closes', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + await send(adapter, 'client-1') + + await adapter.closeSession('session-1') + + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + expect(settlements).toEqual([]) + }) + + it('leaves no waiter behind when the child exits', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + await send(adapter, 'client-1') + + connection.handlers.onExit?.(new Error('codex app-server exited')) + + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + expect(settlements).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-echo.test.ts b/src/main/codex/codex-structured-dispatch-echo.test.ts new file mode 100644 index 00000000000..58203b26964 --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-echo.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { + createCodexDispatchEchoes, + readCodexDispatchEcho, + MAX_CODEX_PENDING_DISPATCH_ECHOES +} from './codex-structured-dispatch-echo' + +const CODEX_IDENTITY: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 3 +} + +describe('codex dispatch echoes', () => { + it('settles by client message id rather than arrival order', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.arm('client-2') + + // Codex coalesces both sends into one turn, and the second can be echoed + // first. Queue position would settle the wrong submission here. + expect(echoes.settle('client-2')).toBe(true) + expect(echoes.settle('client-1')).toBe(true) + expect(echoes.size).toBe(0) + }) + + it('refuses an echo this session never armed', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + + expect(echoes.settle('client-from-history')).toBe(false) + expect(echoes.size).toBe(1) + }) + + it('settles a send exactly once', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + + expect(echoes.settle('client-1')).toBe(true) + expect(echoes.settle('client-1')).toBe(false) + }) + + it('drops a send whose write never reached the provider', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.disarm('client-1') + + expect(echoes.settle('client-1')).toBe(false) + }) + + it('clears every armed send', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.arm('client-2') + + echoes.clear() + + expect(echoes.size).toBe(0) + expect(echoes.settle('client-1')).toBe(false) + }) + + it('refuses new correlations at capacity without dropping an older send', () => { + const echoes = createCodexDispatchEchoes() + for (let index = 0; index < MAX_CODEX_PENDING_DISPATCH_ECHOES; index += 1) { + expect(echoes.arm(`client-${index}`)).toBe(true) + } + + expect(echoes.arm(`client-${MAX_CODEX_PENDING_DISPATCH_ECHOES}`)).toBe(false) + expect(echoes.size).toBe(MAX_CODEX_PENDING_DISPATCH_ECHOES) + expect(echoes.settle('client-0')).toBe(true) + expect(echoes.settle(`client-${MAX_CODEX_PENDING_DISPATCH_ECHOES}`)).toBe(false) + }) +}) + +describe('readCodexDispatchEcho', () => { + it('reads the client message id off a user message', () => { + expect( + readCodexDispatchEcho( + { type: 'userMessage', id: 'item-1', clientId: 'client-1' }, + CODEX_IDENTITY + ) + ).toEqual({ clientMessageId: 'client-1', providerIdentity: CODEX_IDENTITY }) + }) + + it('ignores an item that is not a user message', () => { + expect( + readCodexDispatchEcho( + { type: 'agentMessage', id: 'item-1', clientId: 'client-1' }, + CODEX_IDENTITY + ) + ).toBeNull() + }) + + it('ignores a user message Codex did not correlate', () => { + expect(readCodexDispatchEcho({ type: 'userMessage', id: 'item-1' }, CODEX_IDENTITY)).toBeNull() + }) + + it('ignores an item with no durable Codex identity', () => { + expect( + readCodexDispatchEcho( + { type: 'userMessage', id: 'item-1', clientId: 'client-1' }, + { provider: 'orca', clientMessageId: 'codex-item:thread-1:item-1' } + ) + ).toBeNull() + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts new file mode 100644 index 00000000000..8ea97561c59 --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -0,0 +1,58 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' + +/** Sends awaiting their echo, oldest first. A send whose echo never arrives is + * retired by the journal's pending-submission recovery on exit, not from here. */ +export const MAX_CODEX_PENDING_DISPATCH_ECHOES = 256 + +/** + * Which sends this session is still waiting to hear back about, keyed by the + * client message id Codex echoes on the user message. + * + * Keyed rather than ordered on purpose: Codex coalesces a `turn/start` issued + * while a turn is running into that turn, so two sends can share one turn id and + * their echoes arrive far apart. Queue position identifies neither. + */ +export type CodexDispatchEchoes = { + /** Arms settlement for a send about to be written; false preserves older waits at capacity. */ + arm: (clientMessageId: string) => boolean + /** True once, for a send this session armed and has not yet settled. */ + settle: (clientMessageId: string) => boolean + /** Drops an armed send whose write never reached the provider. */ + disarm: (clientMessageId: string) => void + clear: () => void + readonly size: number +} + +export function createCodexDispatchEchoes(): CodexDispatchEchoes { + const armed = new Set() + return { + arm(clientMessageId) { + if (!armed.has(clientMessageId) && armed.size >= MAX_CODEX_PENDING_DISPATCH_ECHOES) { + return false + } + armed.delete(clientMessageId) + armed.add(clientMessageId) + return true + }, + settle: (clientMessageId) => armed.delete(clientMessageId), + disarm: (clientMessageId) => void armed.delete(clientMessageId), + clear: () => armed.clear(), + get size() { + return armed.size + } + } +} + +/** The user-message echo a settlement is read off, or null for any other item. */ +export function readCodexDispatchEcho( + item: { type: string; id: string } & Record, + identity: AgentJournalItemIdentity +): { clientMessageId: string; providerIdentity: AgentJournalItemIdentity } | null { + if (item.type !== 'userMessage' || identity.provider !== 'codex') { + return null + } + const clientMessageId = item.clientId + return typeof clientMessageId === 'string' && clientMessageId.length > 0 + ? { clientMessageId, providerIdentity: identity } + : null +} diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts new file mode 100644 index 00000000000..5519ffdfdb8 --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -0,0 +1,139 @@ +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + CodexAppServerLaunch, + openCodexAppServerConnection +} from './codex-app-server-connection' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' + +export const CODEX_TEST_THREAD_ID = 'thread-abc' + +export const CODEX_TEST_USER_MESSAGE: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'ship it' }] +} + +export type CodexTestRoute = (params: Record | undefined) => unknown + +type FakeConnection = Omit & { + closed: boolean + launch: CodexAppServerLaunch + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] +} + +export type LateSettlement = { + sessionId: string + clientMessageId: string + providerIdentity: AgentJournalItemIdentity +} + +/** A `codex app-server` whose turn traffic the test drives by hand. */ +export function fakeCodexAppServer(routes: Record = {}): { + connections: FakeConnection[] + openConnection: typeof openCodexAppServerConnection + routes: Record +} { + const connections: FakeConnection[] = [] + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeConnection = { + launch, + handlers, + calls: [], + pid: 4321, + closed: false, + request: async (method, params) => { + connection.calls.push({ method, params }) + return routes[method]?.(params) ?? {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => { + connection.closed = true + return true + } + } + connections.push(connection) + return connection + }) as typeof openCodexAppServerConnection + routes['thread/start'] ??= () => ({ + thread: { id: CODEX_TEST_THREAD_ID, path: '/rollouts/abc.jsonl' } + }) + return { connections, openConnection, routes } +} + +/** A sink that records nothing but keeps the translator alive, which is what + * mints the identities a late settlement carries. */ +export function recordingSink(): StructuredAgentSessionEventSink { + return { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } +} + +export async function acquiredCodexAdapter(input: { + codex: ReturnType + settlements: LateSettlement[] + sink?: StructuredAgentSessionEventSink +}): Promise { + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: input.codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + captureTurnProcesses: async () => null, + now: () => 1_700_000_000_500, + onDispatchSettledLate: (settlement) => input.settlements.push(settlement) + }) + const identity: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: CODEX_TEST_THREAD_ID } + } + await adapter.acquire({ + identity, + fence: 7, + spawnToken: 'spawn-9', + events: input.sink ?? recordingSink() + }) + return adapter +} + +/** Codex's own echo of a user message Orca sent, inside `turnId`. */ +export function echoUserMessage( + connection: FakeConnection, + input: { turnId: string; itemId: string; clientId?: string; threadId?: string } +): void { + connection.handlers.onNotification?.('item/started', { + threadId: input.threadId ?? CODEX_TEST_THREAD_ID, + turn: { id: input.turnId }, + item: { + type: 'userMessage', + id: input.itemId, + ...(input.clientId ? { clientId: input.clientId } : {}) + } + }) +} + +export function startTurn(connection: FakeConnection, turnId: string): void { + connection.handlers.onNotification?.('turn/started', { + threadId: CODEX_TEST_THREAD_ID, + turn: { id: turnId } + }) +} diff --git a/src/main/codex/codex-structured-fast-mode.test.ts b/src/main/codex/codex-structured-fast-mode.test.ts index 917133c7543..3029bff280b 100644 --- a/src/main/codex/codex-structured-fast-mode.test.ts +++ b/src/main/codex/codex-structured-fast-mode.test.ts @@ -112,7 +112,9 @@ describe('Codex structured Fast mode dispatch', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toMatchObject({ state: 'accepted' }) + // `admitted`, not `accepted`: a Codex send now settles its identity on + // the provider echo. What this test pins is the tier the turn carries. + ).resolves.toMatchObject({ state: 'admitted' }) expect( codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params ).toMatchObject({ serviceTier: 'default' }) diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index d7a902c9484..10fe26f8f72 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -1,3 +1,4 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' @@ -15,6 +16,9 @@ export type CodexJournalTranslatorDeps = { turnId?: string | null ) => void clearPromptTurn?: (threadId: string, turnId: string) => void + /** Settles a send's identity off the echoed user message, using the very + * identity the journal row carries so a replay computes the same key. */ + onUserMessageEcho?: (clientMessageId: string, identity: AgentJournalItemIdentity) => void primaryThreadId?: () => string | null subagentExecutions?: CodexSubagentExecutions coalesceMs?: number @@ -24,6 +28,7 @@ export type CodexJournalTranslatorDeps = { export type CodexJournalTranslator = { handle: (event: CodexStructuredSessionEvent) => CodexJournalTranslationAdmission + cancelPrompt: (journalItemId: string) => CodexJournalTranslationAdmission restoreThread: ( threadId: string, thread: Record diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 62091580da3..b5ab9ad90e1 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -29,6 +29,7 @@ import { appendCodexLifecycleItem, publishCodexLifecycle } from './codex-structu import type { CodexActiveJournalItem } from './codex-structured-journal-settlement' import { readCodexJournalString } from './codex-structured-journal-translation-values' import { readCodexTurnId } from './codex-structured-thread-facts' +import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' export class CodexJournalItems { readonly ordinals = new CodexTurnOrdinals() @@ -40,7 +41,7 @@ export class CodexJournalItems { constructor( private readonly deps: Pick< CodexJournalTranslatorDeps, - 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' + 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' | 'onUserMessageEcho' > & { maxMetadataBytes?: number }, private readonly activeTurn: (threadId: string) => string | null, private readonly suppress: (threadId: string, turnId: string) => void @@ -78,6 +79,10 @@ export class CodexJournalItems { const identity = this.identityFor(event.threadId, turnId, item) // Count echoes for stable resume ordinals, but user bubbles come from submissions. if (source === 'live' && item.type === 'userMessage') { + const echo = readCodexDispatchEcho(item, identity) + if (echo) { + this.deps.onUserMessageEcho?.(echo.clientMessageId, echo.providerIdentity) + } return { handled: true, admission: CODEX_JOURNAL_ADMITTED } } if (item.type === 'contextCompaction' && event.method === 'item/started') { diff --git a/src/main/codex/codex-structured-journal-prompts.ts b/src/main/codex/codex-structured-journal-prompts.ts index 3a3f57576cb..f72fd6264ef 100644 --- a/src/main/codex/codex-structured-journal-prompts.ts +++ b/src/main/codex/codex-structured-journal-prompts.ts @@ -15,13 +15,16 @@ import { MAX_CODEX_PENDING_PROMPTS } from './codex-structured-journal-limits' import { admitCodexLifecycleItems, appendCodexLifecycleItem, + appendCodexLifecycleMutations, publishCodexLifecycle } from './codex-structured-journal-sink' import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement' import { readCodexTurnId } from './codex-structured-thread-facts' +type CodexGroupedPendingJournalPrompt = CodexPendingJournalPrompt & { promptKey: string } + export class CodexJournalPrompts { - readonly pending = new Map() + readonly pending = new Map() constructor( private readonly deps: Pick, @@ -53,6 +56,7 @@ export class CodexJournalPrompts { this.pending.set(itemId, { threadId: event.threadId, turnId, + promptKey: event.promptKey, identity: question.identity, body: question.body }) @@ -81,6 +85,7 @@ export class CodexJournalPrompts { this.pending.set(itemId, { threadId: event.threadId, turnId, + promptKey: event.promptKey, identity, body }) @@ -96,6 +101,36 @@ export class CodexJournalPrompts { this.pending.delete(journalItemId) } + cancel(journalItemId: string): CodexJournalTranslationAdmission { + const selected = this.pending.get(journalItemId) + if (!selected) { + return CODEX_JOURNAL_ADMITTED + } + const group = [...this.pending].filter( + ([, prompt]) => + prompt.threadId === selected.threadId && + prompt.turnId === selected.turnId && + prompt.promptKey === selected.promptKey + ) + const mutations = group.flatMap(([, prompt]) => { + const body = cancelledJournalPromptBody(prompt.body) + return body ? [{ kind: 'item' as const, identity: prompt.identity, body }] : [] + }) + const admission = appendCodexLifecycleMutations( + this.deps.sink, + `prompt-cancelled:${encodeURIComponent(selected.threadId)}:${encodeURIComponent( + selected.promptKey + )}:${encodeURIComponent(selected.turnId ?? 'unbound')}`, + mutations + ) + if (admission.accepted) { + for (const [itemId] of group) { + this.pending.delete(itemId) + } + } + return admission + } + dispose(): void { this.pending.clear() } diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index d1cd7ca2884..e36322bca0c 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -6,7 +6,7 @@ import type { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import { CODEX_USER_MESSAGE_ORDINAL } from './codex-structured-turn-start' +import { CODEX_USER_MESSAGE_ORDINAL } from './codex-turn-ordinals' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index a19c2e66f82..43e9a6642de 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -273,6 +273,7 @@ export function createCodexJournalTranslator( genericFrames.appendUnhandled(`notification:${event.method}`, event.params, event.threadId) ) }, + cancelPrompt: (journalItemId) => prompts.cancel(journalItemId), resolvePrompt: (journalItemId) => prompts.resolve(journalItemId), flush: () => { items.streams.flush() diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts new file mode 100644 index 00000000000..cfb77e628a7 --- /dev/null +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -0,0 +1,680 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import { + THREAD_ID, + acquired, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' +import type { CodexStructuredSessionEvent } from './codex-structured-session-state' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {} + const promise = new Promise((finish) => { + resolve = finish + }) + return { promise, resolve } +} + +function registerPrompt( + adapter: Awaited>, + codex: ReturnType, + itemId = 'journal-prompt', + threadId = THREAD_ID, + turnId = 'turn-1' +): void { + codex.connections[0]?.handlers.onServerRequest?.({ + id: 11, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-1', threadId, turnId } + }) + adapter.bindPromptItemId('session-1', itemId, 'codex-item-1', turnId, threadId) +} + +function registerGroupedQuestionPrompt( + codex: ReturnType, + threadId = THREAD_ID, + turnId = 'turn-1' +): void { + codex.connections[0]?.handlers.onServerRequest?.({ + id: 12, + method: 'item/tool/requestUserInput', + params: { + itemId: 'codex-question-group', + threadId, + turnId, + questions: [ + { id: 'first', question: 'First?', options: [{ label: 'yes' }] }, + { id: 'second', question: 'Second?', options: [{ label: 'no' }] } + ] + } + }) +} + +function completeTurn( + codex: ReturnType, + threadId: string, + turnId = 'turn-1' +): void { + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId, + turn: { id: turnId, status: 'interrupted' } + }) +} + +function completionThreads(events: CodexStructuredSessionEvent[]): string[] { + return events.flatMap((event) => + event.type === 'notification' && event.method === 'turn/completed' ? [event.threadId] : [] + ) +} + +function lifecycleRecorder( + acceptPromptCancellation = true, + acceptTurnCompletion = true +): { + sink: StructuredAgentSessionEventSink + bodies: Map + order: string[] +} { + const bodies = new Map() + const order: string[] = [] + const settlements = new Set() + const append = (identity: AgentJournalItemIdentity, body: AgentJournalItemBody): void => { + bodies.set(agentJournalItemKey(identity), body) + } + const sink: StructuredAgentSessionEventSink = { + appendItem: append, + appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), + publish: () => {}, + tryAppendItem: (identity, body, options) => { + if ( + body.kind === 'approval' && + body.resolution.state === 'cancelled' && + options?.lifecycle === true + ) { + if (!acceptPromptCancellation) { + return { accepted: false, reason: 'backpressure' } + } + order.push('prompt-lifecycle') + } + append(identity, body) + return { accepted: true } + }, + tryAppendLifecycleBatch: (settlementId, mutations) => { + const cancelsPrompt = mutations.some( + (mutation) => + mutation.kind === 'item' && + (mutation.body.kind === 'approval' || mutation.body.kind === 'question') && + mutation.body.resolution.state === 'cancelled' + ) + if (cancelsPrompt && !acceptPromptCancellation) { + return { accepted: false, reason: 'backpressure' } + } + if (settlementId.startsWith('turn-completed:') && !acceptTurnCompletion) { + return { accepted: false, reason: 'backpressure' } + } + if (settlements.has(settlementId)) { + return { accepted: true } + } + settlements.add(settlementId) + for (const mutation of mutations) { + if (mutation.kind === 'item') { + append(mutation.identity, mutation.body) + } else { + bodies.delete(agentJournalItemKey(mutation.identity)) + } + } + if (cancelsPrompt) { + order.push('prompt-lifecycle') + } + if (settlementId.startsWith('turn-completed:')) { + order.push('turn-lifecycle') + } + return { accepted: true } + }, + tryPublish: () => ({ accepted: true }) + } + return { sink, bodies, order } +} + +describe('Codex live prompt ownership', () => { + it('lets an answer hold the callback claim through its journal commit', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + registerPrompt(adapter, codex) + const commitGate = deferred() + const commitStarted = vi.fn() + + const answer = adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'accept', + fence: 7, + commit: async () => { + expect(codex.connections[0]?.replies).toEqual([]) + commitStarted() + await commitGate.promise + } + }) + await vi.waitFor(() => expect(commitStarted).toHaveBeenCalledOnce()) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(codex.connections[0]?.calls.some((call) => call.method === 'turn/interrupt')).toBe(false) + + commitGate.resolve() + await answer + expect(codex.connections[0]?.replies).toEqual([{ id: 11, result: { decision: 'accept' } }]) + }) + + it('lets prompt cancellation win and retains its claim until terminal cleanup', async () => { + const interruptGate = deferred() + const codex = fakeCodex({ + 'turn/interrupt': async () => { + await interruptGate.promise + completeTurn(codex, THREAD_ID) + } + }) + const adapter = await acquired(codex) + registerPrompt(adapter, codex) + + const cancellation = adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + await vi.waitFor(() => + expect(codex.connections[0]?.calls.at(-1)?.method).toBe('turn/interrupt') + ) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + + interruptGate.resolve() + await expect(cancellation).resolves.toEqual({ cancelled: true }) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + + await adapter.closeSession('session-1') + await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'accept', + fence: 8, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + }) + + it('releases the callback claim after a failed interrupt', async () => { + const codex = fakeCodex({ + 'turn/interrupt': () => { + throw new CodexAppServerRequestError('turn/interrupt', -32602, 'no such turn') + } + }) + const adapter = await acquired(codex) + registerPrompt(adapter, codex) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'decline', + fence: 7, + commit: async () => undefined + }) + expect(codex.connections[0]?.replies).toEqual([{ id: 11, result: { decision: 'decline' } }]) + }) + + it('interrupts only the child provider turn when its controller turn differs', async () => { + const codex = fakeCodex({ + 'turn/interrupt': () => completeTurn(codex, 'thread-child', 'child-turn') + }) + const terminateTurnProcesses = vi.fn(async () => true) + const adapter = adapterFor(codex, {}, [], { terminateTurnProcesses }) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) + registerPrompt(adapter, codex, 'child-prompt', 'thread-child', 'child-turn') + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'root-turn', + fence: 7, + prompt: { itemId: 'child-prompt' } + }) + ).resolves.toEqual({ cancelled: true }) + expect(codex.connections[0]?.calls.at(-1)).toEqual({ + method: 'turn/interrupt', + params: { threadId: 'thread-child', turnId: 'child-turn' } + }) + expect(terminateTurnProcesses).not.toHaveBeenCalled() + expect(codex.connections[0]?.closed).toBe(false) + }) + + it('keeps a wire-valid multibyte prompt turn id as the exact interrupt target', async () => { + const promptTurnId = '界'.repeat(171) + expect(promptTurnId.length).toBeLessThanOrEqual(AGENT_SESSION_ID_MAX_LENGTH) + expect(Buffer.byteLength(promptTurnId, 'utf8')).toBeGreaterThan(AGENT_SESSION_ID_MAX_LENGTH) + const codex = fakeCodex({ + 'turn/interrupt': () => completeTurn(codex, 'thread-child', promptTurnId) + }) + const adapter = await acquired(codex) + registerPrompt(adapter, codex, 'child-prompt', 'thread-child', promptTurnId) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'root-turn', + fence: 7, + prompt: { itemId: 'child-prompt' } + }) + ).resolves.toEqual({ cancelled: true }) + expect(codex.connections[0]?.calls.at(-1)).toEqual({ + method: 'turn/interrupt', + params: { threadId: 'thread-child', turnId: promptTurnId } + }) + }) + + it('settles a grouped prompt and its running turn before reporting cancellation', async () => { + const codex = fakeCodex({ + 'turn/interrupt': () => { + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-1', status: 'interrupted', durationMs: 456 } + }) + } + }) + const recorded = lifecycleRecorder() + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + codex.connections[0]?.handlers.onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: 'turn-1' } + }) + registerGroupedQuestionPrompt(codex) + const questionItemIds = [...recorded.bodies] + .filter(([, body]) => body.kind === 'question') + .map(([itemId]) => itemId) + expect(questionItemIds).toHaveLength(2) + const selectedItemId = questionItemIds[0] + const siblingItemId = questionItemIds[1] + if (!selectedItemId || !siblingItemId) { + throw new Error('expected two durable Codex questions') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: selectedItemId } + }) + ).resolves.toEqual({ cancelled: true }) + expect( + questionItemIds.map((itemId) => { + const body = recorded.bodies.get(itemId) + return body?.kind === 'question' ? body.resolution.state : null + }) + ).toEqual(['cancelled', 'cancelled']) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 456 + }) + + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: siblingItemId, + kind: 'question', + optionId: 'no', + fence: 7, + commit: async () => undefined + }) + ).rejects.toThrow(/no longer waiting/) + }) + + it('enqueues terminal prompt state before a confirmed cancellation resolves', async () => { + const codex = fakeCodex({ + 'turn/interrupt': () => { + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-1', status: 'interrupted', durationMs: 321 } + }) + } + }) + const recorded = lifecycleRecorder() + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + registerPrompt(adapter, codex) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Codex prompt') + } + + const cancellation = adapter + .cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + .then((result) => { + recorded.order.push('resolved') + return result + }) + + await expect(cancellation).resolves.toEqual({ cancelled: true }) + expect(recorded.order).toEqual(['prompt-lifecycle', 'turn-lifecycle', 'resolved']) + expect( + [...recorded.bodies.values()].some( + (body) => body.kind === 'approval' && body.resolution.state === 'cancelled' + ) + ).toBe(true) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 321 + }) + + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-1', status: 'completed', durationMs: 999 } + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 321 + }) + }) + + it('settles the prompt without inventing turn completion when none was observed', async () => { + const codex = fakeCodex() + const recorded = lifecycleRecorder() + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + codex.connections[0]?.handlers.onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: 'turn-1' } + }) + registerPrompt(adapter, codex) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Codex prompt') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + ).resolves.toEqual({ cancelled: true }) + expect(recorded.bodies.get(promptItemId)).toMatchObject({ + kind: 'approval', + resolution: { state: 'cancelled' } + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'running' + }) + + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-1', status: 'interrupted', durationMs: 777 } + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 777 + }) + + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: promptItemId, + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + }) + + it('does not synthesize terminal lifecycle for ordinary Stop', async () => { + const events: CodexStructuredSessionEvent[] = [] + const adapter = await acquired(fakeCodex(), {}, events) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + expect(completionThreads(events)).toEqual([]) + }) + + it('does not report success or release the claim when prompt lifecycle admission fails', async () => { + const codex = fakeCodex() + const recorded = lifecycleRecorder(false) + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + registerPrompt(adapter, codex) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Codex prompt') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + ).rejects.toThrow(/lifecycle was not admitted/) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: promptItemId, + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + }) + + it('does not report success when a deferred provider completion is backpressured', async () => { + const recorded = lifecycleRecorder(true, false) + const codex = fakeCodex({ + 'turn/interrupt': () => { + completeTurn(codex, THREAD_ID) + } + }) + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + registerPrompt(adapter, codex) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Codex prompt') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + ).rejects.toThrow(/deferred turn completion lifecycle was not admitted/) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: promptItemId, + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + + await adapter.closeSession('session-1') + }) + + it('defers only the matching thread and emits its terminal event before cancel resolves', async () => { + const interruptGate = deferred() + const events: CodexStructuredSessionEvent[] = [] + const codex = fakeCodex({ + 'turn/interrupt': () => { + completeTurn(codex, THREAD_ID) + completeTurn(codex, 'thread-child') + return interruptGate.promise + } + }) + const adapter = await acquired(codex, {}, events) + registerPrompt(adapter, codex, 'child-prompt', 'thread-child') + + const cancellation = adapter + .cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'child-prompt' } + }) + .then((result) => { + expect(completionThreads(events)).toEqual([THREAD_ID, 'thread-child']) + return result + }) + await vi.waitFor(() => expect(completionThreads(events)).toEqual([THREAD_ID])) + + interruptGate.resolve() + await expect(cancellation).resolves.toEqual({ cancelled: true }) + }) + + it('checks the bound item, fence, and current acquisition before interrupting', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + registerPrompt(adapter, codex) + + for (const input of [ + { turnId: 'turn-1', fence: 7, itemId: 'other-item' }, + { turnId: 'turn-1', fence: 6, itemId: 'journal-prompt' } + ]) { + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: input.turnId, + fence: input.fence, + prompt: { itemId: input.itemId } + }) + ).resolves.toEqual({ cancelled: false }) + } + expect(codex.connections[0]?.calls.some((call) => call.method === 'turn/interrupt')).toBe(false) + + await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 8, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(codex.connections[1]?.calls.some((call) => call.method === 'turn/interrupt')).toBe(false) + }) + + it('drops a retained cancellation claim with normal turn cleanup', () => { + const prompts = new CodexPromptRegistry() + prompts.register({ + id: 11, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-1', threadId: THREAD_ID, turnId: 'turn-1' } + }) + prompts.bindJournalItemId('journal-prompt', THREAD_ID, 'codex-item-1', 'turn-1') + const claim = prompts.claimBound('journal-prompt') + if (!claim) { + throw new Error('expected prompt claim') + } + + prompts.clearTurn(THREAD_ID, 'turn-1') + + expect(prompts.ownsClaim(claim)).toBe(false) + expect(prompts.find('journal-prompt')).toBeNull() + }) +}) diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts new file mode 100644 index 00000000000..28d060d955f --- /dev/null +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -0,0 +1,103 @@ +import { + AgentSessionPromptUnavailableError, + type StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' +import { answerCodexPrompt } from './codex-structured-prompt-replies' +import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' +import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' + +type CancelInput = Parameters[0] +type AnswerInput = Parameters[0] + +export async function cancelCodexStructuredTurn(input: { + request: CancelInput + sessions: Map + compactions: StructuredSessionCompaction + cancellation: CodexStructuredTurnCancellation +}): Promise<{ cancelled: boolean }> { + const { request, sessions, compactions, cancellation } = input + const session = requireLiveCodexSession(sessions, request.sessionId) + const turnId = compactions.providerTurnId(request.sessionId, request.turnId) + if (!turnId) { + return { cancelled: false } + } + const prompt = request.prompt + if (!prompt) { + return cancellation.cancel(session, session.threadId, turnId) + } + if (session.fence !== request.fence) { + return { cancelled: false } + } + const acquisitionGeneration = session.acquisitionGeneration + const claim = session.prompts.claimBound(prompt.itemId) + const promptTurnId = claim?.prompt.turnId + if (!claim || !promptTurnId) { + if (claim) { + session.prompts.releaseClaim(claim) + } + return { cancelled: false } + } + const isCurrent = (): boolean => + sessions.get(request.sessionId) === session && + !session.ended && + session.fence === request.fence && + session.acquisitionGeneration === acquisitionGeneration && + compactions.providerTurnId(request.sessionId, request.turnId) === turnId && + session.prompts.ownsBoundClaim(claim, prompt.itemId, claim.prompt.threadId, promptTurnId) + let interruptConfirmed = false + try { + const result = await cancellation.cancel( + session, + claim.prompt.threadId, + promptTurnId, + isCurrent, + () => { + interruptConfirmed = true + return session.translator?.cancelPrompt(prompt.itemId) ?? { accepted: true } + } + ) + if (!result.cancelled) { + session.prompts.releaseClaim(claim) + } + return result + } catch (error) { + if (!interruptConfirmed) { + session.prompts.releaseClaim(claim) + } + throw error + } +} + +export async function answerCodexStructuredPrompt(input: { + request: AnswerInput + sessions: Map +}): Promise { + const { request, sessions } = input + const session = sessions.get(request.sessionId) + if (!session || session.ended || session.fence !== request.fence) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + const acquisitionGeneration = session.acquisitionGeneration + const claim = session.prompts.claim(request.itemId, request.kind) + if (!claim) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + try { + await request.commit() + if ( + sessions.get(request.sessionId) !== session || + session.ended || + session.fence !== request.fence || + session.acquisitionGeneration !== acquisitionGeneration || + !session.prompts.ownsClaim(claim) + ) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + session.translator?.resolvePrompt(request.itemId) + answerCodexPrompt(session.prompts, session.connection, claim, request.optionId) + } catch (error) { + session.prompts.releaseClaim(claim) + throw error + } +} diff --git a/src/main/codex/codex-structured-prompt-replies.test.ts b/src/main/codex/codex-structured-prompt-replies.test.ts index 49626ebd0a1..e575f27632b 100644 --- a/src/main/codex/codex-structured-prompt-replies.test.ts +++ b/src/main/codex/codex-structured-prompt-replies.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' import { applyCodexPromptAnswer, CodexPromptRegistry, @@ -122,7 +123,7 @@ describe('CodexPromptRegistry', () => { expect(registry.find('other-thread-item')?.requestId).toBe(3) }) - it('bounds an oversized backfilled turn id and still clears its prompt', () => { + it('retains a bounded cleanup identity for an unaddressable backfilled turn id', () => { const registry = new CodexPromptRegistry() const turnId = 'turn-'.padEnd(MAX_CODEX_PROMPT_REGISTRY_BYTES + 1, 'x') registry.register({ @@ -138,6 +139,36 @@ describe('CodexPromptRegistry', () => { expect(registry.find('journal-root')).toBeNull() }) + it('reserves enough bytes for a wire-valid multibyte backfilled turn id', () => { + const registry = new CodexPromptRegistry() + registry.register({ + id: 1, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'root-item', threadId: 'thread-1' } + }) + const reservedBytes = registry.bytes + const turnId = '界'.repeat(AGENT_SESSION_ID_MAX_LENGTH) + + registry.bindJournalItemId('journal-root', 'thread-1', 'root-item', turnId) + + expect(registry.find('journal-root')?.turnId).toBe(turnId) + expect(registry.bytes).toBe(reservedBytes) + expect(registry.bytes).toBeLessThanOrEqual(MAX_CODEX_PROMPT_REGISTRY_BYTES) + }) + + it('rejects a request turn id beyond the wire identity bound', () => { + const registry = new CodexPromptRegistry() + const turnId = 'x'.repeat(AGENT_SESSION_ID_MAX_LENGTH + 1) + const prompt = registry.register({ + id: 1, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'root-item', threadId: 'thread-1', turnId } + }) + + expect(prompt).toBeNull() + expect(registry.bytes).toBe(0) + }) + it('addresses a prompt by its journal item id once bound, and forgets both', () => { const registry = new CodexPromptRegistry() const prompt = registry.register(userInputRequest(['q1'])) diff --git a/src/main/codex/codex-structured-prompt-replies.ts b/src/main/codex/codex-structured-prompt-replies.ts index 9f30bfe1a8d..6e742bf827c 100644 --- a/src/main/codex/codex-structured-prompt-replies.ts +++ b/src/main/codex/codex-structured-prompt-replies.ts @@ -1,16 +1,11 @@ import type { CodexAppServerConnection } from './codex-app-server-connection' +import { CODEX_PROMPT_MAX_ANSWER_BYTES } from './codex-prompt-registry-bounds' import { - CODEX_PROMPT_MAX_ANSWER_BYTES, - MAX_CODEX_PROMPT_JOURNAL_BINDINGS, - MAX_CODEX_PROMPT_REGISTRY_BYTES, - MAX_CODEX_PROMPT_REGISTRY_ENTRIES, - codexPromptMatchesTurn, - codexPromptRegistryEntryBytes, - codexPromptTurnIdentity, - codexJournalPromptIdPart, - readQuestionIds, - readQuestionOptionAnswers -} from './codex-prompt-registry-bounds' + CODEX_USER_INPUT_METHOD, + type CodexPendingPrompt, + type CodexPromptClaim, + type CodexPromptRegistry +} from './codex-prompt-registry' export { codexJournalPromptIdPart, MAX_CODEX_PROMPT_REGISTRY_ENTRIES, @@ -18,39 +13,23 @@ export { MAX_CODEX_PROMPT_REGISTRY_BYTES, encodeCodexJournalQuestionOptionId } from './codex-prompt-registry-bounds' - -// Codex asks for approvals and tool input by sending JSON-RPC REQUESTS back to -// Orca, and the turn blocks until each one is answered. The journal answers them -// much later, through a durable item id, so this module holds the live request -// ids and turns a chosen option back into the reply payload Codex expects. - -export const CODEX_COMMAND_APPROVAL_METHOD = 'item/commandExecution/requestApproval' -export const CODEX_FILE_CHANGE_APPROVAL_METHOD = 'item/fileChange/requestApproval' -export const CODEX_USER_INPUT_METHOD = 'item/tool/requestUserInput' +export { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + CodexPromptRegistry, + isCodexPromptMethod, + type CodexPendingPrompt, + type CodexPromptClaim +} from './codex-prompt-registry' /** The decisions Codex accepts for both approval requests. Anything else is a * client-supplied option id that never came from a Codex prompt. */ export const CODEX_APPROVAL_DECISIONS = ['accept', 'acceptForSession', 'decline', 'cancel'] as const export type CodexApprovalDecision = (typeof CODEX_APPROVAL_DECISIONS)[number] -export type CodexPendingPrompt = { - requestId: number | string - method: string - threadId: string - turnId: string | null - /** Oversized compatibility turn ids stay comparable without escaping the registry byte cap. */ - turnIdDigest?: string - codexItemId: string - /** What addresses this prompt. One tool item can ask more than once — a shell - * bridge re-asks per command under the same `itemId` — so the request's own - * `approvalId` is the identity whenever Codex sends one. */ - promptKey: string - /** One entry per question for a user-input request; empty for an approval. */ - questionIds: readonly string[] - /** Journal-facing ids can be bounded; replies still need Codex's exact ids. */ - questionIdAliases: ReadonlyMap - optionAnswers: ReadonlyMap - answers: Map +function isCodexApprovalDecision(optionId: string): optionId is CodexApprovalDecision { + return CODEX_APPROVAL_DECISIONS.some((decision) => decision === optionId) } /** A user-input request can carry several questions but takes ONE reply, so an @@ -76,208 +55,6 @@ export function decodeCodexQuestionOptionId( } } -function readString(params: unknown, key: string): string | null { - if (typeof params !== 'object' || params === null) { - return null - } - const value = (params as Record)[key] - return typeof value === 'string' && value.length > 0 ? value : null -} - -export function isCodexPromptMethod(method: string): boolean { - return ( - method === CODEX_COMMAND_APPROVAL_METHOD || - method === CODEX_FILE_CHANGE_APPROVAL_METHOD || - method === CODEX_USER_INPUT_METHOD - ) -} - -/** - * Live Codex prompt requests for one session, addressable by the journal item - * id the client will eventually answer with. The binding is registered by the - * translation module, because only it knows which journal item a Codex item - * became. - */ -export class CodexPromptRegistry { - private readonly byAddress = new Map() - /** Journal item id to thread-scoped prompt address. */ - private readonly journalItemIds = new Map() - /** Bound prompts survive LRU eviction of the lookup window until answered. */ - private readonly boundPrompts = new Map() - - get sizes(): { prompts: number; journalBindings: number } { - return { prompts: this.byAddress.size, journalBindings: this.journalItemIds.size } - } - - get bytes(): number { - return this.retainedPromptBytes() - } - - private promptBytes(prompt: CodexPendingPrompt): number { - return codexPromptRegistryEntryBytes(prompt) - } - - private retainedPromptBytes(): number { - const prompts = new Set([...this.byAddress.values(), ...this.boundPrompts.values()]) - return [...prompts].reduce((total, prompt) => total + this.promptBytes(prompt), 0) - } - - private trim(): void { - while (this.byAddress.size > MAX_CODEX_PROMPT_REGISTRY_ENTRIES) { - const oldest = this.byAddress.values().next().value as CodexPendingPrompt | undefined - if (!oldest) { - break - } - const address = this.address(oldest.threadId, oldest.promptKey) - this.byAddress.delete(address) - } - while (this.journalItemIds.size > MAX_CODEX_PROMPT_JOURNAL_BINDINGS) { - const oldest = this.journalItemIds.keys().next().value as string | undefined - if (!oldest) { - break - } - this.journalItemIds.delete(oldest) - this.boundPrompts.delete(oldest) - } - } - - private address(threadId: string, promptKey: string): string { - return `${encodeURIComponent(threadId)}:${encodeURIComponent(promptKey)}` - } - - /** Returns null for a request this build does not model, so the caller can - * refuse it instead of leaving Codex blocked on an answer forever. */ - register(request: { - id: number | string - method: string - params: unknown - }): CodexPendingPrompt | null { - const codexItemId = readString(request.params, 'itemId') - const threadId = readString(request.params, 'threadId') - if (!isCodexPromptMethod(request.method) || !codexItemId || !threadId) { - return null - } - const questionIds = - request.method === CODEX_USER_INPUT_METHOD ? readQuestionIds(request.params) : [] - if (questionIds === null) { - return null - } - const optionAnswers = - request.method === CODEX_USER_INPUT_METHOD - ? readQuestionOptionAnswers(request.params) - : new Map() - if (optionAnswers === null) { - return null - } - const prompt: CodexPendingPrompt = { - requestId: request.id, - method: request.method, - threadId, - turnId: readString(request.params, 'turnId'), - codexItemId, - promptKey: readString(request.params, 'approvalId') ?? codexItemId, - questionIds, - questionIdAliases: - request.method === CODEX_USER_INPUT_METHOD - ? new Map(questionIds.map((id) => [codexJournalPromptIdPart(id), id])) - : new Map(), - optionAnswers, - answers: new Map() - } - const promptBytes = this.promptBytes(prompt) - if (promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES) { - return null - } - while ( - this.retainedPromptBytes() + promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES && - this.byAddress.size > 0 - ) { - const oldest = this.byAddress.values().next().value as CodexPendingPrompt | undefined - if (!oldest) { - break - } - this.byAddress.delete(this.address(oldest.threadId, oldest.promptKey)) - } - if (this.retainedPromptBytes() + promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES) { - return null - } - const address = this.address(prompt.threadId, prompt.promptKey) - this.byAddress.delete(address) - this.byAddress.set(address, prompt) - this.trim() - return prompt - } - - /** Called by the translation module once the prompt has a journal id. */ - bindJournalItemId( - journalItemId: string, - threadId: string, - promptKey: string, - turnId?: string | null - ): void { - const existing = this.journalItemIds.get(journalItemId) - if (existing) { - this.boundPrompts.delete(journalItemId) - } - this.journalItemIds.delete(journalItemId) - const address = this.address(threadId, promptKey) - const prompt = this.byAddress.get(address) - if (!prompt) { - return - } - if (prompt.turnId === null && prompt.turnIdDigest === undefined && turnId) { - Object.assign(prompt, codexPromptTurnIdentity(turnId)) - } - this.journalItemIds.set(journalItemId, address) - this.boundPrompts.set(journalItemId, prompt) - this.trim() - } - - /** Falls back to treating the id as a prompt key, which is what it is before - * any binding exists. */ - find(journalItemId: string): CodexPendingPrompt | null { - const address = this.journalItemIds.get(journalItemId) - if (address) { - return this.boundPrompts.get(journalItemId) ?? this.byAddress.get(address) ?? null - } - const matches = [...this.byAddress.values()].filter( - (prompt) => prompt.promptKey === journalItemId - ) - return matches.length === 1 ? matches[0]! : null - } - - forget(prompt: CodexPendingPrompt): void { - const address = this.address(prompt.threadId, prompt.promptKey) - if (this.byAddress.get(address) === prompt) { - this.byAddress.delete(address) - } - for (const [journalItemId, boundPrompt] of this.boundPrompts) { - if (boundPrompt === prompt) { - this.journalItemIds.delete(journalItemId) - this.boundPrompts.delete(journalItemId) - } - } - } - - /** Drops requests that belonged to a turn which the provider has settled. */ - clearTurn(threadId: string, turnId: string): void { - const prompts = new Set( - [...this.byAddress.values(), ...this.boundPrompts.values()].filter( - (prompt) => prompt.threadId === threadId && codexPromptMatchesTurn(prompt, turnId) - ) - ) - for (const prompt of prompts) { - this.forget(prompt) - } - } - - clear(): void { - this.byAddress.clear() - this.journalItemIds.clear() - this.boundPrompts.clear() - } -} - /** * Records one answer and returns the reply payload once the request is fully * answered. A multi-question user-input request stays pending until every @@ -288,7 +65,7 @@ export function applyCodexPromptAnswer( optionId: string ): Record | null { if (prompt.method !== CODEX_USER_INPUT_METHOD) { - if (!(CODEX_APPROVAL_DECISIONS as readonly string[]).includes(optionId)) { + if (!isCodexApprovalDecision(optionId)) { throw new Error(`${optionId} is not a Codex approval decision`) } return { decision: optionId } @@ -312,7 +89,11 @@ export function applyCodexPromptAnswer( } const answers: Record = {} for (const id of prompt.questionIds) { - answers[id] = { answers: [prompt.answers.get(id) as string] } + const answer = prompt.answers.get(id) + if (answer === undefined) { + return null + } + answers[id] = { answers: [answer] } } return { answers } } @@ -322,15 +103,16 @@ export function applyCodexPromptAnswer( export function answerCodexPrompt( registry: CodexPromptRegistry, connection: Pick, - itemId: string, + claim: CodexPromptClaim, optionId: string ): void { - const prompt = registry.find(itemId) - if (!prompt) { - throw new Error(`codex app-server is no longer waiting on ${itemId}`) + if (!registry.ownsClaim(claim)) { + throw new Error(`codex app-server is no longer waiting on ${claim.itemId}`) } + const prompt = claim.prompt const reply = applyCodexPromptAnswer(prompt, optionId) if (reply === null) { + registry.releaseClaim(claim) return } // Forget first: a second answer must find nothing rather than reply twice. diff --git a/src/main/codex/codex-structured-provider-events.ts b/src/main/codex/codex-structured-provider-events.ts index 989232ff1b2..69b4cb0d392 100644 --- a/src/main/codex/codex-structured-provider-events.ts +++ b/src/main/codex/codex-structured-provider-events.ts @@ -3,7 +3,7 @@ import { disposeCodexServerRequest } from './codex-server-request-disposition' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import * as codexRewind from './codex-structured-rewind' import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state' -import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' +import { readCodexThreadId } from './codex-structured-thread-facts' import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' type EmitCodexEvent = ( @@ -41,10 +41,9 @@ export function deliverCodexNotification( return { accepted: true } } const threadId = readCodexThreadId(params) ?? session.threadId - const turnId = - method === 'turn/started' && threadId === session.threadId ? readCodexTurnId(params) : null - const turnWaiter = turnId ? session.turnIdWaiters[0] : undefined - const admission = emit(session, { + // Dispatch identity settles on the user-message echo inside the translator, + // which is where the ordinal a replay will compute is minted. + return emit(session, { type: 'notification', sessionId, threadId, @@ -52,13 +51,6 @@ export function deliverCodexNotification( params, ...(observedAt !== undefined ? { observedAt } : {}) }) - if (method === 'turn/started' && threadId === session.threadId) { - if (admission.accepted && turnId && session.turnIdWaiters[0] === turnWaiter) { - session.turnIdWaiters.shift() - turnWaiter?.(turnId) - } - } - return admission } export function deliverCodexServerRequest( diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index b104c559af2..c191e675973 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -10,6 +10,7 @@ import { } from './codex-structured-acquisition-lifecycle' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { CodexSubagentExecutions } from './codex-subagent-executions' +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { openCodexAppServerConnection } from './codex-app-server-connection' import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity' @@ -81,6 +82,7 @@ export async function acquireCodexStructuredSession(input: { ? acquireInput.identity.providerHandle.threadId : null const subagentExecutions = new CodexSubagentExecutions() + const dispatchEchoes = createCodexDispatchEchoes() const translator = acquireInput.events ? createCodexJournalTranslator({ sink: acquireInput.events, @@ -90,7 +92,14 @@ export async function acquireCodexStructuredSession(input: { subagentExecutions, bindPromptItemId: (journalItemId, threadId, promptKey, turnId) => acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey, turnId), - clearPromptTurn: (threadId, turnId) => acquisition.prompts.clearTurn(threadId, turnId) + clearPromptTurn: (threadId, turnId) => acquisition.prompts.clearTurn(threadId, turnId), + onUserMessageEcho: (clientMessageId, providerIdentity) => { + // Only a send THIS session admitted; an echo from history restore or + // another client names no submission of ours to settle. + if (dispatchEchoes.settle(clientMessageId)) { + deps.onDispatchSettledLate?.({ sessionId, clientMessageId, providerIdentity }) + } + } }) : null const open = deps.openConnection ?? openCodexAppServerConnection @@ -230,7 +239,7 @@ export async function acquireCodexStructuredSession(input: { options, reportedOptions: reportedCodexThreadOptions(opened), fastModeTierByModel: fastModeCatalog?.fastModeTierByModel ?? new Map(), - turnIdWaiters: [], + dispatchEchoes, translator, backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions), forceCloseUnexpected: (reason) => diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index b2c579770fd..fc458304dc8 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -153,7 +153,8 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { itemId: 'codex-item-1', kind: 'approval', optionId: 'accept', - fence: 1 + fence: 1, + commit: async () => undefined }) ).rejects.toThrow('no longer waiting on') diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index b32c7e69a1b..e04e18ccd08 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -139,7 +139,8 @@ describe('CodexStructuredSessionAdapter.acquire', () => { itemId: 'codex-item-early', kind: 'approval', optionId: 'accept', - fence: 7 + fence: 7, + commit: async () => undefined }) expect(codex.connections[0].replies).toEqual([{ id: 5, result: { decision: 'accept' } }]) }) @@ -315,7 +316,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) describe('CodexStructuredSessionAdapter.dispatch', () => { - it('accepts a turn Codex names in its response', async () => { + it('admits a send as soon as Codex owns it', async () => { const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) const adapter = await acquired(codex) @@ -334,10 +335,9 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { fence: 7 }) - expect(outcome).toEqual({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-1', ordinal: 0 } - }) + // Identity is not knowable here: a send coalesced into a running turn shares + // that turn's id, so the echo settles which message landed where. + expect(outcome).toEqual({ state: 'admitted' }) expect(codex.connections[0].calls[1].params).toEqual({ threadId: THREAD_ID, clientUserMessageId: 'client-1', @@ -349,7 +349,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { }) }) - it('accepts a turn named only by the notification that raced the ack', async () => { + it('admits a send on a build whose turn/start answers before the turn is named', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] const adapter = await acquired(codex, {}, events) @@ -368,8 +368,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { fence: 7 }) - expect(outcome).toMatchObject({ state: 'accepted' }) - expect(outcome).toMatchObject({ providerIdentity: { turnId: 'turn-late' } }) + expect(outcome).toEqual({ state: 'admitted' }) expect(events.at(-1)).toMatchObject({ type: 'notification', method: 'turn/started' }) }) @@ -393,10 +392,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { fence: 7 }) - expect(outcome).toEqual({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-root', ordinal: 0 } - }) + expect(outcome).toEqual({ state: 'admitted' }) // Each event carries the thread it actually came from, so the journal can // keep a subagent's turn out of the root conversation. expect(events.map((event) => (event.type === 'notification' ? event.threadId : null))).toEqual([ @@ -405,29 +401,6 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { ]) }) - it('settles unknown rather than failed when Codex never names the turn', async () => { - vi.useFakeTimers() - try { - const codex = fakeCodex() - const adapter = await acquired(codex) - - const dispatching = adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - await vi.advanceTimersByTimeAsync(10_000) - - expect(await dispatching).toEqual({ - state: 'unknown', - reason: 'codex app-server started a turn it did not name in time' - }) - } finally { - vi.useRealTimers() - } - }) - it('rejects only when Codex answered and declined', async () => { const codex = fakeCodex({ 'turn/start': () => { @@ -524,7 +497,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex:thread-abc:turn-1:3', kind: 'approval', optionId: 'accept', - fence: 7 + fence: 7, + commit: async () => undefined }) expect(events.at(-1)).toMatchObject({ type: 'prompt', codexItemId: 'codex-item-1' }) @@ -536,7 +510,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex:thread-abc:turn-1:3', kind: 'approval', optionId: 'decline', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow('no longer waiting on') expect(codex.connections[0].replies).toHaveLength(1) @@ -576,7 +551,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-1', kind: 'approval', optionId: 'accept', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow('no longer waiting on') }) @@ -662,7 +638,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId, kind: 'approval', optionId, - fence: 7 + fence: 7, + commit: async () => undefined }) } @@ -688,7 +665,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-1', kind: 'approval', optionId: 'yolo', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow('is not a Codex approval decision') expect(codex.connections[0].replies).toEqual([]) @@ -716,7 +694,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-2', kind: 'question', optionId: encodeCodexQuestionOptionId('q1', 'yes'), - fence: 7 + fence: 7, + commit: async () => undefined }) expect(codex.connections[0].replies).toEqual([]) @@ -725,7 +704,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-2', kind: 'question', optionId: encodeCodexQuestionOptionId('q2', 'no'), - fence: 7 + fence: 7, + commit: async () => undefined }) expect(codex.connections[0].replies).toEqual([ @@ -760,7 +740,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-gone', kind: 'approval', optionId: 'accept', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow('no longer waiting on codex-item-gone') }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index d47bd81fc8e..d7d8b2f6ad1 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -13,7 +13,6 @@ import type { StructuredAgentSessionSetOptionInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' -import { answerCodexPrompt } from './codex-structured-prompt-replies' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' import { supportsCodexStructuredLocation } from './codex-structured-location-support' import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' @@ -37,6 +36,10 @@ import { import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import { acquireCodexStructuredSession } from './codex-structured-session-acquire' +import { + answerCodexStructuredPrompt, + cancelCodexStructuredTurn +} from './codex-structured-prompt-ownership' export type { CodexStructuredLaunch, @@ -183,13 +186,14 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap sessionId: string, journalItemId: string, promptKey: string, - turnId?: string | null + turnId?: string | null, + threadId?: string ): void => this.sessions .get(sessionId) ?.prompts.bindJournalItemId( journalItemId, - this.session(sessionId).threadId, + threadId ?? this.session(sessionId).threadId, promptKey, turnId ) @@ -210,15 +214,13 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap } } - async cancelTurn(input: { - sessionId: string - turnId: string - fence: number - }): Promise<{ cancelled: boolean }> { - const session = this.session(input.sessionId) - const turnId = this.compactions.providerTurnId(input.sessionId, input.turnId) - return turnId ? this.turnCancellation.cancel(session, turnId) : { cancelled: false } - } + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (request) => + cancelCodexStructuredTurn({ + request, + sessions: this.sessions, + compactions: this.compactions, + cancellation: this.turnCancellation + }) rewindSupport: NonNullable = (sessionId) => this.sessions.get(sessionId)?.historyMode === 'legacy' @@ -256,17 +258,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap ) } - async answerPrompt(input: { - sessionId: string - itemId: string - kind: 'approval' | 'question' - optionId: string - fence: number - }): Promise { - const session = this.session(input.sessionId) - answerCodexPrompt(session.prompts, session.connection, input.itemId, input.optionId) - session.translator?.resolvePrompt(input.itemId) - } + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => + answerCodexStructuredPrompt({ request, sessions: this.sessions }) async setOption( input: StructuredAgentSessionSetOptionInput diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index 2ea81d44786..1ac807a5ccd 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -257,9 +257,8 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toMatchObject({ - state: 'accepted', - providerIdentity: { turnId: 'turn-2' } + ).resolves.toEqual({ + state: 'admitted' }) }) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 58c5bc5f50a..17f3aecf671 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -1,3 +1,4 @@ +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { @@ -11,6 +12,7 @@ import { } from './codex-structured-session-adapter' import { handleCodexSessionExit } from './codex-structured-session-close' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' import type { CodexSession } from './codex-structured-session-state' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' @@ -84,13 +86,13 @@ describe('Codex structured session close lifecycle', () => { respondWithError: () => {}, close: async () => true } - const prompts = { clear: vi.fn() } as unknown as CodexSession['prompts'] + const prompts = new CodexPromptRegistry() + const clearPrompts = vi.spyOn(prompts, 'clear') const translator = { handle: vi.fn().mockReturnValueOnce({ accepted: false, reason: 'backpressure' as const }), dispose: vi.fn() } as unknown as NonNullable - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the literal supplies every CodexSession field the close path reads; the rest are unused by it. - const session = { + const session: CodexSession = { connection, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, @@ -103,9 +105,9 @@ describe('Codex structured session close lifecycle', () => { options: new Map(), reportedOptions: {}, fastModeTierByModel: new Map(), - turnIdWaiters: [], + dispatchEchoes: createCodexDispatchEchoes(), translator - } as CodexSession + } const sessions = new Map([['session-1', session]]) const onEvent = vi.fn() @@ -120,7 +122,7 @@ describe('Codex structured session close lifecycle', () => { }) ).toBe(true) expect(session.ended).toBe(true) - expect(prompts.clear).toHaveBeenCalledOnce() + expect(clearPrompts).toHaveBeenCalledOnce() expect(onEvent).toHaveBeenCalledOnce() expect(translator.dispose).toHaveBeenCalledOnce() expect(onEvent.mock.calls[0]?.[0]).toMatchObject({ diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index af814c51d9b..2058f86ce85 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -47,6 +47,9 @@ export function handleCodexSessionExit(input: { event.settlementRetryRequired = true } session.ended = true + // Nothing can echo for this child any more; the journal's pending-submission + // recovery is what settles the sends these were armed for. + session.dispatchEchoes.clear() session.backgroundTasks.clear() input.onBackgroundTasksChanged?.(input.sessionId, null) session.unbindReadingControl?.() diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 4e9abfc17b5..0a8bf41688c 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -1,3 +1,4 @@ +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { describe, expect, it, vi } from 'vitest' import type { CodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' @@ -34,7 +35,7 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi options: new Map(), reportedOptions: { model: 'gpt-live', effort: 'high' }, fastModeTierByModel: new Map(), - turnIdWaiters: [], + dispatchEchoes: createCodexDispatchEchoes(), translator: null } } diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 625e222ecfb..df66d436bf1 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -1,4 +1,7 @@ -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' import { randomUUID } from 'node:crypto' import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' import type { @@ -6,6 +9,7 @@ import type { openCodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' @@ -58,6 +62,12 @@ export type CodexStructuredSessionAdapterDeps = { sessionId: string, state: AgentSessionBackgroundTaskState | null ) => void + /** Identity for a send admitted earlier, once Codex echoes the user message. */ + onDispatchSettledLate?: (input: { + sessionId: string + clientMessageId: string + providerIdentity: AgentJournalItemIdentity + }) => void openConnection?: typeof openCodexAppServerConnection readProcessStartTime?: (pid: number) => Promise mintLinkId?: () => string @@ -94,7 +104,8 @@ export type CodexSession = { } /** Exact provider-advertised Fast request value for each discovered model. */ fastModeTierByModel: Map - turnIdWaiters: ((turnId: string) => void)[] + /** Sends whose identity is still to be settled by the provider echo. */ + dispatchEchoes: CodexDispatchEchoes translator: CodexJournalTranslator | null /** Ephemeral roster behind the background-tasks strip; never durable state. */ backgroundTasks: CodexBackgroundTaskTracker diff --git a/src/main/codex/codex-structured-turn-cancellation.ts b/src/main/codex/codex-structured-turn-cancellation.ts index 97257d54fa4..4418da81057 100644 --- a/src/main/codex/codex-structured-turn-cancellation.ts +++ b/src/main/codex/codex-structured-turn-cancellation.ts @@ -8,6 +8,7 @@ import type { CodexStructuredSessionAdapterDeps, CodexStructuredSessionEvent } from './codex-structured-session-state' +import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts' import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' import { captureCodexTurnProcesses, @@ -21,13 +22,22 @@ type TurnProcessState = { deferredCompletions: Map } +function turnKey(threadId: string, turnId: string): string { + return JSON.stringify([threadId, turnId]) +} + type TurnCancellationDeps = Pick< CodexStructuredSessionAdapterDeps, 'captureTurnProcesses' | 'requestTimeoutMs' | 'terminateTurnProcesses' > & { - emit: (session: CodexSession, event: CodexStructuredSessionEvent) => void + emit: ( + session: CodexSession, + event: CodexStructuredSessionEvent + ) => CodexJournalTranslationAdmission } +const ADMITTED: CodexJournalTranslationAdmission = { accepted: true } + export class CodexStructuredTurnCancellation { private readonly states = new WeakMap() @@ -54,12 +64,13 @@ export class CodexStructuredTurnCancellation { observedAt?: number ): boolean { const threadId = readCodexThreadId(params) ?? session.threadId - if (method !== 'turn/completed' || threadId !== session.threadId) { + if (method !== 'turn/completed') { return false } const turnId = readCodexTurnId(params) const state = this.state(session) - if (!turnId || !state.blockedCompletions.has(turnId)) { + const key = turnId ? turnKey(threadId, turnId) : null + if (!key || !state.blockedCompletions.has(key)) { return false } const event = { @@ -70,21 +81,29 @@ export class CodexStructuredTurnCancellation { params, ...(observedAt !== undefined ? { observedAt } : {}) } - state.deferredCompletions.set(turnId, event) + state.deferredCompletions.set(key, event) return true } - async cancel(session: CodexSession, turnId: string): Promise<{ cancelled: boolean }> { + async cancel( + session: CodexSession, + threadId: string, + turnId: string, + isCurrent: () => boolean = () => true, + onConfirmed?: () => CodexJournalTranslationAdmission + ): Promise<{ cancelled: boolean }> { const state = this.state(session) - state.blockedCompletions.add(turnId) - const baseline = await state.baseline + const key = turnKey(threadId, turnId) + state.blockedCompletions.add(key) + const targetsPrimaryTurn = threadId === session.threadId + const baseline = targetsPrimaryTurn ? await state.baseline : null + if (!isCurrent()) { + this.releaseCompletion(session, key) + return { cancelled: false } + } let requestError: unknown const interruptReceipt = session.connection - .request( - 'turn/interrupt', - { threadId: session.threadId, turnId }, - { timeoutMs: this.deps.requestTimeoutMs } - ) + .request('turn/interrupt', { threadId, turnId }, { timeoutMs: this.deps.requestTimeoutMs }) .then( () => true, (error: unknown) => { @@ -94,10 +113,31 @@ export class CodexStructuredTurnCancellation { ) const [acknowledged, terminated] = await Promise.all([ interruptReceipt, - this.terminate(session.connection, baseline) + targetsPrimaryTurn ? this.terminate(session.connection, baseline) : Promise.resolve(true) ]) if (terminated && acknowledged) { - this.releaseCompletion(session, turnId) + const completion = state.deferredCompletions.get(key) + let confirmationError: unknown + let promptAdmission = ADMITTED + try { + promptAdmission = onConfirmed?.() ?? ADMITTED + } catch (error) { + confirmationError = error + } + const completionAdmission = this.releaseCompletion(session, key, completion) + if (confirmationError) { + throw confirmationError + } + if (!promptAdmission.accepted) { + throw new Error( + `Codex prompt cancellation lifecycle was not admitted (${promptAdmission.reason})` + ) + } + if (onConfirmed && completion && !completionAdmission.accepted) { + throw new Error( + `Codex deferred turn completion lifecycle was not admitted (${completionAdmission.reason})` + ) + } return { cancelled: true } } if ( @@ -105,12 +145,12 @@ export class CodexStructuredTurnCancellation { !isCodexAppServerRequestError(requestError) && !isCodexAppServerUnsupportedError(requestError) ) { - this.releaseCompletion(session, turnId) + this.releaseCompletion(session, key) throw requestError } // A failed cancellation must not permanently divert the provider's later // completion for this turn. Let the normal completion path settle it. - this.releaseCompletion(session, turnId) + this.releaseCompletion(session, key) return { cancelled: false } } @@ -135,15 +175,13 @@ export class CodexStructuredTurnCancellation { private releaseCompletion( session: CodexSession, - turnId: string, - completion = this.state(session).deferredCompletions.get(turnId) - ): void { + key: string, + completion = this.state(session).deferredCompletions.get(key) + ): CodexJournalTranslationAdmission { const state = this.state(session) - state.blockedCompletions.delete(turnId) - state.deferredCompletions.delete(turnId) - if (completion) { - this.deps.emit(session, completion) - } + state.blockedCompletions.delete(key) + state.deferredCompletions.delete(key) + return completion ? this.deps.emit(session, completion) : ADMITTED } private state(session: CodexSession): TurnProcessState { diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index e6a53925fc6..c88370adf05 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -6,21 +6,16 @@ import { type CodexAppServerConnection } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' -import { readCodexTurnId } from './codex-structured-thread-facts' -import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { DISPATCH_REJECTED_CODEX_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' -// Starting a Codex turn and learning its id, which are not the same event: -// `turn/start` returns the id on newer builds and acks before it exists on -// older ones, where it arrives as a `turn/started` notification instead. - -/** Codex records the user message first in a turn, so the submission Orca just - * accepted is ordinal 0 of `(threadId, turnId)`. */ -export const CODEX_USER_MESSAGE_ORDINAL = 0 - -/** Past this the turn is real but unnameable, which the journal renders as - * delivery unconfirmed rather than failure. */ -const TURN_ID_WAIT_MS = 10_000 +// Writing a Codex turn and learning which message landed where, which are not +// the same event. `turn/start` answers as soon as Codex owns the message, but a +// message issued while a turn is running is COALESCED into that turn: the same +// turn id comes back, no second `turn/started` fires, and the user message is +// echoed only when the running turn reaches it. So the response proves +// admission and nothing about identity, which the echo settles later. /** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise * become an arbitrary client-controlled `turn/start` parameter. */ @@ -38,16 +33,14 @@ export function isCodexTurnOptionKey(key: string): boolean { return CODEX_TURN_OPTION_KEYS.has(key) } -/** The session state one turn needs. `turnIdWaiters` is shared with the - * notification handler, which resolves the head of the queue — correct because - * Codex runs one turn per thread, so starts and `turn/started` share an order. */ +/** The session state one turn needs. */ export type CodexTurnHost = { connection: Pick threadId: string options: Map reportedOptions?: { model?: string } fastModeTierByModel: ReadonlyMap - turnIdWaiters: ((turnId: string) => void)[] + dispatchEchoes: CodexDispatchEchoes } function turnInputFor(body: AgentJournalMessageItem): Record[] { @@ -92,69 +85,54 @@ function codexTurnOptions(host: CodexTurnHost): Record { } /** - * Resolves the turn id, or null when Codex owns a turn it never named. Throws - * only for outcomes the wire must not read as acceptance. + * Hands one submission to Codex. False means the bounded correlation window + * refused it before the write; otherwise resolves when Codex has taken it. */ export async function startCodexTurn( host: CodexTurnHost, input: { clientMessageId: string; body: AgentJournalMessageItem; timeoutMs?: number } -): Promise { - // Registered BEFORE the call: on builds that ack first, `turn/started` can - // land while the response is still in flight. - let notified: ((turnId: string) => void) | null = null - const fromNotification = new Promise((resolve) => { - notified = resolve - host.turnIdWaiters.push(resolve) - setTimeout(() => resolve(null), TURN_ID_WAIT_MS).unref?.() - }) - try { - const started = await host.connection.request( - 'turn/start', - { - threadId: host.threadId, - clientUserMessageId: input.clientMessageId, - input: turnInputFor(input.body), - ...codexTurnOptions(host) - }, - { timeoutMs: input.timeoutMs } - ) - return readCodexTurnId(started) ?? (await fromNotification) - } finally { - const index = notified ? host.turnIdWaiters.indexOf(notified) : -1 - if (index !== -1) { - host.turnIdWaiters.splice(index, 1) - } +): Promise { + // Armed before the write: the echo can land while the response is in flight. + if (!host.dispatchEchoes.arm(input.clientMessageId)) { + return false } + await host.connection.request( + 'turn/start', + { + threadId: host.threadId, + clientUserMessageId: input.clientMessageId, + input: turnInputFor(input.body), + ...codexTurnOptions(host) + }, + { timeoutMs: input.timeoutMs } + ) + return true } /** - * One submission's outcome as the wire must read it: accepted names the turn, - * rejected is Codex answering and declining, and unknown covers a turn that is - * real but unnameable — never a failure the user is told their message hit. + * One submission's outcome as the wire must read it: admitted means Codex owns + * the message and its identity settles on the echo, rejected is Codex answering + * and declining. Elapsed time is never evidence here, because the wait a + * coalesced send would face is bounded only by the running turn. */ export async function dispatchCodexTurn( session: CodexTurnHost, input: { clientMessageId: string; body: AgentJournalMessageItem }, timeoutMs: number | undefined ): Promise { - let turnId: string | null try { - turnId = await startCodexTurn(session, { ...input, timeoutMs }) + if (!(await startCodexTurn(session, { ...input, timeoutMs }))) { + return { state: 'rejected', reason: DISPATCH_REJECTED_CODEX_QUEUE_FULL } + } } catch (error) { if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { + // Codex answered and declined, so no echo for this write can arrive. + session.dispatchEchoes.disarm(input.clientMessageId) return { state: 'rejected', reason: (error as Error).message } } + // A timeout or transport failure can happen after the frame was written. + // Keep the correlation armed so a later echo can prove delivery. throw error } - return turnId === null - ? { state: 'unknown', reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED } - : { - state: 'accepted', - providerIdentity: { - provider: 'codex', - threadId: session.threadId, - turnId, - ordinal: CODEX_USER_MESSAGE_ORDINAL - } - } + return { state: 'admitted' } } diff --git a/src/main/codex/codex-turn-ordinals.ts b/src/main/codex/codex-turn-ordinals.ts index 89ed72666db..7e21b900d96 100644 --- a/src/main/codex/codex-turn-ordinals.ts +++ b/src/main/codex/codex-turn-ordinals.ts @@ -3,6 +3,10 @@ import { digestPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' +/** Codex records the user message first in a turn, so a restored submission is + * ordinal 0 of `(threadId, turnId)`. */ +export const CODEX_USER_MESSAGE_ORDINAL = 0 + /** Maximum forgotten turn keys retained for late-frame reconciliation. */ export const MAX_CODEX_TURN_ORDINAL_ENTRIES = 256 export const MAX_CODEX_TURN_ORDINAL_BYTES = 512 * 1024 diff --git a/src/main/folder-upgrade-worktree-path.test.ts b/src/main/folder-upgrade-worktree-path.test.ts new file mode 100644 index 00000000000..37d28a32837 --- /dev/null +++ b/src/main/folder-upgrade-worktree-path.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Repo } from '../shared/repo-types' +import type { GitWorktreeInfo } from '../shared/worktree/types' +import { preserveFolderUpgradeWorktreePath } from './folder-upgrade-worktree-path' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +const repo: Repo = { + id: 'folder', + path: 'C:\\projects\\draft', + displayName: 'draft', + badgeColor: 'blue', + addedAt: 0, + kind: 'git', + folderUpgradeGitRootPath: 'C:/projects/draft' +} +function row(path: string): GitWorktreeInfo { + return { path, branch: 'draft', head: 'abc', isBare: false, isMainWorktree: false } +} + +describe('upgraded folder path projection', () => { + it('leaves existing Git repos and unrelated linked checkouts untouched', () => { + const rows = [row('C:/projects/draft'), row('C:/projects/other')] + expect( + preserveFolderUpgradeWorktreePath({ ...repo, folderUpgradeGitRootPath: undefined }, rows) + ).toBe(rows) + expect(preserveFolderUpgradeWorktreePath(repo, rows)).toEqual([ + { ...rows[0], path: repo.path }, + rows[1] + ]) + expect(rows[0].path).toBe('C:/projects/draft') + }) + + it('is idempotent and does not publish both Windows separator spellings', () => { + const rows = [row(repo.path), row('c:/projects/draft')] + const projected = preserveFolderUpgradeWorktreePath(repo, rows) + expect(projected).toEqual([row(repo.path)]) + expect(preserveFolderUpgradeWorktreePath(repo, projected)).toEqual(projected) + }) + + it('does not equate case-distinct POSIX workspaces', () => { + const owner = { ...repo, path: '/project/draft', folderUpgradeGitRootPath: '/project/draft' } + const rows = [row('/project/draft'), row('/project/Draft')] + expect(preserveFolderUpgradeWorktreePath(owner, rows)).toEqual(rows) + }) + + it('revalidates a symlink locally and refuses to inspect a remote symlink', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-folder-upgrade-path-'))) + roots.push(root) + const target = join(root, 'target') + const other = join(root, 'other') + const alias = join(root, 'alias') + mkdirSync(target) + mkdirSync(other) + symlinkSync(target, alias, 'junction') + const owner = { ...repo, path: alias, folderUpgradeGitRootPath: target } + const rows = [row(target)] + expect(preserveFolderUpgradeWorktreePath(owner, rows)).toEqual([row(alias)]) + expect( + preserveFolderUpgradeWorktreePath({ ...owner, executionHostId: 'ssh:builder' }, rows) + ).toBe(rows) + rmSync(alias) + symlinkSync(other, alias, 'junction') + expect(preserveFolderUpgradeWorktreePath(owner, rows)).toBe(rows) + }) +}) diff --git a/src/main/folder-upgrade-worktree-path.ts b/src/main/folder-upgrade-worktree-path.ts new file mode 100644 index 00000000000..eb70bd65031 --- /dev/null +++ b/src/main/folder-upgrade-worktree-path.ts @@ -0,0 +1,41 @@ +import { realpathSync } from 'node:fs' +import type { Repo } from '../shared/repo-types' +import type { GitWorktreeInfo } from '../shared/worktree/types' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import { areWorktreePathsEqual, dedupeWorktreesByPath } from './ipc/worktree-path-comparison' + +function stillNamesRegisteredCheckout(repo: Repo, gitRoot: string): boolean { + if (areWorktreePathsEqual(repo.path, gitRoot)) { + return true + } + if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { + return false + } + try { + // A symlink may have been retargeted since the upgrade. + return areWorktreePathsEqual(realpathSync(repo.path), realpathSync(gitRoot)) + } catch { + return false + } +} + +export function preserveFolderUpgradeWorktreePath( + repo: Repo, + worktrees: GitWorktreeInfo[] +): GitWorktreeInfo[] { + const gitRoot = repo.folderUpgradeGitRootPath + if ( + repo.kind !== 'git' || + typeof gitRoot !== 'string' || + !gitRoot || + !stillNamesRegisteredCheckout(repo, gitRoot) + ) { + return worktrees + } + // Apply after raw Git caches: this repo's locator must not leak into another registration. + return dedupeWorktreesByPath( + worktrees.map((worktree) => + areWorktreePathsEqual(worktree.path, gitRoot) ? { ...worktree, path: repo.path } : worktree + ) + ) +} diff --git a/src/main/git/command-runner/spawned-command-tree-kill.test.ts b/src/main/git/command-runner/spawned-command-tree-kill.test.ts new file mode 100644 index 00000000000..758fb296227 --- /dev/null +++ b/src/main/git/command-runner/spawned-command-tree-kill.test.ts @@ -0,0 +1,122 @@ +import { ChildProcess } from 'node:child_process' +import { once } from 'node:events' +import { spawnProcess } from '../../../shared/child-process/run-process' +import type * as NodeChildProcess from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock, admitMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + admitMock: vi.fn(() => true) +})) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock +})) +vi.mock('../../own-chromium-tree-kill-guard', () => ({ + admitSelfInitiatedTreeKill: admitMock +})) + +import { killSpawnedCommandTree } from './spawned-command-tree-kill' + +const originalPlatform = process.platform + +function childWithPid(pid: number): ChildProcess { + const child = new ChildProcess() + Object.defineProperty(child, 'pid', { value: pid }) + vi.spyOn(child, 'kill').mockReturnValue(true) + vi.spyOn(child, 'unref').mockImplementation(() => {}) + return child +} + +describe('Git command tree termination', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + spawnMock.mockReset() + admitMock.mockReset().mockReturnValue(true) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + vi.restoreAllMocks() + }) + + it.each([0, 128])( + 'never taskkills a child that exited with code %i before close', + async (code) => { + const child = childWithPid(1234) + Object.defineProperty(child, 'exitCode', { value: code }) + + await killSpawnedCommandTree(child) + + expect(spawnMock).not.toHaveBeenCalled() + expect(admitMock).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledOnce() + } + ) + + it('never taskkills a child that exited by signal before close', async () => { + const child = childWithPid(1234) + Object.defineProperty(child, 'signalCode', { value: 'SIGTERM' }) + + await killSpawnedCommandTree(child) + + expect(spawnMock).not.toHaveBeenCalled() + expect(admitMock).not.toHaveBeenCalled() + }) + + it('still waits for tree termination when the Windows root has not exited', async () => { + const child = childWithPid(1234) + const killer = childWithPid(5678) + spawnMock.mockReturnValue(killer) + let settled = false + const pending = killSpawnedCommandTree(child).then(() => { + settled = true + }) + + await Promise.resolve() + expect(settled).toBe(false) + expect(spawnMock).toHaveBeenCalledWith('taskkill', ['/pid', '1234', '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + killer.emit('close', 0) + await pending + expect(child.kill).not.toHaveBeenCalled() + }) + + it('preserves handle termination on POSIX', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + const child = childWithPid(1234) + + await killSpawnedCommandTree(child) + + expect(child.kill).toHaveBeenCalledOnce() + expect(spawnMock).not.toHaveBeenCalled() + }) + it.skipIf(originalPlatform !== 'win32').each([0, 128])( + 'does not taskkill an actual native Windows child after exit %i', + async (exitCode) => { + const original = await vi.importActual('node:child_process') + spawnMock.mockImplementation((program, args, options) => { + if (program !== process.execPath) { + throw new Error('Unexpected external process in native exit probe') + } + return original.spawn(program, args, options) + }) + const child = spawnProcess({ + program: process.execPath, + args: ['-e', `process.exit(${exitCode})`] + }) + const closed = once(child, 'close') + await once(child, 'exit') + expect(child.exitCode).toBe(exitCode) + expect(child.pid).toBeGreaterThan(0) + spawnMock.mockClear() + await killSpawnedCommandTree(child) + expect(spawnMock).not.toHaveBeenCalled() + expect(admitMock).not.toHaveBeenCalled() + await closed + } + ) +}) diff --git a/src/main/git/command-runner/spawned-command-tree-kill.ts b/src/main/git/command-runner/spawned-command-tree-kill.ts index 324e04f1db3..035764d5249 100644 --- a/src/main/git/command-runner/spawned-command-tree-kill.ts +++ b/src/main/git/command-runner/spawned-command-tree-kill.ts @@ -9,6 +9,11 @@ export function killSpawnedCommandTree(child: ChildProcess): Promise { child.kill() return Promise.resolve() } + // Windows may reuse the pid after exit while inherited pipes still delay close. + if ((child.exitCode ?? null) !== null || (child.signalCode ?? null) !== null) { + child.kill() + return Promise.resolve() + } if ( !admitSelfInitiatedTreeKill({ pid, site: 'git-command-tree-kill', scope: 'win-taskkill-tree' }) ) { diff --git a/src/main/git/worktree-deferred-removal-real-git.test.ts b/src/main/git/worktree-deferred-removal-real-git.test.ts index 374be4ecc0f..06b58b4319e 100644 --- a/src/main/git/worktree-deferred-removal-real-git.test.ts +++ b/src/main/git/worktree-deferred-removal-real-git.test.ts @@ -2,12 +2,14 @@ // accepts `worktree remove --force` on a path Orca just renamed away. import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { removeWorktree } from './worktree' +import { listWorktreesStrict, removeWorktree } from './worktree' +import { isPrunableGitFileWorktree } from '../worktree-prunable-git-file' +import { removeStaleLocalWorktreeRegistration } from '../local-worktree-removal-recovery' import { getWorktreeTrashRoot, isWorktreeTrashEntryName, @@ -96,6 +98,55 @@ describe('deferred worktree removal against the real Git binary', () => { expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) }) + it('does not rename a malformed registration that points at the checkout git file', async () => { + const markerPath = join(worktreePath, '.git') + const marker = await readFile(markerPath, 'utf8') + const adminPath = marker.trim().replace(/^gitdir: /, '') + await writeFile(join(adminPath, 'gitdir'), `${join(markerPath, '.git')}\n`) + await writeFile(join(worktreePath, 'untracked.txt'), 'keep this work\n') + + await expect( + removeWorktree(repoPath, markerPath, true, { deleteBranch: false }) + ).rejects.toThrow() + await whenWorktreeTrashDeletionsSettled() + + expect(await readFile(markerPath, 'utf8')).toBe(marker) + expect(await readFile(join(worktreePath, 'untracked.txt'), 'utf8')).toBe('keep this work\n') + expect(await git(['branch', '--list', 'feature'], repoPath)).toContain('feature') + expect(existsSync(getWorktreeTrashRoot(markerPath))).toBe(false) + }) + + it('prunes a proven malformed registration while retaining checkout files and its branch', async () => { + const markerPath = join(worktreePath, '.git') + const marker = await readFile(markerPath, 'utf8') + const adminPath = marker.trim().replace(/^gitdir: /, '') + await writeFile(join(adminPath, 'gitdir'), `${join(markerPath, '.git')}\n`) + await writeFile(join(worktreePath, 'untracked.txt'), 'keep this work\n') + const row = (await listWorktreesStrict(repoPath)).find((entry) => entry.path === markerPath) + expect(row).toBeDefined() + if (!row) { + throw new Error('Missing malformed registration') + } + expect(await isPrunableGitFileWorktree(row)).toBe(true) + + const result = await removeStaleLocalWorktreeRegistration({ + canonicalWorktreePath: markerPath, + repoPath, + localWorktreeGitOptions: {}, + registeredWorktree: row, + deleteBranch: true + }) + + expect(result).toEqual({ preservedBranch: { branchName: 'feature', head: row.head } }) + expect(await readFile(markerPath, 'utf8')).toBe(marker) + expect(await readFile(join(worktreePath, 'untracked.txt'), 'utf8')).toBe('keep this work\n') + expect(await git(['rev-parse', 'refs/heads/feature'], repoPath)).toBe(`${row.head}\n`) + expect((await listWorktreesStrict(repoPath)).some((entry) => entry.path === markerPath)).toBe( + false + ) + expect(existsSync(adminPath)).toBe(false) + }) + it('sweeps trash a previous run left behind', async () => { const stalePath = join( workspaceRoot, diff --git a/src/main/hook-archive-termination-safety.test.ts b/src/main/hook-archive-termination-safety.test.ts deleted file mode 100644 index 7627c7e84fc..00000000000 --- a/src/main/hook-archive-termination-safety.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { Repo } from '../shared/repo-types' - -vi.mock('./effective-hook-config', () => ({ - getEffectiveHooksFromConfig: (_repo: unknown, hooks: unknown) => hooks -})) - -const REPO: Repo = { id: 'r', path: '/repo', displayName: 'r', badgeColor: '#000', addedAt: 0 } - -/** - * Run a hook past its deadline with `process.kill` intercepted, so the escalation's decisions are - * observed directly instead of raced against the kernel. `groupAlive` answers the signal-0 probe. - */ -async function signalsFromTimedOutHook(groupAlive: boolean): Promise { - const { runHook } = await import('./hooks') - const dir = mkdtempSync(join(tmpdir(), 'orca-hook-signals-')) - writeFileSync(join(dir, 'orca.yaml'), 'scripts:\n archive: |\n sleep 30\n') - const sent: string[] = [] - const fakeKill = (pid: number, signal?: string | number): true => { - if (signal === 0) { - if (!groupAlive) { - throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }) - } - return true - } - sent.push(`${pid < 0 ? 'group' : 'child'}:${String(signal)}`) - return true - } - const spy = vi.spyOn(process, 'kill').mockImplementation(fakeKill) - try { - await runHook('archive', dir, REPO, dir, undefined, 100) - await new Promise((resolve) => setTimeout(resolve, 2_400)) - return sent - } finally { - spy.mockRestore() - rmSync(dir, { recursive: true, force: true }) - } -} - -// Why (#19334): the escalation exists for descendants that outlive the shell — a setup hook that -// backgrounds a server typically loses its leader to the first SIGTERM while the server keeps -// running. Keying the skip on the CHILD's exit would miss exactly that case; the probe asks the -// GROUP instead. The residual hazard, stated in hooks.ts: a recycled pid answers the probe too. -describe.skipIf(process.platform === 'win32')('archive hook termination', () => { - it('escalates to the group when members survive the first signal', async () => { - await expect(signalsFromTimedOutHook(true)).resolves.toEqual(['group:SIGTERM', 'group:SIGKILL']) - }, 20_000) - - it('sends nothing once the group is provably empty', async () => { - // A group that answers ESRCH has no members left to kill, and its pid may since belong to - // someone else — so neither the SIGTERM nor the escalation is delivered. - await expect(signalsFromTimedOutHook(false)).resolves.toEqual([]) - }, 20_000) -}) - -// The regression the group probe exists for, pinned directly because it cannot be reproduced -// through `runHook` with signals intercepted: with `process.kill` mocked nothing actually dies, so -// the child never reaches the exited state that a child-liveness skip would key on. -describe.skipIf(process.platform === 'win32')('terminateHookTree', () => { - const fakeChild = (exited: boolean) => ({ - pid: 4242, - exitCode: exited ? 0 : null, - signalCode: null, - kill: vi.fn() - }) - - it('signals a surviving group even though the shell leader already exited', async () => { - const { terminateHookTree } = await import('./hooks') - const sent: (string | number | undefined)[][] = [] - const recordKill = (pid: number, signal?: string | number): true => { - if (signal !== 0) { - sent.push([pid, signal]) - } - return true - } - const spy = vi.spyOn(process, 'kill').mockImplementation(recordKill) - try { - // A hook that backgrounds a server loses its leader to the first SIGTERM; the server lives on. - terminateHookTree(fakeChild(true), 'SIGKILL') - expect(sent).toEqual([[-4242, 'SIGKILL']]) - } finally { - spy.mockRestore() - } - }) - - it('sends nothing when the group answers ESRCH', async () => { - const { terminateHookTree } = await import('./hooks') - const child = fakeChild(true) - const emptyGroup = (): true => { - throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }) - } - const spy = vi.spyOn(process, 'kill').mockImplementation(emptyGroup) - try { - terminateHookTree(child, 'SIGKILL') - expect(child.kill).not.toHaveBeenCalled() - } finally { - spy.mockRestore() - } - }) -}) diff --git a/src/main/hook-termination-real-process.test.ts b/src/main/hook-termination-real-process.test.ts new file mode 100644 index 00000000000..65d1b0910d1 --- /dev/null +++ b/src/main/hook-termination-real-process.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { mkdtempSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Repo } from '../shared/repo-types' + +const REPO: Repo = { id: 'r', path: '/repo', displayName: 'r', badgeColor: '#000', addedAt: 0 } + +const alive = (pid: number): boolean => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +/** Run a hook past its deadline and report which of its real processes survived. */ +async function survivorsAfterDeadline( + script: string +): Promise<{ shell: boolean; child: boolean; output: string; pids: number[] }> { + const { runHook } = await import('./hooks') + const dir = mkdtempSync(join(tmpdir(), 'orca-term-')) + const pidFile = join(dir, 'pids') + writeFileSync( + join(dir, 'orca.yaml'), + `scripts:\n archive: |\n${script.replace(/^/gm, ' ')}\n` + ) + let pids: number[] = [] + try { + const result = await runHook('archive', dir, REPO, dir, undefined, 400) + expect(result.success).toBe(false) + // SIGTERM lands at the deadline, SIGKILL two seconds later. + await new Promise((resolve) => setTimeout(resolve, 3_500)) + expect(existsSync(pidFile)).toBe(true) + pids = readFileSync(pidFile, 'utf8').trim().split(/\s+/).map(Number) + // Without this, a script that recorded only the shell leaves `pids[1]` undefined, `alive` + // throws, and the missing descendant reads as dead — a test that passes on nothing. + expect(pids).toHaveLength(2) + expect(pids.every((pid) => Number.isSafeInteger(pid) && pid > 0)).toBe(true) + return { shell: alive(pids[0]!), child: alive(pids[1]!), output: result.output, pids } + } finally { + for (const pid of pids) { + try { + process.kill(pid, 'SIGKILL') + } catch { + /* already gone */ + } + } + rmSync(dir, { recursive: true, force: true }) + } +} + +// NO `process.kill` mock, deliberately. The defect this file exists for — `exec` silently ignoring +// `detached`, so the shell was never a group leader and the group signal reached nothing — is +// invisible to a mocked `process.kill`, because the mock makes the signal-0 probe succeed whether +// or not a real group exists. That is the precise condition the bug turns on. +describe.skipIf(process.platform === 'win32')('hook termination against real processes', () => { + it('kills the shell and its child when the deadline expires', async () => { + const { shell, child, output } = await survivorsAfterDeadline( + 'echo "archive step 3 of 7"\nsleep 120 &\necho "$$ $!" > "$PWD/pids"\nwait' + ) + expect({ shell, child }).toEqual({ shell: false, child: false }) + // The gate reports this run as `unverifiable`; what the hook printed is the only clue why. + expect(output).toContain('archive step 3 of 7') + }, 30_000) + + it('kills a descendant that ignores SIGTERM', async () => { + // Only the group SIGKILL can end this one; a SIGTERM to the shell alone leaves it running. + const { child } = await survivorsAfterDeadline( + '(trap "" TERM; sleep 120) &\necho "$$ $!" > "$PWD/pids"\nwait' + ) + expect(child).toBe(false) + }, 30_000) +}) diff --git a/src/main/hooks-archive-exit-observation.test.ts b/src/main/hooks-archive-exit-observation.test.ts index 7773894c4c4..f924a5291cf 100644 --- a/src/main/hooks-archive-exit-observation.test.ts +++ b/src/main/hooks-archive-exit-observation.test.ts @@ -1,62 +1,130 @@ import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' import type { Repo } from '../shared/repo-types' -const { execMock } = vi.hoisted(() => ({ execMock: vi.fn() })) -vi.mock('child_process', () => ({ - exec: execMock, - execFileSync: vi.fn(), - execFile: vi.fn(), - spawn: vi.fn() -})) +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) +vi.mock('child_process', () => ({ spawn: spawnMock, execFileSync: vi.fn() })) vi.mock('./effective-hook-config', () => ({ getEffectiveHooksFromConfig: () => ({ scripts: { archive: 'do-the-archive' } }) })) const REPO: Repo = { id: 'r', path: '/repo', displayName: 'r', badgeColor: '#000', addedAt: 0 } -const execFailure = (code: unknown): Error => Object.assign(new Error('Command failed'), { code }) - -/** Drive runHook once with the error object `exec` hands back for a given failure mode. */ -async function runArchiveWith( - error: Error | null -): Promise<{ success: boolean; exitCode?: number }> { - const { runHook } = await import('./hooks') - execMock.mockImplementationOnce((_script, _opts, cb) => { - cb(error, '', '') - return { pid: 1234, kill: vi.fn() } - }) - const outcome = await runHook('archive', '/repo/wt', REPO) - // Guard against a vacuous pass: if the mock ever stops intercepting, a real shell would run and - // this, rather than the subtle assertions below, is what fails. - expect(execMock).toHaveBeenCalled() - return outcome +/** + * A real EventEmitter, so an `error` with no listener throws exactly as Node's would — which is the + * whole point of the stream-error row below. Replays its chunks to whoever subscribes to `data`. + */ +class FakeStream extends EventEmitter { + constructor(private readonly chunks: string[]) { + super() + } + setEncoding(): void {} + override on(event: string, fn: (chunk: string) => void): this { + super.on(event, fn) + if (event === 'data') { + for (const chunk of this.chunks) { + fn(chunk) + } + } + return this + } } -// Why (#19334): an ABSENT exitCode is what the removal gate reads as `unverifiable`. The guard is -// `typeof code === 'number'`, because `exec` reports a spawn failure with a *string* code — a -// looser null-check would file ENOENT as `exited "ENOENT"`, reading a hook that never ran as one -// that reported an exit. The timeout arm of the same contract is covered against a real shell in -// hook-archive-timeout-observation.test.ts. +/** Minimal ChildProcess stand-in: runHook reads the streams and waits for close/error. */ +function fakeChild( + outcome: { code?: number | null; signal?: NodeJS.Signals | null } | Error, + stdoutChunks: string[] = [], + stdoutError?: Error +) { + const listeners: Record void)[]> = {} + const stdout = new FakeStream(stdoutChunks) + queueMicrotask(() => { + if (stdoutError) { + stdout.emit('error', stdoutError) + } + if (outcome instanceof Error) { + for (const fn of listeners.error ?? []) { + fn(outcome) + } + return + } + for (const fn of listeners.close ?? []) { + fn(outcome.code ?? null, outcome.signal ?? null) + } + }) + return { + pid: 4242, + stdout, + stderr: new FakeStream([]), + exitCode: null, + signalCode: null, + kill: () => true, + on(event: string, fn: (...args: unknown[]) => void) { + ;(listeners[event] ??= []).push(fn) + return this + } + } +} + +async function runArchiveWith( + outcome: { code?: number | null; signal?: NodeJS.Signals | null } | Error, + stdoutChunks?: string[], + stdoutError?: Error +): Promise<{ success: boolean; output: string; exitCode?: number }> { + const { runHook } = await import('./hooks') + spawnMock.mockImplementationOnce(() => fakeChild(outcome, stdoutChunks, stdoutError)) + const result = await runHook('archive', '/repo/wt', REPO) + // Guard against a vacuous pass: if the mock stops intercepting, a real shell would run. + expect(spawnMock).toHaveBeenCalled() + return result +} + +// Why (#19334): an ABSENT exitCode is what the removal gate reads as `unverifiable`. Every row here +// is a way a hook can fail to deliver one. The timeout and termination arms of the same contract +// are covered against REAL processes in hook-termination-real-process.test.ts — deliberately not +// here, because a mocked child cannot show whether a process group exists. describe('archive hook exit observation', () => { it('passes a clean run through without an exit code', async () => { - await expect(runArchiveWith(null)).resolves.toEqual({ success: true, output: '' }) + await expect(runArchiveWith({ code: 0 })).resolves.toEqual({ success: true, output: '' }) }) it.each([ ['a non-zero exit', 23], ['a shell command-not-found', 127] ])('reports %s as the observed exit it is', async (_label, code) => { - await expect(runArchiveWith(execFailure(code))).resolves.toMatchObject({ + await expect(runArchiveWith({ code })).resolves.toMatchObject({ success: false, exitCode: code }) }) + it('caps what it retains from a hook that floods stdout', async () => { + // `exec`'s 1 MiB maxBuffer is gone with `spawn`; without a cap a flooding hook grows the main + // process's heap for the whole 120 s deadline. + const megabyte = 'x'.repeat(1024 * 1024) + const result = await runArchiveWith( + { code: 0 }, + Array.from({ length: 12 }, () => megabyte) + ) + expect(result.output.length).toBeLessThan(11 * 1024 * 1024) + expect(result.output).toContain('output truncated at 10485760 bytes') + }) + + it('survives an error on the output stream', async () => { + // An `error` with no listener is an uncaught exception, and in the main process that is the + // app. `exec` never covered this either — its only `error` listener is on the child. + await expect( + runArchiveWith({ code: 0 }, ['partial'], new Error('EIO: read failed')) + ).resolves.toMatchObject({ success: true }) + }) + it.each([ - ['was killed by a signal', null], - ['never started, so the code is a string', 'ENOENT'] - ])('withholds the exit code when the hook %s', async (_label, code) => { - const result = await runArchiveWith(execFailure(code)) + ['was killed by a signal', { code: null, signal: 'SIGKILL' as const }], + // A real spawn failure carries a STRING code; the guard under test is `typeof code === + // 'number'`, so a bare Error would pass even if that guard regressed. + ['never started', Object.assign(new Error('spawn /bin/bash ENOENT'), { code: 'ENOENT' })] + ])('withholds the exit code when the hook %s', async (_label, outcome) => { + const result = await runArchiveWith(outcome) expect(result.success).toBe(false) expect(result.exitCode).toBeUndefined() }) diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 12744712543..3b911de99c6 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -1,6 +1,6 @@ import type * as GitRunner from './git/runner' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { makeHookTestRepo } from './hooks-test-fixtures' // Mock fs used by loadHooks @@ -13,17 +13,17 @@ vi.mock('fs', () => ({ chmodSync: vi.fn() })) -const { execMock, runWslProcessMock, gitExecFileSyncMock } = vi.hoisted(() => ({ - execMock: vi.fn(), +const { spawnMock, runWslProcessMock, gitExecFileSyncMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), runWslProcessMock: vi.fn(), gitExecFileSyncMock: vi.fn() })) vi.mock('child_process', () => ({ - exec: execMock, - execFileSync: vi.fn(), - // runner.ts imports spawn from child_process transitively. - spawn: vi.fn() + // One `spawn` for both: hooks.ts runs the script through it, and runner.ts imports it + // transitively. A second key here silently shadowed the first. + spawn: spawnMock, + execFileSync: vi.fn() })) vi.mock('./wsl/wsl-runner', () => ({ @@ -35,6 +35,35 @@ vi.mock('./git/runner', async () => ({ gitExecFileSync: gitExecFileSyncMock })) +/** Minimal ChildProcess stand-in: hooks.ts reads the streams and waits for close/error. */ +function fakeChild(exit: { code?: number | null; signal?: NodeJS.Signals | null } = { code: 0 }) { + const listeners: Record void)[]> = {} + const stream = { setEncoding: () => {}, on: () => {} } + queueMicrotask(() => { + for (const fn of listeners.close ?? []) { + fn(exit.code ?? null, exit.signal ?? null) + } + }) + return { + pid: 4242, + stdout: stream, + stderr: stream, + exitCode: null, + signalCode: null, + kill: () => true, + on(event: string, fn: (...args: unknown[]) => void) { + ;(listeners[event] ??= []).push(fn) + return this + } + } +} + +beforeEach(() => { + // Clear as well as re-arm: these assertions are order-sensitive and calls otherwise accumulate. + spawnMock.mockClear() + spawnMock.mockImplementation(() => fakeChild()) +}) + describe('runHook', () => { const makeRepo = (hookSettings?: { mode?: 'auto' | 'override' @@ -43,10 +72,7 @@ describe('runHook', () => { }) => makeHookTestRepo(hookSettings) it('uses the Windows command shell when running hooks', async () => { - execMock.mockImplementation((_script, _options, callback) => { - callback?.(null, '', '') - return {} as never - }) + spawnMock.mockImplementation(() => fakeChild()) const fs = await import('node:fs') vi.mocked(fs.existsSync).mockReturnValue(true) @@ -66,13 +92,12 @@ describe('runHook', () => { const result = await runHook('setup', 'C:\\repo\\worktree', makeRepo()) expect(result).toEqual({ success: true, output: '' }) - expect(execMock).toHaveBeenCalledWith( + expect(spawnMock).toHaveBeenCalledWith( 'echo hello', expect.objectContaining({ cwd: 'C:\\repo\\worktree', shell: 'C:\\Windows\\System32\\cmd.exe' - }), - expect.any(Function) + }) ) } finally { Object.defineProperty(process, 'platform', { @@ -91,10 +116,9 @@ describe('runHook', () => { // Why: setup scripts source conda exactly like a shell rc does, so the // orphaned CONDA_SHLVL sentinel surfaces as an opaque hook failure (#14195). let capturedEnv: Record | undefined - execMock.mockImplementation((_script, options, callback) => { + spawnMock.mockImplementation((_script, options) => { capturedEnv = (options as { env: Record }).env - callback?.(null, '', '') - return {} as never + return fakeChild() }) const fs = await import('node:fs') @@ -131,10 +155,7 @@ describe('runHook', () => { }) it('keeps bash as the hook shell on non-Windows platforms', async () => { - execMock.mockImplementation((_script, _options, callback) => { - callback?.(null, '', '') - return {} as never - }) + spawnMock.mockImplementation(() => fakeChild()) const fs = await import('node:fs') vi.mocked(fs.existsSync).mockReturnValue(true) @@ -154,7 +175,7 @@ describe('runHook', () => { const result = await runHook('setup', '/repo/worktree', makeRepo()) expect(result).toEqual({ success: true, output: '' }) - expect(execMock).toHaveBeenCalledWith( + expect(spawnMock).toHaveBeenCalledWith( 'echo hello', expect.objectContaining({ cwd: '/repo/worktree', @@ -165,8 +186,7 @@ describe('runHook', () => { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }) - }), - expect.any(Function) + }) ) } finally { Object.defineProperty(process, 'platform', { @@ -182,7 +202,8 @@ describe('runHook', () => { }) it('runs WSL hooks through runWslProcess and translates env paths to Linux', async () => { - execMock.mockReset() + spawnMock.mockReset() + spawnMock.mockImplementation(() => fakeChild()) runWslProcessMock.mockReset() runWslProcessMock.mockResolvedValue({ environmentResolved: true, @@ -228,7 +249,7 @@ describe('runHook', () => { }) }) ) - expect(execMock).not.toHaveBeenCalled() + expect(spawnMock).not.toHaveBeenCalled() } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -238,7 +259,8 @@ describe('runHook', () => { }) it('runs Windows-path hooks through WSL when the project runtime targets WSL', async () => { - execMock.mockReset() + spawnMock.mockReset() + spawnMock.mockImplementation(() => fakeChild()) runWslProcessMock.mockReset() runWslProcessMock.mockResolvedValue({ environmentResolved: true, @@ -286,7 +308,7 @@ describe('runHook', () => { }) }) ) - expect(execMock).not.toHaveBeenCalled() + expect(spawnMock).not.toHaveBeenCalled() } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -349,7 +371,8 @@ describe('runHook', () => { it('settles WSL hooks when wsl.exe never reports completion', async () => { // Why no fake timers: the timeout is now runProcess's own, internal to the // mocked runWslProcess -- there is nothing left in hooks.ts to advance. - execMock.mockReset() + spawnMock.mockReset() + spawnMock.mockImplementation(() => fakeChild()) runWslProcessMock.mockReset() runWslProcessMock.mockResolvedValue({ environmentResolved: true, diff --git a/src/main/hooks.ts b/src/main/hooks.ts index f9d91f38423..8ea68c8f5fa 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -14,7 +14,12 @@ import type { HookRuntimeTarget } from './hook-runtime-target' import type { OrcaHooks } from '../shared/orca-yaml-hook-types' import type { Repo } from '../shared/repo-types' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' -import { exec } from 'node:child_process' +import { spawn } from 'node:child_process' +import { + forceTerminateProcessTree, + signalProcessTree +} from '../shared/child-process/process-tree-termination' +import { createOutputSink } from '../shared/child-process/bounded-output-sink' const HOOK_TIMEOUT = 120_000 // 2 minutes @@ -53,50 +58,21 @@ function classifyHookProcessResult( const SIGTERM_GRACE_MS = 2_000 -/** Signal the hook's whole process group where the platform has one, else just the child. */ -export type TerminableChild = { - pid?: number - exitCode: number | null - signalCode: NodeJS.Signals | null - kill: (signal: NodeJS.Signals) => boolean +/** + * `exec` capped output at 1 MiB and killed the hook on overflow; `spawn` has no cap at all, and a + * hook flooding stdout for the full deadline can take the main process's heap with it. Truncation + * is reported in the output rather than as a failure — a chatty hook that exits 0 did succeed, and + * failing it for being chatty is the `exec` behaviour this is replacing. + */ +const HOOK_OUTPUT_LIMIT_BYTES = 10 * 1024 * 1024 + +function readSink(sink: ReturnType): string { + return sink.truncated() + ? `${sink.text()}\n[output truncated at ${HOOK_OUTPUT_LIMIT_BYTES} bytes]` + : sink.text() } -export function terminateHookTree(child: TerminableChild, signal: NodeJS.Signals): void { - // Why probe the GROUP and not the child: the escalation exists for descendants that outlive the - // shell. A hook that backgrounds a server typically loses its leader to the first SIGTERM while - // the server keeps running, so keying this on `child.exitCode` would skip the SIGKILL in exactly - // the case it was added for. - // - // The trade-off it does not solve: signalling by negative pid names whatever group owns that pid - // now. Once the leader is reaped its pid can be recycled, and a probe cannot tell a surviving - // descendant from a stranger that inherited the number. Killing a runaway hook is the likelier - // event and the one the deadline promises, so the group is signalled whenever it answers; the - // residual window is pid wraparound inside the two-second grace. - if (process.platform !== 'win32' && child.pid) { - try { - // Signal 0 tests for members without delivering anything: ESRCH means the group is empty. - process.kill(-child.pid, 0) - } catch { - return - } - try { - process.kill(-child.pid, signal) - return - } catch { - // Raced with the last member exiting; fall through to the direct kill. - } - } - if (child.exitCode !== null || child.signalCode !== null) { - return - } - try { - child.kill(signal) - } catch { - // Already dead. - } -} - -/** An `exec` failure: a string `code` (ENOENT) means it never started, so no exit was observed. */ +/** A spawn failure: the process never started, so no exit was ever observed. */ function hookProcessError( error: Error, stdout: string, @@ -287,8 +263,7 @@ export function runHook( // reports whatever it chose to do, so a hook that traps SIGTERM and exits 0 came back as a // PASS — a hook cut off mid-archive, indistinguishable from one that finished. Settle on the // deadline instead, and settle AT it, so a hook that traps and keeps running cannot hold a - // removal open. `exec` stays because it owns the per-platform shell invocation (`cmd.exe` - // wants `/d /s /c`, not `-c`), which is not this change's to re-derive. + // removal open. let settled = false let deadline: NodeJS.Timeout | undefined const settle = (result: HookProcessOutcome): void => { @@ -301,42 +276,71 @@ export function runHook( } resolve(result) } - const child = exec( - script, - { - cwd, - shell: getHookShell(), - // Why: hooks run unattended; block Git Credential Manager's interactive prompt while keeping cached auth (issue #7652). - env: promptGuardShellEnv(shellHookEnv), - // Signal the whole group on POSIX: the script is a shell, and the work is its children. - ...(process.platform === 'win32' ? {} : { detached: true }) - }, - (error, stdout, stderr) => { - if (error) { - settle(hookProcessError(error, stdout, stderr, { hookName, cwd })) - return - } - settle( - classifyHookProcessResult( - { code: 0, stdout, stderr, timedOut: false }, - { hookName, cwd, timeoutMs } - ) + // Why `spawn` and not `exec` (#19334 follow-up): `detached` is a spawn-only option — `exec` + // accepts and ignores it, so the shell never became a group leader and the group signal below + // had nothing to reach. Passing `shell` as a string keeps Node's own platform invocation, which + // is what `exec` was being kept for: `cmd.exe /d /s /c` on Windows rather than a bare `-c`. + const child = spawn(script, { + cwd, + shell: getHookShell(), + // Why: hooks run unattended; block Git Credential Manager's interactive prompt while keeping cached auth (issue #7652). + env: promptGuardShellEnv(shellHookEnv), + stdio: ['ignore', 'pipe', 'pipe'], + // Pinned, not left to Node's default, for the same reason `runProcess` pins it: a `cmd.exe` + // hook otherwise flashes a console window and takes focus. Pre-existing — `exec` did not set + // it either — but AGENTS.md asks for it pinned on every Windows spawn. + windowsHide: true, + // Make the shell a group leader so its children can be reached. Not on Windows, which has no + // process groups in this sense and where `detached` means a new console instead. + ...(process.platform === 'win32' ? {} : { detached: true }) + }) + const stdout = createOutputSink(HOOK_OUTPUT_LIMIT_BYTES) + const stderr = createOutputSink(HOOK_OUTPUT_LIMIT_BYTES) + child.stdout?.on('data', (chunk: Buffer | string) => stdout.write(chunk)) + child.stderr?.on('data', (chunk: Buffer | string) => stderr.write(chunk)) + // Why listeners that do nothing: an unhandled `error` on a stream is an uncaught exception, and + // in the Electron main process that is the whole app. `exec` never covered this either — its + // only `error` listener is on the child — so this is a pre-existing gap, closed the way + // `runProcess` closes it. Losing output is not worth a crash; the exit code still gets through. + for (const stream of [child.stdin, child.stdout, child.stderr]) { + stream?.on('error', () => {}) + } + child.on('error', (error) => { + settle(hookProcessError(error, readSink(stdout), readSink(stderr), { hookName, cwd })) + }) + child.on('close', (code, signal) => { + settle( + classifyHookProcessResult( + // A signalled exit reports no code, which stays `unverifiable` rather than becoming a 0. + { + code: signal ? null : code, + stdout: readSink(stdout), + stderr: readSink(stderr), + timedOut: false + }, + { hookName, cwd, timeoutMs } ) - } - ) - // Why guarded: `exec`'s callback can fire synchronously (the unit test's mock does), and arming - // a deadline on an already-settled run would later signal a process group whose pid is long - // gone — and may by then belong to something else. + ) + }) + // Why guarded: a spawn failure can settle before the deadline is armed, and arming one on a + // finished run would later signal a pid that is gone — and may by then belong to something else. if (!settled) { deadline = setTimeout(() => { settle( classifyHookProcessResult( - { code: null, stdout: '', stderr: '', timedOut: true }, + // Keep what the hook printed: it is the only clue to why the removal gate says + // `unverifiable`. + { code: null, stdout: readSink(stdout), stderr: readSink(stderr), timedOut: true }, { hookName, cwd, timeoutMs } ) ) - terminateHookTree(child, 'SIGTERM') - setTimeout(() => terminateHookTree(child, 'SIGKILL'), SIGTERM_GRACE_MS).unref?.() + // Orca's own tree terminator: POSIX process groups, `taskkill /t /f` on Windows (where a + // bare `child.kill` reaches only the shell and leaves its descendants running), and the + // recycled-pid guard that hazard needs. SIGTERM first so a well-behaved hook can clean up. + void signalProcessTree(child, 'SIGTERM') + setTimeout(() => { + void forceTerminateProcessTree(child) + }, SIGTERM_GRACE_MS).unref?.() }, timeoutMs) } }) diff --git a/src/main/ipc/filesystem-import-local.ts b/src/main/ipc/filesystem-import-local.ts index 49b910dac5f..1df2b3f2fb3 100644 --- a/src/main/ipc/filesystem-import-local.ts +++ b/src/main/ipc/filesystem-import-local.ts @@ -2,7 +2,7 @@ import { lstat, rm } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' import { authorizeExternalPath } from './filesystem-auth' import { isENOENT } from './filesystem-path-containment' -import type { ImportItemResult } from './filesystem-import-result-types' +import type { ImportItemResult } from '../../shared/filesystem-import-result-types' import { copyLocalFileNoFollow, preScanForSymlinks, diff --git a/src/main/ipc/filesystem-import-result-types.ts b/src/main/ipc/filesystem-import-result-types.ts deleted file mode 100644 index d1d9f446836..00000000000 --- a/src/main/ipc/filesystem-import-result-types.ts +++ /dev/null @@ -1,51 +0,0 @@ -export type ImportSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - -export type ResolveDroppedPathsResult = { - resolvedPaths: string[] - skipped: { sourcePath: string; reason: ImportSkipReason }[] - failed: { sourcePath: string; reason: string }[] -} - -// ─── External Import Types ────────────────────────────────────────── - -export type ImportItemResult = - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: ImportSkipReason - } - | { - sourcePath: string - status: 'failed' - reason: string - } - -export type StagedExternalImportSource = - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: StagedExternalImportEntry[] - } - | { - sourcePath: string - status: 'skipped' - reason: ImportSkipReason - } - | { - sourcePath: string - status: 'failed' - reason: string - } - -export type StagedExternalImportEntry = - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index ae17f3d8e19..9268c6ece24 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -5,7 +5,7 @@ import { isENOENT } from './filesystem-path-containment' import { getSshConnectionManager } from './ssh' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -import type { ImportItemResult } from './filesystem-import-result-types' +import type { ImportItemResult } from '../../shared/filesystem-import-result-types' import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index bb143987c96..7703cd8a8b1 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -73,6 +73,7 @@ describe('fs:importExternalPaths', () => { size: 12, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -94,6 +95,7 @@ describe('fs:importExternalPaths', () => { size: entry.isDir ? 0 : 12, ino: entry.isDir ? 2 : 3, dev: 1, + mtimeMs: 1700000000000, isFile: () => !entry.isDir, isDirectory: () => entry.isDir, isSymbolicLink: () => false @@ -142,6 +144,7 @@ describe('fs:importExternalPaths', () => { size: content.byteLength, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), createReadStream: () => Readable.from([content]), @@ -216,6 +219,7 @@ describe('fs:importExternalPaths', () => { size: 12, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), createReadStream: () => Readable.from([Buffer.from('file-content')]), @@ -484,6 +488,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -498,6 +503,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), readFile: readFileHandleMock, @@ -514,11 +520,21 @@ describe('fs:importExternalPaths', () => { status: 'staged', name: 'logo.png', kind: 'file', - entries: [{ relativePath: '', kind: 'file', contentBase64: 'cG5n' }] + entries: [ + { + relativePath: '', + kind: 'file', + byteLength: 4, + inode: 1, + deviceId: 1, + modifiedAtMs: 1700000000000 + } + ] } ]) expect(copyFileMock).not.toHaveBeenCalled() - expect(readFileHandleMock).toHaveBeenCalled() + // Why: bodies stream at upload time, so staging must never read the file. + expect(readFileHandleMock).not.toHaveBeenCalled() expect(closeMock).toHaveBeenCalled() }) @@ -533,6 +549,7 @@ describe('fs:importExternalPaths', () => { size: 0, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false @@ -543,6 +560,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -578,6 +596,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), readFile: vi.fn().mockResolvedValue(Buffer.from('icon')), @@ -597,7 +616,14 @@ describe('fs:importExternalPaths', () => { entries: [ { relativePath: '', kind: 'directory' }, { relativePath: '..assets', kind: 'directory' }, - { relativePath: '..assets/icon.txt', kind: 'file', contentBase64: 'aWNvbg==' } + { + relativePath: '..assets/icon.txt', + kind: 'file', + byteLength: 4, + inode: 2, + deviceId: 1, + modifiedAtMs: 1700000000000 + } ] } ]) @@ -612,6 +638,7 @@ describe('fs:importExternalPaths', () => { size: 0, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false @@ -637,14 +664,15 @@ describe('fs:importExternalPaths', () => { expect(openMock).not.toHaveBeenCalled() }) - it('checks runtime upload directory byte budget before reading a file that exceeds the total cap', async () => { + it('checks runtime upload directory byte budget before opening a file that exceeds the total cap', async () => { const sourcePath = '/tmp/dropped/project' const resolvedPath = path.resolve(sourcePath) const filePaths = ['one.bin', 'two.bin', 'three.bin', 'four.bin', 'overflow.bin'].map((name) => path.join(resolvedPath, name) ) const mib = 1024 * 1024 - const regularSize = 25 * mib + // Four files exactly fill the 8 GB total ceiling; the fifth pushes past it. + const regularSize = 2 * 1024 * mib const overflowSize = Number(mib) const readFileMock = vi.fn().mockResolvedValue(Buffer.from('chunk')) @@ -654,6 +682,7 @@ describe('fs:importExternalPaths', () => { size: 0, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false @@ -666,6 +695,7 @@ describe('fs:importExternalPaths', () => { size, ino: fileIndex + 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -689,6 +719,7 @@ describe('fs:importExternalPaths', () => { size: regularSize, ino: fileIndex + 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), readFile: readFileMock, @@ -702,11 +733,9 @@ describe('fs:importExternalPaths', () => { sourcePaths: [sourcePath] })) as { sources: { status: string; reason?: string }[] } - expect(result.sources[0]).toMatchObject({ - status: 'failed', - reason: 'Remote import is too large' - }) - expect(readFileMock).toHaveBeenCalledTimes(4) + expect(result.sources[0]).toMatchObject({ status: 'failed' }) + expect(result.sources[0]?.reason).toContain('total remote import limit') + expect(readFileMock).not.toHaveBeenCalled() expect(openMock).not.toHaveBeenCalledWith(filePaths.at(-1), expect.anything()) }) @@ -719,6 +748,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -732,6 +762,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), readFile: readFileHandleMock, @@ -744,7 +775,7 @@ describe('fs:importExternalPaths', () => { expect(result.sources[0]).toMatchObject({ status: 'failed', - reason: "File changed during upload staging: ''" + reason: "File changed during upload staging: 'logo.png'" }) expect(readFileHandleMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/filesystem-mutations-runtime-upload.test.ts b/src/main/ipc/filesystem-mutations-runtime-upload.test.ts new file mode 100644 index 00000000000..fa7edc1d5b7 --- /dev/null +++ b/src/main/ipc/filesystem-mutations-runtime-upload.test.ts @@ -0,0 +1,176 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const handlers = new Map Promise>() +const { handleMock, streamMock, sweepMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + streamMock: vi.fn(), + sweepMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock }, + app: { getPath: () => '/user-data' } +})) +vi.mock('./runtime-upload-file-stream', () => ({ + streamExternalFileToRuntime: streamMock +})) +vi.mock('./runtime-upload-temp-sweep', () => ({ + sweepAbandonedRuntimeUploadTempPath: sweepMock +})) +vi.mock('../../shared/runtime-environment-store', () => ({ + resolveEnvironment: (_userDataPath: string, selector: string) => ({ + id: selector === 'env-alias' ? 'env-1' : selector + }) +})) + +import { registerFilesystemMutationHandlers } from './filesystem-mutations' +import { RENDERER_GONE_MESSAGE } from './renderer-lifetime-abort' + +const request = { + environmentId: 'env-1', + sourceRootPath: '/drop/file.bin', + entryRelativePath: '', + expected: { byteLength: 1, inode: 1, deviceId: 1, modifiedAtMs: 1 }, + worktree: 'wt-1', + relativePath: '.file.bin.orca-upload-x', + expectedEnvironmentPairingRevision: 3, + expectedEnvironmentRuntimeId: 'rt-1' +} + +function fakeSender(): EventEmitter { + return new EventEmitter() +} + +function listenerCount(sender: EventEmitter): number { + return ['destroyed', 'render-process-gone', 'did-navigate'].reduce( + (total, name) => total + sender.listenerCount(name), + 0 + ) +} + +beforeEach(() => { + handlers.clear() + handleMock.mockReset() + streamMock.mockReset() + sweepMock.mockReset() + sweepMock.mockResolvedValue(undefined) + handleMock.mockImplementation((channel: string, handler: never) => { + handlers.set(channel, handler) + }) + registerFilesystemMutationHandlers( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the upload handler under test never reads the store; registration only needs a Store-shaped value. + { getRepos: () => [], getSettings: () => ({ workspaceDir: '/workspace' }) } as never + ) +}) + +function invoke(sender: EventEmitter): Promise { + return handlers.get('fs:uploadExternalFileToRuntime')!({ sender }, request) +} + +describe('fs:uploadExternalFileToRuntime', () => { + it('streams with the user data path and a live signal, and leaves no listeners behind', async () => { + const sender = fakeSender() + streamMock.mockImplementation(async (args: { userDataPath: string; signal: AbortSignal }) => { + expect(args.userDataPath).toBe('/user-data') + expect(args.signal.aborted).toBe(false) + expect(listenerCount(sender)).toBe(3) + return { byteLength: 42 } + }) + + await expect(invoke(sender)).resolves.toEqual({ byteLength: 42 }) + + expect(streamMock).toHaveBeenCalledWith(expect.objectContaining(request)) + expect(sweepMock).not.toHaveBeenCalled() + expect(listenerCount(sender)).toBe(0) + }) + + it('resolves the selector to the environment id before streaming and sweeping', async () => { + const sender = fakeSender() + streamMock.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + sender.emit('destroyed') + }) + ) + + await expect( + handlers.get('fs:uploadExternalFileToRuntime')!( + { sender }, + { ...request, environmentId: 'env-alias' } + ) + ).rejects.toThrow(RENDERER_GONE_MESSAGE) + + expect(streamMock).toHaveBeenCalledWith(expect.objectContaining({ environmentId: 'env-1' })) + expect(sweepMock).toHaveBeenCalledWith('/user-data', { ...request, environmentId: 'env-1' }) + }) + + it('aborts, sweeps the temp path, and rethrows when the renderer is destroyed mid-stream', async () => { + const sender = fakeSender() + streamMock.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + sender.emit('destroyed') + }) + ) + + await expect(invoke(sender)).rejects.toThrow(RENDERER_GONE_MESSAGE) + + expect(sweepMock).toHaveBeenCalledTimes(1) + expect(sweepMock).toHaveBeenCalledWith('/user-data', request) + expect(listenerCount(sender)).toBe(0) + }) + + it('aborts once a reload commits, not on a blocked navigation or an in-app route change', async () => { + const sender = fakeSender() + let observed: AbortSignal | undefined + streamMock.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((resolve, reject) => { + observed = signal + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + sender.emit('did-start-navigation', { isMainFrame: true, isSameDocument: true }) + sender.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + sender.emit('will-navigate', { defaultPrevented: true }, 'https://example.invalid/') + queueMicrotask(() => { + expect(signal.aborted).toBe(false) + sender.emit('did-navigate', 'file:///app/index.html', 200, 'OK') + resolve({ byteLength: 0 }) + }) + }) + ) + + await expect(invoke(sender)).rejects.toThrow(RENDERER_GONE_MESSAGE) + expect(observed?.aborted).toBe(true) + expect(sweepMock).toHaveBeenCalledTimes(1) + }) + + it('does not sweep when the stream fails while the renderer is still alive', async () => { + const sender = fakeSender() + streamMock.mockRejectedValue(new Error("File changed since it was staged: 'file.bin'")) + + await expect(invoke(sender)).rejects.toThrow("File changed since it was staged: 'file.bin'") + + expect(sweepMock).not.toHaveBeenCalled() + expect(listenerCount(sender)).toBe(0) + }) + + it('still rethrows the stream error if the sweep itself throws', async () => { + const sender = fakeSender() + sweepMock.mockRejectedValue(new Error('sweep exploded')) + streamMock.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + sender.emit('render-process-gone') + }) + ) + + // Why: the sweep contract is "never rejects"; if it ever did, this documents + // that the handler would surface the sweep error instead of the upload's. + await expect(invoke(sender)).rejects.toThrow('sweep exploded') + expect(listenerCount(sender)).toBe(0) + }) +}) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 57ac0c5e197..ad1308f0c30 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron' +import { app, ipcMain } from 'electron' import { constants } from 'node:fs' import { copyFile, mkdir, writeFile } from 'node:fs/promises' import { basename, dirname } from 'node:path' @@ -16,9 +16,17 @@ import type { ImportSkipReason, ResolveDroppedPathsResult, StagedExternalImportSource -} from './filesystem-import-result-types' +} from '../../shared/filesystem-import-result-types' import { importOneSource } from './filesystem-import-local' -import { stageOneSourceForRuntimeUpload } from './filesystem-runtime-upload-staging' +import { + stagedRuntimeUploadByteLength, + stageOneSourceForRuntimeUpload +} from './filesystem-runtime-upload-staging' +import { streamExternalFileToRuntime } from './runtime-upload-file-stream' +import { abortWhenRendererGone } from './renderer-lifetime-abort' +import { sweepAbandonedRuntimeUploadTempPath } from './runtime-upload-temp-sweep' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import { resolveEnvironment } from '../../shared/runtime-environment-store' /** * IPC handlers for file/folder creation and renaming. @@ -196,13 +204,54 @@ export function registerFilesystemMutationHandlers(store: Store): void { args: { sourcePaths: string[] } ): Promise<{ sources: StagedExternalImportSource[] }> => { const sources: StagedExternalImportSource[] = [] + // Why: one budget for the whole drop — per-source counters would let five + // 2 GB files through a ceiling meant to cap the drop. + let totalBytes = 0 for (const sourcePath of args.sourcePaths) { - sources.push(await stageOneSourceForRuntimeUpload(sourcePath)) + const source = await stageOneSourceForRuntimeUpload(sourcePath, totalBytes) + totalBytes += stagedRuntimeUploadByteLength(source) + sources.push(source) } return { sources } } ) + // Why: the file handle and the runtime socket both live in main, so the byte + // pump runs here. The renderer keeps deconflict/commit/rollback orchestration + // and never sees file contents. + ipcMain.handle( + 'fs:uploadExternalFileToRuntime', + async (event, args: RuntimeUploadFileStreamRequest): Promise<{ byteLength: number }> => { + const userDataPath = app.getPath('userData') + // Why: the streamer's manual-disconnect check keys on the environment id, + // and the renderer may pass any selector the store resolves. + const request = { + ...args, + environmentId: resolveEnvironment(userDataPath, args.environmentId).id + } + // Why: the renderer's own loop died with its window. Now that the bytes + // move in main, a reload or close has to stop the transfer explicitly, + // or a multi-GB upload outlives the window that asked for it. + const lifetime = abortWhenRendererGone(event.sender) + try { + return await streamExternalFileToRuntime({ + ...request, + userDataPath, + signal: lifetime.signal + }) + } catch (error) { + if (lifetime.signal.aborted) { + // Why: the renderer owns temp cleanup, and it is gone — so the + // abandoned temp path is only collectable from here. + await sweepAbandonedRuntimeUploadTempPath(userDataPath, request) + } + throw error + } finally { + lifetime.dispose() + } + } + ) + // Why: terminal drag-and-drop resolver. Local worktrees pass paths through // unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees // upload each path into `${worktreePath}/.orca/drops/` and return remote diff --git a/src/main/ipc/filesystem-runtime-upload-staging.test.ts b/src/main/ipc/filesystem-runtime-upload-staging.test.ts new file mode 100644 index 00000000000..3fe70b4e1f2 --- /dev/null +++ b/src/main/ipc/filesystem-runtime-upload-staging.test.ts @@ -0,0 +1,166 @@ +import { lstat, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeImportLimits from './runtime-import-limits' + +type RuntimeImportLimitsModule = typeof RuntimeImportLimits + +vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) +// Why: real ceilings are gigabytes, and truncate() is not sparse on NTFS, so a +// literal over-limit fixture would allocate that much on Windows CI. +vi.mock('./runtime-import-limits', async (importOriginal) => ({ + ...(await importOriginal()), + REMOTE_IMPORT_MAX_FILE_BYTES: 4 * 1024, + REMOTE_IMPORT_MAX_TOTAL_BYTES: 16 * 1024 +})) + +const { stagedRuntimeUploadByteLength, stageOneSourceForRuntimeUpload } = + await import('./filesystem-runtime-upload-staging') + +let workDir: string + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-upload-staging-')) +}) + +afterEach(async () => { + await rm(workDir, { force: true, recursive: true }) +}) + +describe('stageOneSourceForRuntimeUpload', () => { + it('records size instead of file contents so staging never holds the body', async () => { + const filePath = join(workDir, 'note.txt') + await writeFile(filePath, 'hello world') + + const staged = await stageOneSourceForRuntimeUpload(filePath) + + expect(staged).toMatchObject({ + status: 'staged', + kind: 'file', + name: 'note.txt', + entries: [{ relativePath: '', kind: 'file', byteLength: 11 }] + }) + expect(JSON.stringify(staged)).not.toContain('contentBase64') + }) + + it('records the identity the uploader re-checks, not just the size', async () => { + const filePath = join(workDir, 'note.txt') + await writeFile(filePath, 'hello world') + const stat = await lstat(filePath) + + const staged = await stageOneSourceForRuntimeUpload(filePath) + + expect(staged).toMatchObject({ + status: 'staged', + entries: [ + { + byteLength: 11, + inode: stat.ino, + deviceId: stat.dev, + modifiedAtMs: stat.mtimeMs + } + ] + }) + }) + + it('stages a file with no cap error, where the old buffering path refused', async () => { + const filePath = join(workDir, 'big.bin') + await writeFile(filePath, Buffer.alloc(3 * 1024)) + + await expect(stageOneSourceForRuntimeUpload(filePath)).resolves.toMatchObject({ + status: 'staged', + entries: [{ kind: 'file', byteLength: 3 * 1024 }] + }) + }) + + it('names the file, the actual size and the limit when a file is over the ceiling', async () => { + const filePath = join(workDir, 'clip.mp4') + await writeFile(filePath, Buffer.alloc(6 * 1024)) + + const staged = await stageOneSourceForRuntimeUpload(filePath) + + expect(staged).toMatchObject({ status: 'failed' }) + // Why: a dropped file's relative path is '', so this is the regression that + // would otherwise report "'' is 6 KB, over the 4 KB ... limit". + expect(staged.status === 'failed' && staged.reason).toBe( + "'clip.mp4' is 6 KB, over the 4 KB per-file remote import limit" + ) + }) + + it('names the offending entry by its path inside a dropped directory', async () => { + const rootPath = join(workDir, 'media') + await mkdir(join(rootPath, 'clips'), { recursive: true }) + await writeFile(join(rootPath, 'clips', 'big.mp4'), Buffer.alloc(6 * 1024)) + + const staged = await stageOneSourceForRuntimeUpload(rootPath) + + expect(staged.status === 'failed' && staged.reason).toContain("'clips/big.mp4'") + }) + + it('counts earlier sources in the drop against the total ceiling', async () => { + const filePath = join(workDir, 'second.bin') + await writeFile(filePath, Buffer.alloc(3 * 1024)) + + // Alone it fits; after 14 KB of earlier sources the 16 KB drop ceiling is gone. + await expect(stageOneSourceForRuntimeUpload(filePath, 0)).resolves.toMatchObject({ + status: 'staged' + }) + const overBudget = await stageOneSourceForRuntimeUpload(filePath, 14 * 1024) + expect(overBudget).toMatchObject({ status: 'failed' }) + expect(overBudget.status === 'failed' && overBudget.reason).toContain( + 'total remote import limit' + ) + }) + + it('reports the bytes a source contributes to the drop budget', async () => { + const rootPath = join(workDir, 'tree') + await mkdir(join(rootPath, 'nested'), { recursive: true }) + await writeFile(join(rootPath, 'a.txt'), 'aa') + await writeFile(join(rootPath, 'nested', 'b.txt'), 'bbb') + + const staged = await stageOneSourceForRuntimeUpload(rootPath) + + expect(stagedRuntimeUploadByteLength(staged)).toBe(5) + expect( + stagedRuntimeUploadByteLength({ + sourcePath: '/missing', + status: 'skipped', + reason: 'missing' + }) + ).toBe(0) + }) + + // symlink() needs privileges or Developer Mode on Windows. + it.skipIf(process.platform === 'win32')('keeps rejecting symlinked sources', async () => { + const targetPath = join(workDir, 'target.txt') + await writeFile(targetPath, 'data') + const linkPath = join(workDir, 'link.txt') + await symlink(targetPath, linkPath) + + await expect(stageOneSourceForRuntimeUpload(linkPath)).resolves.toMatchObject({ + status: 'skipped', + reason: 'symlink' + }) + }) + + it('stages directory trees as metadata for every entry', async () => { + const rootPath = join(workDir, 'assets') + await mkdir(join(rootPath, 'nested'), { recursive: true }) + await writeFile(join(rootPath, 'a.txt'), 'aa') + await writeFile(join(rootPath, 'nested', 'b.txt'), 'bbb') + + const staged = await stageOneSourceForRuntimeUpload(rootPath) + + expect(staged.status).toBe('staged') + const entries = staged.status === 'staged' ? staged.entries : [] + expect(entries).toEqual( + expect.arrayContaining([ + { relativePath: '', kind: 'directory' }, + expect.objectContaining({ relativePath: 'a.txt', kind: 'file', byteLength: 2 }), + { relativePath: 'nested', kind: 'directory' }, + expect.objectContaining({ relativePath: 'nested/b.txt', kind: 'file', byteLength: 3 }) + ]) + ) + }) +}) diff --git a/src/main/ipc/filesystem-runtime-upload-staging.ts b/src/main/ipc/filesystem-runtime-upload-staging.ts index 5af76029b98..6531f8a499a 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.ts @@ -1,3 +1,8 @@ +import { + formatByteCeiling, + REMOTE_IMPORT_MAX_FILE_BYTES, + REMOTE_IMPORT_MAX_TOTAL_BYTES +} from './runtime-import-limits' import { constants } from 'node:fs' import { lstat, open, readdir, realpath } from 'node:fs/promises' import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' @@ -6,20 +11,33 @@ import { isENOENT } from './filesystem-path-containment' import type { StagedExternalImportEntry, StagedExternalImportSource -} from './filesystem-import-result-types' - -const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024 -const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 +} from '../../shared/filesystem-import-result-types' class RuntimeUploadSymlinkError extends Error {} +/** Bytes this source contributes to the drop budget; 0 unless it staged. */ +export function stagedRuntimeUploadByteLength(source: StagedExternalImportSource): number { + if (source.status !== 'staged') { + return 0 + } + return source.entries.reduce( + (total, entry) => (entry.kind === 'file' ? total + entry.byteLength : total), + 0 + ) +} + +/** + * @param totalBytesBefore Bytes already staged by earlier sources in the same drop, + * so the total ceiling covers the whole drop rather than each source alone. + */ export async function stageOneSourceForRuntimeUpload( - sourcePath: string + sourcePath: string, + totalBytesBefore = 0 ): Promise { const resolvedSource = resolve(sourcePath) // Why: runtime uploads read client-local paths in the client main process; - // authorize before lstat/readFile just like local copy imports. + // authorize before lstat just like local copy imports. authorizeExternalPath(resolvedSource) let sourceStat: Awaited> @@ -52,8 +70,8 @@ export async function stageOneSourceForRuntimeUpload( } try { const entries = sourceStat.isDirectory() - ? await stageDirectoryEntries(resolvedSource) - : [(await stageFileEntry(resolvedSource, '')).entry] + ? await stageDirectoryEntries(resolvedSource, totalBytesBefore) + : [(await stageFileEntry(resolvedSource, '', { totalBytesBefore })).entry] return { sourcePath, status: 'staged', @@ -73,9 +91,12 @@ export async function stageOneSourceForRuntimeUpload( } } -async function stageDirectoryEntries(rootPath: string): Promise { +async function stageDirectoryEntries( + rootPath: string, + totalBytesBefore: number +): Promise { const entries: StagedExternalImportEntry[] = [{ relativePath: '', kind: 'directory' }] - let totalBytes = 0 + let totalBytes = totalBytesBefore const rootRealPath = await realpath(rootPath) async function visit(dirPath: string): Promise { @@ -126,52 +147,52 @@ async function stageDirectoryEntries(rootPath: string): Promise { const statResult = await lstat(filePath) const displayPath = normalizeRelativeUploadPath(relativePath) + // Why: a dropped file's relative path is '', so errors would name nothing. + // The entry keeps '' — only the message falls back to the file's own name. + const displayName = displayPath || basename(filePath) if (statResult.isSymbolicLink()) { - throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${displayPath}'`) + throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${displayName}'`) } if (!statResult.isFile()) { - throw new Error(`Unsupported file type in '${displayPath}'`) + throw new Error(`Unsupported file type in '${displayName}'`) } - if (options?.rootRealPath) { - await assertRealPathInsideRoot(options.rootRealPath, filePath, displayPath) + if (options.rootRealPath) { + await assertRealPathInsideRoot(options.rootRealPath, filePath, displayName) } - const initialTotalBytes = - options?.totalBytesBefore === undefined - ? statResult.size - : options.totalBytesBefore + statResult.size - assertRemoteUploadBudget(relativePath, statResult.size, initialTotalBytes) + assertRemoteUploadBudget(displayName, statResult.size, options.totalBytesBefore + statResult.size) const fileHandle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) try { const openedStat = await fileHandle.stat() if (!openedStat.isFile()) { - throw new Error(`Unsupported file type in '${displayPath}'`) + throw new Error(`Unsupported file type in '${displayName}'`) } if ( openedStat.size !== statResult.size || (statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) || (statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev) ) { - throw new Error(`File changed during upload staging: '${displayPath}'`) - } - const totalBytes = - options?.totalBytesBefore === undefined - ? openedStat.size - : options.totalBytesBefore + openedStat.size - assertRemoteUploadBudget(relativePath, openedStat.size, totalBytes) - const buffer = await fileHandle.readFile() - const afterReadStat = await fileHandle.stat() - if (afterReadStat.size !== openedStat.size) { - throw new Error(`File changed during upload staging: '${displayPath}'`) + throw new Error(`File changed during upload staging: '${displayName}'`) } + assertRemoteUploadBudget( + displayName, + openedStat.size, + options.totalBytesBefore + openedStat.size + ) + // Why: bytes are read slice-by-slice at upload time, so staging records the + // identity the streamer re-checks rather than the body itself. Size alone + // would let a same-size replacement slip through between the two calls. return { entry: { relativePath: displayPath, kind: 'file', - contentBase64: buffer.toString('base64') + byteLength: openedStat.size, + inode: openedStat.ino, + deviceId: openedStat.dev, + modifiedAtMs: openedStat.mtimeMs }, byteLength: openedStat.size } @@ -197,15 +218,21 @@ async function assertRealPathInsideRoot( } function assertRemoteUploadBudget( - relativePath: string, + displayName: string, fileBytes: number, totalBytes: number ): void { if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { - throw new Error(`'${relativePath}' is too large for remote import`) + throw new Error( + `'${displayName}' is ${formatByteCeiling(fileBytes)}, over the ` + + `${formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)} per-file remote import limit` + ) } if (totalBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES) { - throw new Error('Remote import is too large') + throw new Error( + `This import is ${formatByteCeiling(totalBytes)}, over the ` + + `${formatByteCeiling(REMOTE_IMPORT_MAX_TOTAL_BYTES)} total remote import limit` + ) } } diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index d5de650f7ce..1cd92144660 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -183,6 +183,7 @@ describe('folder repo git upgrade watch', () => { expect(store.updateRepo).toHaveBeenCalledWith('folder-repo', { kind: 'git', + folderUpgradeGitRootPath: repoPath.replaceAll('\\', '/'), externalWorktreeVisibility: 'hide' }) expect(prepareLocalWorktreeRootForRepo).toHaveBeenCalledTimes(1) @@ -206,7 +207,10 @@ describe('folder repo git upgrade watch', () => { }) await tick() - expect(store.updateRepo).toHaveBeenCalledWith('folder-repo', { kind: 'git' }) + expect(store.updateRepo).toHaveBeenCalledWith('folder-repo', { + kind: 'git', + folderUpgradeGitRootPath: join(root, 'symlinked-project').replaceAll('\\', '/') + }) }) it('refuses a project that has folder workspaces the git listing would drop', async () => { diff --git a/src/main/ipc/folder-repo-git-upgrade.ts b/src/main/ipc/folder-repo-git-upgrade.ts index ede90ca6ab9..e393ae56158 100644 --- a/src/main/ipc/folder-repo-git-upgrade.ts +++ b/src/main/ipc/folder-repo-git-upgrade.ts @@ -101,7 +101,9 @@ function resolveRealPath(pathValue: string): string { * the path the user picked; when a symlinked parent makes those differ, the root reads * as an *external* worktree, and hiding those would hide the project's only workspace. */ -function resolveUpgrade(repoPath: string): { externalWorktreeVisibility?: 'hide' } | null { +function resolveUpgrade( + repoPath: string +): { folderUpgradeGitRootPath: string; externalWorktreeVisibility?: 'hide' } | null { if (!isGitRepo(repoPath)) { return null } @@ -110,8 +112,8 @@ function resolveUpgrade(repoPath: string): { externalWorktreeVisibility?: 'hide' return null } return normalizeRuntimePathForComparison(gitRoot) === normalizeRuntimePathForComparison(repoPath) - ? { externalWorktreeVisibility: 'hide' } - : {} + ? { folderUpgradeGitRootPath: gitRoot, externalWorktreeVisibility: 'hide' } + : { folderUpgradeGitRootPath: gitRoot } } type UpgradeResult = 'upgraded' | 'blocked' | 'rejected' diff --git a/src/main/ipc/renderer-lifetime-abort.test.ts b/src/main/ipc/renderer-lifetime-abort.test.ts new file mode 100644 index 00000000000..9e5b0f9a7d9 --- /dev/null +++ b/src/main/ipc/renderer-lifetime-abort.test.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it } from 'vitest' +import { + abortWhenRendererGone, + RENDERER_GONE_MESSAGE, + type RendererLifetimeSender +} from './renderer-lifetime-abort' + +function fakeSender(): RendererLifetimeSender & EventEmitter { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: EventEmitter implements the once/on/removeListener surface this helper uses, and those three are all it calls; WebContents' overloaded signatures cannot be satisfied structurally. + return new EventEmitter() as RendererLifetimeSender & EventEmitter +} + +describe('abortWhenRendererGone', () => { + it('aborts when the renderer is destroyed', () => { + const sender = fakeSender() + const { signal } = abortWhenRendererGone(sender) + + expect(signal.aborted).toBe(false) + sender.emit('destroyed') + + expect(signal.aborted).toBe(true) + expect(String(signal.reason)).toContain(RENDERER_GONE_MESSAGE) + }) + + it('aborts when the render process is gone', () => { + const sender = fakeSender() + const { signal } = abortWhenRendererGone(sender) + + sender.emit('render-process-gone') + + expect(signal.aborted).toBe(true) + }) + + it('aborts once a reload has replaced the document, not on in-app route changes', () => { + const sender = fakeSender() + const { signal } = abortWhenRendererGone(sender) + + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: true, + url: 'file:///app#x' + }) + sender.emit('did-navigate-in-page', 'file:///app#x') + expect(signal.aborted).toBe(false) + + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + url: 'file:///app' + }) + sender.emit('did-navigate', 'file:///app', 200, 'OK') + expect(signal.aborted).toBe(true) + }) + + it('ignores a main-frame navigation that starts but is blocked before it commits', () => { + // Why: Electron emits did-start-navigation before will-navigate gets to + // preventDefault() an external link or a stray file drop; the renderer + // document survives those, so the upload must too. + const sender = fakeSender() + const { signal } = abortWhenRendererGone(sender) + + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + url: 'https://example.invalid/' + }) + sender.emit('will-navigate', { defaultPrevented: true }, 'https://example.invalid/') + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + url: 'file:///Users/me/dropped.png' + }) + sender.emit('will-navigate', { defaultPrevented: true }, 'file:///Users/me/dropped.png') + + expect(signal.aborted).toBe(false) + }) + + it('leaves no listeners on a long-lived renderer once disposed', () => { + const sender = fakeSender() + const { dispose } = abortWhenRendererGone(sender) + + expect(sender.listenerCount('destroyed')).toBe(1) + dispose() + dispose() + + expect(sender.listenerCount('destroyed')).toBe(0) + expect(sender.listenerCount('render-process-gone')).toBe(0) + expect(sender.listenerCount('did-navigate')).toBe(0) + }) +}) diff --git a/src/main/ipc/renderer-lifetime-abort.ts b/src/main/ipc/renderer-lifetime-abort.ts new file mode 100644 index 00000000000..213abab8f47 --- /dev/null +++ b/src/main/ipc/renderer-lifetime-abort.ts @@ -0,0 +1,45 @@ +import type { WebContents } from 'electron' + +export type RendererLifetimeSender = Pick + +export const RENDERER_GONE_MESSAGE = 'The window that started this upload went away' + +/** + * Abort signal that fires when the calling renderer goes away. + * + * Work the renderer used to do itself died with it. Once it moves into main, + * nothing stops a long transfer from outliving the window that asked for it, + * so the caller's lifetime has to be wired up explicitly. + * + * Always `dispose()` in a finally — otherwise every call leaks a listener on a + * long-lived WebContents. + */ +export function abortWhenRendererGone(sender: RendererLifetimeSender): { + signal: AbortSignal + dispose: () => void +} { + const controller = new AbortController() + const abort = (): void => controller.abort(new Error(RENDERER_GONE_MESSAGE)) + let disposed = false + + sender.once('destroyed', abort) + sender.once('render-process-gone', abort) + // Why: did-start-navigation also fires for navigations that will-navigate then + // blocks — an external link, a stray file drop — and the renderer survives + // those. did-navigate fires only once a new document has replaced the caller, + // and never for same-document route changes inside the live app. + sender.once('did-navigate', abort) + + return { + signal: controller.signal, + dispose: () => { + if (disposed) { + return + } + disposed = true + sender.removeListener('destroyed', abort) + sender.removeListener('render-process-gone', abort) + sender.removeListener('did-navigate', abort) + } + } +} diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index d461b1d080a..84d2754d556 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -27,7 +27,8 @@ import { import { clearRuntimeEnvironmentManualDisconnect, isRuntimeEnvironmentManuallyDisconnected, - markRuntimeEnvironmentManuallyDisconnected + markRuntimeEnvironmentManuallyDisconnected, + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE } from './runtime-environment-manual-disconnect' import { callRuntimeEnvironment, @@ -42,7 +43,7 @@ function manuallyDisconnectedResponse( ok: false, error: { code: 'runtime_manually_disconnected', - message: 'Runtime environment is manually disconnected.' + message: RUNTIME_MANUALLY_DISCONNECTED_MESSAGE }, _meta: { runtimeId: environment.runtimeId } } diff --git a/src/main/ipc/runtime-environment-manual-disconnect.ts b/src/main/ipc/runtime-environment-manual-disconnect.ts index f9f94e7f438..31c300895df 100644 --- a/src/main/ipc/runtime-environment-manual-disconnect.ts +++ b/src/main/ipc/runtime-environment-manual-disconnect.ts @@ -1,5 +1,7 @@ const manuallyDisconnectedEnvironmentIds = new Set() +export const RUNTIME_MANUALLY_DISCONNECTED_MESSAGE = 'Runtime environment is manually disconnected.' + export function markRuntimeEnvironmentManuallyDisconnected(environmentId: string): void { manuallyDisconnectedEnvironmentIds.add(environmentId) } diff --git a/src/main/ipc/runtime-import-limits.test.ts b/src/main/ipc/runtime-import-limits.test.ts new file mode 100644 index 00000000000..2eaeae603b0 --- /dev/null +++ b/src/main/ipc/runtime-import-limits.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + formatByteCeiling, + REMOTE_IMPORT_MAX_FILE_BYTES, + REMOTE_IMPORT_MAX_TOTAL_BYTES +} from './runtime-import-limits' + +describe('formatByteCeiling', () => { + it('renders a size one byte over a ceiling as larger than the ceiling', () => { + // "is 2 GB, over the 2 GB limit" reads like a broken check, not a big file. + expect(formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)).toBe('2 GB') + expect(formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES + 1)).toBe('2.1 GB') + }) + + it('leaves an exact ceiling as a whole number', () => { + expect(formatByteCeiling(REMOTE_IMPORT_MAX_TOTAL_BYTES)).toBe('8 GB') + expect(formatByteCeiling(1024)).toBe('1 KB') + }) + + it('scales through the units', () => { + expect(formatByteCeiling(512)).toBe('512 B') + expect(formatByteCeiling(1024 * 1024)).toBe('1 MB') + expect(formatByteCeiling(1024 ** 4)).toBe('1 TB') + }) + + it('rounds up rather than to nearest', () => { + expect(formatByteCeiling(1024 * 1024 + 1)).toBe('1.1 MB') + }) + + it('does not crash on zero', () => { + expect(formatByteCeiling(0)).toBe('0 B') + }) +}) diff --git a/src/main/ipc/runtime-import-limits.ts b/src/main/ipc/runtime-import-limits.ts new file mode 100644 index 00000000000..80fd680a24e --- /dev/null +++ b/src/main/ipc/runtime-import-limits.ts @@ -0,0 +1,18 @@ +// Why: staging streams slices at upload time and never holds a whole file, so +// these are user-safety ceilings on an unattended transfer, not memory guards. +// They stay until the drop UI can show progress and cancel a running upload. +export const REMOTE_IMPORT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024 +export const REMOTE_IMPORT_MAX_TOTAL_BYTES = 8 * 1024 * 1024 * 1024 + +/** Rounds up, so a size over a ceiling never renders as the ceiling itself. */ +export function formatByteCeiling(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + const rounded = Math.ceil(value * 10) / 10 + return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)} ${units[unit]}` +} diff --git a/src/main/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts new file mode 100644 index 00000000000..4d11bc18652 --- /dev/null +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -0,0 +1,438 @@ +import { lstat, mkdtemp, mkdir, rename, rm, symlink, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StagedRuntimeUploadFileIdentity } from '../../shared/runtime-upload-staging-contract' +import type * as RuntimeImportLimits from './runtime-import-limits' + +type RuntimeImportLimitsModule = typeof RuntimeImportLimits + +type ChunkCall = { + relativePath: string + contentBase64: string + append: boolean + expectedSshTargetId?: string + expectedSshConnectionGeneration?: number + expectedExecutionHostId?: string +} +type RuntimeCallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal } + +const callRuntimeEnvironment = + vi.fn< + ( + userDataPath: string, + environmentId: string, + method: string, + params: ChunkCall, + timeoutMs?: number, + expectedEnvironmentPairingRevision?: number, + envelope?: unknown, + options?: RuntimeCallOptions + ) => unknown + >() + +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: (...args: Parameters) => + callRuntimeEnvironment(...args) +})) +vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) +// Why: see filesystem-runtime-upload-staging.test.ts — a real over-limit fixture +// would allocate gigabytes on Windows. +vi.mock('./runtime-import-limits', async (importOriginal) => ({ + ...(await importOriginal()), + REMOTE_IMPORT_MAX_FILE_BYTES: 2 * 1024 * 1024 +})) + +const { RUNTIME_UPLOAD_SLICE_BYTES, streamExternalFileToRuntime } = + await import('./runtime-upload-file-stream') +const { + clearRuntimeEnvironmentManualDisconnect, + markRuntimeEnvironmentManuallyDisconnected, + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE +} = await import('./runtime-environment-manual-disconnect') + +let workDir: string + +function chunkCalls(): ChunkCall[] { + return callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , params]) => params) +} + +function uploadedBytes(): Buffer { + return Buffer.concat(chunkCalls().map((call) => Buffer.from(call.contentBase64, 'base64'))) +} + +/** Mirrors what staging records, so tests exercise the real identity contract. */ +async function stagedIdentity(filePath: string): Promise { + const stat = await lstat(filePath) + return { + byteLength: stat.size, + inode: stat.ino, + deviceId: stat.dev, + modifiedAtMs: stat.mtimeMs + } +} + +async function baseArgs(sourceRootPath: string, entryPath?: string) { + return { + userDataPath: '/user-data', + environmentId: 'env-1', + sourceRootPath, + entryRelativePath: entryPath ?? '', + expected: await stagedIdentity(entryPath ? join(sourceRootPath, entryPath) : sourceRootPath), + worktree: 'wt-1', + relativePath: '.upload.tmp' + } +} + +/** A path whose identity was never measured; every field is deliberately absent. */ +function unstagedArgs(sourceRootPath: string, entryPath?: string) { + return { + userDataPath: '/user-data', + environmentId: 'env-1', + sourceRootPath, + entryRelativePath: entryPath ?? '', + expected: { byteLength: 0, inode: 0, deviceId: 0, modifiedAtMs: 0 }, + worktree: 'wt-1', + relativePath: '.upload.tmp' + } +} + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-upload-stream-')) + callRuntimeEnvironment.mockReset() + callRuntimeEnvironment.mockResolvedValue({ id: 'x', ok: true, result: {}, _meta: {} }) +}) + +afterEach(async () => { + await rm(workDir, { force: true, recursive: true }) +}) + +describe('streamExternalFileToRuntime', () => { + it('sends a file larger than the old 25 MB cap as ordered append-only slices', async () => { + const size = RUNTIME_UPLOAD_SLICE_BYTES * 2 + 1234 + const contents = Buffer.alloc(size) + for (let index = 0; index < size; index += 1) { + contents[index] = index % 251 + } + const filePath = join(workDir, 'big.bin') + await writeFile(filePath, contents) + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).resolves.toEqual({ + byteLength: size + }) + + const calls = chunkCalls() + expect(calls).toHaveLength(3) + expect(calls.map((call) => call.append)).toEqual([false, true, true]) + expect(uploadedBytes().equals(contents)).toBe(true) + }) + + it('refuses a source whose size no longer matches what staging measured', async () => { + const filePath = join(workDir, 'grown.bin') + await writeFile(filePath, Buffer.alloc(1024)) + const staged = await stagedIdentity(filePath) + await writeFile(filePath, Buffer.alloc(2048)) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), expected: staged }) + ).rejects.toThrow("File changed since it was staged: 'grown.bin'") + expect(chunkCalls()).toHaveLength(0) + }) + + it('refuses a source swapped for a different file of the same size', async () => { + const filePath = join(workDir, 'swapped.bin') + await writeFile(filePath, Buffer.alloc(2048, 0x41)) + const staged = await stagedIdentity(filePath) + + // A rename-into-place keeps the size and changes the inode. + const decoyPath = join(workDir, 'decoy.bin') + await writeFile(decoyPath, Buffer.alloc(2048, 0x42)) + await rename(decoyPath, filePath) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), expected: staged }) + ).rejects.toThrow('File changed since it was staged') + expect(chunkCalls()).toHaveLength(0) + }) + + it('refuses a source rewritten in place at the same size after staging', async () => { + const filePath = join(workDir, 'rewritten.bin') + await writeFile(filePath, Buffer.alloc(2048, 0x41)) + const staged = await stagedIdentity(filePath) + + // Same inode and size; only the modification time moves. + await writeFile(filePath, Buffer.alloc(2048, 0x42)) + const bumped = new Date(staged.modifiedAtMs + 5_000) + await utimes(filePath, bumped, bumped) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), expected: staged }) + ).rejects.toThrow('File changed since it was staged') + expect(chunkCalls()).toHaveLength(0) + }) + + it('aborts when the source is rewritten at the same size mid-transfer', async () => { + const filePath = join(workDir, 'racing.bin') + const size = RUNTIME_UPLOAD_SLICE_BYTES * 2 + await writeFile(filePath, Buffer.alloc(size, 0x41)) + const args = await baseArgs(filePath) + + let rewritten = false + callRuntimeEnvironment.mockImplementation(async () => { + if (!rewritten) { + rewritten = true + await writeFile(filePath, Buffer.alloc(size, 0x42)) + const bumped = new Date(args.expected.modifiedAtMs + 5_000) + await utimes(filePath, bumped, bumped) + } + return { id: 'x', ok: true, result: {}, _meta: {} } + }) + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow('File changed during upload') + }) + + it('accepts a source that still matches its staged identity', async () => { + const filePath = join(workDir, 'same.bin') + await writeFile(filePath, Buffer.alloc(2048)) + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).resolves.toEqual({ + byteLength: 2048 + }) + }) + + it('refuses a file over the ceiling and names the source, not the temp path', async () => { + const filePath = join(workDir, 'clip.mp4') + await writeFile(filePath, Buffer.alloc(3 * 1024 * 1024)) + + // Why: relativePath here is '.upload.tmp', a path the user never chose. + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow( + "'clip.mp4' is 3 MB, over the 2 MB per-file remote import limit" + ) + expect(chunkCalls()).toHaveLength(0) + }) + + it('never buffers more than one slice per chunk', async () => { + const filePath = join(workDir, 'sliced.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 2)) + + await streamExternalFileToRuntime(await baseArgs(filePath)) + + for (const call of chunkCalls()) { + expect(Buffer.from(call.contentBase64, 'base64').byteLength).toBeLessThanOrEqual( + RUNTIME_UPLOAD_SLICE_BYTES + ) + } + }) + + it('creates an empty destination for a zero-byte source', async () => { + const filePath = join(workDir, 'empty.txt') + await writeFile(filePath, '') + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).resolves.toEqual({ + byteLength: 0 + }) + + expect(chunkCalls()).toEqual([expect.objectContaining({ append: false, contentBase64: '' })]) + }) + + it('refuses to finish a zero-byte upload whose source gained content mid-write', async () => { + const filePath = join(workDir, 'grows.txt') + await writeFile(filePath, '') + const args = await baseArgs(filePath) + + callRuntimeEnvironment.mockImplementation(async () => { + await writeFile(filePath, 'content arrived during the empty write') + return { id: 'x', ok: true, result: {}, _meta: {} } + }) + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow('File changed during upload') + }) + + it('carries the pairing revision and runtime id on every chunk', async () => { + const filePath = join(workDir, 'guarded.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES + 10)) + + await streamExternalFileToRuntime({ + ...(await baseArgs(filePath)), + expectedEnvironmentPairingRevision: 41, + expectedEnvironmentRuntimeId: 'runtime-7' + }) + + const guards = callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , , , revision, , options]) => ({ + revision, + runtimeId: options?.expectedEnvironmentRuntimeId + })) + expect(guards).toEqual([ + { revision: 41, runtimeId: 'runtime-7' }, + { revision: 41, runtimeId: 'runtime-7' } + ]) + }) + + it('stops mid-transfer when the caller aborts instead of streaming the rest', async () => { + const filePath = join(workDir, 'abandoned.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 4)) + const controller = new AbortController() + + callRuntimeEnvironment.mockImplementation(async () => { + controller.abort(new Error('window closed')) + return { id: 'x', ok: true, result: {}, _meta: {} } + }) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), signal: controller.signal }) + ).rejects.toThrow('window closed') + // One slice went out before the abort; the other three never do. + expect(chunkCalls()).toHaveLength(1) + }) + + it('refuses to start once the caller has already aborted', async () => { + const filePath = join(workDir, 'never.bin') + await writeFile(filePath, Buffer.alloc(1024)) + const controller = new AbortController() + controller.abort(new Error('window closed')) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), signal: controller.signal }) + ).rejects.toThrow('window closed') + expect(chunkCalls()).toHaveLength(0) + }) + + it('passes the abort signal to every chunk so an in-flight request is cancelled', async () => { + const filePath = join(workDir, 'signalled.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES + 10)) + const controller = new AbortController() + + await streamExternalFileToRuntime({ + ...(await baseArgs(filePath)), + signal: controller.signal + }) + + const signals = callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , , , , , options]) => options?.signal) + expect(signals).toEqual([controller.signal, controller.signal]) + }) + + it('stops at the failing chunk instead of sending the rest of the file', async () => { + const filePath = join(workDir, 'fails.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 3)) + callRuntimeEnvironment.mockResolvedValueOnce({ id: 'x', ok: true, result: {}, _meta: {} }) + callRuntimeEnvironment.mockResolvedValueOnce({ + id: 'x', + ok: false, + error: { code: 'write_failed', message: 'disk full' } + }) + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow('disk full') + expect(chunkCalls()).toHaveLength(2) + }) + + // symlink() needs privileges or Developer Mode on Windows. + it.skipIf(process.platform === 'win32')('refuses a symlinked source', async () => { + const targetPath = join(workDir, 'secret.txt') + await writeFile(targetPath, 'secret') + const linkPath = join(workDir, 'link.txt') + await symlink(targetPath, linkPath) + + await expect(streamExternalFileToRuntime(unstagedArgs(linkPath))).rejects.toThrow( + 'Symlink not allowed' + ) + expect(chunkCalls()).toHaveLength(0) + }) + + it.skipIf(process.platform === 'win32')( + 'refuses a regular file reached through a symlinked directory inside the root', + async () => { + // Why: the symlink guard only lstats the entry itself, which sees a plain + // file here — realpath containment is the only thing that catches this. + const outsideDir = join(workDir, 'outside') + await mkdir(outsideDir) + await writeFile(join(outsideDir, 'secret.txt'), 'secret') + const rootPath = join(workDir, 'root') + await mkdir(rootPath) + await symlink(outsideDir, join(rootPath, 'sub')) + + await expect( + streamExternalFileToRuntime(unstagedArgs(rootPath, 'sub/secret.txt')) + ).rejects.toThrow('Path escaped upload root during upload') + expect(chunkCalls()).toHaveLength(0) + } + ) + + it('forwards the host ownership expectations into every chunk', async () => { + const filePath = join(workDir, 'owned.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES + 10)) + + await streamExternalFileToRuntime({ + ...(await baseArgs(filePath)), + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 5, + expectedExecutionHostId: 'ssh:ssh-1' + }) + + const calls = callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , params]) => params) + expect(calls).toHaveLength(2) + for (const params of calls) { + expect(params).toMatchObject({ + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 5, + expectedExecutionHostId: 'ssh:ssh-1' + }) + } + }) + + it.skipIf(process.platform === 'win32')( + 'refuses a symlinked directory entry before it reaches the containment check', + async () => { + const outsidePath = join(workDir, 'outside.txt') + await writeFile(outsidePath, 'outside') + const rootPath = join(workDir, 'root') + await mkdir(rootPath) + await symlink(outsidePath, join(rootPath, 'escape.txt')) + + await expect( + streamExternalFileToRuntime(unstagedArgs(rootPath, 'escape.txt')) + ).rejects.toThrow('Symlink not allowed') + expect(chunkCalls()).toHaveLength(0) + } + ) +}) + +describe('manual disconnect during a transfer', () => { + afterEach(() => { + clearRuntimeEnvironmentManualDisconnect('env-1') + }) + + it('stops at the next slice once the environment is manually disconnected', async () => { + const filePath = join(workDir, 'disconnect.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 3, 7)) + callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => { + if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) { + markRuntimeEnvironmentManuallyDisconnected('env-1') + } + return { id: 'x', ok: true, result: {}, _meta: {} } + }) + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow( + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE + ) + expect(chunkCalls()).toHaveLength(1) + }) + + it('refuses the first slice when the environment is already disconnected', async () => { + const filePath = join(workDir, 'disconnected.bin') + await writeFile(filePath, Buffer.alloc(16, 1)) + markRuntimeEnvironmentManuallyDisconnected('env-1') + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow( + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE + ) + expect(chunkCalls()).toHaveLength(0) + }) +}) diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts new file mode 100644 index 00000000000..28271774ce0 --- /dev/null +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -0,0 +1,213 @@ +import { constants, type Stats } from 'node:fs' +import { lstat, open, realpath } from 'node:fs/promises' +import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' +import type { + RuntimeUploadFileStreamRequest, + StagedRuntimeUploadFileIdentity +} from '../../shared/runtime-upload-staging-contract' +import { authorizeExternalPath } from './filesystem-auth' +import { formatByteCeiling, REMOTE_IMPORT_MAX_FILE_BYTES } from './runtime-import-limits' +import { + isRuntimeEnvironmentManuallyDisconnected, + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE +} from './runtime-environment-manual-disconnect' +import { callRuntimeEnvironment } from './runtime-environment-transport-routing' + +// Why: base64 turns 3 bytes into 4 chars, so a 384 KiB slice lands on the wire +// as exactly 512 KiB — the chunk size the renderer used before streaming. +export const RUNTIME_UPLOAD_SLICE_BYTES = 384 * 1024 + +const RUNTIME_UPLOAD_CHUNK_TIMEOUT_MS = 30_000 + +export type RuntimeUploadFileStreamArgs = RuntimeUploadFileStreamRequest & { + /** Resolved environment id, not a selector: the manual-disconnect check keys on it. */ + environmentId: string + userDataPath: string + /** Aborts the transfer; the caller's lifetime is what raises it today. */ + signal?: AbortSignal +} + +/** + * Stream one client-local file to a runtime environment in slices. + * + * Replaces reading the whole file into memory and base64-encoding it before the + * first byte moves. Peak memory is one slice, so imports are no longer bounded + * by main-process heap. + */ +export async function streamExternalFileToRuntime( + args: RuntimeUploadFileStreamArgs +): Promise<{ byteLength: number }> { + const sourcePath = resolveEntrySourcePath(args.sourceRootPath, args.entryRelativePath) + + // Why: parity with staging — an OS drop authorizes the paths it hands over. + authorizeExternalPath(sourcePath) + + // Why: relativePath is the hidden .orca-upload- temp destination, so a + // dropped file names its source instead of a path the user never chose. + const displayPath = args.entryRelativePath || basename(args.sourceRootPath) + const lstatResult = await lstat(sourcePath) + if (lstatResult.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${displayPath}'`) + } + if (!lstatResult.isFile()) { + throw new Error(`Unsupported file type in '${displayPath}'`) + } + if (args.entryRelativePath) { + await assertEntryInsideRoot(args.sourceRootPath, sourcePath, displayPath) + } + assertMatchesStagedIdentity(lstatResult, args.expected, displayPath) + + args.signal?.throwIfAborted() + + const handle = await open(sourcePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const openedStat = await handle.stat() + if (!openedStat.isFile()) { + throw new Error(`Unsupported file type in '${displayPath}'`) + } + if (!isSameFile(openedStat, lstatResult)) { + throw new Error(`File changed during upload: '${displayPath}'`) + } + // Why: the handle is what the slices are read from, so the staged identity + // has to hold here too — checking only the pre-open lstat leaves a window + // where the path is swapped between lstat and open. + assertMatchesStagedIdentity(openedStat, args.expected, displayPath) + + const totalBytes = openedStat.size + // Why: enforced again where the bytes actually move. Staging is a separate + // call, so the ceiling only holds here if this boundary checks it too. + if (totalBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { + throw new Error( + `'${displayPath}' is ${formatByteCeiling(totalBytes)}, over the ` + + `${formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)} per-file remote import limit` + ) + } + if (totalBytes === 0) { + // Why: a zero-byte source produces no slices, but the destination still + // has to exist before commitUpload renames it into place. + await sendChunk(args, '', false) + } else { + const buffer = Buffer.allocUnsafe(Math.min(RUNTIME_UPLOAD_SLICE_BYTES, totalBytes)) + let offset = 0 + while (offset < totalBytes) { + // Why: checked per slice, so an abort stops the transfer at the next + // boundary instead of after the whole file has moved. + args.signal?.throwIfAborted() + const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset) + if (bytesRead === 0) { + throw new Error(`File truncated during upload: '${displayPath}'`) + } + await sendChunk(args, buffer.subarray(0, bytesRead).toString('base64'), offset > 0) + offset += bytesRead + } + } + + // Why: the destination is a temp path the caller commits, so a source + // rewritten mid-transfer is caught before anything lands at the final path. + // mtime catches an in-place edit that kept the size. An empty source runs + // this too: its chunk is still a round trip the source can change during. + const afterReadStat = await handle.stat() + if (afterReadStat.mtimeMs !== openedStat.mtimeMs || !isSameFile(afterReadStat, openedStat)) { + throw new Error(`File changed during upload: '${displayPath}'`) + } + return { byteLength: totalBytes } + } finally { + await handle.close() + } +} + +/** + * Refuse a source that no longer matches what staging measured. + * + * Inode and device are compared only when both sides report one, because some + * filesystems leave them at 0; size and mtime then carry the check alone. + */ +function assertMatchesStagedIdentity( + observed: Stats, + expected: StagedRuntimeUploadFileIdentity, + displayPath: string +): void { + const changed = + observed.size !== expected.byteLength || + observed.mtimeMs !== expected.modifiedAtMs || + (expected.inode !== 0 && observed.ino !== 0 && observed.ino !== expected.inode) || + (expected.deviceId !== 0 && observed.dev !== 0 && observed.dev !== expected.deviceId) + if (changed) { + throw new Error(`File changed since it was staged: '${displayPath}'`) + } +} + +/** Same inode on the same device, where the filesystem reports them. */ +function isSameFile(a: Stats, b: Stats): boolean { + return ( + a.size === b.size && + (a.ino === 0 || b.ino === 0 || a.ino === b.ino) && + (a.dev === 0 || b.dev === 0 || a.dev === b.dev) + ) +} + +/** Append one base64 slice, carrying the host guards that must hold per chunk. */ +async function sendChunk( + args: RuntimeUploadFileStreamArgs, + contentBase64: string, + append: boolean +): Promise { + // Why: the renderer's per-chunk calls went through an IPC handler that refuses + // a manually disconnected environment. The loop lives in main now, so it makes + // the same check, or a disconnect mid-upload keeps pushing bytes to that host. + if (isRuntimeEnvironmentManuallyDisconnected(args.environmentId)) { + throw new Error(RUNTIME_MANUALLY_DISCONNECTED_MESSAGE) + } + const response = await callRuntimeEnvironment( + args.userDataPath, + args.environmentId, + 'files.writeBase64Chunk', + { + worktree: args.worktree, + relativePath: args.relativePath, + contentBase64, + append, + expectedSshTargetId: args.expectedSshTargetId, + expectedSshConnectionGeneration: args.expectedSshConnectionGeneration, + expectedExecutionHostId: args.expectedExecutionHostId + }, + RUNTIME_UPLOAD_CHUNK_TIMEOUT_MS, + // Why: re-checked per chunk, so a re-pair mid-upload aborts instead of + // appending the rest of the file on a different host. + args.expectedEnvironmentPairingRevision, + undefined, + { + // Why: a replacement runtime keeps the pairing but invalidates its + // predecessor's capability proof, so the identity rides every chunk too. + expectedEnvironmentRuntimeId: args.expectedEnvironmentRuntimeId, + signal: args.signal + } + ) + if (response.ok !== true) { + throw new Error(response.error.message || response.error.code) + } +} + +function resolveEntrySourcePath(sourceRootPath: string, entryRelativePath: string): string { + // Why: staging resolves before authorizing, so the streamer has to agree on + // the same absolute path or the two checks can disagree. + const root = resolve(sourceRootPath) + return entryRelativePath ? join(root, entryRelativePath) : root +} + +async function assertEntryInsideRoot( + sourceRootPath: string, + candidatePath: string, + displayPath: string +): Promise { + const rootRealPath = await realpath(sourceRootPath) + const candidateRealPath = await realpath(candidatePath) + const relativeToRoot = relative(rootRealPath, candidateRealPath) + // Why: `..name` is a valid child path; only `..` and `../...` escape. + if ( + relativeToRoot !== '' && + (relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) + ) { + throw new Error(`Path escaped upload root during upload: '${displayPath}'`) + } +} diff --git a/src/main/ipc/runtime-upload-slice-boundaries.test.ts b/src/main/ipc/runtime-upload-slice-boundaries.test.ts new file mode 100644 index 00000000000..0c2bd457b1f --- /dev/null +++ b/src/main/ipc/runtime-upload-slice-boundaries.test.ts @@ -0,0 +1,383 @@ +import { + appendFile, + mkdir, + mkdtemp, + readFile, + rm, + stat, + truncate, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FileWriteBase64Chunk } from '../../shared/rpc-contract/files-mutation-params' +import type { StagedRuntimeUploadFileIdentity } from '../../shared/runtime-upload-staging-contract' + +// Why: real limits, real host write flags ('wx' then 'a') and the real chunk +// schema — the slice loop is exercised exactly at the boundaries it must respect. +vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) + +type ChunkParams = { relativePath: string; contentBase64: string; append: boolean } +type CallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal } +type CallArgs = [ + userDataPath: string, + environmentId: string, + method: string, + params: ChunkParams, + timeoutMs?: number, + expectedEnvironmentPairingRevision?: number, + envelope?: unknown, + options?: CallOptions +] + +const callRuntimeEnvironment = vi.fn<(...args: CallArgs) => Promise>() +// Why: vi.fn retains every call's params; a 2 GiB stream would pin ~2.8 GB of +// base64 in mock.calls and masquerade as a leak. Big tests swap in a plain fn. +let transportImpl: (...args: CallArgs) => Promise = (...args) => + callRuntimeEnvironment(...args) +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: (...args: CallArgs) => transportImpl(...args) +})) + +const { RUNTIME_UPLOAD_SLICE_BYTES, streamExternalFileToRuntime } = + await import('./runtime-upload-file-stream') +const { stageOneSourceForRuntimeUpload } = await import('./filesystem-runtime-upload-staging') +const { REMOTE_IMPORT_MAX_FILE_BYTES, REMOTE_IMPORT_MAX_TOTAL_BYTES, formatByteCeiling } = + await import('./runtime-import-limits') + +const SLICE = RUNTIME_UPLOAD_SLICE_BYTES +const WIRE_CHUNK_CHARS = 512 * 1024 +const OK = { id: 'x', ok: true, result: {}, _meta: {} } + +let workDir: string +let remoteDir: string + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-upload-bounds-')) + remoteDir = join(workDir, 'remote') + await mkdir(remoteDir) + callRuntimeEnvironment.mockReset() + callRuntimeEnvironment.mockResolvedValue(OK) + transportImpl = (...args) => callRuntimeEnvironment(...args) +}) + +afterEach(async () => { + await rm(workDir, { force: true, recursive: true }) +}) + +function chunkCalls(): ChunkParams[] { + return callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , params]) => params) +} + +/** Mirrors the host: first chunk is an exclusive create, appends open with 'a'. */ +function installRealHostWrites(): void { + callRuntimeEnvironment.mockImplementation(async (_u, _e, method, params) => { + if (method === 'files.writeBase64Chunk') { + const parsed = FileWriteBase64Chunk.parse({ worktree: 'wt-1', ...params }) + await writeFile( + join(remoteDir, parsed.relativePath), + Buffer.from(parsed.contentBase64, 'base64'), + { + flag: parsed.append ? 'a' : 'wx' + } + ) + } + return OK + }) +} + +async function identityOf(path: string): Promise { + const s = await stat(path) + return { byteLength: s.size, inode: s.ino, deviceId: s.dev, modifiedAtMs: s.mtimeMs } +} + +async function argsFor(sourceRootPath: string, entryRelativePath = '', relativePath = 'dest.tmp') { + const target = entryRelativePath ? join(sourceRootPath, entryRelativePath) : sourceRootPath + return { + userDataPath: '/user-data', + environmentId: 'env-1', + sourceRootPath, + entryRelativePath, + expected: await identityOf(target), + worktree: 'wt-1', + relativePath, + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'rt-1' + } +} + +function patterned(size: number, seed: number): Buffer { + const buffer = Buffer.allocUnsafe(size) + for (let i = 0; i < size; i += 1) { + buffer[i] = (i * 31 + seed) & 0xff + } + return buffer +} + +describe('slice boundaries', () => { + const sizes = [ + 1, + 2, + 3, + 4, + SLICE - 1, + SLICE, + SLICE + 1, + 2 * SLICE - 1, + 2 * SLICE, + 2 * SLICE + 1, + 3 * SLICE + 7 + ] + + for (const size of sizes) { + it(`streams ${size} bytes as ceil(size/slice) schema-valid chunks that the host reassembles exactly`, async () => { + installRealHostWrites() + const contents = patterned(size, size) + const source = join(workDir, `s-${size}.bin`) + await writeFile(source, contents) + const dest = `dest-${size}.tmp` + + await expect(streamExternalFileToRuntime(await argsFor(source, '', dest))).resolves.toEqual({ + byteLength: size + }) + + const calls = chunkCalls() + const expectedChunks = Math.ceil(size / SLICE) + expect(calls).toHaveLength(expectedChunks) + expect(calls.map((c) => c.append)).toEqual(calls.map((_, i) => i > 0)) + for (const [index, call] of calls.entries()) { + const isLast = index === calls.length - 1 + expect(call.contentBase64.length).toBeLessThanOrEqual(WIRE_CHUNK_CHARS) + if (!isLast) { + expect(call.contentBase64.length).toBe(WIRE_CHUNK_CHARS) + } + expect(call.relativePath).toBe(dest) + } + const remote = await readFile(join(remoteDir, dest)) + expect(remote.equals(contents)).toBe(true) + }) + } + + it('sends a zero-byte file as one empty exclusive create the host schema accepts', async () => { + installRealHostWrites() + const source = join(workDir, 'empty.bin') + await writeFile(source, '') + + await expect( + streamExternalFileToRuntime(await argsFor(source, '', 'empty.tmp')) + ).resolves.toEqual({ + byteLength: 0 + }) + expect(chunkCalls()).toHaveLength(1) + expect(chunkCalls()[0]).toMatchObject({ + relativePath: 'empty.tmp', + contentBase64: '', + append: false + }) + expect((await stat(join(remoteDir, 'empty.tmp'))).size).toBe(0) + }) + + it('carries the pairing revision, runtime id and signal on every chunk', async () => { + const source = join(workDir, 'guards.bin') + await writeFile(source, patterned(2 * SLICE + 1, 3)) + + await streamExternalFileToRuntime(await argsFor(source)) + + const chunkInvocations = callRuntimeEnvironment.mock.calls.filter( + ([, , method]) => method === 'files.writeBase64Chunk' + ) + expect(chunkInvocations).toHaveLength(3) + for (const [, environmentId, , , timeoutMs, revision, envelope, options] of chunkInvocations) { + expect(environmentId).toBe('env-1') + expect(timeoutMs).toBe(30_000) + expect(revision).toBe(7) + expect(envelope).toBeUndefined() + expect(options?.expectedEnvironmentRuntimeId).toBe('rt-1') + } + }) +}) + +describe('staging → streaming end to end on a real filesystem', () => { + it('streams every staged entry of a dropped directory using the identity staging recorded', async () => { + installRealHostWrites() + const root = join(workDir, 'drop me') + await mkdir(join(root, 'sub', 'deeper'), { recursive: true }) + const files: Record = { + 'a.txt': Buffer.from('alpha'), + '..keep': Buffer.from('dot-dot-prefixed name is a valid child'), + 'héllo wörld.bin': patterned(SLICE, 9), + 'sub/empty': Buffer.alloc(0), + 'sub/deeper/big.bin': patterned(2 * SLICE + 5, 11) + } + for (const [rel, body] of Object.entries(files)) { + await writeFile(join(root, rel), body) + } + + const staged = await stageOneSourceForRuntimeUpload(root) + expect(staged.status).toBe('staged') + if (staged.status !== 'staged') { + return + } + const fileEntries = staged.entries.filter((e) => e.kind === 'file') + expect(fileEntries.map((e) => e.relativePath).sort()).toEqual(Object.keys(files).sort()) + + for (const entry of fileEntries) { + if (entry.kind !== 'file') { + continue + } + const dest = `up-${entry.relativePath.replace(/[^a-z0-9]/gi, '_')}.tmp` + await expect( + streamExternalFileToRuntime({ + userDataPath: '/u', + environmentId: 'env-1', + sourceRootPath: staged.sourcePath, + entryRelativePath: entry.relativePath, + expected: { + byteLength: entry.byteLength, + inode: entry.inode, + deviceId: entry.deviceId, + modifiedAtMs: entry.modifiedAtMs + }, + worktree: 'wt-1', + relativePath: dest + }) + ).resolves.toEqual({ byteLength: files[entry.relativePath]!.length }) + const remote = await readFile(join(remoteDir, dest)) + expect(remote.equals(files[entry.relativePath]!)).toBe(true) + } + }) + + it('streams a dropped single file using the identity staging recorded', async () => { + installRealHostWrites() + const source = join(workDir, 'single.bin') + const body = patterned(SLICE + 1, 5) + await writeFile(source, body) + + const staged = await stageOneSourceForRuntimeUpload(source) + expect(staged.status).toBe('staged') + if (staged.status !== 'staged') { + return + } + const entry = staged.entries[0]! + expect(entry.kind).toBe('file') + if (entry.kind !== 'file') { + return + } + + await expect( + streamExternalFileToRuntime({ + userDataPath: '/u', + environmentId: 'env-1', + sourceRootPath: staged.sourcePath, + entryRelativePath: entry.relativePath, + expected: entry, + worktree: 'wt-1', + relativePath: 'single.tmp' + }) + ).resolves.toEqual({ byteLength: body.length }) + expect((await readFile(join(remoteDir, 'single.tmp'))).equals(body)).toBe(true) + }) +}) + +describe('source mutation during transfer', () => { + it('rejects a source that grows during the transfer and never claims success', async () => { + const source = join(workDir, 'growing.bin') + await writeFile(source, patterned(2 * SLICE, 1)) + const args = await argsFor(source) + callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => { + if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) { + await appendFile(source, 'extra') + } + return OK + }) + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow( + "File changed during upload: 'growing.bin'" + ) + }) + + it('rejects a source truncated during the transfer instead of sending a short file', async () => { + const source = join(workDir, 'shrinking.bin') + await writeFile(source, patterned(3 * SLICE, 2)) + const args = await argsFor(source) + callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => { + if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) { + await truncate(source, SLICE) + } + return OK + }) + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow( + "File truncated during upload: 'shrinking.bin'" + ) + expect(chunkCalls().length).toBeLessThan(3) + }) + + it('accepts a staged identity whose inode and device are unreported (0) when size and mtime match', async () => { + const source = join(workDir, 'no-ino.bin') + await writeFile(source, patterned(10, 4)) + const args = await argsFor(source) + args.expected = { ...args.expected, inode: 0, deviceId: 0 } + + await expect(streamExternalFileToRuntime(args)).resolves.toEqual({ byteLength: 10 }) + }) + + it('still refuses a wrong inode when only the device is unreported', async () => { + const source = join(workDir, 'wrong-ino.bin') + await writeFile(source, patterned(10, 4)) + const args = await argsFor(source) + args.expected = { ...args.expected, inode: args.expected.inode + 1, deviceId: 0 } + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow( + "File changed since it was staged: 'wrong-ino.bin'" + ) + expect(chunkCalls()).toHaveLength(0) + }) + + it('stops before the next slice when the signal aborts while a chunk is in flight', async () => { + const source = join(workDir, 'abort.bin') + await writeFile(source, patterned(3 * SLICE, 6)) + const controller = new AbortController() + callRuntimeEnvironment.mockImplementation(async (_u, _e, method, _p, _t, _r, _env, options) => { + if (method !== 'files.writeBase64Chunk') { + return OK + } + if (chunkCalls().length === 2) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { + once: true + }) + controller.abort(new Error('window gone')) + }) + } + return OK + }) + + await expect( + streamExternalFileToRuntime({ ...(await argsFor(source)), signal: controller.signal }) + ).rejects.toThrow('window gone') + expect(chunkCalls()).toHaveLength(2) + }) +}) + +describe('formatByteCeiling bounds', () => { + it.each([ + [0, '0 B'], + [1, '1 B'], + [1023, '1023 B'], + [1024, '1 KB'], + [1025, '1.1 KB'], + [25 * 1024 * 1024, '25 MB'], + [25 * 1024 * 1024 + 1, '25.1 MB'], + [REMOTE_IMPORT_MAX_FILE_BYTES, '2 GB'], + [REMOTE_IMPORT_MAX_FILE_BYTES + 1, '2.1 GB'], + [REMOTE_IMPORT_MAX_TOTAL_BYTES, '8 GB'], + [REMOTE_IMPORT_MAX_TOTAL_BYTES + 1, '8.1 GB'], + [1024 ** 5, '1024 TB'] + ])('%i → %s', (bytes, text) => { + expect(formatByteCeiling(bytes)).toBe(text) + }) +}) diff --git a/src/main/ipc/runtime-upload-temp-sweep.test.ts b/src/main/ipc/runtime-upload-temp-sweep.test.ts new file mode 100644 index 00000000000..06e7fe9282c --- /dev/null +++ b/src/main/ipc/runtime-upload-temp-sweep.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' + +const callRuntimeEnvironment = + vi.fn< + ( + userDataPath: string, + environmentId: string, + method: string, + params: { relativePath: string; recursive: boolean }, + timeoutMs?: number, + expectedEnvironmentPairingRevision?: number, + envelope?: unknown, + options?: { expectedEnvironmentRuntimeId?: string } + ) => unknown + >() + +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: (...args: Parameters) => + callRuntimeEnvironment(...args) +})) + +const { sweepAbandonedRuntimeUploadTempPath } = await import('./runtime-upload-temp-sweep') + +const request: RuntimeUploadFileStreamRequest = { + environmentId: 'env-1', + sourceRootPath: '/Users/me/clip.mp4', + entryRelativePath: '', + expected: { byteLength: 4, inode: 1, deviceId: 2, modifiedAtMs: 3 }, + worktree: 'id:wt-1', + relativePath: 'uploads/.clip.mp4.orca-upload-abc', + expectedEnvironmentPairingRevision: 17, + expectedEnvironmentRuntimeId: 'runtime-7', + expectedExecutionHostId: 'local' +} + +function deleteCalls(): { relativePath: string; recursive: boolean }[] { + return callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.delete') + .map(([, , , params]) => params) +} + +beforeEach(() => { + vi.useFakeTimers() + callRuntimeEnvironment.mockReset() + callRuntimeEnvironment.mockResolvedValue({ id: 'x', ok: true, result: {}, _meta: {} }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('sweepAbandonedRuntimeUploadTempPath', () => { + it('deletes twice, because a straggling append recreates the file with flag a', async () => { + const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request) + await vi.runAllTimersAsync() + await swept + + expect(deleteCalls()).toEqual([ + expect.objectContaining({ relativePath: request.relativePath, recursive: false }), + expect.objectContaining({ relativePath: request.relativePath, recursive: false }) + ]) + }) + + it('still makes the second pass when the first one fails', async () => { + callRuntimeEnvironment.mockRejectedValueOnce(new Error('connection lost')) + + const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request) + await vi.runAllTimersAsync() + await expect(swept).resolves.toBeUndefined() + + expect(deleteCalls()).toHaveLength(2) + }) + + it('carries the host ownership guards so it cannot delete on a re-paired host', async () => { + const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request) + await vi.runAllTimersAsync() + await swept + + for (const call of callRuntimeEnvironment.mock.calls) { + expect(call[5]).toBe(17) + expect(call[7]?.expectedEnvironmentRuntimeId).toBe('runtime-7') + } + }) + + it('never rejects, so cleanup cannot mask the upload failure', async () => { + callRuntimeEnvironment.mockRejectedValue(new Error('runtime gone')) + + const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request) + await vi.runAllTimersAsync() + + await expect(swept).resolves.toBeUndefined() + }) +}) diff --git a/src/main/ipc/runtime-upload-temp-sweep.ts b/src/main/ipc/runtime-upload-temp-sweep.ts new file mode 100644 index 00000000000..46150fd96e5 --- /dev/null +++ b/src/main/ipc/runtime-upload-temp-sweep.ts @@ -0,0 +1,49 @@ +import { setTimeout } from 'node:timers/promises' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import { callRuntimeEnvironment } from './runtime-environment-transport-routing' + +const RUNTIME_UPLOAD_SWEEP_ATTEMPTS = 2 +const RUNTIME_UPLOAD_SWEEP_SETTLE_MS = 250 + +/** + * Sweep an abandoned upload temp path after an abort. + * + * Aborting rejects the in-flight chunk locally, but the host may still apply + * that append — and appends open with `flag: 'a'`, which recreates the file a + * delete just removed. Slices are strictly sequential, so at most one append + * can be outstanding: a second pass after it has had time to land is enough. + * + * Best-effort throughout. The runtime may be why the upload failed, and a + * failed cleanup of a hidden temp file is not actionable. + */ +export async function sweepAbandonedRuntimeUploadTempPath( + userDataPath: string, + args: RuntimeUploadFileStreamRequest +): Promise { + for (let attempt = 0; attempt < RUNTIME_UPLOAD_SWEEP_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + await setTimeout(RUNTIME_UPLOAD_SWEEP_SETTLE_MS) + } + try { + await callRuntimeEnvironment( + userDataPath, + args.environmentId, + 'files.delete', + { + worktree: args.worktree, + relativePath: args.relativePath, + recursive: false, + expectedSshTargetId: args.expectedSshTargetId, + expectedSshConnectionGeneration: args.expectedSshConnectionGeneration, + expectedExecutionHostId: args.expectedExecutionHostId + }, + 15_000, + args.expectedEnvironmentPairingRevision, + undefined, + { expectedEnvironmentRuntimeId: args.expectedEnvironmentRuntimeId } + ) + } catch { + // Nothing to escalate; the next pass (if any) still runs. + } + } +} diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index e8571449321..55d47bc680a 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -402,29 +402,63 @@ describe('registerWorktreeHandlers', () => { } }) - it('retries stale Git registration cleanup after prior local filesystem recovery', async () => { - setPlatform('win32') - const missingWorktreePath = 'C:\\workspace\\already-removed' - const worktreeId = `repo-1::${missingWorktreePath}` - const registeredWorktrees = mockKnownFeatureWorktree(missingWorktreePath) - listWorktreesMock.mockResolvedValueOnce(registeredWorktrees).mockResolvedValue([]) - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta()) + it.each([false, true])( + 'retries missing registration cleanup (prunable marker: %s)', + async (prunableMarker) => { + setPlatform('win32') + const missingWorktreePath = prunableMarker + ? 'C:\\workspace\\already-removed\\.git' + : 'C:\\workspace\\already-removed' + const worktreeId = `repo-1::${missingWorktreePath}` + const registeredWorktrees = mockKnownFeatureWorktree(missingWorktreePath).map((row) => + prunableMarker && row.path === missingWorktreePath + ? { ...row, branch: 'refs/heads/feature', prunable: true } + : row + ) + listWorktreesMock.mockResolvedValueOnce(registeredWorktrees).mockResolvedValue([]) + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta()) - const result = await handlers['worktrees:remove'](null, { - worktreeId, - force: true - }) + const result = await handlers['worktrees:remove'](null, { + worktreeId, + force: true + }) - expect(result).toEqual({ - preservedBranch: { branchName: 'feature', head: 'feature' } - }) - expect(runHookMock).not.toHaveBeenCalled() - expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() - expect(removeWorktreeMock).not.toHaveBeenCalled() - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { - cwd: '/workspace/repo' - }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(result).toEqual({ + preservedBranch: { branchName: 'feature', head: 'feature' } + }) + expect(runHookMock).not.toHaveBeenCalled() + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { + cwd: '/workspace/repo' + }) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + } + ) + + it('cleans a prunable Git-file row before archive or checkout teardown', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-prunable-ipc-')) + const markerPath = join(root, '.git') + await writeFile(markerPath, 'gitdir: /preserved/admin\n') + const worktreeId = `repo-1::${markerPath}` + const rows = mockKnownFeatureWorktree(markerPath).map((row) => + row.path === markerPath ? { ...row, branch: 'refs/heads/feature', prunable: true } : row + ) + listWorktreesMock.mockResolvedValueOnce(rows).mockResolvedValue([]) + try { + const result = await handlers['worktrees:remove'](null, { worktreeId }) + expect(result).toEqual({ preservedBranch: { branchName: 'feature', head: 'feature' } }) + expect(runHookMock).not.toHaveBeenCalled() + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { + cwd: '/workspace/repo' + }) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect((await lstat(markerPath)).isFile()).toBe(true) + } finally { + await rm(root, { recursive: true, force: true }) + } }) it('preserves a locked missing registration even with force', async () => { diff --git a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts index ecf8ab3abc1..594b75cc5da 100644 --- a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts +++ b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts @@ -1,3 +1,4 @@ +import { preserveFolderUpgradeWorktreePath } from '../../../folder-upgrade-worktree-path' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import { parseWorktreeId, areWorktreePathsEqual, mergeWorktree } from '../../worktree-logic' import { @@ -144,7 +145,9 @@ export function buildDetectedGitWorktrees( const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) // Why: a prunable registration has no working directory (issue #8389); only this listing omits it — cleanup flows list separately. const liveWorktrees = dedupeWorktreesByPath( - gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable) + preserveFolderUpgradeWorktreePath(repo, gitWorktrees).filter( + (gitWorktree) => !gitWorktree.prunable + ) ) const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( [repo.path, ...liveWorktrees.map((worktree) => worktree.path)], diff --git a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts index 6f0d91ffef5..443e05627ca 100644 --- a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts +++ b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts @@ -8,8 +8,9 @@ import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git- import { listWorktreesStrict as listGitWorktreesStrict } from '../../../git/worktree' import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch' import { resolveWorktreeRemovalMetadata } from '../../../worktree-removal-repo-owner' +import { isPrunableGitFileWorktree } from '../../../worktree-prunable-git-file' import { findRegisteredDeletableWorktree } from '../../../worktree-removal-safety' -import { removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval } from '../../../local-worktree-removal-recovery' +import { removeStaleLocalWorktreeRegistration } from '../../../local-worktree-removal-recovery' import { runHook } from '../../../hooks' import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' import { @@ -85,13 +86,14 @@ export async function executeWorktreeRemoval( if ( !repo.connectionId && - args.force === true && - process.platform === 'win32' && - (isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) && - removedMeta && - (await isAlreadyRemovedWorktreePath(repo, canonicalWorktreePath, localWorktreeGitOptions)) + ((await isPrunableGitFileWorktree(registeredWorktree, localWorktreeGitOptions)) || + (args.force === true && + process.platform === 'win32' && + (isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) && + removedMeta && + (await isAlreadyRemovedWorktreePath(repo, canonicalWorktreePath, localWorktreeGitOptions)))) ) { - const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + const removalResult = await removeStaleLocalWorktreeRegistration({ canonicalWorktreePath, repoPath: repo.path, localWorktreeGitOptions, diff --git a/src/main/local-worktree-removal-recovery.test.ts b/src/main/local-worktree-removal-recovery.test.ts index 1be0fd02cce..7d5aa442a1e 100644 --- a/src/main/local-worktree-removal-recovery.test.ts +++ b/src/main/local-worktree-removal-recovery.test.ts @@ -22,7 +22,7 @@ vi.mock('./git/worktree', () => ({ import { recoverLocalWindowsWorktreeRemoval, - removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval + removeStaleLocalWorktreeRegistration } from './local-worktree-removal-recovery' async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { @@ -306,7 +306,7 @@ describe('recoverLocalWindowsWorktreeRemoval', () => { }) }) -describe('removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval', () => { +describe('removeStaleLocalWorktreeRegistration', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() listWorktreesStrictMock.mockReset() @@ -314,9 +314,27 @@ describe('removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval', () => { listWorktreesStrictMock.mockResolvedValue([]) }) + it('prunes and strictly verifies on the selected WSL host without deleting files or branches', async () => { + const options = { wslDistro: 'Ubuntu' } + const result = await removeStaleLocalWorktreeRegistration({ + canonicalWorktreePath: '/home/dev/feature/.git', + repoPath: '/home/dev/repo', + localWorktreeGitOptions: options, + registeredWorktree: { branch: 'refs/heads/feature', head: 'abc123' }, + deleteBranch: true + }) + expect(result).toEqual({ preservedBranch: { branchName: 'feature', head: 'abc123' } }) + expect(gitExecFileAsyncMock).toHaveBeenCalledExactlyOnceWith(['worktree', 'prune'], { + cwd: '/home/dev/repo', + wslDistro: 'Ubuntu' + }) + expect(listWorktreesStrictMock).toHaveBeenCalledExactlyOnceWith('/home/dev/repo', options) + expect(removeLocalWorktreePathMock).not.toHaveBeenCalled() + }) + it('does not override a locked missing registration', async () => { await expect( - removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + removeStaleLocalWorktreeRegistration({ canonicalWorktreePath: 'C:/workspaces/feature', repoPath: 'C:/repo', localWorktreeGitOptions: {}, @@ -345,7 +363,7 @@ describe('removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval', () => { ]) await expect( - removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + removeStaleLocalWorktreeRegistration({ canonicalWorktreePath: 'C:/workspaces/feature', repoPath: 'C:/repo', localWorktreeGitOptions: {}, diff --git a/src/main/local-worktree-removal-recovery.ts b/src/main/local-worktree-removal-recovery.ts index 630dd338c20..46f1bb15c87 100644 --- a/src/main/local-worktree-removal-recovery.ts +++ b/src/main/local-worktree-removal-recovery.ts @@ -47,7 +47,7 @@ function staleRegistrationRecoveryError( error, canonicalWorktreePath, force - )} The worktree directory was removed, but Git still has stale worktree registration. Retry deletion after resolving the Git registration error.` + )} Git still has stale worktree registration. Retry deletion after resolving the Git registration error.` ) } @@ -151,7 +151,7 @@ async function isRecoverableWindowsFilesystemRemovalFailure( } } -export async function removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval( +export async function removeStaleLocalWorktreeRegistration( args: StaleLocalWorktreeRegistrationArgs ): Promise { return removeRequiredGitWorktreeRegistration(args) diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index c8d48ef336b..d142672f8e5 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -16,7 +16,6 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' -import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from './journal-dispatch-doubt-reasons' import { dispatchWriteFailureReason } from '../../../shared/structured-agent-session-dispatch-rejection' import { digestPayload } from './journal-payload-bounds' import { @@ -55,6 +54,8 @@ function userMessage(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } +const LEGACY_CODEX_TURN_UNNAMED = 'codex app-server started a turn it did not name in time' + const journals = createTrackedJournalOpener() async function open() { @@ -196,6 +197,8 @@ describe('crash between provider accept and journal commit', () => { expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) }) + // Only an older Orca minted this reason -- Codex now settles a send on the + // provider echo -- but rows written under it still come back from disk. it('keeps a codex turn it could not name in doubt, never rejected', async () => { const journal = await open() await journal.appendSubmission({ @@ -207,7 +210,7 @@ describe('crash between provider accept and journal commit', () => { await journal.resolveDispatch({ clientMessageId: 'cm_codex_unnamed', state: 'unknown', - reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED, + reason: LEGACY_CODEX_TURN_UNNAMED, fence: 1 }) @@ -218,7 +221,7 @@ describe('crash between provider accept and journal commit', () => { // and it may never become a rejection, which would license a re-delivery. expect(restarted.submissions()[0]).toMatchObject({ dispatchState: 'unknown', - reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED, + reason: LEGACY_CODEX_TURN_UNNAMED, recovered: true }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts index dbd56132100..206bd19e3ef 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts @@ -23,13 +23,6 @@ export const DISPATCH_DOUBT_PERSISTENCE_FAILED = 'dispatch_result_persistence_fa /** The operation tombstone survived recovery but its journal submission did not. */ export const DISPATCH_DOUBT_SUBMISSION_MISSING = 'durable_send_submission_missing' -/** Codex owns a turn it started but did not name, because its turn-start still - * settles on a deadline. The turn IS running, so this must never be treated as - * proof of non-delivery. Delete it once Codex settles on the app-server's - * turn-start response instead. */ -export const DISPATCH_DOUBT_CODEX_TURN_UNNAMED = - 'codex app-server started a turn it did not name in time' - /** The SDK took the frame, but its input pump did not prove whether the write completed. */ export const DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN = 'provider_write_outcome_unknown' diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts new file mode 100644 index 00000000000..70052865242 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts @@ -0,0 +1,87 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { + DISPATCH_REJECTED_WRITE_FAILED, + dispatchRejectionReasonIsInternal, + dispatchRejectionWasTransportWriteFailure +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' +import type { openAgentSessionJournal } from './journal-store-factory' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const HUGE = 'x'.repeat(4 * DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes) + +let root: string +let clock = 1_000 + +const journals = createTrackedJournalOpener() + +async function open(overrides: Partial[0]> = {}) { + return journals.open({ + identity: IDENTITY, + journalDir: root, + now: () => (clock += 1), + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) +} + +async function settle(reason: string): Promise { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'msg-1', + payloadFingerprint: 'e'.repeat(64), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + await journal.resolveDispatch({ clientMessageId: 'msg-1', state: 'rejected', reason, fence: 1 }) + return journal.snapshot().submissions[0]?.reason ?? null +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-dispatch-reason-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('dispatch reason bounding', () => { + it('bounds an oversized provider error before it reaches the row', async () => { + const stored = await settle(HUGE) + expect(stored).not.toBeNull() + expect(stored?.length).toBeLessThan(HUGE.length) + }) + + it('marks the clipped reason rather than truncating it silently', async () => { + const stored = await settle(HUGE) + expect(stored).toContain('[Orca: output truncated') + }) + + it('leaves a reason that already fits exactly as written', async () => { + const stored = await settle(`${DISPATCH_REJECTED_WRITE_FAILED}: broken pipe`) + expect(stored).toBe(`${DISPATCH_REJECTED_WRITE_FAILED}: broken pipe`) + }) + + // Head-first, not hash-replacing: the classifier prefix-matches, so a bound that kept + // the tail would render raw provider text to the user as an ordinary rejection notice. + it('keeps a clipped transport failure classifiable', async () => { + const stored = await settle(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) + expect(stored).not.toBe(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) + expect(dispatchRejectionWasTransportWriteFailure(stored)).toBe(true) + expect(dispatchRejectionReasonIsInternal(stored)).toBe(true) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index 58d12dfcf89..2ecd4e22cb6 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -2,6 +2,7 @@ import type { AgentJournalApprovalItem, AgentJournalItemBody, AgentJournalPromptOption, + AgentJournalQuestion, AgentJournalQuestionItem } from '../../../shared/agent-session-journal-types' import { @@ -11,6 +12,7 @@ import { } from './journal-payload-bounds' export const MAX_JOURNAL_PROMPT_OPTIONS = 64 +export const MAX_JOURNAL_GROUPED_PROMPT_QUESTIONS = 4 const JOURNAL_PROMPT_OPTION_LIMITS = { inlineHeadBytes: 1024 } const JOURNAL_PROMPT_ID_MAX_BYTES = 1024 @@ -37,7 +39,12 @@ export function boundJournalStatusText(text: string): string { return boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } -function boundJournalPromptBody( +export function boundJournalPromptBody(body: AgentJournalApprovalItem): AgentJournalApprovalItem +export function boundJournalPromptBody(body: AgentJournalQuestionItem): AgentJournalQuestionItem +export function boundJournalPromptBody( + body: AgentJournalApprovalItem | AgentJournalQuestionItem +): AgentJournalApprovalItem | AgentJournalQuestionItem +export function boundJournalPromptBody( body: AgentJournalApprovalItem | AgentJournalQuestionItem ): AgentJournalApprovalItem | AgentJournalQuestionItem { if (body.kind === 'approval') { @@ -52,18 +59,41 @@ function boundJournalPromptBody( ...body, question: boundPromptText(body.question), options: boundPromptOptions(body.options), + ...(body.questions + ? { + questions: body.questions + .slice(0, MAX_JOURNAL_GROUPED_PROMPT_QUESTIONS) + .map(boundPromptQuestion) + } + : {}), ...(body.freeTextQuestionId ? { freeTextQuestionId: boundPromptIdentifier(body.freeTextQuestionId) } : {}) } } +function boundPromptQuestion(question: AgentJournalQuestion): AgentJournalQuestion { + return { + id: boundPromptIdentifier(question.id), + question: boundPromptText(question.question), + ...(question.header === undefined ? {} : { header: boundPromptText(question.header) }), + multiSelect: question.multiSelect, + options: boundPromptOptions(question.options), + ...(question.freeTextQuestionId + ? { freeTextQuestionId: boundPromptIdentifier(question.freeTextQuestionId) } + : {}) + } +} + function boundPromptOptions( options: readonly AgentJournalPromptOption[] ): AgentJournalPromptOption[] { return options.slice(0, MAX_JOURNAL_PROMPT_OPTIONS).map((option) => ({ id: boundPromptIdentifier(option.id), - label: boundInlineText(option.label, JOURNAL_PROMPT_OPTION_LIMITS).text + label: boundInlineText(option.label, JOURNAL_PROMPT_OPTION_LIMITS).text, + ...(option.description === undefined + ? {} + : { description: boundInlineText(option.description, JOURNAL_PROMPT_OPTION_LIMITS).text }) })) } diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index be2c2552775..e5e376940fe 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -20,6 +20,7 @@ import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES, MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS } from './journal-row-schema' +import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import type { ResolveDispatchInput } from './journal-store-contracts' type RowBuilder = (seq: number, ts: number) => T @@ -76,8 +77,7 @@ export function journalDispatchRowBuilder( clientMessageId: input.clientMessageId, dispatchState: input.state, providerItemId, - reason: - input.state === 'accepted' || input.state === 'pending' ? null : (input.reason ?? null), + reason: boundedDispatchReason(input), seq, fence: input.fence, ts, @@ -85,6 +85,17 @@ export function journalDispatchRowBuilder( }) } +/** `reason` is the only unbounded field written by Orca's own code: a provider error is + * arbitrary text, and a multi-megabyte one reached the row verbatim. Bounded head-first, + * because `dispatchRejectionWasTransportWriteFailure` prefix-matches the value. Rows + * written before this keep their full text, so readers still meet unbounded ones. */ +function boundedDispatchReason(input: ResolveDispatchInput): string | null { + if (input.state === 'accepted' || input.state === 'pending' || !input.reason) { + return null + } + return boundInlineText(input.reason, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text +} + export type JournalLifecycleMutationInput = | { kind: 'item'; identity: AgentJournalItemIdentity; body: AgentJournalItemBody } | { kind: 'tombstone'; identity: AgentJournalItemIdentity } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 6b12ba61c6c..81ef7f79062 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -47,6 +47,13 @@ export class AgentSessionRewindRefusal extends AgentSessionAcquisitionRefusal { } } +export class AgentSessionPromptUnavailableError extends Error { + constructor(itemId: string) { + super(`The provider is no longer waiting on ${itemId}.`) + this.name = 'AgentSessionPromptUnavailableError' + } +} + /** * The provider's own root process was observed to exit, but its descendant tree * could not be verified. The lease keys on the root's pid and start time, so its @@ -194,6 +201,7 @@ export type StructuredAgentSessionAdapter = { sessionId: string turnId: string fence: number + prompt?: { itemId: string } }): Promise<{ cancelled: boolean }> stopBackgroundTasks?(input: { sessionId: string @@ -204,14 +212,15 @@ export type StructuredAgentSessionAdapter = { /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined - /** Fires the provider callback for an approval or a question. The wire calls - * this only after the durable compare-and-set won, so it runs exactly once. */ + /** Claims the live callback, commits the journal CAS while that claim is held, then answers it. + * A prompt cancel claims the same callback, so only one operation can commit. */ answerPrompt(input: { sessionId: string itemId: string kind: 'approval' | 'question' optionId: string fence: number + commit: () => Promise }): Promise setOption( input: StructuredAgentSessionSetOptionInput diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 85e02f2fea7..eec3841a08c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -22,7 +22,7 @@ import { } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' -import { settleStaleRunningTurnsOnAcquire } from './structured-agent-session-stale-turn-verdict' +import { settleStaleSessionStateOnAcquire } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { forgetStructuredAgentSession } from './structured-agent-session-host-lifetime' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -105,7 +105,7 @@ export function attachStructuredAgentSession( try { if (acquiredOwner) { // Before the drain: the buffered events are the new child's, never a stale row's. - await settleStaleRunningTurnsOnAcquire({ + await settleStaleSessionStateOnAcquire({ journal: attached.journal, sessionId, fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index 6082ab074f5..bbc1ec49d4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -2,12 +2,11 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -66,45 +65,43 @@ function adapter(): StructuredAgentSessionAdapter { } async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number }> { - const journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir: journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) + const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 100 } + const events = acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('seedGroupedQuestion requires an acquired session') + } + events.appendItem(identity, { + kind: 'question', + question: '2 grouped questions from Claude', + options: [], + questions: [ + { + id: 'q1', + question: 'Targets', + multiSelect: true, + options: [ + { id: 'target-web', label: 'Web' }, + { id: 'target-mobile', label: 'Mobile' } + ] + }, + { + id: 'q2', + question: 'Host', + multiSelect: false, + options: [], + freeTextQuestionId: 'q2' + } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }) - const appended = await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 100 }, - { - kind: 'question', - question: '2 grouped questions from Claude', - options: [], - questions: [ - { - id: 'q1', - question: 'Targets', - multiSelect: true, - options: [ - { id: 'target-web', label: 'Web' }, - { id: 'target-mobile', label: 'Mobile' } - ] - }, - { - id: 'q2', - question: 'Host', - multiSelect: false, - options: [], - freeTextQuestionId: 'q2' - } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { fence: 1 } - ) - return { itemId: appended.itemId, revision: appended.revision } + await host.flushStreamedEvents(SESSION) + const itemId = agentJournalItemKey(identity) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null + if (!appended) { + throw new Error('provider question was not written to the journal') + } + return { itemId, revision: appended.revision } } beforeEach(async () => { @@ -126,7 +123,7 @@ beforeEach(async () => { observedAt: NOW } })) - answerPrompt = vi.fn(async () => undefined) + answerPrompt = vi.fn(async ({ commit }) => commit()) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ store, @@ -145,9 +142,9 @@ afterEach(async () => { describe('grouped question admission', () => { it('admits renderer question-group payloads with child ids and multi-select answers', async () => { - const prompt = await seedGroupedQuestion() const attached = await host.attach(CALLER, attachParams()) expect(attached.ok).toBe(true) + const prompt = await seedGroupedQuestion() const optionId = encodeAgentSessionQuestionAnswers([ { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, { questionId: 'q2', optionIds: [], other: 'SSH host' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 9d50adaaa1a..abc468c9ea7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -37,6 +37,7 @@ export type StructuredAgentSessionMutationContext = { deps: StructuredAgentSessionHostDeps sessions: Map publish: (sessionId: string, journal: StructuredAgentSessionHostSession['journal']) => void + flushStreamedEvents: (sessionId: string) => Promise requireSession: (sessionId: string) => StructuredAgentSessionHostSession serialize: (sessionId: string, task: () => Promise) => Promise now: () => number @@ -57,6 +58,7 @@ function mutate( plan, journal: context.sessions.get(envelope.sessionId)?.journal, publish: (journal) => context.publish(envelope.sessionId, journal), + flushStreamedEvents: context.flushStreamedEvents, now: () => context.now() }) ) @@ -109,6 +111,7 @@ export function cancelStructuredAgentSessionTurn( turnId: string scope?: 'background-tasks' taskId?: string + prompt?: { itemId: string; expectedRevision: number } } ): Promise> { const command = context.deps.store.getRecord(params.envelope.sessionId)?.conversationCommand @@ -177,20 +180,28 @@ export async function settleStructuredAgentSessionLateDispatch( input: { sessionId: string clientMessageId: string - providerIdentity: AgentJournalItemIdentity - } + } & ({ providerIdentity: AgentJournalItemIdentity } | { state: 'rejected'; reason: string }) ): Promise { const session = context.sessions.get(input.sessionId) if (!session) { return } // The journal queue drains before close; the host queue would defer this past teardown. - await session.journal.resolveDispatch({ - clientMessageId: input.clientMessageId, - state: 'accepted', - providerIdentity: input.providerIdentity, - fence: session.fence - }) + await session.journal.resolveDispatch( + 'providerIdentity' in input + ? { + clientMessageId: input.clientMessageId, + state: 'accepted', + providerIdentity: input.providerIdentity, + fence: session.fence + } + : { + clientMessageId: input.clientMessageId, + state: 'rejected', + reason: input.reason, + fence: session.fence + } + ) context.publish(input.sessionId, session.journal) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index c67a8fabf58..f14b1308810 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -3,10 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { AgentSessionDispatchOutcome, @@ -87,33 +87,28 @@ async function attach(): Promise { return store.getRecord(SESSION) } -/** Puts a pending approval in the journal BEFORE attach, which is the only way - * 1d can stage one: the adapter that would emit it is phase 2's. */ +/** Emits a pending approval through the acquired provider sink. */ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revision: number }> { const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 } - const journalDir = journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) - const journal = await journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir + const events = acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('seedApproval requires an acquired session') + } + events.appendItem(identity, { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: optionId, label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }) - const appended = await journal.appendItem( - identity, - { - kind: 'approval', - title: 'Run the command?', - detail: null, - options: [{ id: optionId, label: 'Allow' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { fence: 1 } - ) - return { itemId: appended.itemId, revision: appended.revision } + await host.flushStreamedEvents(SESSION) + const itemId = agentJournalItemKey(identity) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null + if (!appended) { + throw new Error('provider approval was not written to the journal') + } + return { itemId, revision: appended.revision } } beforeEach(async () => { @@ -138,7 +133,7 @@ beforeEach(async () => { releaseAcquisition = vi.fn(async () => true) dispatch = vi.fn(async () => accepted()) cancelTurn = vi.fn(async () => ({ cancelled: true })) - answerPrompt = vi.fn(async () => undefined) + answerPrompt = vi.fn(async ({ commit }) => commit()) setOption = vi.fn(async () => undefined) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index d53c3c30e50..a19735657d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -234,12 +234,117 @@ describe('cancel', () => { }) expect(cancelTurn).toHaveBeenCalledTimes(1) }) + + it.each([ + ['a missing prompt item', { itemId: 'missing-item', expectedRevision: 1 }], + ['a stale prompt revision', { itemId: 'seeded', expectedRevision: 2 }] + ])('refuses %s before interrupting the provider', async (_case, requestedPrompt) => { + await attach() + const prompt = await seedApproval() + const strictPrompt = { + ...requestedPrompt, + ...(requestedPrompt.itemId === 'seeded' ? { itemId: prompt.itemId } : {}) + } + const fields = { turnId: 'turn-1', prompt: strictPrompt } + + expect( + await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', fields), + ...fields + }) + ).toMatchObject({ ok: false }) + expect(cancelTurn).not.toHaveBeenCalled() + }) + + it('refuses cancellation after an answer has already resolved the prompt', async () => { + await attach() + const prompt = await seedApproval() + const answer = { + itemId: prompt.itemId, + expectedRevision: prompt.revision, + optionId: 'allow' + } + await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', answer), + kind: 'approval', + ...answer + }) + const fields = { + turnId: 'turn-1', + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision } + } + + expect( + await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', fields), + ...fields + }) + ).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_item_revision_stale' } + }) + expect(cancelTurn).not.toHaveBeenCalled() + }) + + it('records an unknown outcome when lifecycle draining fails and never interrupts on replay', async () => { + await attach() + const prompt = await seedApproval() + vi.spyOn(host, 'flushStreamedEvents').mockRejectedValueOnce(new Error('journal drain failed')) + const fields = { + turnId: 'turn-1', + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision } + } + const params = { + envelope: envelope('agentSession.cancel', fields), + ...fields + } + + await expect(host.cancel(CALLER, params)).rejects.toThrow('journal drain failed') + expect(await host.cancel(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + }) + + it('records an unknown outcome when strict prompt interruption throws and never retries it', async () => { + await attach() + const prompt = await seedApproval() + cancelTurn.mockRejectedValueOnce(new Error('interrupt receipt lost')) + const fields = { + turnId: 'turn-1', + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision } + } + const params = { + envelope: envelope('agentSession.cancel', fields), + ...fields + } + + await expect(host.cancel(CALLER, params)).rejects.toThrow('interrupt receipt lost') + expect(await host.cancel(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ + ok: true, + page: { + items: [ + expect.objectContaining({ + body: expect.objectContaining({ + resolution: expect.objectContaining({ state: 'pending' }) + }) + }) + ] + } + }) + }) }) describe('respondToPrompt', () => { it('commits the answer before the provider callback', async () => { - const prompt = await seedApproval() await attach() + const prompt = await seedApproval() const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } const result = await host.respondToPrompt(CALLER, { envelope: envelope('agentSession.respondTo:approval', fields), @@ -254,8 +359,8 @@ describe('respondToPrompt', () => { }) it('refuses a second answer to one prompt and says which answer won', async () => { - const prompt = await seedApproval() await attach() + const prompt = await seedApproval() const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } await host.respondToPrompt(CALLER, { envelope: envelope('agentSession.respondTo:approval', fields), @@ -281,8 +386,8 @@ describe('respondToPrompt', () => { }) it('refuses an option the prompt does not offer', async () => { - const prompt = await seedApproval() await attach() + const prompt = await seedApproval() const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'deny' } expect( await host.respondToPrompt(CALLER, { @@ -295,8 +400,8 @@ describe('respondToPrompt', () => { }) it("does not turn a recorded refusal into another client's successful answer", async () => { - const prompt = await seedApproval() await attach() + const prompt = await seedApproval() const rejectedFields = { itemId: prompt.itemId, expectedRevision: prompt.revision, @@ -326,9 +431,12 @@ describe('respondToPrompt', () => { }) it('keeps the answer and reports it undelivered when the provider callback throws', async () => { - const prompt = await seedApproval() await attach() - answerPrompt.mockRejectedValueOnce(new Error('pipe closed')) + const prompt = await seedApproval() + answerPrompt.mockImplementationOnce(async ({ commit }) => { + await commit() + throw new Error('pipe closed') + }) const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } const result = await host.respondToPrompt(CALLER, { envelope: envelope('agentSession.respondTo:approval', fields), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index c60d9e5db26..8fcdef95480 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -267,6 +267,7 @@ export class StructuredAgentSessionHost { deps: this.deps, sessions: this.sessions, publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), + flushStreamedEvents: this.flushStreamedEvents, requireSession: (sessionId) => this.requireSession(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), now: () => this.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 293f6ab2d8c..1874f7ee1c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent @@ -204,6 +205,27 @@ describe('settling a send the provider proves it received after the ack window', expect(dispatch).toHaveBeenCalledTimes(1) }) + it('settles a provider-cancelled queued send as rejected', async () => { + dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const params = sendParams('queued behind the active turn') + await host.send(CALLER, params) + + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: params.envelope.clientOperationId, + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + + expect(submissions()).toMatchObject([ + { + clientMessageId: params.envelope.clientOperationId, + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + } + ]) + }) + it('accepts from the durable echo row when the direct settlement write fails', async () => { dispatch.mockResolvedValueOnce({ state: 'admitted' }) const params = sendParams('settle from provider echo') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index c59a538ead7..34e8004cc81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -45,6 +45,7 @@ export type AgentSessionMutationRequest = { /** Journal of the attached session; absent when this host holds none. */ journal: AgentSessionJournal | undefined publish: (journal: AgentSessionJournal) => void + flushStreamedEvents: (sessionId: string) => Promise now: () => number } @@ -147,6 +148,7 @@ function turnContext( .then(() => undefined), resolvedBy: request.callerKey, publish: () => request.publish(journal), + flushStreamedEvents: () => request.flushStreamedEvents(request.envelope.sessionId), now: () => request.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 96c50036701..d8d2876d272 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -96,20 +96,23 @@ export function cancelPlan(params: { turnId: string scope?: 'background-tasks' taskId?: string + prompt?: { itemId: string; expectedRevision: number } }): MutationPlan { return { method: 'agentSession.cancel', fields: { turnId: params.turnId, ...(params.scope ? { scope: params.scope } : {}), - ...(params.taskId ? { taskId: params.taskId } : {}) + ...(params.taskId ? { taskId: params.taskId } : {}), + ...(params.prompt ? { prompt: params.prompt } : {}) }, run: (ctx) => performCancel(ctx, { clientOperationId: params.envelope.clientOperationId, turnId: params.turnId, ...(params.scope ? { scope: params.scope } : {}), - ...(params.taskId ? { taskId: params.taskId } : {}) + ...(params.taskId ? { taskId: params.taskId } : {}), + ...(params.prompt ? { prompt: params.prompt } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a // replay reports the turn as already handled instead. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts new file mode 100644 index 00000000000..aeb2c41095f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -0,0 +1,212 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} +const PROMPT_IDENTITY = { + provider: 'codex' as const, + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 1 +} + +const journals = createTrackedJournalOpener() +let root: string | null = null + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +async function pendingPrompt(): Promise<{ journal: AgentSessionJournal; itemId: string }> { + root = await mkdtemp(join(tmpdir(), 'orca-prompt-cancel-')) + const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const item = await journal.appendItem( + PROMPT_IDENTITY, + { + kind: 'approval', + title: 'Approve?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + { fence: 1 } + ) + return { journal, itemId: item.itemId } +} + +function context( + journal: AgentSessionJournal, + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'], + flushStreamedEvents: () => Promise +): AgentSessionTurnContext { + return { + sessionId: 'session-1', + journal, + fence: 1, + adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + flushStreamedEvents, + now: () => 1 + } +} + +describe('performCancel for a pending prompt', () => { + it('refuses a stale prompt revision before reaching the provider', async () => { + const { journal, itemId } = await pendingPrompt() + const cancelTurn = vi.fn(async () => ({ cancelled: true })) + const flush = vi.fn(async () => undefined) + + const result = await performCancel(context(journal, cancelTurn, flush), { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 2 } + }) + + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_item_revision_stale', currentRevision: 1 } + }) + expect(cancelTurn).not.toHaveBeenCalled() + expect(flush).not.toHaveBeenCalled() + }) + + it('drains terminal lifecycle before recording a confirmed cancellation', async () => { + const { journal, itemId } = await pendingPrompt() + const order: string[] = [] + const cancelTurn = vi.fn(async () => { + order.push('interrupt') + return { cancelled: true } + }) + const flush = vi.fn(async () => { + order.push('lifecycle') + const current = journal.snapshot().items.find((item) => item.itemId === itemId)! + if (current.body.kind !== 'approval') { + throw new Error('expected approval prompt') + } + await journal.appendItem( + PROMPT_IDENTITY, + { + ...current.body, + resolution: { + state: 'cancelled', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + { fence: 1 } + ) + }) + + await expect( + performCancel(context(journal, cancelTurn, flush), { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + }) + ).resolves.toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: true } }) + + expect(order).toEqual(['interrupt', 'lifecycle']) + expect(cancelTurn).toHaveBeenCalledWith({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 1, + prompt: { itemId } + }) + expect(journal.snapshot().items.map((item) => item.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }), + { kind: 'status', text: 'Cancellation requested.' } + ]) + }) + + it('keeps the callback answerable when interruption is declined', async () => { + const { journal, itemId } = await pendingPrompt() + const flush = vi.fn(async () => undefined) + + await expect( + performCancel( + context(journal, async () => ({ cancelled: false }), flush), + { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + } + ) + ).resolves.toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: false } }) + + expect(flush).not.toHaveBeenCalled() + expect(journal.snapshot().items.map((item) => item.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'pending' }) }), + { kind: 'status', text: 'The provider had already finished this turn.' } + ]) + }) + + it('propagates an unconfirmed adapter failure and leaves the prompt pending', async () => { + const { journal, itemId } = await pendingPrompt() + const flush = vi.fn(async () => undefined) + + await expect( + performCancel( + context( + journal, + async () => { + throw new Error('interrupt receipt lost') + }, + flush + ), + { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + } + ) + ).rejects.toThrow('interrupt receipt lost') + + expect(flush).not.toHaveBeenCalled() + expect(journal.snapshot().items.map((item) => item.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'pending' }) }) + ]) + }) + + it('surfaces a lifecycle drain failure after the provider confirms interruption', async () => { + const { journal, itemId } = await pendingPrompt() + const flush = vi.fn(async () => { + throw new Error('journal drain failed') + }) + + await expect( + performCancel( + context(journal, async () => ({ cancelled: true }), flush), + { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + } + ) + ).rejects.toThrow('journal drain failed') + expect(journal.snapshot().items).toHaveLength(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts new file mode 100644 index 00000000000..7f71a9c28ce --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts @@ -0,0 +1,59 @@ +import type { + AgentJournalItemBody, + AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +type PendingPromptBody = Extract + +export type PendingPromptValidation = + | { ok: true; item: AgentJournalRenderItem; prompt: PendingPromptBody } + | { ok: false; refusal: AgentSessionWireRefusal } + +function invalid(message: string): PendingPromptValidation { + return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +} + +export function validatePendingPrompt( + ctx: Pick, + input: { + itemId: string + expectedRevision: number + kind?: 'approval' | 'question' + } +): PendingPromptValidation { + const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) + if (!item) { + return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) + } + const prompt = item.body.kind === 'approval' || item.body.kind === 'question' ? item.body : null + if (!prompt || (input.kind !== undefined && prompt.kind !== input.kind)) { + return invalid( + `Item ${input.itemId} is not a pending${input.kind ? ` ${input.kind}` : ' prompt'}.` + ) + } + if (item.revision !== input.expectedRevision) { + return { + ok: false, + refusal: { + code: 'agent_session_item_revision_stale', + message: `Item ${input.itemId} has moved on.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + if (prompt.resolution.state !== 'pending') { + return { + ok: false, + refusal: { + code: 'agent_session_already_resolved', + message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + return { ok: true, item, prompt } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index cb7cbb1f20b..c301845c7c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -38,6 +38,7 @@ export async function rewindStructuredAgentSession( envelope: params.envelope, journal: context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), + flushStreamedEvents: context.flushStreamedEvents, now: context.now, plan: { method: 'agentSession.rewind', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index c0b56aa7d14..0f75c9a3322 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -8,7 +8,6 @@ import { structuredAgentSessionPayloadFingerprint } from '../../../shared/struct import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' const journals = createTrackedJournalOpener() @@ -39,7 +38,10 @@ describe('structured send idempotency', () => { it.each([ ['a refused write', 'provider_write_failed: broken pipe'], ['a dead host', 'host_restarted_before_acknowledgement'], - ['a codex turn it could not name', DISPATCH_DOUBT_CODEX_TURN_UNNAMED] + [ + 'a codex turn an older Orca could not name', + 'codex app-server started a turn it did not name in time' + ] ])('never puts an unknown back on the wire after %s', async (_case, reason) => { const body: AgentJournalMessageItem = { kind: 'message', @@ -61,6 +63,7 @@ describe('structured send idempotency', () => { persistOptions: async () => undefined, resolvedBy: 'caller', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 }, input @@ -101,6 +104,7 @@ describe('structured send idempotency', () => { persistOptions: async () => undefined, resolvedBy: 'caller', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } const input = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index e2f294f40f7..f2d1ab12230 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -6,7 +6,10 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import type { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { + DISPATCH_DOUBT_PROVIDER_EXITED, + DISPATCH_DOUBT_SUBMISSION_MISSING +} from '../agent-session-journal/journal-dispatch-doubt-reasons' import { accepted, attach, @@ -169,13 +172,15 @@ describe('send', () => { expect(state.ok && state.page.submissions).toHaveLength(2) }) - it('refuses to redeliver a retry for a turn the provider already owns', async () => { + it('refuses to redeliver a retry for a message the provider may already hold', async () => { await attach() + // A dead child ends the wait without proving non-delivery: the message was + // already written to that child's stdin. dispatch.mockImplementationOnce(async () => ({ state: 'unknown' as const, - reason: 'codex app-server started a turn it did not name in time' + reason: DISPATCH_DOUBT_PROVIDER_EXITED })) - const body = hostTestMessage('a turn codex owns but did not name') + const body = hostTestMessage('a message the provider may already hold') const params = { envelope: envelope('agentSession.send', { body }), body } const first = await host.send(CALLER, params) @@ -183,8 +188,8 @@ describe('send', () => { ok: true, value: { submission: { dispatchState: 'unknown' } } }) - // The turn is running; a second delivery would be a duplicate, so Retry - // replays the recorded outcome instead of re-sending. + // No `unknown` is re-delivered under its own id, whatever its reason says, + // so Retry replays the recorded outcome instead of writing again. await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 8ffb7acf6ce..d0c9f04c27f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -4,7 +4,7 @@ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journ import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { runningTurnLifecycleRevisions, - settleStaleRunningTurnsOnAcquire, + settleStaleSessionStateOnAcquire, turnVerdictFromDeathEvidence } from './structured-agent-session-stale-turn-verdict' @@ -43,6 +43,32 @@ function legacyLifecycleItem(turnId: string, startedAt: number): AgentJournalRen } } +function promptItem(state: 'pending' | 'resolved', sequence: number): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey({ + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: `approval-${state}` + }), + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'approval', + title: 'Approve?', + detail: null, + options: [], + resolution: { + state, + selectedOptionId: state === 'resolved' ? 'allow' : null, + resolvedBy: state === 'resolved' ? 'client-1' : null, + resolvedAt: state === 'resolved' ? 10 : null + } + } + } +} + describe('turn verdict from death evidence', () => { it('earns an end time only from an observed exit', () => { expect( @@ -105,7 +131,7 @@ describe('running turn lifecycle revisions', () => { }) }) -describe('stale running turns on a cold acquire', () => { +describe('stale session state on a cold acquire', () => { function journalWith(items: AgentJournalRenderItem[]) { const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 9 })) const journal = { @@ -123,7 +149,7 @@ describe('stale running turns on a cold acquire', () => { ]) await expect( - settleStaleRunningTurnsOnAcquire({ + settleStaleSessionStateOnAcquire({ journal, sessionId: 'session-1', fence: 14, @@ -132,7 +158,7 @@ describe('stale running turns on a cold acquire', () => { ).resolves.toBe(1) expect(appendLifecycleBatch).toHaveBeenCalledExactlyOnceWith({ - settlementId: 'stale-turn:session-1:14:generation-2', + settlementId: 'stale-session:session-1:14:generation-2', fence: 14, recovered: true, mutations: [ @@ -145,12 +171,53 @@ describe('stale running turns on a cold acquire', () => { }) }) + it('cancels only prompts whose callbacks were lost with the prior owner', async () => { + const pending = promptItem('pending', 1) + const resolved = promptItem('resolved', 2) + const { journal, appendLifecycleBatch } = journalWith([pending, resolved]) + + await expect( + settleStaleSessionStateOnAcquire({ + journal, + sessionId: 'session-1', + fence: 14, + acquisitionGeneration: 'generation-2' + }) + ).resolves.toBe(1) + + expect(appendLifecycleBatch).toHaveBeenCalledExactlyOnceWith({ + settlementId: 'stale-session:session-1:14:generation-2', + fence: 14, + recovered: true, + mutations: [ + { + kind: 'item', + identity: { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'approval-pending' + }, + body: { + ...pending.body, + resolution: { + state: 'cancelled', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } + ] + }) + }) + it('writes nothing when no turn is running and keys on the journal position without a generation', async () => { const idle = journalWith([ lifecycleItem('turn-1', 'completed', 1, { startedAt: 10, completedAt: 20 }) ]) await expect( - settleStaleRunningTurnsOnAcquire({ + settleStaleSessionStateOnAcquire({ journal: idle.journal, sessionId: 'session-1', fence: 14, @@ -160,14 +227,14 @@ describe('stale running turns on a cold acquire', () => { expect(idle.appendLifecycleBatch).not.toHaveBeenCalled() const running = journalWith([lifecycleItem('turn-2', 'running', 2)]) - await settleStaleRunningTurnsOnAcquire({ + await settleStaleSessionStateOnAcquire({ journal: running.journal, sessionId: 'session-1', fence: 14, acquisitionGeneration: null }) expect(running.appendLifecycleBatch).toHaveBeenCalledWith( - expect.objectContaining({ settlementId: 'stale-turn:session-1:14:seq-8' }) + expect.objectContaining({ settlementId: 'stale-session:session-1:14:seq-8' }) ) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index 940b0c8be17..78609bd199b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -17,6 +17,7 @@ import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-re import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' export type StructuredAgentSessionTurnVerdict = | { state: 'interrupted'; completedAt: number } @@ -58,6 +59,28 @@ export function runningTurnLifecycleRevisions( return revisions } +function staleSessionLifecycleRevisions( + items: readonly AgentJournalRenderItem[] +): JournalLifecycleMutationInput[] { + const revisions: JournalLifecycleMutationInput[] = [] + for (const item of items) { + const identity = parseAgentJournalItemKey(item.itemId) + if (!identity) { + continue + } + const cancelled = + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ? cancelledJournalPromptBody(item.body) + : null + if (cancelled) { + revisions.push({ kind: 'item', identity, body: cancelled }) + } + } + revisions.push(...runningTurnLifecycleRevisions(items, UNVERIFIABLE_TURN_VERDICT)) + return revisions +} + function settledLifecycle( lifecycle: AgentJournalTurnLifecycle, verdict: StructuredAgentSessionTurnVerdict @@ -77,19 +100,16 @@ function settledLifecycle( /** A running row found when a NEW child is acquired belongs to a generation whose exit nobody * observed. Must run before that child's buffered events land, or a live turn would be judged. */ -export async function settleStaleRunningTurnsOnAcquire(input: { +export async function settleStaleSessionStateOnAcquire(input: { journal: AgentSessionJournal sessionId: string fence: number acquisitionGeneration: string | null }): Promise { const { journal } = input - const revisions = runningTurnLifecycleRevisions( - journal.snapshot().items, - UNVERIFIABLE_TURN_VERDICT - ) + const revisions = staleSessionLifecycleRevisions(journal.snapshot().items) const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` - const settlementId = `stale-turn:${input.sessionId}:${input.fence}:${generation}` + const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` for (const chunk of partitionJournalLifecycleMutations(settlementId, revisions)) { await journal.appendLifecycleBatch({ settlementId: chunk.settlementId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index 26ad85b5cfb..6ac29cab0ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -3,28 +3,17 @@ import { decodeAgentSessionQuestionAnswers, isValidAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' -import type { - AgentJournalItemBody, - AgentJournalQuestion, - AgentJournalResolution -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalResolution } from '../../../shared/agent-session-journal-types' import type { AgentSessionPromptResult } from '../../../shared/agent-session-wire' import { decodeCodexQuestionOptionId } from '../../codex/codex-structured-prompt-replies' +import { AgentSessionPromptUnavailableError } from './structured-agent-session-adapter' +import { validatePendingPrompt } from './structured-agent-session-prompt-state' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' function invalid(message: string): TurnOutcome { return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } } -function promptBodyOf(body: AgentJournalItemBody): { - options: readonly { id: string }[] - freeTextQuestionId?: string - questions?: AgentJournalQuestion[] - resolution: AgentJournalResolution -} | null { - return body.kind === 'approval' || body.kind === 'question' ? body : null -} - export async function performPrompt( ctx: AgentSessionTurnContext, input: { @@ -34,50 +23,22 @@ export async function performPrompt( kind: 'approval' | 'question' } ): Promise> { - const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) - if (!item) { - return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) - } - const prompt = promptBodyOf(item.body) - if (!prompt || item.body.kind !== input.kind) { - return invalid(`Item ${input.itemId} is not a pending ${input.kind}.`) - } - if (item.revision !== input.expectedRevision) { - return { - ok: false, - refusal: { - code: 'agent_session_item_revision_stale', - message: `Item ${input.itemId} has moved on.`, - currentRevision: item.revision, - resolution: prompt.resolution - } - } - } - if (prompt.resolution.state !== 'pending') { - return { - ok: false, - refusal: { - code: 'agent_session_already_resolved', - message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, - currentRevision: item.revision, - resolution: prompt.resolution - } - } + const validated = validatePendingPrompt(ctx, input) + if (!validated.ok) { + return validated } + const { prompt } = validated + const question = prompt.kind === 'question' ? prompt : null const freeText = decodeCodexQuestionOptionId(input.optionId) const acceptsFreeText = - item.body.kind === 'question' && - prompt.freeTextQuestionId !== undefined && - freeText?.questionId === prompt.freeTextQuestionId && + question?.freeTextQuestionId !== undefined && + freeText?.questionId === question.freeTextQuestionId && freeText.answer.trim().length > 0 - const grouped = - item.body.kind === 'question' && prompt.questions - ? decodeAgentSessionQuestionAnswers(input.optionId) - : null + const grouped = question?.questions ? decodeAgentSessionQuestionAnswers(input.optionId) : null const acceptsGrouped = grouped !== null && - prompt.questions !== undefined && - isValidAgentSessionQuestionAnswers(prompt.questions, grouped) + question?.questions !== undefined && + isValidAgentSessionQuestionAnswers(question.questions, grouped) if ( !acceptsFreeText && !acceptsGrouped && @@ -96,24 +57,32 @@ export async function performPrompt( resolvedBy: ctx.resolvedBy, resolvedAt: ctx.now() } - const appended = await ctx.journal.appendItem( - identity, - { ...item.body, resolution }, - { - fence: ctx.fence - } - ) - ctx.publish() - + const committed: { item?: Awaited> } = {} try { await ctx.adapter.answerPrompt({ sessionId: ctx.sessionId, itemId: input.itemId, kind: input.kind, optionId: input.optionId, - fence: ctx.fence + fence: ctx.fence, + commit: async () => { + committed.item = await ctx.journal.appendItem( + identity, + { ...prompt, resolution }, + { + fence: ctx.fence + } + ) + ctx.publish() + } }) } catch (error) { + if (!committed.item && error instanceof AgentSessionPromptUnavailableError) { + return invalid(error.message) + } + if (!committed.item) { + throw error + } await ctx.journal.appendItem( { provider: 'orca', clientMessageId: `${input.itemId}#delivery` }, { @@ -126,6 +95,10 @@ export async function performPrompt( ) ctx.publish() } + const appended = committed.item + if (!appended) { + throw new Error(`Provider adapter did not commit prompt ${input.itemId}.`) + } return { ok: true, value: { itemId: appended.itemId, revision: appended.revision, resolution } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index aa0785da31a..59b1e74898c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -54,6 +54,7 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } @@ -101,6 +102,7 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } @@ -133,6 +135,7 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } @@ -164,6 +167,7 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 4c275ecd738..c666047e1e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -21,6 +21,7 @@ import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { validatePendingPrompt } from './structured-agent-session-prompt-state' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' @@ -34,6 +35,8 @@ export type AgentSessionTurnContext = { /** Opaque client identity recorded as the resolver of a prompt. */ resolvedBy: string publish: () => void + /** Drains provider lifecycle already accepted by the execution host. */ + flushStreamedEvents: () => Promise now: () => number } @@ -186,8 +189,15 @@ export async function performCancel( turnId: string scope?: 'background-tasks' taskId?: string + prompt?: { itemId: string; expectedRevision: number } } ): Promise> { + if (input.prompt) { + const validated = validatePendingPrompt(ctx, input.prompt) + if (!validated.ok) { + return validated + } + } let cancelled = false let note = 'Cancellation requested.' try { @@ -203,17 +213,24 @@ export async function performCancel( await ctx.adapter.cancelTurn({ sessionId: ctx.sessionId, turnId: input.turnId, - fence: ctx.fence + fence: ctx.fence, + ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) }) ).cancelled if (!cancelled) { note = 'The provider had already finished this turn.' } } catch (error) { + if (input.prompt) { + throw error + } note = `Cancellation was not confirmed: ${ error instanceof Error ? error.message : String(error) }` } + if (cancelled && input.prompt) { + await ctx.flushStreamedEvents() + } if (input.scope) { return { ok: true, value: { turnId: input.turnId, cancelled } } } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 9a2bff7c9fc..44131886f5b 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -54,6 +54,7 @@ export function runStructuredConversationCommand( envelope, journal: context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), + flushStreamedEvents: context.flushStreamedEvents, now: context.now, plan: { method: 'agentSession.conversationCommand', diff --git a/src/main/native-chat/transcript-stream-lines.test.ts b/src/main/native-chat/transcript-stream-lines.test.ts index 48feedfbd98..25ac54d904a 100644 --- a/src/main/native-chat/transcript-stream-lines.test.ts +++ b/src/main/native-chat/transcript-stream-lines.test.ts @@ -1,6 +1,6 @@ import { Readable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' -import { decodeTranscriptStream } from './transcript-stream-lines' +import { decodeTranscriptStream, splitTranscriptStreamLines } from './transcript-stream-lines' const decode = (line: string, id: string) => ({ id, @@ -171,3 +171,36 @@ describe('decodeTranscriptStream', () => { expect(result.consumedBytes).toBe(Buffer.byteLength(complete, 'utf8')) }) }) + +describe('bounded transcript records', () => { + async function collect(chunks: (Buffer | string)[], limit: number) { + const records: string[] = [] + for await (const record of splitTranscriptStreamLines(Readable.from(chunks), limit)) { + records.push(record.line) + } + return records + } + + it.each(['', '\n', '\nnext\n'])('rejects an oversized record ending in %j', async (ending) => { + await expect(collect(['1234', `5${ending}`], 4)).rejects.toThrow('record exceeds 4 byte limit') + await expect(collect([`12345${ending}`], 4)).rejects.toThrow('record exceeds 4 byte limit') + }) + + it('resets the byte budget per record and accepts the exact limit', async () => { + expect(await collect(['1234\n123', '4\n1234'], 4)).toEqual(['1234', '1234', '1234']) + }) + + it('counts UTF-8 bytes across split codepoints', async () => { + const bytes = Buffer.from('😀é') + const chunks = [bytes.subarray(0, 2), bytes.subarray(2, 5), bytes.subarray(5)] + expect((await collect(chunks, 6))[0]).toBe('😀é') + await expect(collect(chunks, 5)).rejects.toThrow('record exceeds 5 byte limit') + expect((await collect(['\ud83d', '\ude00'], 4))[0]).toBe('😀') + }) + + it('checks the decoder tail before emitting it', async () => { + await expect(collect([Buffer.from([0x61, 0xf0, 0x9f])], 3)).rejects.toThrow( + 'record exceeds 3 byte limit' + ) + }) +}) diff --git a/src/main/native-chat/transcript-stream-lines.ts b/src/main/native-chat/transcript-stream-lines.ts index 4a58b4e180f..ce22822b76c 100644 --- a/src/main/native-chat/transcript-stream-lines.ts +++ b/src/main/native-chat/transcript-stream-lines.ts @@ -42,12 +42,13 @@ export async function decodeTranscriptStream( type TranscriptLine = { line: string; byteLength: number; terminated: boolean } export async function* splitTranscriptStreamLines( - stream: AsyncIterable + stream: AsyncIterable, + maxRecordBytes = Infinity ): AsyncGenerator { let records: TranscriptLine[] = [] const framer = createTranscriptLineFramer((line, byteLength, terminated) => { records.push({ line, byteLength, terminated }) - }) + }, maxRecordBytes) for await (const chunk of stream) { framer.write(chunk) for (const record of records) { @@ -63,10 +64,12 @@ export async function* splitTranscriptStreamLines( /** Frame chunks synchronously so native decoding avoids a promise per record. */ function createTranscriptLineFramer( - emit: (line: string, byteLength: number, terminated: boolean) => void + emit: (line: string, byteLength: number, terminated: boolean) => void, + maxRecordBytes = Infinity ): { write(chunk: Buffer | string): void; end(): void } { const decoder = new StringDecoder('utf8') let pending: string[] = [] + let pendingBytes = 0 return { write, end } function write(chunk: Buffer | string): void { @@ -75,23 +78,44 @@ function createTranscriptLineFramer( let newlineIndex = text.indexOf('\n') while (newlineIndex !== -1) { let segment = text.slice(lineStart, newlineIndex + 1) + checkRecordBytes(segment.slice(0, -1)) if (pending.length > 0) { pending.push(segment) segment = pending.join('') pending = [] } + pendingBytes = 0 emit(segment.slice(0, -1), Buffer.byteLength(segment, 'utf8'), true) lineStart = newlineIndex + 1 newlineIndex = text.indexOf('\n', lineStart) } if (lineStart < text.length) { - pending.push(text.slice(lineStart)) + const segment = text.slice(lineStart) + checkRecordBytes(segment) + pending.push(segment) + } + } + + function checkRecordBytes(segment: string): void { + if (maxRecordBytes === Infinity) { + return + } + pendingBytes += Buffer.byteLength(segment, 'utf8') + const previous = pending.at(-1) + // Separately encoded surrogate halves become one four-byte codepoint when joined. + if (previous && /[\uD800-\uDBFF]$/.test(previous) && /^[\uDC00-\uDFFF]/.test(segment)) { + pendingBytes -= 2 + } + if (pendingBytes > maxRecordBytes) { + pending = [] + throw new Error(`Session transcript record exceeds ${maxRecordBytes} byte limit`) } } function end(): void { const tail = decoder.end() if (tail) { + checkRecordBytes(tail) pending.push(tail) } const line = pending.join('') diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index e5c75c1ff01..ec1df02d714 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -172,6 +172,7 @@ export class RepoLifecycleOperations { | 'worktreeBaseRef' | 'worktreeBasePath' | 'kind' + | 'folderUpgradeGitRootPath' | 'executionHostId' | 'symlinkPaths' | 'issueSourcePreference' diff --git a/src/main/persistence/tracking-repos/repo-hydration.ts b/src/main/persistence/tracking-repos/repo-hydration.ts index 10728f55b67..3c5a455419c 100644 --- a/src/main/persistence/tracking-repos/repo-hydration.ts +++ b/src/main/persistence/tracking-repos/repo-hydration.ts @@ -28,6 +28,7 @@ export function repoGitUsernameCacheKey( export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap): Repo { const { + folderUpgradeGitRootPath, repoIcon: rawRepoIcon, upstream: rawUpstream, gitRemoteIdentity: rawGitRemoteIdentity, @@ -57,6 +58,9 @@ export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap ({ resolveForegroundMock: vi.fn() })) + +vi.mock('./agent-foreground-process', () => ({ + resolveAgentForegroundProcessWithAvailability: resolveForegroundMock, + confirmShellForegroundProcess: vi.fn() +})) +import { + hasLocalPtyChildProcesses, + inspectLocalPtyChildProcesses +} from './local-pty-foreground-inspection' +import { LocalPtyProvider } from './local-pty-provider' +import { ptyProcesses, ptyShellName } from './local-pty-provider-state' +import { inspectPtyProviderProcess } from './pty-process-inspection' + +function registerPane(id: string, foreground: string | (() => string), shell?: string): void { + const pane: pty.IPty = { + pid: 4242, + cols: 80, + rows: 24, + get process(): string { + return typeof foreground === 'function' ? foreground() : foreground + }, + handleFlowControl: false, + onData: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + resize() {}, + clear() {}, + write() {}, + kill() {}, + pause() {}, + resume() {} + } + ptyProcesses.set(id, pane) + if (shell) { + ptyShellName.set(id, shell) + } +} + +beforeEach(() => { + resolveForegroundMock.mockReset() + resolveForegroundMock.mockResolvedValue({ available: true, processName: '/bin/zsh' }) +}) + +afterEach(() => { + ptyProcesses.clear() + ptyShellName.clear() +}) + +describe('inspectLocalPtyChildProcesses', () => { + it('reports unverifiable when the pty fd cannot be read', () => { + registerPane( + 'pty-closed', + () => { + throw new Error('EBADF: bad file descriptor') + }, + '/bin/zsh' + ) + + // Not `no-children`: the close guard reads that as "nothing is running here" and kills the pane. + expect(inspectLocalPtyChildProcesses('pty-closed')).toBe('unverifiable') + }) + + it('still answers no-children when the shell itself is in the foreground', () => { + registerPane('pty-idle', '/bin/zsh', '/bin/zsh') + expect(inspectLocalPtyChildProcesses('pty-idle')).toBe('no-children') + }) + + it('answers children when something else is in the foreground', () => { + registerPane('pty-busy', 'vim', '/bin/zsh') + expect(inspectLocalPtyChildProcesses('pty-busy')).toBe('children') + }) + + it('treats a pane this provider does not hold as a real negative', () => { + expect(inspectLocalPtyChildProcesses('pty-absent')).toBe('no-children') + }) + + it('collapses uncertainty to false only in the boolean adapter', async () => { + registerPane( + 'pty-closed', + () => { + throw new Error('EBADF: bad file descriptor') + }, + '/bin/zsh' + ) + + // The adapter exists for `IPtyProvider.hasChildProcesses`, which has no third slot. + await expect(hasLocalPtyChildProcesses('pty-closed')).resolves.toBe(false) + }) +}) + +describe('inspectPtyProviderProcess child-process evidence', () => { + const provider = new LocalPtyProvider() + + it('carries unverifiable evidence when the child read fails after foreground inspection', async () => { + let reads = 0 + registerPane( + 'pty-closing', + () => { + reads += 1 + if (reads > 1) { + throw new Error('EBADF: bad file descriptor') + } + return '/bin/zsh' + }, + '/bin/zsh' + ) + + await expect(inspectPtyProviderProcess(provider, 'pty-closing')).resolves.toEqual({ + foregroundProcess: '/bin/zsh', + hasChildProcesses: false, + childProcessEvidence: 'unverifiable' + }) + }) + + it('samples child evidence after foreground inspection', async () => { + let reads = 0 + registerPane('pty-became-busy', () => (reads++ === 0 ? '/bin/zsh' : 'vim'), '/bin/zsh') + + const inspection = await inspectPtyProviderProcess(provider, 'pty-became-busy') + expect(inspection.hasChildProcesses).toBe(true) + expect(inspection.childProcessEvidence).toBe('children') + }) + + it('carries no-children evidence from the local inspectProcess operation', async () => { + registerPane('pty-idle', '/bin/zsh', '/bin/zsh') + + const inspection = await inspectPtyProviderProcess(provider, 'pty-idle') + expect(inspection.hasChildProcesses).toBe(false) + expect(inspection.childProcessEvidence).toBe('no-children') + }) + + it('carries children evidence from the local inspectProcess operation', async () => { + registerPane('pty-busy', 'vim', '/bin/zsh') + + const inspection = await inspectPtyProviderProcess(provider, 'pty-busy') + expect(inspection.hasChildProcesses).toBe(true) + expect(inspection.childProcessEvidence).toBe('children') + }) +}) diff --git a/src/main/providers/local-pty-foreground-inspection.ts b/src/main/providers/local-pty-foreground-inspection.ts index d4a717a9de2..eec6a19a621 100644 --- a/src/main/providers/local-pty-foreground-inspection.ts +++ b/src/main/providers/local-pty-foreground-inspection.ts @@ -1,3 +1,4 @@ +import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { getCheapProcessTableSnapshot } from '../../shared/cheap-process-table-snapshot-reader' import { getProcessTableSnapshot } from '../../shared/process-table-snapshot-reader' @@ -21,23 +22,28 @@ import { import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes' import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows-pty-job-membership' -export async function hasLocalPtyChildProcesses(id: string): Promise { +export function inspectLocalPtyChildProcesses(id: string): PtyChildProcessVerdict { const proc = ptyProcesses.get(id) if (!proc) { - return false + return 'no-children' } try { const foreground = proc.process const shell = ptyShellName.get(id) if (!shell) { - return true + return 'children' } - return foreground !== shell + return foreground === shell ? 'no-children' : 'children' } catch { - return false + // An unreadable PTY is not evidence that its children exited. + return 'unverifiable' } } +export async function hasLocalPtyChildProcesses(id: string): Promise { + return inspectLocalPtyChildProcesses(id) === 'children' +} + /** * POSIX twin of the Windows job-membership short-circuit below: a pane that already holds a * recognized agent re-proves it from the cheap `ps` tier when the subtree fingerprint is diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index f50ad36d34c..056a829d1dd 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -9,9 +9,11 @@ import { confirmLocalPtyForegroundProcess, confirmLocalPtyShellForeground, getLocalPtyForegroundProcess, - hasLocalPtyChildProcesses + hasLocalPtyChildProcesses, + inspectLocalPtyChildProcesses } from './local-pty-foreground-inspection' import type { LocalPtyProviderOptions } from './local-pty-provider-types' +import type { PtyProcessInspection } from './pty-process-inspection' import { advanceLoadGeneration, clearPtyState, @@ -127,6 +129,16 @@ export class LocalPtyProvider implements IPtyProvider { return hasLocalPtyChildProcesses(id) } + async inspectProcess(id: string): Promise { + const foregroundProcess = await getLocalPtyForegroundProcess(id) + const childProcessEvidence = inspectLocalPtyChildProcesses(id) + return { + foregroundProcess, + hasChildProcesses: childProcessEvidence === 'children', + childProcessEvidence + } + } + getForegroundProcess(id: string): Promise { return getLocalPtyForegroundProcess(id) } diff --git a/src/main/pty/omp-shell-wrapper-alias-safety.test.ts b/src/main/pty/omp-shell-wrapper-alias-safety.test.ts index 3863b3aacaf..d1d0e0aad83 100644 --- a/src/main/pty/omp-shell-wrapper-alias-safety.test.ts +++ b/src/main/pty/omp-shell-wrapper-alias-safety.test.ts @@ -68,3 +68,28 @@ describe.skipIf(process.platform === 'win32')('omp wrapper under a user alias na expectAliasedOmpNameSurvives('/bin/zsh', 'setopt aliases') }) }) + +describe.skipIf(process.platform === 'win32' || !zshAvailable)('OMP wrapper global aliases', () => { + it.each(['--help', '-v', 'models'])('parses with hostile global alias %s', (token) => { + const root = mkdtempSync(join(tmpdir(), 'orca-omp-global-alias-')) + roots.push(root) + const startup = join(root, 'startup.zsh') + writeFileSync( + startup, + [ + `alias -g -- ${token}='${token} 2>&1 | cat'`, + getPosixOmpShellWrapper(), + `if ! __orca_omp_should_skip_extension '${token}'; then exit 1; fi`, + 'printf "parsed\\n"', + `alias -g -- '${token}'` + ].join('\n') + ) + const result = spawnSync('/bin/zsh', ['-f', startup], { + encoding: 'utf8', + env: { ...process.env, HOME: root, ZDOTDIR: root } + }) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('parsed') + expect(result.stdout).toContain('2>&1 | cat') + }) +}) diff --git a/src/main/pty/omp-shell-wrapper.ts b/src/main/pty/omp-shell-wrapper.ts index f5bc25421bb..de1d9bed2af 100644 --- a/src/main/pty/omp-shell-wrapper.ts +++ b/src/main/pty/omp-shell-wrapper.ts @@ -40,13 +40,13 @@ const OMP_SUBCOMMANDS = [ ] as const export function getPosixOmpShellWrapper(): string { - const subcommands = OMP_SUBCOMMANDS.join('|') + const subcommands = OMP_SUBCOMMANDS.map((value) => `'${value}'`).join('|') return `# Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as \`omp config\` keep # their normal argv shape. __orca_omp_should_skip_extension() { case "\${1:-}" in - help|--help|-h|--version|-v) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; ${subcommands}) return 0 ;; esac return 1 diff --git a/src/main/repo-worktrees.test.ts b/src/main/repo-worktrees.test.ts index a6b20c5445f..f211d604002 100644 --- a/src/main/repo-worktrees.test.ts +++ b/src/main/repo-worktrees.test.ts @@ -9,7 +9,8 @@ const { listWorktreeGraphMock, listWorktreesMock, listWorktreesStrictMock } = vi vi.mock('./git/worktree', () => ({ listWorktreeGraph: listWorktreeGraphMock, listWorktrees: listWorktreesMock, - listWorktreesStrict: listWorktreesStrictMock + listWorktreesStrict: listWorktreesStrictMock, + listWorktreesSharedStrictAllowingTrueEmpty: listWorktreesStrictMock })) import { @@ -17,7 +18,8 @@ import { isRepoRoot, listLocalRepoWorktreesStrict, listRepoWorktreeGraph, - listRepoWorktrees + listRepoWorktrees, + listRepoWorktreesForDetectedScan } from './repo-worktrees' import { registerSshGitProvider, unregisterSshGitProvider } from './providers/ssh-git-dispatch' import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability' @@ -270,3 +272,37 @@ describe('repo-worktrees', () => { expect(isRepoRoot(repos, String.raw`c:\repo`)).toBe(true) }) }) + +it('keeps an upgraded linked folder locator in every local listing, including restart hydration', async () => { + const repo = { + id: 'folder', + path: 'C:\\projects\\draft', + displayName: 'draft', + badgeColor: 'blue', + addedAt: 0, + kind: 'git' as const, + folderUpgradeGitRootPath: 'C:/projects/draft' + } + const raw = [ + { path: 'C:/projects/main', head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, + { + path: 'C:/projects/draft', + head: 'def', + branch: 'draft', + isBare: false, + isMainWorktree: false + } + ] + listWorktreesMock.mockResolvedValue(raw) + listWorktreeGraphMock.mockResolvedValue(raw) + listWorktreesStrictMock.mockResolvedValue(raw) + for (const list of [ + listRepoWorktrees, + listRepoWorktreesForDetectedScan, + listRepoWorktreeGraph, + listLocalRepoWorktreesStrict + ]) { + expect(await list(repo)).toEqual([raw[0], { ...raw[1], path: repo.path }]) + } + expect(raw[1].path).toBe('C:/projects/draft') +}) diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index f5d67523286..02495c9ff5c 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -1,3 +1,4 @@ +import { preserveFolderUpgradeWorktreePath } from './folder-upgrade-worktree-path' import type { Repo } from '../shared/repo-types' import type { GitWorktreeInfo } from '../shared/worktree/types' import { @@ -93,9 +94,10 @@ async function listRoutedRepoWorktrees( } return await route.provider.listWorktrees(repo.path) } - return hasLocalRepoWorktreeListOptions(options) + const worktrees = hasLocalRepoWorktreeListOptions(options) ? await listLocal(repo.path, options) : await listLocal(repo.path) + return preserveFolderUpgradeWorktreePath(repo, worktrees) } /** @@ -122,9 +124,10 @@ export async function listRepoWorktreeGraph( if (route.kind === 'ssh') { return route.provider ? await route.provider.listWorktrees(repo.path) : [] } - return hasLocalRepoWorktreeListOptions(options) + const worktrees = hasLocalRepoWorktreeListOptions(options) ? await listWorktreeGraph(repo.path, options) : await listWorktreeGraph(repo.path) + return preserveFolderUpgradeWorktreePath(repo, worktrees) } export async function listLocalRepoWorktreesStrict( @@ -137,7 +140,8 @@ export async function listLocalRepoWorktreesStrict( if (isFolderRepo(repo)) { return [createFolderWorktree(repo)] } - return hasLocalRepoWorktreeListOptions(options) + const worktrees = hasLocalRepoWorktreeListOptions(options) ? await listWorktreesStrict(repo.path, options) : await listWorktreesStrict(repo.path) + return preserveFolderUpgradeWorktreePath(repo, worktrees) } diff --git a/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json b/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json new file mode 100644 index 00000000000..6f64734a888 --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-14T11:25:01.730Z", + "platform": "darwin", + "command": ["bun", "tests/tools/omp-native-title-capture.mjs", ""], + "cols": 100, + "rows": 30, + "note": "OMP source ne7546987ca526eac8f605fac19ef9805b8f01898 buildTerminalTitleWithState; explicit win32 argument on macOS PTY, synthetic state transitions, no model/account. Not a Windows runtime capture.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/omp-native-title-win32.txt b/src/main/runtime/__fixtures__/omp-native-title-win32.txt new file mode 100644 index 00000000000..ac67e688dce --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-native-title-win32.txt @@ -0,0 +1 @@ +]0;π : Run a long task]0;π : release | π : note | OMP ! action required ✦]0;π > Run a long task]0;π > release | π : note | OMP ! action required ✦]0;π ! Run a long task]0;π ! release | π : note | OMP ! action required ✦ \ No newline at end of file diff --git a/src/main/runtime/agent-session-backup-recovery-fence.ts b/src/main/runtime/agent-session-backup-recovery-fence.ts index 88c4ad8d720..dc0fd835fa8 100644 --- a/src/main/runtime/agent-session-backup-recovery-fence.ts +++ b/src/main/runtime/agent-session-backup-recovery-fence.ts @@ -1,15 +1,14 @@ // Recovering the agent-session store from its backup, without minting a second writer. // // The backup is the previous committed generation. The commit that never landed may have granted a -// fence one higher than anything the backup records show, and `isAgentSessionFenceCurrent` compares -// with STRICT EQUALITY — so a next-fence of `recordFence + 1` would *equal* that lost grant and -// accept a writer holding it. `+2` strictly dominates it. +// fence chosen by `nextAgentSessionFence` from the backup lease, and +// `isAgentSessionFenceCurrent` compares with STRICT EQUALITY. That choice may already be above +// `runtimeFence + 1` after an earlier recovery; the new floor must strictly dominate it. // -// The bound "one lost commit can advance a session's fence by at most 1" is what makes +2 enough. -// It holds because every mint site routes through `nextAgentSessionFence` and each performs one -// transition per transaction, and because the save path aborts rather than letting the primary -// advance past a stale backup. A batching refactor would break it silently, so it is pinned by a -// test. +// The bound is one lost mint per backup generation: each mint site uses +// `nextAgentSessionFence` once per transaction, and the save path aborts rather than advancing the +// primary past a stale backup. A source-level ratchet rejects direct `+ 1` mints; an indirected +// mint is not caught. // // This records a FLOOR for the next grant and leaves the current fence alone. Rewriting the current // fence is what an earlier version did, and it corrupted exactly the records it meant to save: a @@ -24,19 +23,20 @@ // once transactions are admitted. Nulling that evidence is how you get two writers on one provider // session; the fence protects the store, not the provider session. +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import type { AgentSessionStoreState } from './agent-session-record-store-file' -/** Strictly above any fence the lost commit could have granted for that session. */ -export const AGENT_SESSION_BACKUP_RECOVERY_FENCE_MARGIN = 2 - export function raiseAgentSessionFencesAfterBackupRecovery(state: AgentSessionStoreState): void { for (const [sessionId, record] of state.records) { - const floor = record.lease.runtimeFence + AGENT_SESSION_BACKUP_RECOVERY_FENCE_MARGIN + const floor = nextAgentSessionFence(record.lease) + 1 + if (!Number.isSafeInteger(floor)) { + throw new Error('agent_session_fence_exhausted') + } state.records.set(sessionId, { ...record, lease: { ...record.lease, - minimumNextFence: Math.max(floor, record.lease.minimumNextFence ?? 0) + minimumNextFence: floor } }) } diff --git a/src/main/runtime/agent-session-backup-recovery.test.ts b/src/main/runtime/agent-session-backup-recovery.test.ts index 18d1ec00bda..0ea430774aa 100644 --- a/src/main/runtime/agent-session-backup-recovery.test.ts +++ b/src/main/runtime/agent-session-backup-recovery.test.ts @@ -187,6 +187,120 @@ describe('recovery from the committed backup', () => { expect(granted.decision === 'granted' && granted.nextFence).toBeGreaterThan(fence + 1) }) + it('does not reissue a grant after two backup fallbacks and a backup rotation', async () => { + await seedSession('session-a') + await seedSession('session-b') + const loaded = await loadAgentSessionStore(storePath, 'local') + const record = loaded.state.records.get('session-a') + if (!record) { + throw new Error('seeded session missing') + } + loaded.state.records.set('session-a', { + ...record, + lease: { + ...record.lease, + runtimeFence: 7, + claimStatus: 'released', + handoffStage: null, + reservedSpawnToken: null + } + }) + // Two commits put the prepared generation in the backup, just as normal rotation would. + await saveAgentSessionStore(storePath, loaded.state, { primaryStatus: 'validated' }) + await saveAgentSessionStore(storePath, loaded.state, { primaryStatus: 'validated' }) + const identity = { + location: record.location, + provider: record.provider, + accountHome: record.accountHome, + runtimeKind: record.lease.runtimeKind, + claimKeyId: record.lease.claimKeyId + } + + await rm(storePath, { force: true }) + const first = await openStore() + await first.retireClaimKey(`retire-${operationId()}`, NOW) + await first.reconcileOnRestart({ + probe: async () => ({ outcome: 'reservation-unused' }), + now: NOW + }) + expect(first.getRecord('session-a')?.lease).toMatchObject({ + runtimeFence: 7, + minimumNextFence: 9, + unreconciled: false + }) + const firstGrant = await first.reserveOwner({ + ...identity, + sessionId: 'session-a', + expectedFence: 7, + spawnToken: 'first-recovery', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'first-recovery' }, + now: NOW + }) + const firstFence = firstGrant.record.lease.runtimeFence + const rotated = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.get( + 'session-a' + ) + expect(rotated?.lease).toMatchObject({ runtimeFence: 7, minimumNextFence: 9 }) + + // The primary's grant is lost, but its owner may still hold that exact fence. + await rm(storePath, { force: true }) + const second = await openStore() + await second.retireClaimKey(`retire-${operationId()}`, NOW) + await second.reconcileOnRestart({ + probe: async () => ({ outcome: 'reservation-unused' }), + now: NOW + }) + const secondGrant = await second.reserveOwner({ + ...identity, + sessionId: 'session-a', + expectedFence: 7, + spawnToken: 'second-recovery', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'second-recovery' }, + now: NOW + }) + expect(secondGrant.record.lease.runtimeFence).toBeGreaterThan(firstFence) + expect((await openStore()).getRecord('session-a')?.lease.runtimeFence).toBe( + secondGrant.record.lease.runtimeFence + ) + }) + + it.each([ + [Number.MAX_SAFE_INTEGER - 2, Number.MAX_SAFE_INTEGER], + [Number.MAX_SAFE_INTEGER - 1, null] + ])('keeps the recovered floor safe at fence %i', async (runtimeFence, expectedFloor) => { + await seedSession('session-a') + await seedSession('session-b') + const backupPath = `${storePath}.bak` + const backup = JSON.parse(await readFile(backupPath, 'utf-8')) + backup.records['session-a'].lease.runtimeFence = runtimeFence + await writeFile(backupPath, JSON.stringify(backup)) + await rm(storePath, { force: true }) + + const recovered = await openStore() + if (expectedFloor === null) { + await expect(recovered.retireClaimKey(`retire-${operationId()}`, NOW)).rejects.toThrow( + 'agent_session_fence_exhausted' + ) + await expect(stat(storePath)).rejects.toMatchObject({ code: 'ENOENT' }) + const preserved = (await loadAgentSessionStore(backupPath, 'local')).state.records.get( + 'session-a' + )?.lease + expect(preserved?.runtimeFence).toBe(runtimeFence) + expect(preserved?.minimumNextFence).toBeUndefined() + } else { + await recovered.retireClaimKey(`retire-${operationId()}`, NOW) + expect(recovered.getRecord('session-a')?.lease).toMatchObject({ + runtimeFence, + minimumNextFence: expectedFloor + }) + expect((await openStore()).getRecord('session-a')?.lease.minimumNextFence).toBe(expectedFloor) + } + }) + it('leaves recovered records valid, so the next load does not quarantine them', async () => { await seedLiveSession('session-a') await seedSession('session-b') diff --git a/src/main/runtime/agent-session-surface-release-transition.test.ts b/src/main/runtime/agent-session-surface-release-transition.test.ts new file mode 100644 index 00000000000..5e671acc77f --- /dev/null +++ b/src/main/runtime/agent-session-surface-release-transition.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { releaseAgentSessionOwnerAfterSurfaceClose } from './agent-session-surface-release-transition' + +describe('agent session surface release transition', () => { + it('honours a recovery floor when releasing the owner', () => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'native', minimumNextFence: 9 }) + ) + + const released = releaseAgentSessionOwnerAfterSurfaceClose({ + record, + expectedFence: 7, + now: 1_800_000_001_000 + }) + + expect(released.lease.runtimeFence).toBe(9) + }) +}) diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index a59d5cb5141..61da9021a5f 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -9,6 +9,7 @@ // against the dead generation land on the next one. import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import { assertFence, withLease } from './agent-session-lease-transitions' import type { AgentSessionRecordStore } from './agent-session-record-store' @@ -40,7 +41,7 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { } return withLease(record, { ...record.lease, - runtimeFence: record.lease.runtimeFence + 1, + runtimeFence: nextAgentSessionFence(record.lease), ownerProcess: null, reservedSpawnToken: null, processlessAt: null, diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index fc86de47451..3459c7c8b6e 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -138,7 +138,16 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper // an agent whose first live title is already idle (claude --resume at its // prompt) then shows no transition — the row would strand, which is // exactly #12536. Waiter semantics stay transition-only above. - if (agentStatus === 'idle' && (prevStatus !== 'idle' || !prevObservedLive)) { + // Why the title change joins the edge: a name-only frame routinely lands before the + // hook's `X ready`, and it consumes the working→idle transition. The later ready title + // is an idle→idle step, so gating delivery on `prevStatus !== 'idle'` meant the + // strongest evidence this pane will ever emit never reached delivery at all. The + // waiter branch above already re-offers on that step; the gate makes a repeat harmless. + if ( + agentStatus === 'idle' && + (prevStatus !== 'idle' || !prevObservedLive || prevLeafTitle !== recordedTitle) && + this.checkDeliverySettledAndArmRecheck(leaf) + ) { this.deliverPendingMessagesForLeaf(leaf) } } diff --git a/src/main/runtime/orca-runtime-deliver-pending-messages.ts b/src/main/runtime/orca-runtime-deliver-pending-messages.ts index 8d55cddd112..55c6a8dad5e 100644 --- a/src/main/runtime/orca-runtime-deliver-pending-messages.ts +++ b/src/main/runtime/orca-runtime-deliver-pending-messages.ts @@ -111,7 +111,8 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv if ( currentLeaf?.ptyId === probedPtyId && currentLeaf.lastAgentStatus === 'idle' && - currentLeaf.lastAgentStatusObservedLive + currentLeaf.lastAgentStatusObservedLive && + this.checkDeliverySettledAndArmRecheck(currentLeaf) ) { this.deliverPendingMessages(currentLeaf, { mailboxHandle, diff --git a/src/main/runtime/orca-runtime-remove-managed-worktree.ts b/src/main/runtime/orca-runtime-remove-managed-worktree.ts index 25a294c2a90..9cb95e5d354 100644 --- a/src/main/runtime/orca-runtime-remove-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-managed-worktree.ts @@ -13,13 +13,14 @@ import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { resolveWorktreeRemovalRoute } from '../worktree-removal-execution-host-route' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { listWorktreesStrict } from '../git/worktree' +import { isPrunableGitFileWorktree } from '../worktree-prunable-git-file' import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' import { removeRuntimeUnregisteredWorktree } from './runtime-unregistered-worktree-removal' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' import { formatWorktreeRemovalError } from '../ipc/worktree-logic' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { isRuntimeWorktreePathMissing } from './runtime-worktree-filesystem' -import { removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval } from '../local-worktree-removal-recovery' +import { removeStaleLocalWorktreeRegistration } from '../local-worktree-removal-recovery' import { cleanupUnusedWorktreePushTargetRemote } from '../ipc/worktree-remote' import { removeRuntimeRegisteredRemoteWorktree } from './runtime-registered-remote-worktree-removal' import { removeRuntimeRegisteredLocalWorktree } from './runtime-registered-local-worktree-removal' @@ -146,18 +147,19 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM } if ( route.kind === 'local' && - force === true && - process.platform === 'win32' && - (isWindowsAbsolutePathLike(canonicalWorktreePath) || - !!localWorktreeGitOptions.wslDistro) && - removedMeta && - (await isRuntimeWorktreePathMissing( - route.hostId, - canonicalWorktreePath, - localWorktreeGitOptions - )) + ((await isPrunableGitFileWorktree(registeredWorktree, localWorktreeGitOptions)) || + (force === true && + process.platform === 'win32' && + (isWindowsAbsolutePathLike(canonicalWorktreePath) || + !!localWorktreeGitOptions.wslDistro) && + removedMeta && + (await isRuntimeWorktreePathMissing( + route.hostId, + canonicalWorktreePath, + localWorktreeGitOptions + )))) ) { - const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + const removalResult = await removeStaleLocalWorktreeRegistration({ canonicalWorktreePath, repoPath: repo.path, localWorktreeGitOptions, diff --git a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts index 221bb873545..ea6329db3e6 100644 --- a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts +++ b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts @@ -117,6 +117,76 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc }) } + /** + * Settled-enough-to-type check that also arms a retry when it says no. + * + * Why the retry: the wait path POLLS, so weak evidence that only becomes valid with the + * passage of time (a pane going quiet) eventually satisfies it. Delivery is edge-driven — + * a title transition, a graph sync, a new message — with no poll behind it, so a refusal + * at an edge is final unless another edge happens to arrive. A hookless Codex pane never + * emits an explicit `X ready`, so the refusal below would strand the queued message + * permanently once the pane fell quiet. One-shot timer, armed only for a leaf that + * actually refused, cleared as soon as any path delivers. + */ + protected checkDeliverySettledAndArmRecheck(leaf: { tabId: string; leafId: string }): boolean { + const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + if (this.isAgentSettledForDelivery(leaf)) { + this.clearDeliveryRecheck(leafKey) + return true + } + this.armDeliveryRecheck(leafKey) + return false + } + + protected clearDeliveryRecheck(leafKey: string): void { + const timer = this.deliveryRecheckTimersByLeafKey.get(leafKey) + if (timer) { + clearTimeout(timer) + this.deliveryRecheckTimersByLeafKey.delete(leafKey) + } + } + + private armDeliveryRecheck(leafKey: string): void { + if (this.deliveryRecheckTimersByLeafKey.has(leafKey)) { + return + } + const live = this.leaves.get(leafKey) + // Why this delay: the only refusal that time alone can lift is tier 3 waiting on the + // stream to go quiet, so wake just after the window could have elapsed. A pane that is + // still producing output re-arms from its own fresher timestamp rather than spinning. + const elapsed = live?.lastOutputAt ? Date.now() - live.lastOutputAt : 0 + const delay = Math.max(TUI_IDLE_QUIESCENCE_MS - elapsed, 0) + 50 + const timer = setTimeout(() => { + this.deliveryRecheckTimersByLeafKey.delete(leafKey) + const current = this.leaves.get(leafKey) + if (!current) { + return + } + // Why the gate again here: delivery sites gate at the CALL, not inside + // deliverPendingMessagesForLeaf, so firing straight into it would hand the retry the + // very injection the gate exists to prevent. A pane that went busy again re-arms. + if (this.checkDeliverySettledAndArmRecheck(current)) { + this.deliverPendingMessagesForLeaf(current) + } + }, delay) + timer.unref?.() + this.deliveryRecheckTimersByLeafKey.set(leafKey, timer) + } + + /** + * Whether this pane is settled enough to TYPE INTO. + * + * Why the same ranking as the wait path: mailbox delivery writes the pointer plus Enter + * into the pane, so acting on a name-only `Codex` title mid-turn injects keystrokes into + * a running agent's session. That is the #6011 mis-settlement in a path with a worse + * failure mode than a racing script. Liveness stays a separate requirement — callers + * keep their own `lastAgentStatusObservedLive` checks. + */ + protected isAgentSettledForDelivery(leaf: { tabId: string; leafId: string }): boolean { + const live = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + return live ? this.isTuiIdleSatisfiedForLeaf(live) : false + } + protected isTuiIdleSatisfiedForPty(pty: RuntimePtyWorktreeRecord): boolean { return isTuiIdleSatisfied({ record: pty, diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 0d934548332..44f4524c782 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -134,7 +134,13 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith return { handle, tabId: leaf.tabId, title } } } - return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, title } + const tabId = pty.pty.tabId ?? pty.record.tabId + // A notifier can exist before its pane graph; retain the rename on the known tab. + if (this.notifier?.renameTerminal && tabId) { + this.persistHeadlessTerminalTitle(pty.pty.worktreeId, tabId, title) + this.notifier.renameTerminal(tabId, title) + } + return { handle, tabId, title } } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 0806b705fdf..00ccdde9da9 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -274,6 +274,9 @@ export class OrcaRuntimeWithRuntimeId { return pty?.launchAgent ?? pty?.foregroundAgent ?? null } + /** One-shot delivery retries, keyed by leaf. See checkDeliverySettledAndArmRecheck. */ + protected deliveryRecheckTimersByLeafKey = new Map>() + protected leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index 1e321bc7584..dd3afbfa23e 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -139,7 +139,11 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi leaf.lastAgentStatus = restoredStatus if (restoredStatus === 'idle') { this.resolveTuiIdleWaiters(leaf) - this.deliverPendingMessagesForLeaf(leaf) + // Why gated like every other delivery edge: a neutral-title restoration can + // reinstate `idle` from a name-only title, which is not evidence a turn ended. + if (this.checkDeliverySettledAndArmRecheck(leaf)) { + this.deliverPendingMessagesForLeaf(leaf) + } } } } diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index c7f883fcfcb..4989245fdf6 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -203,6 +203,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getLeaf: (leafKey) => this.leaves.get(leafKey), getLeafKey: (tabId, leafId) => this.getLeafKey(tabId, leafId), getLiveLeafForHandle: (handle) => this.getLiveLeafForHandle(handle).leaf, + isAgentSettledForDelivery: (leaf) => this.checkDeliverySettledAndArmRecheck(leaf), getMessageWaiters: (mailboxHandle) => this.messageWaiters.get(mailboxHandle), getTabTitle: (tabId) => this.tabs.get(tabId)?.title, getCliCommand: (terminalHandle) => this.getTerminalOrchestrationCliCommand(terminalHandle), diff --git a/src/main/runtime/orca-runtime-sync-window-graph.ts b/src/main/runtime/orca-runtime-sync-window-graph.ts index 3a784fcd622..e820d15130b 100644 --- a/src/main/runtime/orca-runtime-sync-window-graph.ts +++ b/src/main/runtime/orca-runtime-sync-window-graph.ts @@ -283,6 +283,7 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow this._orchestrationDb && leaf.lastAgentStatus === 'idle' && leaf.lastAgentStatusObservedLive && + this.checkDeliverySettledAndArmRecheck(leaf) && leaf.writable && (!graphWasReady || previousLeaf?.ptyId !== leaf.ptyId || diff --git a/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts b/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts new file mode 100644 index 00000000000..87aabaf4c54 --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts @@ -0,0 +1,92 @@ +import './orca-runtime-test-lifecycle.spec' +import type { RuntimeStore } from './runtime-store-contract' +import { describe, expect, it, vi } from 'vitest' +import { createMobileCreateTestNotifier } from './orca-runtime-test-scenario-builders.spec' +import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec' +import { + HEADLESS_LEAF_ID, + TEST_WORKTREE_ID, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} from './orca-runtime-test-fixtures.spec' + +describe('terminal rename before renderer graph hydration', () => { + it.each(['Media Engine Orch', null])( + 'persists and forwards title %s across PTY replacement', + async (title) => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + session.tabsByWorktree[TEST_WORKTREE_ID][0].customTitle = 'Previous name' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The shared fixture supplies RuntimeStore methods; its legacy Mock return type loses callable signatures. + const checkedStore = runtimeStore as RuntimeStore + const runtime = new OrcaRuntimeService(checkedStore) + const renameTerminal = vi.fn() + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'omp-initial-pty' })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal, + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + + await runtime.renameTerminal(created.handle, title) + + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID][0].customTitle).toBe(title) + expect(renameTerminal).toHaveBeenCalledWith('host-tab', title) + runtime.onPtyExit('omp-initial-pty', 0) + const restored = new OrcaRuntimeService(checkedStore) + restored.setPtyController({ + spawn: vi.fn(async () => ({ id: 'omp-replacement-pty' })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await restored.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID][0].customTitle).toBe(title) + } + ) + it('does not recreate a closed persisted tab from a surviving PTY record', async () => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Shared fixture implements RuntimeStore; its legacy Mock typing loses callable signatures. + const runtime = new OrcaRuntimeService(runtimeStore as RuntimeStore) + const notifier = createMobileCreateTestNotifier(vi.fn()) + runtime.setNotifier(notifier) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'surviving-pty' })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + setSession({ ...getSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) + runtimeStore.setWorkspaceSession.mockClear() + + await runtime.renameTerminal(created.handle, 'Late rename') + + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(runtimeStore.setWorkspaceSession).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orchestration/mailbox-pointer-delivery-contract.ts b/src/main/runtime/orchestration/mailbox-pointer-delivery-contract.ts index a0b19b2d27a..91fcac67bd1 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-delivery-contract.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-delivery-contract.ts @@ -22,6 +22,8 @@ export type PointerDeliveryDependencies OrchestrationMailboxLeaf | undefined getLeafKey: (tabId: string, leafId: string) => string getLiveLeafForHandle: (handle: string) => OrchestrationMailboxLeaf + /** Whether the pane is settled enough to type the pointer plus Enter into it. */ + isAgentSettledForDelivery: (leaf: OrchestrationMailboxLeaf) => boolean getMessageWaiters: (mailboxHandle: string) => ReadonlySet | undefined getTabTitle: (tabId: string) => string | null | undefined getCliCommand: (terminalHandle: string) => OrchestrationCliCommand diff --git a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts index b16e5c474b3..b8c8ad7a0c4 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts @@ -70,6 +70,16 @@ export class OrchestrationMailboxPointerDelivery WriteSettlement) { getLeaf: () => LEAF, getLeafKey: () => 'tab-1:leaf-1', getLiveLeafForHandle: () => LEAF, + // These cases exercise staging and Enter phases, not the idle gate; the pane is settled. + isAgentSettledForDelivery: () => true, getMessageWaiters: () => undefined, getTabTitle: () => null, getCliCommand: () => 'orca' as const, diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index fe08e5f808d..91bf1158e07 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -42,8 +42,8 @@ describe('structured mailbox pointer host', () => { // The defect this pins: a running turn is announced by ONE lifecycle item, and settlement // tombstones it rather than rewriting it. A long tool-calling turn pushes that item arbitrarily // far from the tail, so any page-sized read reports a busy worker as idle — and the pointer is - // then delivered mid-turn, which Codex answers with `turn already running` and Claude settles - // `unknown` while the message is really queued. + // then delivered mid-turn, which Codex coalesces into the running turn and Claude queues behind + // it -- either way folded into work already in flight rather than read as a new instruction. const items = [runningTurn(), ...transcript(500)] hostRef.current = { journalSnapshot: () => ({ items }) } expect(createStructuredMailboxPointerHost().readGateFacts('s1')).toEqual({ diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts index 272d7799947..dd8ccf64f71 100644 --- a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts @@ -81,11 +81,14 @@ export function structuredSessionGateFacts( * Decide whether the nudge may be sent right now. * * Mid-turn delivery is refused for both providers rather than delegated to - * them: Codex answers a mid-turn `turn/start` with `turn already running`, and - * Claude accepts the frame but cannot acknowledge it inside the dispatch ack - * window, settling `unknown` while the message is really queued. Waiting for - * the turn to settle is the one contract that holds for both, and it preserves - * orchestration's existing idle-edge-only delivery policy. + * them. Neither refuses the frame: Codex COALESCES a mid-turn `turn/start` into + * the running turn -- measured on codex-cli 0.147.0, 0.150.1 and 0.153.4, none + * of which refuse it and none of which fire a second `turn/started` -- and + * Claude queues it behind the turn. Both therefore + * fold the nudge into work already in flight, where it reads as part of the + * running turn rather than a new instruction. Waiting for the turn to settle is + * the one contract that holds for both, and it preserves orchestration's + * existing idle-edge-only delivery policy. */ export function decideStructuredPointerDelivery(input: { refusal: AgentSessionPtyWriteRefusal diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.ts index 8abad118de7..168bd870118 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.ts @@ -49,8 +49,8 @@ export function listAddressableStructuredWorkers(): OrchestrationAddressableAgen * A structured worker's agent status, in the vocabulary `@idle` already matches on. * * Null when the session cannot be read: unknown must not read as idle, or a broadcast to `@idle` - * would wake a worker mid-turn — which Codex answers with `turn already running` and Claude queues - * behind the running turn. + * would wake a worker mid-turn — which Codex coalesces into the running turn and Claude queues + * behind it. */ export function structuredWorkerAgentStatus(sessionId: string): string | null { const facts = readStructuredSessionGateFacts(sessionId) diff --git a/src/main/runtime/repo-worktree-row-resolution.test.ts b/src/main/runtime/repo-worktree-row-resolution.test.ts index 32111ef2a53..3576b89fe88 100644 --- a/src/main/runtime/repo-worktree-row-resolution.test.ts +++ b/src/main/runtime/repo-worktree-row-resolution.test.ts @@ -4,6 +4,8 @@ import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import type { Store } from '../persistence' +import { mergeWorktreeMetaForWrite } from '../persistence/loading-store/worktree-meta-write-normalization' +import { buildDetectedGitWorktrees } from '../ipc/worktrees/listing/ssh-worktree-fallback' import { listStoredWorktreeRowsForRepo, resolveRepoWorktreeRows, @@ -350,3 +352,42 @@ describe('scoped worktree id resolution across path spellings (#16243)', () => { expect(deps.scanRepo).not.toHaveBeenCalled() }) }) + +describe('folder-to-Git checkout identity', () => { + it.each([ + ['C:\\projects\\draft', 'C:/projects/draft'], + ['C:\\projects\\draft', 'c:/projects/draft'] + ])( + 'preserves the live folder locator %s in desktop and runtime listings', + async (folderPath, gitPath) => { + const owner = { + ...repo('folder', folderPath), + kind: 'git' as const, + folderUpgradeGitRootPath: gitPath + } + const deps = createDeps([owner]) + const oldId = `folder::${folderPath}` + const metadata = mergeWorktreeMetaForWrite(undefined, { + hostId: 'local', + instanceId: 'existing-omp', + comment: 'keep me' + }) + deps.metaById[oldId] = metadata + Object.assign(deps.store, { getProjectHostSetups: () => [] }) + deps.scanRepo.mockResolvedValue({ ok: true, worktrees: [gitWorktree(gitPath)] }) + + const detected = buildDetectedGitWorktrees(deps.store, owner, [gitWorktree(gitPath)]) + const rows = await resolveRepoWorktreeRows(deps, owner, deps.metaById, new Map()) + for (const result of [detected, rows]) { + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + id: oldId, + path: folderPath, + instanceId: 'existing-omp', + comment: 'keep me' + }) + } + expect(Object.keys(deps.metaById)).toEqual([oldId]) + } + ) +}) diff --git a/src/main/runtime/repo-worktree-row-resolution.ts b/src/main/runtime/repo-worktree-row-resolution.ts index e5e88607629..6fca5a5f3b2 100644 --- a/src/main/runtime/repo-worktree-row-resolution.ts +++ b/src/main/runtime/repo-worktree-row-resolution.ts @@ -1,3 +1,4 @@ +import { preserveFolderUpgradeWorktreePath } from '../folder-upgrade-worktree-path' import { splitWorktreeId, splitWorktreeIdForFilesystem, @@ -132,7 +133,7 @@ export async function resolveRepoWorktreeRows( RESOLVED_WORKTREE_REPO_TIMEOUT_MS, null )) ?? { ok: false, worktrees: listStoredWorktreeRowsForRepo(store, repo, repoOwnerCount) } - const gitWorktrees = scan.worktrees + const gitWorktrees = preserveFolderUpgradeWorktreePath(repo, scan.worktrees) if (scan.ok) { pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index c2d46b09818..73e3113aea8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -606,6 +606,19 @@ describe('method routing', () => { expect(hostCalls.cancel).toHaveBeenCalledWith(expect.anything(), params) }) + it('routes strict prompt identity through cancellation', async () => { + const params = { + envelope: envelope(), + turnId: 'turn-1', + prompt: { itemId: 'prompt-1', expectedRevision: 2 } + } + + const response = await call('agentSession.cancel', params, STRUCTURED_CLIENT) + + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.cancel).toHaveBeenCalledWith(expect.anything(), params) + }) + it('routes the structured handoff mutation through the host', async () => { const response = await call('agentSession.requestHandoff', { envelope: envelope(), @@ -648,6 +661,17 @@ describe('parameter validation', () => { turnId: 'turn-1', taskId: 'task-2' }) + await rejects('agentSession.cancel', { + envelope: envelope(), + turnId: 'background-tasks', + scope: 'background-tasks', + prompt: { itemId: 'prompt-1', expectedRevision: 1 } + }) + await rejects('agentSession.cancel', { + envelope: envelope(), + turnId: 'turn-1', + prompt: { itemId: 'prompt-1', expectedRevision: 0 } + }) expect(hostCalls.cancel).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/runtime-terminal-idle-polls.ts b/src/main/runtime/runtime-terminal-idle-polls.ts index 4fb5c470c2f..e1be9654611 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.ts @@ -17,6 +17,19 @@ import { type FirstPartyAgentStatus } from './tui-idle-evidence' import type { TuiAgent } from '../../shared/tui-agent' + +/** + * Why null counts as quiet: a record with no output timestamp has produced nothing the + * RUNTIME OBSERVED since it was created. That is not the same as silence — the reachable + * case is a daemon-hosted pane whose bytes never reach the runtime, which may still be + * streaming. The trade is deliberate: "never settles" becomes "settles uncorroborated", + * the caller keeps its timeout, and delivery cannot reach this lane. Reading it as `0ms since output` + * inverted that — `0 >= quiescenceMs` is false forever, so an adopted pane that never + * emitted could not settle no matter how long the caller waited. + */ +function isQuietForQuiescence(lastOutputAt: number | null, quiescenceMs: number): boolean { + return lastOutputAt === null ? true : Date.now() - lastOutputAt >= quiescenceMs +} import type { TerminalWaiter } from './runtime-terminal-contracts' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' @@ -141,7 +154,7 @@ export class RuntimeTerminalIdlePolls { if ( foreground && !isShellProcess(foreground) && - (live.lastOutputAt ? Date.now() - live.lastOutputAt : 0) >= this.deps.quiescenceMs + isQuietForQuiescence(live.lastOutputAt, this.deps.quiescenceMs) ) { this.stop(entry) this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', live)) @@ -206,7 +219,7 @@ export class RuntimeTerminalIdlePolls { if ( foreground && !isShellProcess(foreground) && - (pty.lastOutputAt ? Date.now() - pty.lastOutputAt : 0) >= this.deps.quiescenceMs + isQuietForQuiescence(pty.lastOutputAt, this.deps.quiescenceMs) ) { this.stop(entry) this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index 990aa293ee4..4fa390be0fb 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -17,7 +17,10 @@ import type { } from '../codex/codex-app-server-connection' import type { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' @@ -37,10 +40,16 @@ const SESSION = 'session-integration-1' const THREAD = 'thread-integration' const TURN = 'turn-1' const WORKSPACE = 'workspace-1' +// The capability set the desktop renderer advertises. Without the pending-send +// one the host holds the reply until the send settles, which is a shim for +// clients too old to render a pending bubble — not what this suite models. const CLIENT = { clientId: 'device-a', clientKind: 'runtime' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: [ + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] } // ─── the fake `codex app-server` ──────────────────────────────────────────── diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index f5a59033d73..219b132566e 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -16,8 +16,14 @@ import type { openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../shared/agent-session-journal-types' import type { AgentSessionHistoryResult, AgentSessionSubscribeEvent @@ -43,10 +49,16 @@ const SESSION = 'session-integration-1' const THREAD = 'thread-integration' const TURN = 'turn-1' const WORKSPACE = 'workspace-1' +// The capability set the desktop renderer advertises. Without the pending-send +// one the host holds the reply until the send settles, which is a shim for +// clients too old to render a pending bubble — not what this suite models. const CLIENT = { clientId: 'device-a', clientKind: 'runtime' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: [ + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] } // ─── the fake `codex app-server` ──────────────────────────────────────────── @@ -269,6 +281,13 @@ function textOf(item: AgentJournalRenderItem): string { : '' } +/** The durable submission row, which settlement rewrites after the send returns. */ +function submissionOf(clientMessageId: string): AgentJournalSubmission | undefined { + return getStructuredAgentSessionHost() + ?.journalSnapshot(SESSION) + .submissions.find((entry) => entry.clientMessageId === clientMessageId) +} + async function historyPage( direction: 'tail' | 'before' | 'after', extra: Record = {} @@ -438,18 +457,25 @@ describe('a structured codex session over agentSession.*', () => { envelope: envelope('agentSession.send', { body }, created.fence), body }) - expect(sent.submission).toMatchObject({ - dispatchState: 'accepted', - providerItemId: `codex:${THREAD}:${TURN}:0` - }) + // Admission, not identity. `turn/start` proves Codex owns the message, but a + // send coalesced into a running turn is answered with that turn's id, so + // which message landed where is knowable only from the echo. + expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) expect(codex.live().calls.at(-1)).toMatchObject({ method: 'turn/start', params: { threadId: THREAD, clientUserMessageId: sent.clientMessageId } }) codex.notify('turn/started', { turn: { id: TURN } }) + // Codex echoes the message back carrying the `clientId` it was sent under, + // which is the only thing that names which submission this row settles. codex.notify('item/completed', { - item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'hi' }] } + item: { + type: 'userMessage', + id: 'item-0', + clientId: sent.clientMessageId, + content: [{ type: 'text', text: 'hi' }] + } }) codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello.' }) @@ -459,6 +485,15 @@ describe('a structured codex session over agentSession.*', () => { await drainStreamedEvents() expect(itemsOf(stream).map(textOf).filter(Boolean)).toEqual(['hi', 'Hello.']) + // The echo is the first item of this turn, so the settled key is ordinal 0 — + // minted by the same `identityFor` a history replay computes with, rather + // than guessed from the turn/start response. + await vi.waitFor(() => + expect(submissionOf(sent.clientMessageId)).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `codex:${THREAD}:${TURN}:0` + }) + ) }) it('runs create → send → stream → approval → cancel → reconnect → page history', async () => { @@ -513,12 +548,10 @@ describe('a structured codex session over agentSession.*', () => { envelope: envelope('agentSession.send', { body }, fence), body }) - // Codex named the turn, so the submission is accepted rather than - // "delivery unconfirmed", and adopts the provider's own item identity. - expect(sent.submission).toMatchObject({ - dispatchState: 'accepted', - providerItemId: `codex:${THREAD}:${TURN}:0` - }) + // Codex took the message, so the submission is pending rather than + // "delivery unconfirmed" — it carries no identity yet, because the response + // to a coalesced send names the running turn rather than this message. + expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) expect(codex.live().calls.at(-1)).toMatchObject({ method: 'turn/start', params: { @@ -531,14 +564,28 @@ describe('a structured codex session over agentSession.*', () => { // ── stream ────────────────────────────────────────────────────────────── codex.notify('turn/started', { turn: { id: TURN } }) - // Codex echoes the user message back as ordinal 0 of the turn. That is the - // key the submission adopted, so the echo has to reconcile into the bubble - // the client already has rather than append a second copy of it. + // Codex echoes the user message back as ordinal 0 of the turn, carrying the + // `clientId` it was sent under. That echo settles the submission's identity, + // and has to reconcile into the bubble the client already has rather than + // append a second copy of it. codex.notify('item/completed', { - item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'list files' }] } + item: { + type: 'userMessage', + id: 'item-0', + clientId: sent.clientMessageId, + content: [{ type: 'text', text: 'list files' }] + } }) await drainStreamedEvents() expect(itemsOf(stream).filter((item) => textOf(item) === 'list files')).toHaveLength(1) + // Settled from the echo's own journal identity, so it is by construction the + // key a replay recomputes for this row. + await vi.waitFor(() => + expect(submissionOf(sent.clientMessageId)).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `codex:${THREAD}:${TURN}:0` + }) + ) codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Two ' }) diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 506a45ae821..7a3736b9009 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -121,9 +121,13 @@ describe('structured session runtime provider-exit wiring', () => { }) } + // `pending` is this send's real answer now, not a weaker one: admission settles + // when the transport takes the frame, and identity arrives later on the + // provider's echo. What proves the message reached the REACQUIRED provider is + // the turn it starts below, which is what this test exists to check. await expect( host.send({ callerKey: 'runtime-test' }, { envelope, body }) - ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) + ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) expect(turn).toBe(1) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index fbc13b58fca..92643fcae57 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -246,6 +246,16 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise[0] + ): void => { + void host?.settleLateDispatch(settlement).catch((error) => + deps.onError?.({ + scope: `structured-agent-session-late-settlement:${settlement.sessionId}`, + error + }) + ) + } const codex = new CodexStructuredSessionAdapter({ resolveLaunch: createCodexStructuredLaunchResolver({ store, @@ -257,6 +267,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishBackgroundTaskState(sessionId, state), + onDispatchSettledLate, onEvent: (event) => { if (event.type !== 'ended' || !('cause' in event) || event.cause !== 'unexpected-exit') { return @@ -298,14 +309,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishBackgroundTaskState(sessionId, state), - onDispatchSettledLate: (settlement) => { - void host?.settleLateDispatch(settlement).catch((error) => - deps.onError?.({ - scope: `structured-agent-session-late-settlement:${settlement.sessionId}`, - error - }) - ) - }, + onDispatchSettledLate, ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) }) diff --git a/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts b/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts new file mode 100644 index 00000000000..02d3a62dcc9 --- /dev/null +++ b/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeTuiIdleRuntime } from './tui-idle-wait-test-harness' +import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types' +import type { OrcaRuntimeService } from './orca-runtime' +import type { TuiAgent } from '../../shared/tui-agent' + +// Follow-ons to #6011. The evidence ranking that fixed the wait path did not reach two +// other consumers of the same signal: mailbox delivery, which TYPES INTO the pane, and +// the idle poll's quiescence gate, which read a missing output clock as "never quiet". + +const WORKTREE_ID = 'repo-1::/tmp/followups' +const TAB_ID = 'c1c1c1c1-c1c1-4c1c-8c1c-c1c1c1c1c1c1' +const LEAF_ID = 'c2c2c2c2-c2c2-4c2c-8c2c-c2c2c2c2c2c2' +const PTY_ID = 'pty-followups' +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) +const osc = (title: string) => `${ESC}]0;${title}${BEL}` +const agentStatus = (state: string, agentType: string) => + `${ESC}]9999;{"state":"${state}","agentType":"${agentType}"}${BEL}` + +const GRAPH: RuntimeSyncWindowGraph = { + tabs: [ + { tabId: TAB_ID, worktreeId: WORKTREE_ID, title: 'Agent', activeLeafId: LEAF_ID, layout: null } + ], + leaves: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle: null, + title: '' + } + ] +} + +async function makeRuntime(launchAgent: TuiAgent | null, foreground = 'codex') { + const runtime = makeTuiIdleRuntime({ + repoPath: '/tmp/followups', + getForegroundProcess: async () => foreground + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, GRAPH) + runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: 'followups-inc', + ...(launchAgent ? { agentLaunchAuthority: { launchToken: 'tok', launchAgent } } : {}) + }) + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + return { runtime, handle: terminals[0].handle } +} + +/** Counts real delivery attempts. Spies on the delivery entry point, NOT on the gate + * under test — the gate runs for real and decides whether this is ever reached. */ +function watchDelivery(runtime: OrcaRuntimeService) { + return vi + .spyOn( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the delivery entry point is protected; the spy only needs its name and signature. + runtime as never as { deliverPendingMessagesForLeaf: (leaf: unknown) => void }, + 'deliverPendingMessagesForLeaf' + ) + .mockImplementation(() => {}) +} + +// Why fake timers: the retry fires on a real 3s quiescence window, and asserting around it +// with wall-clock sleeps made the result depend on how promptly a loaded CI runner schedules +// an interval. The clock is the thing under test, so it has to be the deterministic part. +describe('mailbox delivery honours the tui-idle evidence ranking', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('does not deliver into a pane that is only showing its agent name mid-turn', async () => { + const { runtime } = await makeRuntime('codex') + const deliver = watchDelivery(runtime) + runtime.onPtyData(PTY_ID, `${osc('⠋ Codex')}working\n`, Date.now()) + expect(deliver).not.toHaveBeenCalled() + + // The busy agent repaints its title to the bare product name. That reads as `idle` + // for display, but it is emitted just as often mid-turn — typing into the pane here + // injects the pointer plus Enter into a running turn. + runtime.onPtyData(PTY_ID, `${osc('Codex')}still working\n`, Date.now()) + expect(deliver).not.toHaveBeenCalled() + }) + + it('delivers once the agent states it is done', async () => { + const { runtime } = await makeRuntime('codex') + const deliver = watchDelivery(runtime) + runtime.onPtyData(PTY_ID, `${osc('⠋ Codex')}working\n`, Date.now()) + runtime.onPtyData(PTY_ID, `${osc('Codex ready')}done\n`, Date.now()) + expect(deliver).toHaveBeenCalled() + }) + + // Why this case exists: the wait path POLLS, so weak evidence that only becomes valid + // with time eventually satisfies it. Delivery is edge-driven with no poll behind it, so a + // refusal at an edge is final unless another edge arrives. A hookless Codex never emits an + // explicit `X ready`, so without a retry the queued message strands permanently once the + // pane falls quiet — trading a visible mis-delivery for an invisible lost message. + it('retries a refused delivery once the pane falls quiet', async () => { + const { runtime } = await makeRuntime('codex') + const deliver = watchDelivery(runtime) + runtime.onPtyData(PTY_ID, `${osc('\u280b Codex')}working\n`, Date.now()) + runtime.onPtyData(PTY_ID, `${osc('Codex')}output\n`, Date.now()) + expect(deliver).not.toHaveBeenCalled() + + // Output stops. No further title frame and no renderer graph sync — a daemon-hosted + // pane has nobody publishing one, so nothing re-fires an edge on its own. + await vi.advanceTimersByTimeAsync(5_000) + expect(deliver).toHaveBeenCalled() + }) + + it('does not retry into a pane that went busy again', async () => { + const { runtime } = await makeRuntime('codex') + const deliver = watchDelivery(runtime) + runtime.onPtyData(PTY_ID, `${osc('Codex')}output\n`, Date.now()) + // Keep the stream alive across the whole retry window. + // Deterministic streaming: one chunk every 250ms of virtual time, so the gap between + // chunks can never drift past the quiescence window the way a real interval can. + for (let tick = 0; tick < 20; tick += 1) { + runtime.onPtyData(PTY_ID, 'more output\n', Date.now()) + await vi.advanceTimersByTimeAsync(250) + } + expect(deliver).not.toHaveBeenCalled() + }) + + // Case B, the mainline path: a hooked Codex emits a name-only frame BEFORE the hook's + // `Codex ready`. The name-only frame consumes the working->idle transition, leaving the + // ready title as an idle->idle step that delivery was never offered — so the strongest + // evidence the agent ever emits could not reach it. + it('delivers when the ready title arrives after a name-only frame', async () => { + const { runtime } = await makeRuntime('codex') + const deliver = watchDelivery(runtime) + runtime.onPtyData(PTY_ID, `${osc('\u280b Codex')}working\n`, Date.now()) + runtime.onPtyData(PTY_ID, `${osc('Codex')}out\n`, Date.now()) + expect(deliver).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(100) + runtime.onPtyData(PTY_ID, osc('Codex ready'), Date.now()) + // Promptly, on the ready title itself — not after waiting out a quiescence window. + expect(deliver).toHaveBeenCalled() + }) + + // Case C: the agent's own status stream vetoes the idle title, then reports done with no + // edge behind it. `working` stays fresh for 30 minutes, so without a re-offer the veto + // outlives the turn it described. + it('delivers when a done status lands after the idle title was vetoed', async () => { + const { runtime } = await makeRuntime('claude') + const deliver = watchDelivery(runtime) + runtime.onPtyData( + PTY_ID, + `${agentStatus('working', 'claude')}${osc('\u280b Claude')}w\n`, + Date.now() + ) + runtime.onPtyData(PTY_ID, `${osc('claude')}out\n`, Date.now()) + expect(deliver).not.toHaveBeenCalled() + + runtime.onPtyData(PTY_ID, agentStatus('done', 'claude'), Date.now()) + await vi.advanceTimersByTimeAsync(4_500) + expect(deliver).toHaveBeenCalled() + }) + + it('still delivers for an agent whose name is its only rest signal', async () => { + const { runtime } = await makeRuntime('grok', 'grok') + const deliver = watchDelivery(runtime) + runtime.onPtyData(PTY_ID, `${osc('⠋ Grok')}working\n`, Date.now()) + runtime.onPtyData(PTY_ID, `${osc('grok')}banner\n`, Date.now()) + expect(deliver).toHaveBeenCalled() + }) +}) + +describe('quiescence treats a missing output clock as quiet', () => { + it('settles a pane that has never produced output but holds a live agent process', async () => { + // No launch metadata: Orca did not start this agent, so the quiet-foreground lane is + // the only evidence available, and `lastOutputAt` is null because nothing ever arrived. + const { runtime, handle } = await makeRuntime(null, 'codex') + const leaves = + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reading the runtime's own leaf map to assert the precondition this test depends on. + (runtime as never as { leaves: Map }).leaves + expect([...leaves.values()][0].lastOutputAt).toBeNull() + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 8_000 }) + ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }, 20_000) +}) diff --git a/src/main/worktree-prunable-git-file.test.ts b/src/main/worktree-prunable-git-file.test.ts new file mode 100644 index 00000000000..f05e5e5cb4c --- /dev/null +++ b/src/main/worktree-prunable-git-file.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitWorktreeInfo } from '../shared/worktree/types' +import { isPrunableGitFileWorktree } from './worktree-prunable-git-file' + +const { statPath, pathAccess, runtimePath } = vi.hoisted(() => ({ + statPath: vi.fn(), + pathAccess: vi.fn(), + runtimePath: vi.fn() +})) +vi.mock('./local-worktree-filesystem', () => ({ + getLocalWorktreePathAccess: pathAccess, + toLocalWorktreeRuntimePath: runtimePath +})) +const worktree: GitWorktreeInfo = { + path: '/workspaces/feature/.git', + branch: 'refs/heads/feature', + head: 'a'.repeat(40), + isMainWorktree: false, + isBare: false, + prunable: true +} +beforeEach(() => { + vi.resetAllMocks() + statPath.mockResolvedValue({ isFile: () => true }) + pathAccess.mockReturnValue({ statPath }) + runtimePath.mockImplementation((path) => path) +}) +describe('prunable Git-file registration proof', () => { + it('accepts an attested named-branch file without reading or changing its parent', async () => { + await expect(isPrunableGitFileWorktree(worktree)).resolves.toBe(true) + expect(statPath).toHaveBeenCalledExactlyOnceWith(worktree.path) + }) + it.each([ + { prunable: false }, + { prunable: undefined }, + { isMainWorktree: true }, + { isBare: true }, + { locked: true }, + { branch: '' }, + { branch: 'refs/tags/feature' }, + { branch: 'refs/heads/' }, + { head: '' }, + { path: '/workspaces/feature' } + ])('refuses insufficient registration evidence %j', async (override) => { + await expect(isPrunableGitFileWorktree({ ...worktree, ...override })).resolves.toBe(false) + expect(statPath).not.toHaveBeenCalled() + }) + it.each([{ isFile: () => false }, { type: 'directory' }, { type: 'symlink' }, {}, null])( + 'refuses non-file or unknown filesystem evidence %j', + async (entry) => { + statPath.mockResolvedValue(entry) + await expect(isPrunableGitFileWorktree(worktree)).resolves.toBe(false) + } + ) + it('leaves a vanished marker to existing missing-path recovery', async () => { + statPath.mockRejectedValue(Object.assign(new Error('marker vanished'), { code: 'ENOENT' })) + await expect(isPrunableGitFileWorktree(worktree)).resolves.toBe(false) + }) + it('does not turn host failure into cleanup permission', async () => { + statPath.mockRejectedValue(new Error('host unavailable')) + await expect(isPrunableGitFileWorktree(worktree)).rejects.toThrow('host unavailable') + }) + it('uses the selected WSL distro and translated execution path', async () => { + const options = { wslDistro: 'Ubuntu' } + runtimePath.mockReturnValue('/home/dev/feature/.git') + statPath.mockResolvedValue({ type: 'file' }) + await expect( + isPrunableGitFileWorktree({ ...worktree, path: 'C:\\workspaces\\feature\\.git' }, options) + ).resolves.toBe(true) + expect(pathAccess).toHaveBeenCalledExactlyOnceWith(options) + expect(runtimePath).toHaveBeenCalledWith('C:\\workspaces\\feature\\.git', options) + expect(statPath).toHaveBeenCalledExactlyOnceWith('/home/dev/feature/.git') + }) +}) diff --git a/src/main/worktree-prunable-git-file.ts b/src/main/worktree-prunable-git-file.ts new file mode 100644 index 00000000000..a89f3408eb3 --- /dev/null +++ b/src/main/worktree-prunable-git-file.ts @@ -0,0 +1,41 @@ +import { isENOENT } from './ipc/filesystem-path-containment' +import type { GitWorktreeInfo } from '../shared/worktree/types' +import type { LocalWorktreeFilesystemOptions } from './local-worktree-filesystem' +import { getLocalWorktreePathAccess, toLocalWorktreeRuntimePath } from './local-worktree-filesystem' + +/** Registration cleanup must never reinterpret a malformed .git row as its parent checkout. */ +export async function isPrunableGitFileWorktree( + worktree: GitWorktreeInfo, + options: LocalWorktreeFilesystemOptions = {} +): Promise { + if ( + worktree.prunable !== true || + worktree.isMainWorktree || + worktree.isBare || + worktree.locked || + !worktree.branch.startsWith('refs/heads/') || + worktree.branch === 'refs/heads/' || + !worktree.head || + worktree.path.split(/[\\/]/).at(-1) !== '.git' + ) { + return false + } + const access = getLocalWorktreePathAccess(options) + const entry = await access + .statPath(toLocalWorktreeRuntimePath(worktree.path, options)) + .catch((error: unknown) => { + // A vanished marker leaves missing-path recovery to its existing stricter gate. + if (isENOENT(error)) { + return null + } + throw error + }) + if (!entry || typeof entry !== 'object') { + return false + } + // WSL returns the owning guest's lstat-equivalent type; native lstat rejects symlinks too. + return ( + ('type' in entry && entry.type === 'file') || + ('isFile' in entry && typeof entry.isFile === 'function' && entry.isFile() === true) + ) +} diff --git a/src/main/worktree-trash.test.ts b/src/main/worktree-trash.test.ts index 5bec9f53475..2819c789bf5 100644 --- a/src/main/worktree-trash.test.ts +++ b/src/main/worktree-trash.test.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -45,6 +45,26 @@ describe('moveWorktreeDirectoryToTrash', () => { expect(existsSync(join(trashPath!, 'node_modules', 'pkg', 'index.js'))).toBe(true) }) + it('leaves a file target untouched without creating a trash root', async () => { + const worktreePath = join(scratchDir, '.git') + await writeFile(worktreePath, 'gitdir: /preserved/admin\n') + + expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() + expect(await readFile(worktreePath, 'utf8')).toBe('gitdir: /preserved/admin\n') + expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) + }) + + it('leaves a directory symlink and its target untouched', async () => { + const target = join(scratchDir, 'target') + const worktreePath = join(scratchDir, 'link') + await createWorktreeDirectory(target) + await symlink(target, worktreePath, process.platform === 'win32' ? 'junction' : 'dir') + + expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() + expect(existsSync(join(worktreePath, 'node_modules', 'pkg', 'index.js'))).toBe(true) + expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) + }) + it('generates sweepable, collision-free entry names', async () => { const first = await moveWorktreeDirectoryToTrash(await seededWorktree('one')) const second = await moveWorktreeDirectoryToTrash(await seededWorktree('two')) diff --git a/src/main/worktree-trash.ts b/src/main/worktree-trash.ts index cec17bf87cd..cb4d3d52e03 100644 --- a/src/main/worktree-trash.ts +++ b/src/main/worktree-trash.ts @@ -40,6 +40,11 @@ export async function moveWorktreeDirectoryToTrash( const trashRoot = getWorktreeTrashRoot(worktreePath) const trashPath = join(trashRoot, `wt-${Date.now()}-${randomBytes(4).toString('hex')}`) try { + // A malformed Git registration can name the checkout's .git file. + const worktreeStat = await lstat(worktreePath) + if (!worktreeStat.isDirectory() || worktreeStat.isSymbolicLink()) { + return undefined + } await mkdir(trashRoot, { recursive: true }) const trashRootStat = await lstat(trashRoot) if (!trashRootStat.isDirectory() || trashRootStat.isSymbolicLink()) { diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 478f3040f72..acb49f500d3 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -5,6 +5,11 @@ import type { FsChangedPayload, MarkdownDocument } from '../../shared/filesystem-entry-types' +import type { + ImportItemResult, + ResolveDroppedPathsResult, + StagedExternalImportSource +} from '../../shared/filesystem-import-result-types' import type { LocalLogTailChangedPayload, LocalLogTailReadArgs, @@ -12,6 +17,7 @@ import type { LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' import type { SshMutationExpectation } from '../../shared/ssh-types' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' export type ExportApi = { htmlToPdf: (args: { @@ -134,65 +140,20 @@ export type FilesystemApi = { connectionId?: string ensureDir?: boolean } & SshMutationExpectation - ) => Promise<{ - results: ( - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> - stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] }) => Promise<{ - sources: ( - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: ( - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } - )[] - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> + ) => Promise<{ results: ImportItemResult[] }> + stageExternalPathsForRuntimeUpload: (args: { + sourcePaths: string[] + }) => Promise<{ sources: StagedExternalImportSource[] }> + uploadExternalFileToRuntime: ( + args: RuntimeUploadFileStreamRequest + ) => Promise<{ byteLength: number }> resolveDroppedPathsForAgent: ( args: { paths: string[] worktreePath: string connectionId?: string } & SshMutationExpectation - ) => Promise<{ - resolvedPaths: string[] - skipped: { - sourcePath: string - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - }[] - failed: { sourcePath: string; reason: string }[] - }> + ) => Promise watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 207b34d8519..2f702d9464f 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -1,8 +1,14 @@ import type { PathExistenceResult } from '../../shared/path-existence-batch' import { ipcRenderer } from 'electron' import type { SshMutationExpectation } from '../../shared/ssh-types' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' import type { SearchResult } from '../../shared/code-search-types' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' +import type { + ImportItemResult, + ResolveDroppedPathsResult, + StagedExternalImportSource +} from '../../shared/filesystem-import-result-types' import type { LocalLogTailChangedPayload, LocalLogTailReadArgs, @@ -151,67 +157,22 @@ export const fsApi = { connectionId?: string ensureDir?: boolean } & SshMutationExpectation - ): Promise<{ - results: ( - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> => ipcRenderer.invoke('fs:importExternalPaths', args), + ): Promise<{ results: ImportItemResult[] }> => ipcRenderer.invoke('fs:importExternalPaths', args), stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] - }): Promise<{ - sources: ( - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: ( - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } - )[] - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), + }): Promise<{ sources: StagedExternalImportSource[] }> => + ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), + uploadExternalFileToRuntime: ( + args: RuntimeUploadFileStreamRequest + ): Promise<{ byteLength: number }> => ipcRenderer.invoke('fs:uploadExternalFileToRuntime', args), resolveDroppedPathsForAgent: ( args: { paths: string[] worktreePath: string connectionId?: string } & SshMutationExpectation - ): Promise<{ - resolvedPaths: string[] - skipped: { - sourcePath: string - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - }[] - failed: { sourcePath: string; reason: string }[] - }> => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), + ): Promise => + ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:watchWorktree', args), unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => diff --git a/src/renderer/src/assets/terminal-container-geometry.test.ts b/src/renderer/src/assets/terminal-container-geometry.test.ts index db079c9095f..cf17cb00540 100644 --- a/src/renderer/src/assets/terminal-container-geometry.test.ts +++ b/src/renderer/src/assets/terminal-container-geometry.test.ts @@ -15,4 +15,8 @@ describe('terminal container geometry', () => { /\.pane-link-tooltip\s*{[^}]*height:\s*var\(--orca-terminal-link-tooltip-height\);/s ) }) + + it('bounds cursor-blink repaints to the terminal surface (#10481)', () => { + expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*contain:\s*paint;/s) + }) }) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 1ee09586892..0d17b1f77df 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -512,6 +512,10 @@ height: calc(100% - var(--pane-padding-y, 4px)); margin-top: var(--pane-padding-y, 4px); margin-left: var(--pane-padding-x, 4px); + /* Why (#10481): a blinking cursor otherwise invalidates paint all the way up + the pane ancestry. The link tooltip and drag handle are .pane siblings, so + clipping to this box costs no visible chrome. */ + contain: paint; } /* When a pane has a title, shift the terminal content down to make room. diff --git a/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts b/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts index 46ed021ee2e..2576bb31b21 100644 --- a/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts +++ b/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts @@ -1,9 +1,10 @@ -import { Markdown } from '@tiptap/markdown' +import { createRichMarkdownExtension } from './rich-markdown-extension' import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' export function createIsolatedMarkdownExtensionForTests() { - return Markdown.configure({ - marked: createRichMarkdownEditorCodec().marked, + const codec = createRichMarkdownEditorCodec() + return createRichMarkdownExtension(codec).configure({ + marked: codec.marked, markedOptions: { gfm: true } }) } diff --git a/src/renderer/src/components/editor/raw-markdown-html.ts b/src/renderer/src/components/editor/raw-markdown-html.ts index 1262d5254ab..edcf8715808 100644 --- a/src/renderer/src/components/editor/raw-markdown-html.ts +++ b/src/renderer/src/components/editor/raw-markdown-html.ts @@ -7,16 +7,14 @@ import type { RichMarkdownSourceKind, RichMarkdownSourceTransport } from './rich-markdown-source-transport' -import { isReservedRichMarkdownTransportBody } from './rich-markdown-source-transport' +import { + isReservedRichMarkdownTransportBody, + skipInlineTransportStartScan +} from './rich-markdown-source-transport' import { matchHtmlSuperscriptLinkSource } from './rich-markdown-html-superscript-link-source' const INLINE_HTML_PATTERN = /^|^<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>/ -function matchInlineHtml(src: string): string | null { - const match = src.match(INLINE_HTML_PATTERN) - return match?.[0] ?? null -} - function isEscaped(content: string, index: number): boolean { let backslashCount = 0 for (let i = index - 1; i >= 0 && content[i] === '\\'; i -= 1) { @@ -186,7 +184,7 @@ export function encodeRawMarkdownHtmlForRichEditor( const inlineHtml = normalizedContent.startsWith('B;\n```'} /> diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index 0999c23c8c6..8b862c90c78 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -11,7 +11,8 @@ import { createDocumentCommentMarkdownComponents, documentCommentMarkdownComponents, isTrustedCompactImageSrc, - type CommentMarkdownLinkClickHandler + type CommentMarkdownLinkClickHandler, + type DocumentCodeBlockRenderer } from './comment-markdown-element-renderers' import { remarkNativeChatFileLinks } from './comment-markdown-native-chat-file-links' @@ -188,6 +189,7 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { allowFileUriLinks?: boolean linkifyFilePaths?: boolean expandImages?: boolean + renderCodeBlock?: DocumentCodeBlockRenderer } // Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref @@ -204,6 +206,7 @@ const CommentMarkdown = React.memo( allowFileUriLinks = false, linkifyFilePaths = false, expandImages = false, + renderCodeBlock, ...rest }, ref @@ -211,15 +214,17 @@ const CommentMarkdown = React.memo( const components = React.useMemo(() => { if (!onLinkClick) { return variant === 'document' - ? documentCommentMarkdownComponents + ? renderCodeBlock + ? createDocumentCommentMarkdownComponents(undefined, renderCodeBlock) + : documentCommentMarkdownComponents : expandImages ? createCompactCommentMarkdownComponents(undefined, true) : compactCommentMarkdownComponents } return variant === 'document' - ? createDocumentCommentMarkdownComponents(onLinkClick) + ? createDocumentCommentMarkdownComponents(onLinkClick, renderCodeBlock) : createCompactCommentMarkdownComponents(onLinkClick, expandImages) - }, [expandImages, variant, onLinkClick]) + }, [expandImages, renderCodeBlock, variant, onLinkClick]) const activeRemarkPlugins = React.useMemo(() => { const plugins = linkifyFilePaths ? [...remarkPlugins, remarkNativeChatFileLinks] diff --git a/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx b/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx index bb82a47356b..7d4e6d1b3bc 100644 --- a/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx +++ b/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx @@ -15,6 +15,19 @@ export type CommentMarkdownLinkClickHandler = ( href: string | undefined ) => void +export type DocumentCodeBlockRenderer = (props: { + children?: React.ReactNode + language?: string +}) => React.JSX.Element + +function extractCodeFenceLanguage(children: React.ReactNode): string | undefined { + const child = React.Children.toArray(children)[0] + if (!React.isValidElement<{ className?: string }>(child)) { + return undefined + } + return child.props.className?.match(/(?:^|\s)language-([^\s]+)/)?.[1] +} + export function isTrustedCompactImageSrc(src: string | undefined): src is string { if (!src) { return false @@ -223,7 +236,8 @@ export function createCompactCommentMarkdownComponents( } export function createDocumentCommentMarkdownComponents( - onLinkClick?: CommentMarkdownLinkClickHandler + onLinkClick?: CommentMarkdownLinkClickHandler, + renderCodeBlock?: DocumentCodeBlockRenderer ): Components { return { p: ({ children }) =>

{children}

, @@ -259,6 +273,8 @@ export function createDocumentCommentMarkdownComponents( pre: ({ children }) => isMermaidPre(children) ? ( <>{children} + ) : renderCodeBlock ? ( + renderCodeBlock({ children, language: extractCodeFenceLanguage(children) }) ) : (
           {children}
diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts
index c5de2eb1813..b0e7340f373 100644
--- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts
@@ -90,6 +90,26 @@ describe('buildTitleDerivedAgentRows', () => {
     ])
   })
 
+  it.each([
+    [':', 'working'],
+    ['>', 'idle'],
+    ['!', 'waiting']
+  ])('retains hook-less OMP rows for owner marker %s', (marker, state) => {
+    const title = `OMP ${marker} Run a long task`
+    const rows = buildWorktreeAgentRows({
+      tabs: [makeTab('tab-1', { launchAgent: 'omp' })],
+      entries: [],
+      retained: [],
+      runtimePaneTitlesByTabId: { 'tab-1': { 1: title } },
+      ptyIdsByTabId: { 'tab-1': ['pty-omp'] },
+      terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
+      now: 2000
+    })
+    expect(rows.map((row) => [row.agentType, row.state, row.entry.terminalTitle])).toEqual([
+      ['omp', state, title]
+    ])
+  })
+
   it('keeps Pi-compatible title-derived rows as Pi for launched Pi sessions', () => {
     const rows = buildWorktreeAgentRows({
       tabs: [makeTab('tab-1', { launchAgent: 'pi' })],
diff --git a/src/renderer/src/components/skills/SkillsPage.test.tsx b/src/renderer/src/components/skills/SkillsPage.test.tsx
index 1570f70ed28..bb8296097eb 100644
--- a/src/renderer/src/components/skills/SkillsPage.test.tsx
+++ b/src/renderer/src/components/skills/SkillsPage.test.tsx
@@ -264,6 +264,34 @@ describe('SkillsPage', () => {
     expect(renderedSkillNames()).not.toContain('local-only')
   })
 
+  it("does not show one runtime's skills when the next runtime scan fails", async () => {
+    const discover = vi.fn().mockResolvedValue(discoveryResult(['local-only']))
+    const call = vi.fn(async (args: { method: string; selector?: string }) => {
+      const compatibilityResponse = createCompatibleRuntimeStatusResponseIfNeeded(args)
+      if (compatibilityResponse) {
+        return compatibilityResponse
+      }
+      throw new Error('remote unavailable')
+    })
+    Object.defineProperty(window, 'api', {
+      configurable: true,
+      value: { skills: skillsApi(discover), runtimeEnvironments: { call } }
+    })
+    vi.spyOn(console, 'error').mockImplementation(() => {})
+
+    await renderPage()
+    await flushMicrotasks()
+    expect(renderedSkillNames()).toEqual(['local-only'])
+
+    await act(async () => {
+      setRuntimeOwner('env-1')
+    })
+    await flushMicrotasks()
+
+    expect(container?.textContent).toContain('Could not scan skills')
+    expect(renderedSkillNames()).toEqual([])
+  })
+
   it('keeps scanning rather than listing client skills before the owner is known', async () => {
     const discover = vi.fn().mockResolvedValue(discoveryResult(['local-only']))
     const call = vi.fn()
@@ -451,4 +479,70 @@ describe('SkillsPage', () => {
     expect(container?.textContent).toContain('0 selected')
     expect(renderedSkillNames()).toEqual(['beta'])
   })
+  it('distinguishes a failed scan from empty skill folders', async () => {
+    const discover = vi
+      .fn()
+      .mockRejectedValue(
+        new Error(
+          "Error invoking remote method 'skills:discover': Error: EACCES: permission denied\nSSH host unavailable"
+        )
+      )
+    Object.defineProperty(window, 'api', {
+      configurable: true,
+      value: { skills: skillsApi(discover), runtimeEnvironments: { call: vi.fn() } }
+    })
+    vi.spyOn(console, 'error').mockImplementation(() => {})
+
+    await renderPage()
+    await flushMicrotasks()
+
+    expect(container?.textContent).toContain('Could not scan skills')
+    expect(container?.textContent).toContain('EACCES: permission denied')
+    expect(container?.textContent).toContain('SSH host unavailable')
+    expect(container?.textContent).not.toContain('Error invoking remote method')
+    // Why: nothing was scanned, so "the scanned skill folders are empty" would be a claim we cannot make.
+    expect(container?.textContent).not.toContain('No skills found')
+  })
+
+  it('retries the failed scan from the error band and clears it on success', async () => {
+    const discover = vi
+      .fn()
+      .mockRejectedValueOnce(new Error('EACCES: permission denied'))
+      .mockResolvedValueOnce(discoveryResult(['alpha']))
+    Object.defineProperty(window, 'api', {
+      configurable: true,
+      value: { skills: skillsApi(discover), runtimeEnvironments: { call: vi.fn() } }
+    })
+    vi.spyOn(console, 'error').mockImplementation(() => {})
+
+    await renderPage()
+    await flushMicrotasks()
+    await act(async () => fireEvent.click(buttonNamed('Retry')))
+    await flushMicrotasks()
+
+    expect(container?.textContent).not.toContain('Could not scan skills')
+    expect(renderedSkillNames()).toEqual(['alpha'])
+  })
+
+  it('keeps a previously confirmed empty result visible when a refresh fails', async () => {
+    const discover = vi
+      .fn()
+      .mockResolvedValueOnce(discoveryResult([]))
+      .mockRejectedValueOnce(new Error('host unavailable'))
+    Object.defineProperty(window, 'api', {
+      configurable: true,
+      value: { skills: skillsApi(discover), runtimeEnvironments: { call: vi.fn() } }
+    })
+    vi.spyOn(console, 'error').mockImplementation(() => {})
+
+    await renderPage()
+    await flushMicrotasks()
+    expect(container?.textContent).toContain('No skills found')
+
+    await act(async () => fireEvent.click(buttonNamed('Refresh')))
+    await flushMicrotasks()
+
+    expect(container?.textContent).toContain('Could not scan skills')
+    expect(container?.textContent).toContain('No skills found')
+  })
 })
diff --git a/src/renderer/src/components/skills/SkillsPage.tsx b/src/renderer/src/components/skills/SkillsPage.tsx
index 5d85afc85fc..c733e03ac09 100644
--- a/src/renderer/src/components/skills/SkillsPage.tsx
+++ b/src/renderer/src/components/skills/SkillsPage.tsx
@@ -1,8 +1,10 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import { Share2, Trash2 } from 'lucide-react'
+import { readIpcErrorDetail } from '@/lib/ipc-error'
 import { cn } from '@/lib/utils'
 import { useAppStore } from '@/store'
 import { discoverSkillsForRuntimeTarget } from '@/runtime/runtime-skills-client'
+import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
 import { useActiveSkillDiscoveryRuntimeTarget } from '@/hooks/use-active-skill-discovery-runtime-target'
 import { useMountedRef } from '@/hooks/useMountedRef'
 import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills'
@@ -58,6 +60,12 @@ const NO_FILTERS: SkillsFilterState = {
   agent: 'all'
 }
 
+type SkillScanState = {
+  runtimeTarget: RuntimeClientTarget
+  result: SkillDiscoveryResult | null
+  error: { detail?: string } | null
+}
+
 export default function SkillsPage(): React.JSX.Element {
   const closeSkillsPage = useAppStore((s) => s.closeSkillsPage)
   const pendingSkillShareId = useAppStore((s) => s.pendingSkillShareId)
@@ -66,9 +74,12 @@ export default function SkillsPage(): React.JSX.Element {
   const clearPendingSkillsSharedView = useAppStore((s) => s.clearPendingSkillsSharedView)
   const runtimeTarget = useActiveSkillDiscoveryRuntimeTarget()
   const hostLabel = useSkillDiscoveryHostLabel(runtimeTarget)
-  const [result, setResult] = useState(null)
+  const [scanState, setScanState] = useState(null)
+  // Target identity changes on host switches and same-ID re-pairs.
+  const currentScan = scanState?.runtimeTarget === runtimeTarget ? scanState : null
+  const result = currentScan?.result ?? null
   const [loading, setLoading] = useState(true)
-  const [scanError, setScanError] = useState(null)
+  const scanError = currentScan?.error ?? null
   const [shareSkills, setShareSkills] = useState([])
   const [selectionMode, setSelectionMode] = useState<'share' | 'delete' | null>(null)
   const [selectedSkillIds, setSelectedSkillIds] = useState>(() => new Set())
@@ -108,8 +119,7 @@ export default function SkillsPage(): React.JSX.Element {
         )
         const local = runtimeTarget.kind === 'local'
         if (isCurrentScan()) {
-          setResult(nextResult)
-          setScanError(null)
+          setScanState({ runtimeTarget, result: nextResult, error: null })
           setSelectedSkillIds((current) =>
             selectionModeRef.current === 'delete'
               ? retainedDeletableSkillSelection(current, nextResult.skills)
@@ -121,9 +131,11 @@ export default function SkillsPage(): React.JSX.Element {
         if (isCurrentScan()) {
           // Why: a failed scan needs to stay on screen with a retry — a toast
           // disappears before the user can act on it.
-          setScanError(
-            translate('auto.components.skills.SkillsPage.ea72d6185b', 'Could not scan skills')
-          )
+          setScanState((current) => ({
+            runtimeTarget,
+            result: current?.runtimeTarget === runtimeTarget ? current.result : null,
+            error: { detail: readIpcErrorDetail(error) }
+          }))
         }
       } finally {
         if (isCurrentScan()) {
@@ -310,7 +322,7 @@ export default function SkillsPage(): React.JSX.Element {
       />
       {scanError ? (
          {
             deleteFlow.reprobe()
@@ -359,7 +371,7 @@ export default function SkillsPage(): React.JSX.Element {
                 />
               ) : skills.length > 0 ? (
                  setFilters(NO_FILTERS)} />
-              ) : (
+              ) : result ? (
                  {
                     deleteFlow.reprobe()
@@ -367,7 +379,7 @@ export default function SkillsPage(): React.JSX.Element {
                   }}
                   onInstallFromLink={openInstallDialog}
                 />
-              )}
+              ) : null}
             
           )}
         
diff --git a/src/renderer/src/components/skills/skills-page-states.tsx b/src/renderer/src/components/skills/skills-page-states.tsx
index 1db52379e8f..0a61078e665 100644
--- a/src/renderer/src/components/skills/skills-page-states.tsx
+++ b/src/renderer/src/components/skills/skills-page-states.tsx
@@ -84,11 +84,11 @@ export function SkillsEmptyState({
 }
 
 export function SkillsScanErrorBand({
-  message,
+  detail,
   disabled,
   onRetry
 }: {
-  message: string
+  detail?: string
   disabled: boolean
   onRetry: () => void
 }): React.JSX.Element {
@@ -97,9 +97,18 @@ export function SkillsScanErrorBand({
       
-

- {message} -

+ {/* Announce the detail with the headline. */} +
+

+ {translate('auto.components.skills.SkillsPage.ea72d6185b', 'Could not scan skills')} +

+ {detail ? ( + // Preserve multi-line git and SSH errors. +

+ {detail} +

+ ) : null} +
diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts index 019b3e297ee..1269ade41a4 100644 --- a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts @@ -1,10 +1,11 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Tab } from '../../../../shared/tab-types' import { useAppStore } from '../../store' import { applyDragPreviewTab, captureTabDragActivationSnapshot, - restoreTabDragActivationSnapshot + restoreTabDragActivationSnapshot, + restoreSourceGroupActiveTabAfterCrossGroupDrop } from './tab-drag-preview-activation' const WT = 'wt-preview-restore' @@ -59,6 +60,30 @@ describe('restoreTabDragActivationSnapshot', () => { }) }) + it('does not publish repeated preview and restore actions', () => { + const snapshot = captureTabDragActivationSnapshot(WT) + const subscriber = vi.fn() + const unsubscribe = useAppStore.subscribe(subscriber) + try { + applyDragPreviewTab({ + worktreeId: WT, + groupId: 'group-1', + tabId: 'tab-1', + activeGroupId: 'group-1' + }) + restoreTabDragActivationSnapshot(WT, snapshot) + restoreSourceGroupActiveTabAfterCrossGroupDrop({ + worktreeId: WT, + snapshot, + sourceGroupId: 'group-1', + movedTabId: 'tab-2' + }) + expect(subscriber).not.toHaveBeenCalled() + } finally { + unsubscribe() + } + }) + it('restores active-surface fields after a drag preview is cancelled', () => { const snapshot = captureTabDragActivationSnapshot(WT) diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts index a0060271c99..31726672db2 100644 --- a/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts @@ -30,6 +30,14 @@ function previewActiveSurfacePatch( }) if (unifiedTab.contentType === 'terminal') { + if ( + state.activeTabType === 'terminal' && + state.activeTabTypeByWorktree[worktreeId] === 'terminal' && + state.activeTabId === unifiedTab.entityId && + state.activeTabIdByWorktree[worktreeId] === unifiedTab.entityId + ) { + return {} + } return { activeTabId: unifiedTab.entityId, activeTabType: 'terminal', @@ -41,6 +49,14 @@ function previewActiveSurfacePatch( } } if (unifiedTab.contentType === 'browser') { + if ( + state.activeTabType === 'browser' && + state.activeTabTypeByWorktree[worktreeId] === 'browser' && + state.activeBrowserTabId === unifiedTab.entityId && + state.activeBrowserTabIdByWorktree[worktreeId] === unifiedTab.entityId + ) { + return {} + } return { activeBrowserTabId: unifiedTab.entityId, activeTabType: 'browser', @@ -52,11 +68,25 @@ function previewActiveSurfacePatch( } } if (unifiedTab.contentType === 'simulator') { + if ( + state.activeTabType === 'simulator' && + state.activeTabTypeByWorktree[worktreeId] === 'simulator' + ) { + return {} + } return { activeTabType: 'simulator', activeTabTypeByWorktree: nextActiveTabTypeByWorktree('simulator') } } + if ( + state.activeTabType === 'editor' && + state.activeTabTypeByWorktree[worktreeId] === 'editor' && + state.activeFileId === unifiedTab.entityId && + state.activeFileIdByWorktree[worktreeId] === unifiedTab.entityId + ) { + return {} + } return { activeFileId: unifiedTab.entityId, activeTabType: 'editor', @@ -95,7 +125,7 @@ export function applyDragPreviewTab({ const focusUnchanged = (state.activeGroupIdByWorktree[worktreeId] ?? null) === activeGroupId const surfacePatch = previewActiveSurfacePatch(state, worktreeId, groupId, tabId) if (groupUnchanged && focusUnchanged) { - return Object.keys(surfacePatch).length > 0 ? surfacePatch : {} + return Object.keys(surfacePatch).length > 0 ? surfacePatch : state } const next: Partial = { ...surfacePatch } @@ -162,7 +192,7 @@ export function restoreTabDragActivationSnapshot( } if (Object.keys(next).length === 0) { - return {} + return state } return next @@ -191,7 +221,7 @@ export function restoreSourceGroupActiveTabAfterCrossGroupDrop({ const groups = state.groupsByWorktree[worktreeId] ?? [] const sourceGroup = groups.find((group) => group.id === sourceGroupId) if (!sourceGroup || sourceGroup.activeTabId === preDragActiveTabId) { - return {} + return state } return { groupsByWorktree: { diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts index e9d60cabace..9579ea2ba2c 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts @@ -290,7 +290,25 @@ describe('agent completion coordinator', () => { expect(dispatchCompletion).toHaveBeenCalledExactlyOnceWith('done') }) - it('resets exit confirmation across an unavailable inspection', async () => { + it.each([ + { + label: 'client-only uncertainty', + result: { + foregroundProcess: null, + hasChildProcesses: false, + verdict: 'unverifiable', + reason: 'transport_loss' + } satisfies RuntimeTerminalProcessInspection + }, + { + label: 'host child-process uncertainty', + result: { + foregroundProcess: '/bin/zsh', + hasChildProcesses: false, + childProcessEvidence: 'unverifiable' + } satisfies RuntimeTerminalProcessInspection + } + ])('resets exit confirmation across $label', async ({ result: unavailableResult }) => { let result: RuntimeTerminalProcessInspection = processResult('codex') const dispatchCompletion = vi.fn() const coordinator = createAgentCompletionCoordinator({ @@ -306,12 +324,7 @@ describe('agent completion coordinator', () => { await vi.advanceTimersByTimeAsync(2_000) result = processResult(null, false) await vi.advanceTimersByTimeAsync(750) - result = { - foregroundProcess: null, - hasChildProcesses: false, - verdict: 'unverifiable', - reason: 'transport_loss' - } + result = unavailableResult await vi.advanceTimersByTimeAsync(750) result = processResult(null, false) await vi.advanceTimersByTimeAsync(1_500) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts b/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts index f27960361bb..b1a74972311 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts @@ -53,13 +53,16 @@ export function handleAgentCompletionInspectionResult(args: { dispatchCompletion, remoteInspection } = args - if (isClientOnlyUnverifiableInspection(result)) { + const remote = options.isRemotePtyId?.(options.getPtyId() ?? '') === true + if ( + isClientOnlyUnverifiableInspection(result) || + (!remote && result.childProcessEvidence === 'unverifiable') + ) { state.pendingProcessExitAgent = null state.consecutiveInspectionErrors += 1 scheduleNextPoll() return false } - const remote = options.isRemotePtyId?.(options.getPtyId() ?? '') === true if (remote) { const evidence = result.foregroundProcessEvidence // Remote identity is host-authoritative. Compatibility names and unverifiable observations diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts index 09126e91c6b..dac6bbaf326 100644 --- a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' // Why: Phase 3 slice 1 of terminal-side-effect-authority.md runs a per-PTY // title tracker in main alongside the renderer transport's byte parser. Both // must derive IDENTICAL ordered title/status facts from the same bytes, or @@ -103,6 +104,19 @@ describe('main title tracker parity with the renderer transport processor', () = vi.useRealTimers() }) + it('agrees on captured OMP native frames before and after owner rebranding', () => { + const captured = readFileSync( + new URL('../../../../main/runtime/__fixtures__/omp-native-title-win32.txt', import.meta.url), + 'utf8' + ) + feedBoth(paths, captured) + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.some((event) => event.kind === 'became-working')).toBe(true) + expect(paths.main.events.some((event) => event.kind === 'became-idle')).toBe(true) + feedBoth(paths, captured.replaceAll(']0;π', ']0;OMP')) + expect(paths.main.events).toEqual(paths.renderer.events) + }) + it('derives identical facts from a coalesced spinner+idle chunk (issue #1083)', () => { // One realistic node-pty batch: Pi's 80ms spinner frames plus agent_end's // trailing idle title. A last-title reader sees only the idle title and diff --git a/src/renderer/src/hooks/composer-drop-failure-toast.test.ts b/src/renderer/src/hooks/composer-drop-failure-toast.test.ts new file mode 100644 index 00000000000..ab2945065cb --- /dev/null +++ b/src/renderer/src/hooks/composer-drop-failure-toast.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { toastError } = vi.hoisted(() => ({ + toastError: vi.fn<(title: string, options?: { id?: string; description?: string }) => void>() +})) +vi.mock('sonner', () => ({ toast: { error: toastError } })) + +import { showComposerDropFailureToast } from './composer-drop-failure-toast' +import type { ImportSkipReason } from '../../../shared/filesystem-import-result-types' + +const SKIP_REASON_COPY = [ + ['missing', 'No longer at its original path.'], + ['symlink', 'Symbolic links cannot be attached.'], + ['permission-denied', 'Permission denied.'], + ['unsupported', 'Unsupported file type.'] +] as const satisfies readonly (readonly [ImportSkipReason, string])[] + +function lastToast(): { title: string; id?: string; description?: string } { + const call = toastError.mock.calls.at(-1) + return { + title: String(call?.[0]), + id: call?.[1]?.id, + description: call?.[1]?.description + } +} + +describe('showComposerDropFailureToast', () => { + beforeEach(() => { + toastError.mockClear() + }) + + it('stays neutral about the gesture, and pluralises like its namespace siblings', () => { + showComposerDropFailureToast({ failureCount: 1, total: 1 }) + expect(lastToast().title).toBe('1 of 1 item could not be attached.') + + showComposerDropFailureToast({ failureCount: 2, total: 5 }) + expect(lastToast().title).toBe('2 of 5 items could not be attached.') + }) + + it("turns the import client's skip enum into copy instead of leaking the token", () => { + for (const [reason, expected] of SKIP_REASON_COPY) { + showComposerDropFailureToast({ + failureCount: 1, + total: 3, + commonFailure: { status: 'skipped', reason } + }) + expect(lastToast().description).toBe(expected) + } + }) + + it('passes a free-form failure reason straight through', () => { + showComposerDropFailureToast({ + failureCount: 2, + total: 4, + commonFailure: { status: 'failed', reason: 'EACCES: permission denied' } + }) + expect(lastToast().description).toBe('EACCES: permission denied') + }) + + it('shows no description when nothing explained the failure', () => { + showComposerDropFailureToast({ failureCount: 1, total: 2 }) + expect(lastToast().description).toBeUndefined() + }) + + it('unwraps and clamps a host-minted failure reason before it reaches the row', () => { + showComposerDropFailureToast({ + failureCount: 1, + total: 2, + commonFailure: { + status: 'failed', + reason: + "Error invoking remote method 'runtime:call': Error: EACCES: permission denied\nat Object.upload" + } + }) + expect(lastToast().description).toBe('EACCES: permission denied') + }) + + it('reuses one slot so a second failed drop replaces the first instead of stacking', () => { + showComposerDropFailureToast({ failureCount: 1, total: 2 }) + const first = lastToast().id + showComposerDropFailureToast({ failureCount: 2, total: 3 }) + expect(first).toBeDefined() + expect(lastToast().id).toBe(first) + }) + + it('gives no reason at all when the batch failed for differing reasons', () => { + showComposerDropFailureToast({ failureCount: 3, total: 6 }) + expect(lastToast().title).toBe('3 of 6 items could not be attached.') + expect(lastToast().description).toBeUndefined() + }) +}) diff --git a/src/renderer/src/hooks/composer-drop-failure-toast.ts b/src/renderer/src/hooks/composer-drop-failure-toast.ts new file mode 100644 index 00000000000..a3dde2b7493 --- /dev/null +++ b/src/renderer/src/hooks/composer-drop-failure-toast.ts @@ -0,0 +1,57 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { compactIpcErrorMessage } from '@/lib/ipc-error' +import type { ComposerDropFailure } from './composer-drop-result' +import type { ImportSkipReason } from '../../../shared/filesystem-import-result-types' + +// Own slot, not Source Control's: a drop failure must not erase an unread stage/discard failure. +const DROP_FAILURE_TOAST_ID = 'composer-drop-failure' + +const SKIP_REASON_COPY: Record = { + missing: { + key: 'auto.hooks.useComposerState.attachSkipMissing', + fallback: 'No longer at its original path.' + }, + symlink: { + key: 'auto.hooks.useComposerState.attachSkipSymlink', + fallback: 'Symbolic links cannot be attached.' + }, + 'permission-denied': { + key: 'auto.hooks.useComposerState.attachSkipPermissionDenied', + fallback: 'Permission denied.' + }, + unsupported: { + key: 'auto.hooks.useComposerState.attachSkipUnsupported', + fallback: 'Unsupported file type.' + } +} + +function failureDescription(failure: ComposerDropFailure): string | undefined { + if (failure.status === 'failed') { + return failure.reason ? compactIpcErrorMessage(failure.reason) : undefined + } + const copy = SKIP_REASON_COPY[failure.reason] + return copy ? translate(copy.key, copy.fallback) : undefined +} + +export function showComposerDropFailureToast({ + failureCount, + total, + commonFailure +}: { + failureCount: number + total: number + commonFailure?: ComposerDropFailure +}): void { + toast.error( + translate( + 'auto.hooks.useComposerState.dropPartiallyAttached', + '{{failureCount}} of {{count}} items could not be attached.', + { failureCount, count: total } + ), + { + id: DROP_FAILURE_TOAST_ID, + description: commonFailure ? failureDescription(commonFailure) : undefined + } + ) +} diff --git a/src/renderer/src/hooks/composer-drop-result.test.ts b/src/renderer/src/hooks/composer-drop-result.test.ts new file mode 100644 index 00000000000..7909c3b91e0 --- /dev/null +++ b/src/renderer/src/hooks/composer-drop-result.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { collectComposerDropResult, type ComposerDropItemResult } from './composer-drop-result' + +describe('composer drop result', () => { + it('separates imported files and folders while summarizing failures', () => { + const results: ComposerDropItemResult[] = [ + { status: 'imported', kind: 'file', destPath: '/repo/.orca/drops/file.txt' }, + { status: 'imported', kind: 'directory', destPath: '/repo/.orca/drops/folder' }, + { status: 'skipped', reason: 'permission-denied' }, + { status: 'failed', reason: 'disk full' } + ] + + expect(collectComposerDropResult(results)).toEqual({ + filePaths: ['/repo/.orca/drops/file.txt'], + folderPaths: ['/repo/.orca/drops/folder'], + failureCount: 2, + commonFailure: undefined + }) + }) + + it('keeps a failure only when it explains the whole failed subset', () => { + expect( + collectComposerDropResult([ + { status: 'skipped', reason: 'missing' }, + { status: 'skipped', reason: 'missing' } + ]).commonFailure + ).toEqual({ status: 'skipped', reason: 'missing' }) + + expect( + collectComposerDropResult([ + { status: 'imported', kind: 'file', destPath: '/repo/.orca/drops/file.txt' } + ]).commonFailure + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/hooks/composer-drop-result.ts b/src/renderer/src/hooks/composer-drop-result.ts new file mode 100644 index 00000000000..dd7334b98db --- /dev/null +++ b/src/renderer/src/hooks/composer-drop-result.ts @@ -0,0 +1,58 @@ +import type { ImportSkipReason } from '../../../shared/filesystem-import-result-types' + +export type ComposerDropItemResult = + | { + status: 'imported' + destPath: string + kind: 'file' | 'directory' + } + | { + status: 'skipped' + reason: ImportSkipReason + } + | { + status: 'failed' + reason?: string + } + +export type ComposerDropFailure = Exclude + +export type ComposerDropResult = { + filePaths: string[] + folderPaths: string[] + failureCount: number + commonFailure?: ComposerDropFailure +} + +function sameFailure(left: ComposerDropFailure, right: ComposerDropFailure): boolean { + return left.status === right.status && left.reason === right.reason +} + +export function collectComposerDropResult( + results: readonly ComposerDropItemResult[] +): ComposerDropResult { + const filePaths: string[] = [] + const folderPaths: string[] = [] + const failures: ComposerDropFailure[] = [] + + for (const result of results) { + if (result.status !== 'imported') { + failures.push(result) + } else if (result.kind === 'directory') { + folderPaths.push(result.destPath) + } else { + filePaths.push(result.destPath) + } + } + + const firstFailure = failures[0] + return { + filePaths, + folderPaths, + failureCount: failures.length, + commonFailure: + firstFailure && failures.every((failure) => sameFailure(firstFailure, failure)) + ? firstFailure + : undefined + } +} diff --git a/src/renderer/src/hooks/composer-drop-upload-result.test.ts b/src/renderer/src/hooks/composer-drop-upload-result.test.ts deleted file mode 100644 index fd7633cd9cf..00000000000 --- a/src/renderer/src/hooks/composer-drop-upload-result.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - collectComposerDropUploadResult, - shouldReportComposerDropUploadFailure, - type ComposerDropUploadImportResult -} from './composer-drop-upload-result' - -describe('composer drop upload result', () => { - it('separates imported files and folders while counting skipped or failed paths', () => { - const results: ComposerDropUploadImportResult[] = [ - { status: 'imported', kind: 'file', destPath: '/repo/.orca/drops/file.txt' }, - { status: 'imported', kind: 'directory', destPath: '/repo/.orca/drops/folder' }, - { status: 'skipped' }, - { status: 'failed' } - ] - - expect(collectComposerDropUploadResult(results)).toEqual({ - filePaths: ['/repo/.orca/drops/file.txt'], - folderPaths: ['/repo/.orca/drops/folder'], - skippedOrFailed: 2 - }) - }) - - it('suppresses failed-upload reporting after a composer loses drop ownership', () => { - const uploadResult = { skippedOrFailed: 1 } - - expect(shouldReportComposerDropUploadFailure(uploadResult, () => true)).toBe(true) - expect(shouldReportComposerDropUploadFailure(uploadResult, () => false)).toBe(false) - expect(shouldReportComposerDropUploadFailure({ skippedOrFailed: 0 }, () => true)).toBe(false) - }) -}) diff --git a/src/renderer/src/hooks/composer-drop-upload-result.ts b/src/renderer/src/hooks/composer-drop-upload-result.ts deleted file mode 100644 index a77cbbc9304..00000000000 --- a/src/renderer/src/hooks/composer-drop-upload-result.ts +++ /dev/null @@ -1,44 +0,0 @@ -export type ComposerDropUploadImportResult = - | { - status: 'imported' - destPath: string - kind: 'file' | 'directory' - } - | { - status: 'skipped' | 'failed' - } - -export type ComposerDropUploadResult = { - filePaths: string[] - folderPaths: string[] - skippedOrFailed: number -} - -export function collectComposerDropUploadResult( - results: readonly ComposerDropUploadImportResult[] -): ComposerDropUploadResult { - const filePaths: string[] = [] - const folderPaths: string[] = [] - let skippedOrFailed = 0 - - for (const result of results) { - if (result.status !== 'imported') { - skippedOrFailed += 1 - continue - } - if (result.kind === 'directory') { - folderPaths.push(result.destPath) - } else { - filePaths.push(result.destPath) - } - } - - return { filePaths, folderPaths, skippedOrFailed } -} - -export function shouldReportComposerDropUploadFailure( - uploadResult: Pick, - canReport: () => boolean -): boolean { - return uploadResult.skippedOrFailed > 0 && canReport() -} diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx b/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx new file mode 100644 index 00000000000..9b72a3d0dba --- /dev/null +++ b/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { createRef } from 'react' +import type { Dispatch, SetStateAction } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ toastError: vi.fn(), importExternalPaths: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } })) +vi.mock('@/store', () => ({ + useAppStore: Object.assign(() => undefined, { getState: () => ({}) }) +})) +vi.mock('@/runtime/runtime-file-client', () => ({ + importExternalPathsToRuntime: (...args: unknown[]) => mocks.importExternalPaths(...args) +})) +vi.mock('./composer-drop-listener', () => ({ useComposerDropListener: vi.fn() })) + +import { useAttachmentDropState } from './attachment-drop-state' + +const FAILING_PATHS = new Set(['/drop/bad-1.png', '/drop/bad-2.png', '/drop/bad-3.png']) + +function dropPaths(count: number): string[] { + return [ + ...FAILING_PATHS, + ...Array.from({ length: count - FAILING_PATHS.size }, (_, index) => `/drop/ok-${index}.png`) + ] +} + +function installFsApi(): void { + Object.assign(window, { + api: { + fs: { + authorizeExternalPath: vi.fn(async () => {}), + stat: vi.fn(async ({ filePath }: { filePath: string }) => { + if (FAILING_PATHS.has(filePath)) { + throw new Error( + "Error invoking remote method 'fs:stat': Error: ENOENT: no such file or directory" + ) + } + return { isDirectory: false } + }) + } + } + }) +} + +function renderDropState(setAttachmentPaths: Dispatch>) { + return renderHook(() => + useAttachmentDropState({ + agentPromptRef: { current: '' }, + cancelPromptCaretFrame: () => {}, + connectionId: null, + promptCaretFrameRef: { current: null }, + promptTextareaRef: createRef(), + selectedRepoPath: '/repo', + selectedRepoSettings: null, + setAgentPrompt: () => {}, + setAttachmentPaths + }) + ) +} + +beforeEach(() => { + vi.clearAllMocks() + installFsApi() +}) + +describe('local composer drop failures', () => { + it('reports partially skipped paths in one aggregated toast and still attaches the rest', async () => { + const attached: string[] = [] + const { result } = renderDropState((next) => { + attached.push(...(typeof next === 'function' ? next([]) : next)) + }) + + await act(async () => { + await result.current.applyLocalComposerDrop(dropPaths(12)) + }) + + expect(mocks.toastError).toHaveBeenCalledTimes(1) + const [title, options] = mocks.toastError.mock.calls[0] ?? [] + expect(title).toBe('3 of 12 items could not be attached.') + expect(options.description).toBe('No longer at its original path.') + expect(attached).toHaveLength(9) + expect(attached).not.toContain('/drop/bad-1.png') + }) + + it('stays silent when every dropped path attaches', async () => { + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.applyLocalComposerDrop(['/drop/ok-0.png', '/drop/ok-1.png']) + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('says nothing once the composer that owned the drop is gone', async () => { + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.applyLocalComposerDrop(dropPaths(12), () => false) + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + }) +}) + +// Why: the upload branch returns early unless a runtime environment or connection is resolved. +const RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: 'env-1' } + +describe('composer upload failures', () => { + it('aggregates a mixed runtime import into one toast, and withholds a reason that is not shared', async () => { + mocks.importExternalPaths.mockResolvedValue({ + results: [ + { + sourcePath: '/a.png', + status: 'imported', + destPath: '/repo/.orca/drops/a.png', + kind: 'file', + renamed: false + }, + { sourcePath: '/b.png', status: 'skipped', reason: 'permission-denied' }, + { sourcePath: '/c.png', status: 'failed', reason: 'disk full' } + ] + }) + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.uploadComposerPaths( + ['/a.png', '/b.png', '/c.png'], + RUNTIME_SETTINGS, + null, + '/repo' + ) + }) + + expect(mocks.toastError).toHaveBeenCalledTimes(1) + const [title, options] = mocks.toastError.mock.calls[0] ?? [] + expect(title).toBe('2 of 3 items could not be attached.') + expect(options.description).toBeUndefined() + }) + + it('stays silent when every uploaded path imports', async () => { + mocks.importExternalPaths.mockResolvedValue({ + results: [ + { + sourcePath: '/a.png', + status: 'imported', + destPath: '/repo/.orca/drops/a.png', + kind: 'file', + renamed: false + } + ] + }) + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.uploadComposerPaths(['/a.png'], RUNTIME_SETTINGS, null, '/repo') + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('does not report after the composer that owned the upload is gone', async () => { + mocks.importExternalPaths.mockResolvedValue({ + results: [{ sourcePath: '/b.png', status: 'skipped', reason: 'missing' }] + }) + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.uploadComposerPaths( + ['/b.png'], + RUNTIME_SETTINGS, + null, + '/repo', + () => false + ) + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('does give the shared reason when every uploaded path failed the same way', async () => { + mocks.importExternalPaths.mockResolvedValue({ + results: [ + { sourcePath: '/b.png', status: 'skipped', reason: 'permission-denied' }, + { sourcePath: '/c.png', status: 'skipped', reason: 'permission-denied' } + ] + }) + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.uploadComposerPaths( + ['/b.png', '/c.png'], + RUNTIME_SETTINGS, + null, + '/repo' + ) + }) + + const [, options] = mocks.toastError.mock.calls[0] ?? [] + expect(options.description).toBe('Permission denied.') + }) +}) diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts index 6dcd27e8af5..ace0e0ba4f7 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts +++ b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts @@ -20,13 +20,27 @@ import { joinPath } from '@/lib/path' import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { useAppStore } from '@/store' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' +import { readIpcErrorMessage } from '@/lib/ipc-error' +import { showComposerDropFailureToast } from '../composer-drop-failure-toast' import { - collectComposerDropUploadResult, - shouldReportComposerDropUploadFailure -} from '../composer-drop-upload-result' + collectComposerDropResult, + type ComposerDropFailure, + type ComposerDropItemResult +} from '../composer-drop-result' import { applyComposerNativeFileDrop } from '../composer-native-file-drop' import { useComposerDropListener } from './composer-drop-listener' +// Local drops bypass the runtime importer's skip classification. +function localDropFailure(detail: string | undefined): ComposerDropFailure { + if (detail?.startsWith('ENOENT')) { + return { status: 'skipped', reason: 'missing' } + } + if (/^(EACCES|EPERM)/.test(detail ?? '')) { + return { status: 'skipped', reason: 'permission-denied' } + } + return { status: 'failed', reason: detail } +} + export function useAttachmentDropState(input: AttachmentDropStateInput) { const { agentPromptRef, @@ -164,14 +178,13 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { destinationDir, { ensureDestinationDir: true, assertCurrent } ) - const uploadResult = collectComposerDropUploadResult(results) - if (shouldReportComposerDropUploadFailure(uploadResult, canReportFailure)) { - toast.error( - translate( - 'auto.hooks.useComposerState.a9ff236145', - 'Some attachments could not be uploaded.' - ) - ) + const uploadResult = collectComposerDropResult(results) + if (uploadResult.failureCount > 0 && canReportFailure()) { + showComposerDropFailureToast({ + failureCount: uploadResult.failureCount, + total: sourcePaths.length, + commonFailure: uploadResult.commonFailure + }) } return { filePaths: uploadResult.filePaths, folderPaths: uploadResult.folderPaths } }, @@ -199,27 +212,34 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { const applyLocalComposerDrop = useCallback( async (paths: string[], canApply: () => boolean = () => true): Promise => { - const fileAttachments: string[] = [] - const folderPaths: string[] = [] + const results: ComposerDropItemResult[] = [] for (const filePath of paths) { try { await window.api.fs.authorizeExternalPath({ targetPath: filePath }) const stat = await window.api.fs.stat({ filePath }) - if (stat.isDirectory) { - folderPaths.push(filePath) - } else { - fileAttachments.push(filePath) - } - } catch { - // Skip paths we cannot authorize or stat. + results.push({ + status: 'imported', + destPath: filePath, + kind: stat.isDirectory ? 'directory' : 'file' + }) + } catch (error) { + results.push(localDropFailure(readIpcErrorMessage(error))) } } if (!canApply()) { return } - addComposerAttachments(fileAttachments) - insertComposerFolderPaths(folderPaths) + const dropResult = collectComposerDropResult(results) + addComposerAttachments(dropResult.filePaths) + insertComposerFolderPaths(dropResult.folderPaths) + if (dropResult.failureCount > 0) { + showComposerDropFailureToast({ + failureCount: dropResult.failureCount, + total: paths.length, + commonFailure: dropResult.commonFailure + }) + } }, [addComposerAttachments, insertComposerFolderPaths] ) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 8707f0d0adb..8fb5d19446c 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2535,6 +2535,14 @@ } }, "hooks": { + "useComposerState": { + "attachSkipMissing": "No longer at its original path.", + "attachSkipPermissionDenied": "Permission denied.", + "attachSkipSymlink": "Symbolic links cannot be attached.", + "attachSkipUnsupported": "Unsupported file type.", + "dropPartiallyAttached_one": "{{failureCount}} of {{count}} item could not be attached.", + "dropPartiallyAttached_other": "{{failureCount}} of {{count}} items could not be attached." + }, "useIpcEvents": { "60428567b4": "Local terminal reveal is unavailable while a remote runtime is active", "f6300deb8b": "New Browser Tab" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index aecae8bc23d..ebca3398176 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -990,14 +990,20 @@ "useComposerState": { "7eb3f44ff7": "Selected agent is disabled. Choose an enabled agent before creating.", "b2ead86962": "Failed to resolve PR base.", - "a9ff236145": "Some attachments could not be uploaded.", "3db83fc58a": "No project path is available on this host for attachments.", "ba6cb77082": "Failed to connect to project.", "chooseOrAddProjectBeforeWorkspace": "Choose or add a project before creating a workspace.", "folderWorkspaceCreateFailedTitle": "Folder workspace creation failed", "folderWorkspaceCreateFailedMessage": "The folder workspace could not be created. Check the error details above, then try again.", "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior.", - "5f3d2c8a1b": "Failed to resolve MR base." + "5f3d2c8a1b": "Failed to resolve MR base.", + "dropPartiallyAttached": "{{failureCount}} of {{count}} items could not be attached.", + "dropPartiallyAttached_one": "{{failureCount}} of {{count}} item could not be attached.", + "dropPartiallyAttached_other": "{{failureCount}} of {{count}} items could not be attached.", + "attachSkipMissing": "No longer at its original path.", + "attachSkipSymlink": "Symbolic links cannot be attached.", + "attachSkipPermissionDenied": "Permission denied.", + "attachSkipUnsupported": "Unsupported file type." }, "useGlobalFileDrop": { "38c9f034ff": "Failed to upload dropped files.", @@ -11810,7 +11816,9 @@ "storedAuthFailed": "The saved Bitbucket credential could not authenticate. Edit it, or check that the token still has pull request access.", "storedCredential": "Saved in Orca on this machine. ORCA_BITBUCKET_* environment variables take precedence when set.", "notConfigured": "Connect a Bitbucket Cloud account with an Atlassian API token or an access token. ORCA_BITBUCKET_* environment variables work too and take precedence.", - "disconnectFailed": "Could not remove the saved Bitbucket credential." + "disconnectFailed": "Could not remove the saved Bitbucket credential.", + "statusLoadFailed": "Could not check for a saved Bitbucket credential.", + "replaceCredentials": "Add or replace credentials" } } }, @@ -12372,7 +12380,12 @@ "a4e93c21d7": "Current branch: {{value0}}", "c7d4e2f801": "Change base ref: {{value0}}", "f3a1b8c204": "upstream", - "createPrIntentGenerateDetailsFailed": "Could not generate review details. Retry Create PR." + "createPrIntentGenerateDetailsFailed": "Could not generate review details. Retry Create PR.", + "entryStageFailed": "Failed to stage “{{value0}}”", + "entryUnstageFailed": "Failed to unstage “{{value0}}”", + "entryDiscardFailed": "Failed to discard “{{value0}}”", + "entryDeleteFailed": "Failed to delete “{{value0}}”", + "entryFailedInWorkspace": "{{value0}} in {{value1}}" }, "SourceControlAgentActionDialog": { "8e856842d1": "Could not start the selected agent.", @@ -14298,7 +14311,8 @@ "4e71d72912": "Claude sign-in failed.", "9ddeb558f9": "Claude account added.", "29d0653961": "Sign in", - "945865332e": "Signing in" + "945865332e": "Signing in", + "accountStatusUnknown": "Account status unknown" }, "UsagePage": { "64265cb295": "29% used 5h", @@ -17311,7 +17325,8 @@ "drop": { "title": "Drop to attach to this chat", "subtitle": "Files are added to your message as paths the agent can read." - } + }, + "copyCode": "Copy code" }, "tab": { "bar": { @@ -17875,5 +17890,15 @@ "stopped": "stopped", "finished": "finished" } + }, + "aiVault": { + "subagents": { + "loading": "Loading subagents…", + "loadError": "Could not load all subagents.", + "empty": "No subagents found." + } + }, + "common": { + "retry": "Retry" } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 61c3f812f5f..2a47a73502d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -713,7 +713,6 @@ "useComposerState": { "7eb3f44ff7": "El agente seleccionado está deshabilitado. Elige un agente habilitado antes de crear.", "b2ead86962": "No se pudo resolver la base del PR.", - "a9ff236145": "Algunos archivos adjuntos no se pudieron cargar.", "3db83fc58a": "No hay ninguna ruta de proyecto remoto disponible para los archivos adjuntos.", "ba6cb77082": "No se pudo conectar al proyecto.", "chooseOrAddProjectBeforeWorkspace": "Elige o agrega un proyecto antes de crear un espacio de trabajo.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c21e08e7a5a..3a56fce87a6 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -835,7 +835,6 @@ "useComposerState": { "7eb3f44ff7": "L'agent sélectionné est désactivé. Choisissez un agent activé avant de créer.", "b2ead86962": "Échec de la résolution de la base de la PR.", - "a9ff236145": "Certaines pièces jointes n'ont pas pu être envoyées.", "3db83fc58a": "Aucun chemin de projet n'est disponible sur cet hôte pour les pièces jointes.", "ba6cb77082": "Échec de la connexion au projet.", "chooseOrAddProjectBeforeWorkspace": "Choisissez ou ajoutez un projet avant de créer un espace de travail.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 422e2b39fbc..696135a7669 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -713,7 +713,6 @@ "useComposerState": { "7eb3f44ff7": "選択した Agent は無効です。作成する前に、有効な Agent を選択してください。", "b2ead86962": "PR ベースを解決できませんでした。", - "a9ff236145": "一部の添付ファイルをアップロードできませんでした。", "3db83fc58a": "このホスト上に、添付に使用できるプロジェクトパスがありません。", "ba6cb77082": "プロジェクトへの接続に失敗しました。", "chooseOrAddProjectBeforeWorkspace": "ワークスペースを作成する前に、プロジェクトを選択または追加してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 45d2d30a225..5fd1e6addb8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -716,7 +716,6 @@ "useComposerState": { "7eb3f44ff7": "선택한 agent가 비활성화되었습니다. 생성하기 전에 활성화된 agent를 선택하세요.", "b2ead86962": "PR 기반을 해결하지 못했습니다.", - "a9ff236145": "일부 첨부파일을 업로드할 수 없습니다.", "3db83fc58a": "이 호스트에 첨부 파일에 사용할 수 있는 프로젝트 경로가 없습니다.", "ba6cb77082": "프로젝트에 연결하지 못했습니다.", "chooseOrAddProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 선택하거나 추가하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f64188d7201..125876be1a0 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -716,7 +716,6 @@ "useComposerState": { "7eb3f44ff7": "所选智能体已禁用。创建之前选择启用的智能体。", "b2ead86962": "无法解析 PR 基础引用。", - "a9ff236145": "部分附件无法上传。", "3db83fc58a": "没有可用于附件的远程项目路径。", "ba6cb77082": "无法连接到项目。", "chooseOrAddProjectBeforeWorkspace": "创建工作区前,请选择或添加项目。", diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index b6075183947..cf46fd9aae7 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -21,6 +21,7 @@ export type AgentCatalogEntry = { label: string /** Default CLI binary name used for PATH detection. */ cmd: string + searchAliases?: readonly string[] /** Direct or bundled image URL for agents whose project identity is not represented by a favicon service. */ iconUrl?: string /** Domain for Google's favicon service — used for agents without an SVG icon. */ @@ -123,6 +124,7 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => id: 'omp', label: translate('auto.lib.agent.catalog.09973b4d84', 'OMP'), cmd: 'omp', + searchAliases: ['oh-my-pi', 'oh my pi'], // Why: no faviconDomain — omp renders the hand-authored OmpIcon glyph, so a // favicon fallback would never be reached. homepageUrl: 'https://omp.sh' diff --git a/src/renderer/src/lib/agent-picker-search.test.ts b/src/renderer/src/lib/agent-picker-search.test.ts index fe26fd53dc3..12fe441103f 100644 --- a/src/renderer/src/lib/agent-picker-search.test.ts +++ b/src/renderer/src/lib/agent-picker-search.test.ts @@ -25,6 +25,15 @@ afterEach(() => { }) describe('agent picker search', () => { + it.each(['oh-my-pi', 'oh my pi', 'OH-MY-PI'])('finds OMP by its project name: %s', (query) => { + expect(searchAgentPickerEntries(AGENT_CATALOG, query).map((agent) => agent.id)).toEqual(['omp']) + }) + + it('does not offer unavailable OMP through a search alias', () => { + const available = AGENT_CATALOG.filter((agent) => agent.id !== 'omp') + expect(searchAgentPickerEntries(available, 'oh-my-pi')).toEqual([]) + }) + it('keeps catalog order for an empty query', () => { expect(searchAgentPickerEntries(agents, '').map((agent) => agent.id)).toEqual( agents.map((agent) => agent.id) diff --git a/src/renderer/src/lib/agent-picker-search.ts b/src/renderer/src/lib/agent-picker-search.ts index 6fb980bc8ea..38c368f3d73 100644 --- a/src/renderer/src/lib/agent-picker-search.ts +++ b/src/renderer/src/lib/agent-picker-search.ts @@ -87,7 +87,8 @@ function scoreAgent(agent: AgentCatalogEntry, query: string): number { return Math.min( scoreCandidate(query, agent.label, 0), scoreCandidate(query, agent.id, 600), - scoreCandidate(query, agent.cmd, 650) + scoreCandidate(query, agent.cmd, 650), + ...(agent.searchAliases ?? []).map((alias) => scoreCandidate(query, alias, 650)) ) } diff --git a/src/renderer/src/lib/ipc-error.test.ts b/src/renderer/src/lib/ipc-error.test.ts index 52d789f0027..cf7ba9d6817 100644 --- a/src/renderer/src/lib/ipc-error.test.ts +++ b/src/renderer/src/lib/ipc-error.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { extractIpcErrorMessage, readIpcErrorDetail, readIpcErrorMessage } from './ipc-error' +import { + compactIpcErrorMessage, + extractIpcErrorMessage, + readIpcErrorDetail, + readIpcErrorMessage +} from './ipc-error' describe('readIpcErrorMessage', () => { it('strips the Electron invoke wrapper Electron adds to a rejected handler', () => { @@ -45,6 +50,16 @@ describe('readIpcErrorMessage', () => { }) }) +describe('compactIpcErrorMessage', () => { + it('normalizes string error fields without manufacturing an Error', () => { + expect( + compactIpcErrorMessage( + "Error invoking remote method 'files:import': Error: permission denied\nstack" + ) + ).toBe('permission denied') + }) +}) + describe('extractIpcErrorMessage', () => { it('unwraps the same way readIpcErrorMessage does', () => { expect( diff --git a/src/renderer/src/lib/ipc-error.ts b/src/renderer/src/lib/ipc-error.ts index d9ef8a364eb..020bc7e9629 100644 --- a/src/renderer/src/lib/ipc-error.ts +++ b/src/renderer/src/lib/ipc-error.ts @@ -2,19 +2,20 @@ const IPC_INVOKE_PREFIX = /Error invoking remote method '[^']*':\s*(?:Error:\s*)?/ const IPC_HANDLER_PREFIX = /Error occurred in handler for '[^']*':\s*(?:Error:\s*)?/ +function unwrapIpcErrorMessage(message: string): string | undefined { + const detail = message.replace(IPC_INVOKE_PREFIX, '').replace(IPC_HANDLER_PREFIX, '').trim() + return detail || undefined +} + +export function compactIpcErrorMessage(message: string): string | undefined { + return unwrapIpcErrorMessage(message)?.split('\n')[0]?.trim() || undefined +} export function readIpcErrorDetail(error: unknown): string | undefined { - if (!(error instanceof Error)) { - return undefined - } - const message = error.message - .replace(IPC_INVOKE_PREFIX, '') - .replace(IPC_HANDLER_PREFIX, '') - .trim() - return message || undefined + return error instanceof Error ? unwrapIpcErrorMessage(error.message) : undefined } export function readIpcErrorMessage(error: unknown): string | undefined { - return readIpcErrorDetail(error)?.split('\n')[0]?.trim() || undefined + return error instanceof Error ? compactIpcErrorMessage(error.message) : undefined } // Preserve the legacy contract: wrapped errors are compact, while plain errors retain detail. diff --git a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts index 13f90af696c..6cdb5dd7508 100644 --- a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts +++ b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts @@ -12,10 +12,16 @@ import type { Terminal } from '@xterm/xterm' * and the pane blinks — redrawing its whole cursor row through * `WebglRenderer._updateModel` — until the 5-minute idle timeout. * - * `cursorBlink` is the public option that tears the timer down deterministically + * `cursorBlink` is the public option that tears the timer down * (`RenderService.handleOptionsChanged` -> `WebglRenderer._updateCursorBlink`), so * "a hidden pane does not blink" stops depending on which CSS hid it. * + * Not unconditional, though: `_updateCursorBlink` resolves + * `decPrivateModes.cursorBlink ?? options.cursorBlink`, and DECSCUSR with a + * blinking style (`CSI 5 SP q`) pins that DEC mode. On a pane whose shell or agent + * has emitted one, parking the option here has no effect and the hidden pane keeps + * blinking. Making this deterministic means clearing the DEC mode too. + * * Resume restores the parked value rather than the settings value, so a pane that * was not blinking before the hide never comes back blinking. */ diff --git a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts index fb0f0e8e092..52f0bb83d09 100644 --- a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts @@ -4,6 +4,7 @@ import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revisi import { fsImportExternalPaths, fsStageExternalPathsForRuntimeUpload, + fsUploadExternalFileToRuntime, runtimeEnvironmentCall, runtimeEnvironmentTransportCall, installRuntimeFileClientEnvironment @@ -11,11 +12,65 @@ import { installRuntimeFileClientEnvironment() +const okResponse = (id: string): unknown => ({ + id, + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } +}) + +const notFoundResponse = (id: string): unknown => ({ + id, + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } +}) + +/** Matches what main-process staging now records for a file entry. */ +const stagedFile = ( + relativePath: string, + byteLength: number, + inode: number +): Record => ({ + relativePath, + kind: 'file', + byteLength, + inode, + deviceId: 66, + modifiedAtMs: 1_700_000_000_000 +}) + +/** The upload request main receives; `never[]` mock args widen to it without a cast. */ +type UploadRequest = { + environmentId: string + sourceRootPath: string + entryRelativePath: string + expected: Record + worktree: string + relativePath: string + expectedExecutionHostId?: string + expectedSshTargetId?: string + expectedSshConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedEnvironmentRuntimeId?: string +} + +function uploadRequests(): UploadRequest[] { + return fsUploadExternalFileToRuntime.mock.calls.flat() +} + +const identityOf = (entry: Record): Record => ({ + byteLength: entry.byteLength, + inode: entry.inode, + deviceId: entry.deviceId, + modifiedAtMs: entry.modifiedAtMs +}) + describe('runtime file client', () => { - it('uploads a staged directory after one ownership and one cold compatibility preflight', async () => { + it('streams staged directory entries through main instead of sending base64 itself', async () => { replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 17 }]) - const firstChunk = 'A'.repeat(512 * 1024) - const secondChunk = 'BBBBBBBB' + const logo = stagedFile('logo.png', 3, 101) + const large = stagedFile('large.bin', 40 * 1024 * 1024, 102) fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -23,85 +78,19 @@ describe('runtime file client', () => { status: 'staged', name: 'assets', kind: 'directory', - entries: [ - { relativePath: '', kind: 'directory' }, - { relativePath: 'logo.png', kind: 'file', contentBase64: 'cG5n' }, - { - relativePath: 'large.bin', - kind: 'file', - contentBase64: `${firstChunk}${secondChunk}` - } - ] + entries: [{ relativePath: '', kind: 'directory' }, logo, large] } ] }) runtimeEnvironmentCall - .mockResolvedValueOnce({ - id: 'stat-destination-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-destination-dir', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'stat-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-dir', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-file', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'commit-upload', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-2', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'commit-large-upload', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-large-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) + .mockResolvedValueOnce(notFoundResponse('stat-destination-miss')) + .mockResolvedValueOnce(okResponse('create-destination-dir')) + .mockResolvedValueOnce(notFoundResponse('stat-miss')) + .mockResolvedValueOnce(okResponse('create-dir')) + .mockResolvedValueOnce(okResponse('commit-upload')) + .mockResolvedValueOnce(okResponse('delete-temp')) + .mockResolvedValueOnce(okResponse('commit-large-upload')) + .mockResolvedValueOnce(okResponse('delete-large-temp')) await expect( importExternalPathsToRuntime( @@ -136,75 +125,48 @@ describe('runtime file client', () => { 'files.createDir', 'files.stat', 'files.createDirNoClobber', - 'files.writeBase64', 'files.commitUpload', 'files.delete', - 'files.writeBase64Chunk', - 'files.writeBase64Chunk', 'files.commitUpload', 'files.delete' ]) - expect(transportCalls.filter((args) => args.method === 'status.get')).toHaveLength(2) + // Why: the whole point of the change — no file body crosses this boundary. + expect(transportCalls.some((args) => String(args.method).startsWith('files.writeBase64'))).toBe( + false + ) expect(transportCalls.every((args) => args.expectedEnvironmentPairingRevision === 17)).toBe( true ) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { - selector: 'env-1', - method: 'files.stat', - params: { - worktree: 'id:wt-1', - relativePath: 'uploads' - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: 17 - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { - selector: 'env-1', - method: 'files.createDir', - params: { - worktree: 'id:wt-1', - relativePath: 'uploads', - expectedExecutionHostId: 'local' - }, - timeoutMs: 15_000, + + const uploads = uploadRequests() + expect(uploads).toHaveLength(2) + expect(uploads[0]?.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/) + expect(uploads[0]).toEqual({ + environmentId: 'env-1', + sourceRootPath: '/Users/me/assets', + entryRelativePath: 'logo.png', + expected: identityOf(logo), + worktree: 'id:wt-1', + relativePath: uploads[0]?.relativePath, + expectedExecutionHostId: 'local', + expectedSshTargetId: undefined, + expectedSshConnectionGeneration: undefined, expectedEnvironmentPairingRevision: 17, expectedEnvironmentRuntimeId: 'remote-runtime' }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { - selector: 'env-1', - method: 'files.stat', - params: { - worktree: 'id:wt-1', - relativePath: 'uploads/assets' - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: 17 + expect(uploads[1]).toMatchObject({ + entryRelativePath: 'large.bin', + expected: identityOf(large) }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { - selector: 'env-1', - method: 'files.createDirNoClobber', - params: { - worktree: 'id:wt-1', - relativePath: 'uploads/assets', - expectedExecutionHostId: 'local' - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - const smallWriteCall = runtimeEnvironmentCall.mock.calls[4]?.[0] as { - params: { relativePath: string } - } - expect(smallWriteCall.params.relativePath).toMatch( - /^uploads\/assets\/\.logo\.png\.orca-upload-/ - ) + expect(uploads[1]?.relativePath).toMatch(/^uploads\/assets\/\.large\.bin\.orca-upload-/) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { selector: 'env-1', - method: 'files.writeBase64', + method: 'files.commitUpload', params: { worktree: 'id:wt-1', - relativePath: smallWriteCall.params.relativePath, - contentBase64: 'cG5n', + tempRelativePath: uploads[0]?.relativePath, + finalRelativePath: 'uploads/assets/logo.png', expectedExecutionHostId: 'local', expectedSshTargetId: undefined, expectedSshConnectionGeneration: undefined @@ -214,99 +176,11 @@ describe('runtime file client', () => { expectedEnvironmentRuntimeId: 'remote-runtime' }) expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, { - selector: 'env-1', - method: 'files.commitUpload', - params: { - worktree: 'id:wt-1', - tempRelativePath: smallWriteCall.params.relativePath, - finalRelativePath: 'uploads/assets/logo.png', - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, { selector: 'env-1', method: 'files.delete', params: { worktree: 'id:wt-1', - relativePath: smallWriteCall.params.relativePath, - recursive: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - const largeWriteParams = runtimeEnvironmentCall.mock.calls[7]?.[0].params - if ( - typeof largeWriteParams !== 'object' || - largeWriteParams === null || - !('relativePath' in largeWriteParams) || - typeof largeWriteParams.relativePath !== 'string' - ) { - throw new Error('missing large file write call') - } - const largeWriteRelativePath = largeWriteParams.relativePath - expect(largeWriteRelativePath).toMatch(/^uploads\/assets\/\.large\.bin\.orca-upload-/) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(8, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: largeWriteRelativePath, - contentBase64: firstChunk, - append: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(9, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: largeWriteRelativePath, - contentBase64: secondChunk, - append: true, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(10, { - selector: 'env-1', - method: 'files.commitUpload', - params: { - worktree: 'id:wt-1', - tempRelativePath: largeWriteRelativePath, - finalRelativePath: 'uploads/assets/large.bin', - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(11, { - selector: 'env-1', - method: 'files.delete', - params: { - worktree: 'id:wt-1', - relativePath: largeWriteRelativePath, + relativePath: uploads[0]?.relativePath, recursive: false, expectedExecutionHostId: 'local', expectedSshTargetId: undefined, @@ -319,9 +193,8 @@ describe('runtime file client', () => { expect(fsImportExternalPaths).not.toHaveBeenCalled() }) - it('chunks large staged runtime uploads below the WebSocket frame budget', async () => { - const firstChunk = 'A'.repeat(512 * 1024) - const secondChunk = 'AA==' + it('forwards a single staged file with the identity staging measured', async () => { + const entry = stagedFile('', 40 * 1024 * 1024, 55) fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -329,55 +202,16 @@ describe('runtime file client', () => { status: 'staged', name: 'large.bin', kind: 'file', - entries: [ - { relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` } - ] + entries: [entry] } ] }) runtimeEnvironmentCall - .mockResolvedValueOnce({ - id: 'stat-destination-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-destination-dir', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'stat-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-2', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'commit-upload', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) + .mockResolvedValueOnce(notFoundResponse('stat-destination-miss')) + .mockResolvedValueOnce(okResponse('create-destination-dir')) + .mockResolvedValueOnce(notFoundResponse('stat-miss')) + .mockResolvedValueOnce(okResponse('commit-upload')) + .mockResolvedValueOnce(okResponse('delete-temp')) await expect( importExternalPathsToRuntime( @@ -401,79 +235,20 @@ describe('runtime file client', () => { ] }) - const chunkWriteCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as { - params: { relativePath: string } - } - expect(chunkWriteCall.params.relativePath).toMatch(/^uploads\/\.large\.bin\.orca-upload-/) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: chunkWriteCall.params.relativePath, - contentBase64: firstChunk, - append: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: undefined, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: chunkWriteCall.params.relativePath, - contentBase64: secondChunk, - append: true, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: undefined, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, { - selector: 'env-1', - method: 'files.commitUpload', - params: { - worktree: 'id:wt-1', - tempRelativePath: chunkWriteCall.params.relativePath, - finalRelativePath: 'uploads/large.bin', - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: undefined, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, { - selector: 'env-1', - method: 'files.delete', - params: { - worktree: 'id:wt-1', - relativePath: chunkWriteCall.params.relativePath, - recursive: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: undefined, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) + const upload = uploadRequests()[0] + expect(upload?.relativePath).toMatch(/^uploads\/\.large\.bin\.orca-upload-/) + expect(upload?.sourceRootPath).toBe('/Users/me/large.bin') + expect(upload?.entryRelativePath).toBe('') + expect(upload?.expected).toEqual(identityOf(entry)) expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.writeBase64' }) ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.writeBase64Chunk' }) + ) }) - it('stops a chunked upload when its owner generation changes between writes', async () => { - const firstChunk = 'A'.repeat(512 * 1024) + it('does not commit an upload when the owner generation changes while it streams', async () => { fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -481,7 +256,7 @@ describe('runtime file client', () => { status: 'staged', name: 'large.bin', kind: 'file', - entries: [{ relativePath: '', kind: 'file', contentBase64: `${firstChunk}BBBBBBBB` }] + entries: [stagedFile('', 40 * 1024 * 1024, 55)] } ] }) @@ -492,22 +267,12 @@ describe('runtime file client', () => { result: { size: 0, isDirectory: true, mtime: 1 }, _meta: { runtimeId: 'remote-runtime' } }) - .mockResolvedValueOnce({ - id: 'stat-file-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockImplementationOnce(async () => { - ownerChanged = true - return { - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - } - }) + .mockResolvedValueOnce(notFoundResponse('stat-file-miss')) let ownerChanged = false + fsUploadExternalFileToRuntime.mockImplementation(async () => { + ownerChanged = true + return { byteLength: 40 * 1024 * 1024 } + }) const assertCurrent = vi.fn(() => { if (ownerChanged) { throw new Error('runtime owner generation changed') @@ -531,20 +296,14 @@ describe('runtime file client', () => { expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ 'files.stat', - 'files.stat', - 'files.writeBase64Chunk' + 'files.stat' ]) expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) ) - expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( - expect.objectContaining({ method: 'files.delete' }) - ) }) - it('cleans up staged runtime upload temp files when a later chunk fails', async () => { - const firstChunk = 'A'.repeat(512 * 1024) - const secondChunk = 'BBBBBBBB' + it('cleans up the staged temp path when the streamed upload fails', async () => { fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -552,49 +311,19 @@ describe('runtime file client', () => { status: 'staged', name: 'large.bin', kind: 'file', - entries: [ - { relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` } - ] + entries: [stagedFile('', 40 * 1024 * 1024, 55)] } ] }) runtimeEnvironmentCall - .mockResolvedValueOnce({ - id: 'stat-destination-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-destination-dir', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'stat-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-2', - ok: false, - error: { code: 'write_failed', message: 'disk full' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) + .mockResolvedValueOnce(notFoundResponse('stat-destination-miss')) + .mockResolvedValueOnce(okResponse('create-destination-dir')) + .mockResolvedValueOnce(notFoundResponse('stat-miss')) + .mockResolvedValueOnce(okResponse('delete-temp')) + // Electron wraps a main-process throw; the reason must not leak that. + fsUploadExternalFileToRuntime.mockRejectedValue( + new Error("Error invoking remote method 'fs:uploadExternalFileToRuntime': Error: disk full") + ) await expect( importExternalPathsToRuntime( @@ -610,13 +339,7 @@ describe('runtime file client', () => { results: [{ status: 'failed', reason: 'disk full' }] }) - const chunkCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as - | { params: { relativePath: string } } - | undefined - if (!chunkCall) { - throw new Error('missing first chunk call') - } - const tempRelativePath = chunkCall.params.relativePath + const tempRelativePath = uploadRequests()[0]?.relativePath expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) ) @@ -645,10 +368,7 @@ describe('runtime file client', () => { status: 'staged', name: 'assets', kind: 'directory', - entries: [ - { relativePath: '', kind: 'directory' }, - { relativePath: 'logo.png', kind: 'file', contentBase64: 'cG5n' } - ] + entries: [{ relativePath: '', kind: 'directory' }, stagedFile('logo.png', 3, 101)] } ] }) @@ -659,36 +379,11 @@ describe('runtime file client', () => { result: { size: 0, isDirectory: true, mtime: 1 }, _meta: { runtimeId: 'remote-runtime' } }) - .mockResolvedValueOnce({ - id: 'stat-import-root-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-import-root', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-file', - ok: false, - error: { code: 'write_failed', message: 'disk full' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-import-root', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) + .mockResolvedValueOnce(notFoundResponse('stat-import-root-miss')) + .mockResolvedValueOnce(okResponse('create-import-root')) + .mockResolvedValueOnce(okResponse('delete-temp')) + .mockResolvedValueOnce(okResponse('delete-import-root')) + fsUploadExternalFileToRuntime.mockRejectedValue(new Error('disk full')) await expect( importExternalPathsToRuntime( @@ -704,13 +399,7 @@ describe('runtime file client', () => { results: [{ status: 'failed', reason: 'disk full' }] }) - const writeCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as - | { params: { relativePath: string } } - | undefined - if (!writeCall) { - throw new Error('missing failed file write call') - } - expect(writeCall.params.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/) + expect(uploadRequests()[0]?.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/) expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({ selector: 'env-1', method: 'files.delete', @@ -763,6 +452,7 @@ describe('runtime file client', () => { expectedSshConnectionGeneration: 5 }) expect(fsStageExternalPathsForRuntimeUpload).not.toHaveBeenCalled() + expect(fsUploadExternalFileToRuntime).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/runtime/runtime-file-client-test-harness.ts b/src/renderer/src/runtime/runtime-file-client-test-harness.ts index 844080626be..321c6abe6d1 100644 --- a/src/renderer/src/runtime/runtime-file-client-test-harness.ts +++ b/src/renderer/src/runtime/runtime-file-client-test-harness.ts @@ -54,6 +54,7 @@ export const fsFinishDownloadedFile: PreloadStub = vi.fn() export const fsCancelDownloadedFile: PreloadStub = vi.fn() export const fsImportExternalPaths: PreloadStub = vi.fn() export const fsStageExternalPathsForRuntimeUpload: PreloadStub = vi.fn() +export const fsUploadExternalFileToRuntime: PreloadStub = vi.fn() export const runtimeEnvironmentCall: RuntimeRpcStub = vi.fn() export const runtimeEnvironmentTransportCall: RuntimeRpcStub = vi.fn() export const runtimeEnvironmentSubscribe: RuntimeSubscribeStub = vi.fn() @@ -88,6 +89,8 @@ export function installRuntimeFileClientEnvironment(): void { fsCancelDownloadedFile.mockReset() fsImportExternalPaths.mockReset() fsStageExternalPathsForRuntimeUpload.mockReset() + fsUploadExternalFileToRuntime.mockReset() + fsUploadExternalFileToRuntime.mockResolvedValue({ byteLength: 0 }) runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() runtimeEnvironmentSubscribe.mockReset() @@ -131,7 +134,8 @@ export function installRuntimeFileClientEnvironment(): void { finishDownloadedFile: fsFinishDownloadedFile, cancelDownloadedFile: fsCancelDownloadedFile, importExternalPaths: fsImportExternalPaths, - stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload + stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload, + uploadExternalFileToRuntime: fsUploadExternalFileToRuntime }, runtime: { call: runtimeCall }, runtimeEnvironments: { diff --git a/src/renderer/src/runtime/runtime-file-import-client.ts b/src/renderer/src/runtime/runtime-file-import-client.ts index 7e274343d0a..2aebfbcfa8c 100644 --- a/src/renderer/src/runtime/runtime-file-import-client.ts +++ b/src/renderer/src/runtime/runtime-file-import-client.ts @@ -1,4 +1,5 @@ import { basename, joinPath } from '@/lib/path' +import type { ImportItemResult } from '../../../shared/filesystem-import-result-types' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment-revision' @@ -21,50 +22,12 @@ import { import { getActiveRuntimeTarget } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -type StagedRuntimeImportSource = - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: StagedRuntimeImportEntry[] - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { sourcePath: string; status: 'failed'; reason: string } - -type StagedRuntimeImportEntry = - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } - -type RuntimeImportResult = - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - export async function importExternalPathsToRuntime( context: RuntimeFileOperationArgs, sourcePaths: string[], destinationDir: string, options?: { ensureDestinationDir?: boolean; assertCurrent?: () => void } -): Promise<{ results: RuntimeImportResult[] }> { +): Promise<{ results: ImportItemResult[] }> { const target = getActiveRuntimeTarget(context.settings) if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { return window.api.fs.importExternalPaths( @@ -108,12 +71,12 @@ export async function importExternalPathsToRuntime( importSession.assertCurrent() const staged = await window.api.fs.stageExternalPathsForRuntimeUpload({ sourcePaths }) importSession.assertCurrent() - const results: RuntimeImportResult[] = [] + const results: ImportItemResult[] = [] const reservedNames = new Set() await ensureRuntimeDirectory(context, destinationDir, importSession) - for (const source of staged.sources as StagedRuntimeImportSource[]) { + for (const source of staged.sources) { if (source.status !== 'staged') { results.push(source) continue @@ -150,7 +113,16 @@ export async function importExternalPathsToRuntime( importSession, context.worktreeId, entryRelativePath, - entry.contentBase64, + { + sourceRootPath: source.sourcePath, + entryRelativePath: entry.relativePath, + expected: { + byteLength: entry.byteLength, + inode: entry.inode, + deviceId: entry.deviceId, + modifiedAtMs: entry.modifiedAtMs + } + }, context.expectedSshConnectionGeneration, context.expectedSshTargetId, context.expectedExecutionHostId ?? diff --git a/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts b/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts index 34da21c8966..eb8dad53970 100644 --- a/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts +++ b/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts @@ -32,6 +32,7 @@ type RuntimeCallArgs = { const runtimeEnvironmentCall = vi.fn<(args: RuntimeCallArgs) => unknown>() const stageExternalPathsForRuntimeUpload = vi.fn() +const uploadExternalFileToRuntime = vi.fn<(args: Record) => unknown>() const importExternalPaths = vi.fn() const nestedSshContext = { @@ -99,7 +100,8 @@ function repairedRuntimeResponse(method: string) { } } -function mockStagedFile(sourcePath: string, name: string, contentBase64: string): void { +/** Staging now hands over identity, not a body; the streamer in main reads the bytes. */ +function mockStagedFile(sourcePath: string, name: string, byteLength: number): void { stageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -107,12 +109,28 @@ function mockStagedFile(sourcePath: string, name: string, contentBase64: string) status: 'staged', name, kind: 'file', - entries: [{ relativePath: '', kind: 'file', contentBase64 }] + entries: [ + { + relativePath: '', + kind: 'file', + byteLength, + inode: 91, + deviceId: 66, + modifiedAtMs: 1_700_000_000_000 + } + ] } ] }) } +function expectUploadsBoundToCapturedRevision(): void { + for (const [args] of uploadExternalFileToRuntime.mock.calls) { + expect(args.expectedEnvironmentPairingRevision).toBe(CAPTURED_REVISION) + expect(args.expectedEnvironmentRuntimeId).toBe('hub-runtime') + } +} + function expectEveryRuntimeCallBoundToCapturedRevision(ownership: { expectedExecutionHostId: string expectedSshTargetId?: string @@ -146,12 +164,15 @@ beforeEach(() => { markRuntimeEnvironmentCompatible(ENVIRONMENT_ID) runtimeEnvironmentCall.mockReset() stageExternalPathsForRuntimeUpload.mockReset() + uploadExternalFileToRuntime.mockReset() + uploadExternalFileToRuntime.mockResolvedValue({ byteLength: 0 }) importExternalPaths.mockReset() vi.stubGlobal('window', { api: { fs: { importExternalPaths, - stageExternalPathsForRuntimeUpload + stageExternalPathsForRuntimeUpload, + uploadExternalFileToRuntime }, runtimeEnvironments: { call: runtimeEnvironmentCall @@ -183,7 +204,7 @@ describe('runtime file import pairing revision', () => { }) it('stops when the HUB runtime changes without a pairing change', async () => { - mockStagedFile('/client/screenshot.png', 'screenshot.png', `${'A'.repeat(512 * 1024)}BBBBBBBB`) + mockStagedFile('/client/screenshot.png', 'screenshot.png', 40 * 1024 * 1024) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -191,14 +212,15 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64Chunk') { - setRuntimeEnvironmentConnectionGenerationForTests( - ENVIRONMENT_ID, - REPLACEMENT_CONNECTION_GENERATION - ) - } return successfulRuntimeResponse(args.method) }) + uploadExternalFileToRuntime.mockImplementation(async () => { + setRuntimeEnvironmentConnectionGenerationForTests( + ENVIRONMENT_ID, + REPLACEMENT_CONNECTION_GENERATION + ) + return { byteLength: 40 * 1024 * 1024 } + }) await expect( importExternalPathsToRuntime(nestedSshContext, ['/client/screenshot.png'], '/ssh/repo') @@ -208,9 +230,9 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', - 'files.stat', - 'files.writeBase64Chunk' + 'files.stat' ]) + expectUploadsBoundToCapturedRevision() expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) ) @@ -236,8 +258,8 @@ describe('runtime file import pairing revision', () => { expect(importExternalPaths).not.toHaveBeenCalled() }) - it('stops a rich-markdown upload between chunks without contacting the replacement HUB', async () => { - mockStagedFile('/client/screenshot.png', 'screenshot.png', `${'A'.repeat(512 * 1024)}BBBBBBBB`) + it('never commits a streamed upload against a replacement HUB re-paired mid-stream', async () => { + mockStagedFile('/client/screenshot.png', 'screenshot.png', 40 * 1024 * 1024) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -245,11 +267,12 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64Chunk') { - setEnvironmentRevision(REPLACEMENT_REVISION) - } return successfulRuntimeResponse(args.method) }) + uploadExternalFileToRuntime.mockImplementation(async () => { + setEnvironmentRevision(REPLACEMENT_REVISION) + return { byteLength: 40 * 1024 * 1024 } + }) await expect( importExternalPathsToRuntime(nestedSshContext, ['/client/screenshot.png'], '/ssh/repo') @@ -259,20 +282,9 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', - 'files.stat', - 'files.writeBase64Chunk' + 'files.stat' ]) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - method: 'files.writeBase64Chunk', - expectedEnvironmentPairingRevision: CAPTURED_REVISION, - params: expect.objectContaining({ - contentBase64: 'A'.repeat(512 * 1024), - append: false - }) - }) - ) + expectUploadsBoundToCapturedRevision() expectEveryRuntimeCallBoundToCapturedRevision(nestedSshContext) expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) @@ -283,7 +295,7 @@ describe('runtime file import pairing revision', () => { }) it('keeps a HUB-local composer commit on its entry revision when re-paired during commit', async () => { - mockStagedFile('/client/note.txt', 'note.txt', 'bm90ZQ==') + mockStagedFile('/client/note.txt', 'note.txt', 4) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.expectedEnvironmentPairingRevision !== CAPTURED_REVISION) { throw new Error('replacement HUB received an import RPC') @@ -310,14 +322,13 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', 'files.stat', - 'files.writeBase64', 'files.commitUpload' ]) expectEveryRuntimeCallBoundToCapturedRevision(hubLocalContext) }) it('does not clean up against a replacement HUB after commit', async () => { - mockStagedFile('/client/drop.txt', 'drop.txt', 'ZHJvcA==') + mockStagedFile('/client/drop.txt', 'drop.txt', 4) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -340,7 +351,6 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', 'files.stat', - 'files.writeBase64', 'files.commitUpload' ]) expectEveryRuntimeCallBoundToCapturedRevision(nestedSshContext) @@ -356,7 +366,14 @@ describe('runtime file import pairing revision', () => { kind: 'directory', entries: [ { relativePath: '', kind: 'directory' }, - { relativePath: 'broken.txt', kind: 'file', contentBase64: 'YnJva2Vu' } + { + relativePath: 'broken.txt', + kind: 'file', + byteLength: 6, + inode: 92, + deviceId: 66, + modifiedAtMs: 1_700_000_000_000 + } ] } ] @@ -368,16 +385,9 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64') { - return { - id: args.method, - ok: false, - error: { code: 'write_failed', message: 'disk full' }, - _meta: { runtimeId: 'hub-runtime' } - } - } return successfulRuntimeResponse(args.method) }) + uploadExternalFileToRuntime.mockRejectedValue(new Error('disk full')) await expect( importExternalPathsToRuntime(nestedSshContext, ['/client/assets'], '/ssh/repo') @@ -387,7 +397,6 @@ describe('runtime file import pairing revision', () => { 'status.get', 'files.stat', 'files.createDirNoClobber', - 'files.writeBase64', 'files.delete', 'files.delete' ]) diff --git a/src/renderer/src/runtime/runtime-file-upload-client.ts b/src/renderer/src/runtime/runtime-file-upload-client.ts index 2f3c1582ec5..fc3d0f83a9a 100644 --- a/src/renderer/src/runtime/runtime-file-upload-client.ts +++ b/src/renderer/src/runtime/runtime-file-upload-client.ts @@ -1,4 +1,6 @@ +import { extractIpcErrorMessage } from '@/lib/ipc-error' import { joinPath, normalizeRelativePath } from '@/lib/path' +import type { StagedRuntimeUploadFileIdentity } from '../../../shared/runtime-upload-staging-contract' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' import { callRuntimeFileImportMutation, @@ -12,28 +14,48 @@ import { import { runtimePathExists } from './runtime-file-metadata-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -const REMOTE_UPLOAD_BASE64_CHUNK_CHARS = 512 * 1024 +/** Locates a staged file on the client so main can stream it without the renderer reading it. */ +export type RuntimeUploadSource = { + sourceRootPath: string + entryRelativePath: string + /** What staging observed; main refuses the upload if the source no longer matches. */ + expected: StagedRuntimeUploadFileIdentity +} +/** Stream one staged file to a temp path, then commit it; the temp path is always cleaned up. */ export async function uploadRuntimeFileWithoutClobber( session: RuntimeFileImportSession, worktreeId: string, relativePath: string, - contentBase64: string, + source: RuntimeUploadSource, expectedSshConnectionGeneration?: number, expectedSshTargetId?: string, expectedExecutionHostId?: 'local' | `ssh:${string}` ): Promise { const tempRelativePath = makeRuntimeUploadTempPath(relativePath) try { - await writeRuntimeBase64File( - session, - worktreeId, - tempRelativePath, - contentBase64, - expectedSshConnectionGeneration, - expectedSshTargetId, - expectedExecutionHostId - ) + session.assertCurrent() + // Why: main owns the file handle and the runtime socket, so it streams the + // body in slices; the renderer never holds the whole file. + try { + await window.api.fs.uploadExternalFileToRuntime({ + environmentId: session.target.environmentId, + sourceRootPath: source.sourceRootPath, + entryRelativePath: source.entryRelativePath, + expected: source.expected, + worktree: toRuntimeWorktreeSelector(worktreeId), + relativePath: tempRelativePath, + expectedSshTargetId, + expectedSshConnectionGeneration, + expectedExecutionHostId, + expectedEnvironmentPairingRevision: session.expectedEnvironmentPairingRevision, + expectedEnvironmentRuntimeId: session.expectedEnvironmentRuntimeId + }) + } catch (error) { + // Why: this surfaces in the import result as-is, and Electron wraps a + // main-process throw in "Error invoking remote method '…'". + throw new Error(extractIpcErrorMessage(error, 'Upload failed')) + } await callRuntimeFileImportMutation( session, 'files.commitUpload', @@ -64,50 +86,7 @@ export async function uploadRuntimeFileWithoutClobber( } } -async function writeRuntimeBase64File( - session: RuntimeFileImportSession, - worktreeId: string, - relativePath: string, - contentBase64: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: 'local' | `ssh:${string}` -): Promise { - if (contentBase64.length <= REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { - await callRuntimeFileImportMutation( - session, - 'files.writeBase64', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - relativePath, - contentBase64, - expectedSshTargetId, - expectedSshConnectionGeneration, - expectedExecutionHostId - }, - 30_000 - ) - return - } - - for (let offset = 0; offset < contentBase64.length; offset += REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { - await callRuntimeFileImportMutation( - session, - 'files.writeBase64Chunk', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - relativePath, - contentBase64: contentBase64.slice(offset, offset + REMOTE_UPLOAD_BASE64_CHUNK_CHARS), - append: offset > 0, - expectedSshTargetId, - expectedSshConnectionGeneration, - expectedExecutionHostId - }, - 30_000 - ) - } -} - +/** Hidden sibling of the destination, so a failed upload never leaves a plausible-looking file. */ function makeRuntimeUploadTempPath(relativePath: string): string { const normalized = normalizeRelativePath(relativePath) const slashIndex = normalized.lastIndexOf('/') diff --git a/src/renderer/src/runtime/structured-agent-session-client.test.ts b/src/renderer/src/runtime/structured-agent-session-client.test.ts index 4d3ed6960c6..d0f65fd526e 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.test.ts @@ -1,12 +1,17 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENT_SESSION_REWIND_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, + AGENT_SESSION_REWIND_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' const mocks = vi.hoisted(() => ({ subscribe: vi.fn(), call: vi.fn(), - supportsCapability: vi.fn() + supportsCapability: vi.fn(), + readLocalCapabilities: vi.fn(), + ensureLocalCapabilities: vi.fn() })) vi.mock('./runtime-environment-revision', () => ({ @@ -17,12 +22,50 @@ vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: mocks.call, runtimeEnvironmentSupportsCapability: mocks.supportsCapability })) +vi.mock('./local-runtime-capabilities', () => ({ + readLocalRuntimeCapabilitiesOrUnknown: mocks.readLocalCapabilities, + ensureLocalRuntimeCapabilities: mocks.ensureLocalCapabilities +})) import { callStructuredAgentSession, - subscribeStructuredAgentSession + subscribeStructuredAgentSession, + supportsStructuredAgentSessionPromptCancel } from './structured-agent-session-client' +describe('structured prompt cancellation capability', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.readLocalCapabilities.mockReturnValue(null) + mocks.ensureLocalCapabilities.mockResolvedValue(null) + }) + + it('uses the local status cache and fails closed until the host answers', async () => { + const target = { kind: 'local' } as const + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(false) + mocks.ensureLocalCapabilities.mockResolvedValue([ + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY + ]) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(true) + mocks.readLocalCapabilities.mockReturnValue([AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY]) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(true) + expect(mocks.ensureLocalCapabilities).toHaveBeenCalledTimes(2) + }) + + it('checks the selected remote runtime and downgrades on absent or failed capability', async () => { + const target = { kind: 'environment', environmentId: 'ssh-env-1' } as const + mocks.supportsCapability.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(true) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(false) + mocks.supportsCapability.mockRejectedValue(new Error('Disconnected')) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(false) + expect(mocks.supportsCapability).toHaveBeenCalledWith( + 'ssh-env-1', + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY + ) + }) +}) + describe('callStructuredAgentSession rewind capability', () => { const target = { kind: 'environment', environmentId: 'env-1' } as const const params = { itemId: 'item-1', expectedEpoch: 'epoch-1' } diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index c769ec302c1..5ccbe6360f3 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -4,12 +4,39 @@ import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' -import { AGENT_SESSION_REWIND_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, + AGENT_SESSION_REWIND_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import { callRuntimeRpc, runtimeEnvironmentSupportsCapability, type RuntimeClientTarget } from './runtime-rpc-client' +import { + ensureLocalRuntimeCapabilities, + readLocalRuntimeCapabilitiesOrUnknown +} from './local-runtime-capabilities' +/** Read the prompt-cancel capability through the runtime's existing status cache. + * A failed/unknown probe is treated as legacy so strict prompt fields are never + * sent before the host has proved it understands them. */ +export async function supportsStructuredAgentSessionPromptCancel( + target: RuntimeClientTarget +): Promise { + try { + if (target.kind === 'local') { + const known = readLocalRuntimeCapabilitiesOrUnknown() + const capabilities = known ?? (await ensureLocalRuntimeCapabilities()) + return capabilities?.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY) === true + } + return await runtimeEnvironmentSupportsCapability( + target.environmentId, + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY + ) + } catch { + return false + } +} export async function callStructuredAgentSession( target: RuntimeClientTarget, diff --git a/src/renderer/src/store/github/project-cache.ts b/src/renderer/src/store/github/project-cache.ts index df8ac361025..eb296633201 100644 --- a/src/renderer/src/store/github/project-cache.ts +++ b/src/renderer/src/store/github/project-cache.ts @@ -76,11 +76,11 @@ export function applyRowPatch( set((s) => { const entry = s.projectViewCache[cacheKey] if (!entry?.data) { - return {} + return s } const rowIndex = entry.data.rows.findIndex((r) => r.id === rowId) if (rowIndex === -1) { - return {} + return s } const rows = [...entry.data.rows] rows[rowIndex] = nextRow diff --git a/src/renderer/src/store/github/pull-request-execution.ts b/src/renderer/src/store/github/pull-request-execution.ts index 72069802064..553ef5c1e13 100644 --- a/src/renderer/src/store/github/pull-request-execution.ts +++ b/src/renderer/src/store/github/pull-request-execution.ts @@ -153,7 +153,7 @@ export function startPullRequestLookup(args: { // Why: unlinking a PR mid exact-linked-PR-lookup must stop the older result from restoring the manual link UI. if (isStaleExactLinkedPRLookup(s, options?.worktreeId, linkedPRNumber)) { skippedStaleLinkedPRLookup = true - return {} + return s } const updates = setGitHubPRResultCaches(s, { prCacheKey: cacheKey, @@ -174,7 +174,7 @@ export function startPullRequestLookup(args: { requestStartedEntry: requestStartedHostedReviewEntry }) didUpdatePRCache = updates.prCache !== undefined - return updates + return updates.prCache || updates.hostedReviewCache ? updates : s }) if (skippedStaleLinkedPRLookup) { return null diff --git a/src/renderer/src/store/github/refresh-event-actions.ts b/src/renderer/src/store/github/refresh-event-actions.ts index 1e4bccbebd6..803d0168c21 100644 --- a/src/renderer/src/store/github/refresh-event-actions.ts +++ b/src/renderer/src/store/github/refresh-event-actions.ts @@ -229,6 +229,7 @@ export const createRefreshEventActions = ( } } + // Preserve root identity so no-op writes do not notify every store subscriber. return changed ? { prRefreshSequences: capPrRefreshSequences(nextSequences), @@ -237,7 +238,7 @@ export const createRefreshEventActions = ( prCache: nextPRCache, hostedReviewCache: nextHostedReviewCache } - : {} + : s }) if (didUpdatePRCache && event.outcome && event.outcome.kind !== 'upstream-error') { debouncedSaveCache(get()) diff --git a/src/renderer/src/store/github/refresh-event-noop-notification.test.ts b/src/renderer/src/store/github/refresh-event-noop-notification.test.ts new file mode 100644 index 00000000000..ccc94c30b30 --- /dev/null +++ b/src/renderer/src/store/github/refresh-event-noop-notification.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { useAppStore } from '../index' +import { createGitHubSlice } from '../slices/github' +import { createHostedReviewSlice } from '../slices/hosted-review' +import type { AppState } from '../types' + +// @ts-expect-error test window mock +globalThis.window = { api: { gh: { prChecks: vi.fn() }, cache: { setGitHub: vi.fn() } } } + +function createTestStore() { + return create()((...a) => ({ + ...useAppStore.getInitialState(), + ...createGitHubSlice(...a), + ...createHostedReviewSlice(...a) + })) +} + +const inFlightEvent = (sequence: number) => ({ + sequence, + aliases: [{ cacheKey: 'repo-1::main', repoId: 'repo-1', repoPath: '/repo', branch: 'main' }], + reason: 'visible' as const, + status: 'in-flight' as const +}) + +describe('applyGitHubPRRefreshEvent no-op updates', () => { + // Why a listener count: zustand bails out only on Object.is(next, state), so a + // `return {}` no-op branch still rebuilds the root and wakes every selector in the + // app. The state looks unchanged afterwards, which is exactly why it goes unnoticed. + it('does not notify subscribers when a stale sequence changes nothing', () => { + const store = createTestStore() + store.getState().applyGitHubPRRefreshEvent(inFlightEvent(4)) + + let notifications = 0 + const unsubscribe = store.subscribe(() => { + notifications += 1 + }) + try { + store.getState().applyGitHubPRRefreshEvent(inFlightEvent(4)) + store.getState().applyGitHubPRRefreshEvent(inFlightEvent(3)) + } finally { + unsubscribe() + } + + expect(notifications).toBe(0) + }) + + it('still notifies when the event advances the sequence', () => { + const store = createTestStore() + store.getState().applyGitHubPRRefreshEvent(inFlightEvent(1)) + + let notifications = 0 + const unsubscribe = store.subscribe(() => { + notifications += 1 + }) + try { + store.getState().applyGitHubPRRefreshEvent(inFlightEvent(2)) + } finally { + unsubscribe() + } + + expect(notifications).toBe(1) + }) +}) diff --git a/src/renderer/src/store/github/work-item-mutation-actions.ts b/src/renderer/src/store/github/work-item-mutation-actions.ts index e1d02a09d3b..97a6d3b66f3 100644 --- a/src/renderer/src/store/github/work-item-mutation-actions.ts +++ b/src/renderer/src/store/github/work-item-mutation-actions.ts @@ -45,7 +45,7 @@ export const createWorkItemMutationActions = ( nextCache[key] = { ...entry, data: updatedItems } changed = true } - return changed ? { workItemsCache: nextCache } : {} + return changed ? { workItemsCache: nextCache } : s }) }, diff --git a/src/renderer/src/store/slices/browser/browser-host-actions.ts b/src/renderer/src/store/slices/browser/browser-host-actions.ts index 2977717d38c..193dfdafe60 100644 --- a/src/renderer/src/store/slices/browser/browser-host-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-host-actions.ts @@ -24,7 +24,7 @@ export function createBrowserHostActions( closes, Date.now() ) - return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} + return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : s }) }, @@ -34,7 +34,7 @@ export function createBrowserHostActions( s.clientHostedBrowserCloseIntentsByEnvironment, { environmentId, browserPageIds, now: Date.now() } ) - return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} + return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : s }) }, diff --git a/src/renderer/src/store/slices/browser/browser-host-state.ts b/src/renderer/src/store/slices/browser/browser-host-state.ts index 1b4b3a70e9a..7ee75ac54a1 100644 --- a/src/renderer/src/store/slices/browser/browser-host-state.ts +++ b/src/renderer/src/store/slices/browser/browser-host-state.ts @@ -153,7 +153,7 @@ export function browserImportStateForHostUpdate( hostId: ExecutionHostId, browserSessionImportState: BrowserSlice['browserSessionImportState'] ): Partial { - return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : {} + return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : state } export function getFallbackTabTypeForWorktree( diff --git a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts index a2ed3f5bbb1..13beee05872 100644 --- a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts @@ -257,7 +257,7 @@ export function createBrowserHydrationActions( } } } - return {} + return s }) } } diff --git a/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts b/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts index ec03690bf8e..9b79ec9adbc 100644 --- a/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts @@ -133,13 +133,13 @@ export function createBrowserProfileImportActions( set((s) => getBrowserSettingsHostId(s) === hostId ? { detectedBrowsers: browsers, detectedBrowsersLoaded: true, detectedBrowsersHost } - : {} + : s ) } catch { set((s) => getBrowserSettingsHostId(s) === hostId ? { detectedBrowsers: [], detectedBrowsersLoaded: true, detectedBrowsersHost: null } - : {} + : s ) } return @@ -161,11 +161,11 @@ export function createBrowserProfileImportActions( detectedBrowsersLoaded: true, detectedBrowsersHost: null } - : {} + : s ) } catch { /* best-effort — empty list is acceptable fallback */ - set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : {})) + set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : s)) } } } diff --git a/src/renderer/src/store/slices/commit-message-generation.ts b/src/renderer/src/store/slices/commit-message-generation.ts index e361958932c..4e7ef3e722d 100644 --- a/src/renderer/src/store/slices/commit-message-generation.ts +++ b/src/renderer/src/store/slices/commit-message-generation.ts @@ -160,7 +160,7 @@ export const createCommitMessageGenerationSlice: StateCreator< set((state) => { const nextRecord = updater(state.commitMessageGenerationRecords[key] ?? null) if (!nextRecord) { - return {} + return state } return { commitMessageGenerationRecords: { @@ -184,6 +184,6 @@ export const createCommitMessageGenerationSlice: StateCreator< changed = true } } - return changed ? { commitMessageGenerationRecords: nextRecords } : {} + return changed ? { commitMessageGenerationRecords: nextRecords } : state }) }) diff --git a/src/renderer/src/store/slices/diff-comment-persistence.ts b/src/renderer/src/store/slices/diff-comment-persistence.ts index 92e1eb9ea9f..4d28d7b7f1d 100644 --- a/src/renderer/src/store/slices/diff-comment-persistence.ts +++ b/src/renderer/src/store/slices/diff-comment-persistence.ts @@ -239,13 +239,13 @@ export function mutateDiffComments( if (scope?.type === 'folder') { const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId) if (!target) { - return {} + return s } folderExecutionHostId = getExecutionHostIdForFolderWorkspace(s, scope.folderWorkspaceId) previous = target.diffComments const computed = mutate(previous ?? []) if (computed === null) { - return {} + return s } next = computed return { @@ -256,16 +256,16 @@ export function mutateDiffComments( } const repoList = s.worktreesByRepo[repoId] if (!repoList) { - return {} + return s } const target = repoList.find((w) => w.id === worktreeId) if (!target) { - return {} + return s } previous = target.diffComments const computed = mutate(previous ?? []) if (computed === null) { - return {} + return s } next = computed const nextList: Worktree[] = repoList.map((w) => @@ -293,7 +293,7 @@ function rollback( if (scope?.type === 'folder') { const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId, folderExecutionHostId) if (!target || target.diffComments !== expectedCurrent) { - return {} + return s } return { folderWorkspaces: s.folderWorkspaces.map((workspace) => @@ -303,16 +303,16 @@ function rollback( } const repoList = s.worktreesByRepo[repoId] if (!repoList) { - return {} + return s } const target = repoList.find((w) => w.id === worktreeId) // Why: worktree gone since the mutation; bail before remapping so we don't allocate a new array identity and fire spurious notifications. if (!target) { - return {} + return s } // Why: only roll back if no later mutation replaced the array, else our stale `previous` would erase newer state. if (target.diffComments !== expectedCurrent) { - return {} + return s } const nextList: Worktree[] = repoList.map((w) => w.id === worktreeId ? { ...w, diffComments: previous } : w diff --git a/src/renderer/src/store/slices/empty-update-notifications.test.ts b/src/renderer/src/store/slices/empty-update-notifications.test.ts new file mode 100644 index 00000000000..692fa50b7d4 --- /dev/null +++ b/src/renderer/src/store/slices/empty-update-notifications.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import { createTabsSliceMockApi } from './tabs-slice-test-harness' +import { browserImportStateForHostUpdate } from './browser/browser-host-state' +import { mutateDiffComments } from './diff-comment-persistence' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +createTabsSliceMockApi() + +describe('empty store updates', () => { + it('does not notify for missing tab actions', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.setTabLabel('missing', 'label') + before.setTabCustomLabel('missing', 'label') + before.setUnifiedTabColor('missing', null) + before.setTabViewMode('missing', 'chat') + before.toggleTabViewMode('missing') + before.pinTab('missing') + before.unpinTab('missing') + before.reorderUnifiedTabs('missing', []) + before.moveUnifiedTabToGroup('missing', 'missing') + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) + + it('does not notify for unchanged labels but publishes changed labels', () => { + const store = createTestStore() + const tab = store + .getState() + .createUnifiedTab('folder-workspace', 'terminal', { label: 'label' }) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.setTabLabel(tab.id, 'label') + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + + before.setTabLabel(tab.id, 'new label') + expect(listener).toHaveBeenCalledTimes(1) + expect(store.getState().getTab(tab.id)?.label).toBe('new label') + }) + + it('does not notify for rejected generation updates or empty pruning', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.updateCommitMessageGenerationRecord('missing', () => null) + before.updatePullRequestGenerationRecord('missing', () => null) + before.pruneCommitMessageGenerationRecords(new Set()) + before.prunePullRequestGenerationRecords(new Set()) + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) + + it('does not notify for absent Jira issues, browser pages or diff comments', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.patchJiraIssue('MISSING-1', {}) + before.patchLinearIssue('missing', {}) + before.switchBrowserTabProfile('missing', null, 'persist:missing') + before.recordClientHostedBrowserCloseIntents([]) + before.clearClientHostedBrowserCloseIntents('missing', []) + mutateDiffComments(store.setState, 'missing', () => null) + store.setState((state) => browserImportStateForHostUpdate(state, 'runtime:other', null)) + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts index 418b6648fa0..8b42bf3e56f 100644 --- a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts @@ -86,6 +86,8 @@ describe('createGitHubSlice.fetchPRForBranch', () => { hostedReviewCache: {}, prCache: {} } as unknown as Partial) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) resolveRefresh({ kind: 'found', pr: makePR({ number: 12, title: 'Stale exact linked PR' }), @@ -93,6 +95,8 @@ describe('createGitHubSlice.fetchPRForBranch', () => { }) await expect(request).resolves.toBeNull() + unsubscribe() + expect(subscriber).not.toHaveBeenCalled() expect(store.getState().prCache[`${repoId}::${branch}`]).toBeUndefined() expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toBeUndefined() }) diff --git a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts index ef22da072d1..ba741de5cfb 100644 --- a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts +++ b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts @@ -15,6 +15,15 @@ describe('createGitHubSlice.patchWorkItem', () => { resetRemoteRuntimeMocks() }) + it('does not notify when a patch has no matching cached work item', () => { + const store = createTestStore() + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) + store.getState().patchWorkItem('pr:missing', { title: 'Missing' }, 'repo-1') + unsubscribe() + expect(subscriber).not.toHaveBeenCalled() + }) + it('can scope patches to one repo when different repos have the same work-item id', () => { const store = createTestStore() const repoOneItem = { diff --git a/src/renderer/src/store/slices/hosted-review-cache-race.test.ts b/src/renderer/src/store/slices/hosted-review-cache-race.test.ts index 59e253e1823..16d765fe0d5 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-race.test.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-race.test.ts @@ -112,10 +112,14 @@ describe('hosted review cache race protection', () => { } } }) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) vi.setSystemTime(300) resolveFetch(olderReview) await expect(request).resolves.toEqual(olderReview) + unsubscribe() + expect(subscriber).not.toHaveBeenCalled() expect(store.getState().hostedReviewCache[cacheKey]).toEqual({ data: newerReview, fetchedAt: 200, diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 53cdb00db55..cad350d986b 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -234,7 +234,7 @@ export const createHostedReviewSlice: StateCreator { const nextRecord = updater(state.pullRequestGenerationRecords[key] ?? null) if (!nextRecord) { - return {} + return state } return { pullRequestGenerationRecords: { @@ -312,6 +312,6 @@ export const createPullRequestGenerationSlice: StateCreator< changed = true } } - return changed ? { pullRequestGenerationRecords: nextRecords } : {} + return changed ? { pullRequestGenerationRecords: nextRecords } : state }) }) diff --git a/src/renderer/src/store/slices/sparse-presets.ts b/src/renderer/src/store/slices/sparse-presets.ts index b9e763bf72e..6a4a2d68245 100644 --- a/src/renderer/src/store/slices/sparse-presets.ts +++ b/src/renderer/src/store/slices/sparse-presets.ts @@ -145,7 +145,7 @@ export const createSparsePresetsSlice: StateCreator { const existing = s.sparsePresetsByRepo[args.repoId] if (existing === undefined) { - return {} + return s } const without = existing.filter((preset) => preset.id !== saved.id) return { diff --git a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts index b4f1d611b2f..f8736ed250b 100644 --- a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts @@ -120,7 +120,7 @@ export function createTabsCreateActions( target.sourceGroupId ) if (!sourceGroup) { - return {} + return state } const existingTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] const currentGroups = state.groupsByWorktree[worktreeId] ?? [] diff --git a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts index 2cadf76365e..612bee0b538 100644 --- a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts @@ -25,19 +25,19 @@ export function createTabsDropActions( const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) const foundTarget = findGroupAndWorktree(state.groupsByWorktree, target.groupId) if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return {} + return state } const { tab, worktreeId } = foundTab const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) const targetGroup = foundTarget.group if (!sourceGroup) { - return {} + return state } const isSplitDrop = Boolean(target.splitDirection) if (!isSplitDrop && tab.groupId === target.groupId) { - return {} + return state } const layout = state.layoutByWorktree[worktreeId] if ( @@ -51,7 +51,7 @@ export function createTabsDropActions( }) ) { // Why: dropping a group's last tab on its own/sibling matching edge only makes a transient column that immediately collapses. - return {} + return state } moved = true diff --git a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts index 39a0dd38858..6e4134c6a1b 100644 --- a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts @@ -53,7 +53,7 @@ export function createTabsFocusActions( found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) } if (!found) { - return {} + return state } const { tab, worktreeId } = found // Why: activating a terminal tab dismisses its tab-level bell — the user has moved their eyes here. diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts index 42b8e3d7163..3ee06aa105c 100644 --- a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -50,7 +50,7 @@ export function createTabsLabelActions( } } } - return {} + return state }) if (reordered && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') @@ -58,17 +58,24 @@ export function createTabsLabelActions( }, setTabLabel: (tabId, label) => { - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}) + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? state) }, setTabViewMode: (tabId, mode) => { - set((state) => ({ - ...patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }), - // Why the row too: viewMode is declared on both types and host-sync - // already writes it to the row. Only these local toggles skipped it, so - // readers had to OR the two indices to find out who owns the surface. - ...patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: mode }) - })) + set((state) => { + const tabPatch = patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) + const rowPatch = patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: mode }) + if (!tabPatch && !rowPatch.tabsByWorktree) { + return state + } + return { + ...tabPatch, + // Why the row too: viewMode is declared on both types and host-sync + // already writes it to the row. Only these local toggles skipped it, so + // readers had to OR the two indices to find out who owns the surface. + ...rowPatch + } + }) mirrorTabViewModeToHost(get(), tabId, mode) }, @@ -81,7 +88,7 @@ export function createTabsLabelActions( set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { - return {} + return state } // Why: viewMode defaults to 'terminal' for legacy/missing, so the first toggle flips to 'chat'. const fromMode: 'terminal' | 'chat' = found.tab.viewMode === 'chat' ? 'chat' : 'terminal' @@ -111,7 +118,7 @@ export function createTabsLabelActions( setTabCustomLabel: (tabId, label, opts) => { const exists = get().getTab(tabId) !== null - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}) + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? state) if (exists && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') } @@ -119,7 +126,7 @@ export function createTabsLabelActions( setUnifiedTabColor: (tabId, color) => { const exists = get().getTab(tabId) !== null - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? {}) + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? state) if (exists) { get().recordFeatureInteraction?.('terminal-tabs') } @@ -130,7 +137,7 @@ export function createTabsLabelActions( set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { - return {} + return state } const { tab, worktreeId } = found const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => @@ -168,7 +175,7 @@ export function createTabsLabelActions( set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { - return {} + return state } const { tab, worktreeId } = found const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => diff --git a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts index 4c651f613ac..8e8a51f5c0c 100644 --- a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts @@ -22,16 +22,16 @@ export function createTabsMoveActions( const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) const foundTarget = findGroupAndWorktree(state.groupsByWorktree, targetGroupId) if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return {} + return state } const { tab, worktreeId } = foundTab if (tab.groupId === targetGroupId) { - return {} + return state } const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) const targetGroup = foundTarget.group if (!sourceGroup) { - return {} + return state } moved = true diff --git a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.test.ts b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.test.ts new file mode 100644 index 00000000000..811b97f3aec --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { useAppStore } from '../../index' + +describe('workspace port-scan surface actions', () => { + beforeEach(() => { + useAppStore.setState({ workspacePortScanRefreshing: false }) + }) + + // Why a listener count and not a state assertion: zustand notifies on identity, so an + // unconditional `set` is invisible in the resulting state yet re-runs every selector. + const countNotifications = (run: () => void): number => { + let notifications = 0 + const unsubscribe = useAppStore.subscribe(() => { + notifications += 1 + }) + try { + run() + } finally { + unsubscribe() + } + return notifications + } + + it('does not notify subscribers when the refreshing flag is unchanged', () => { + const setRefreshing = useAppStore.getState().setWorkspacePortScanRefreshing + + expect(countNotifications(() => setRefreshing(false))).toBe(0) + expect(countNotifications(() => setRefreshing(false))).toBe(0) + expect(useAppStore.getState().workspacePortScanRefreshing).toBe(false) + }) + + it('still notifies once on a real transition, in both directions', () => { + const setRefreshing = useAppStore.getState().setWorkspacePortScanRefreshing + + expect(countNotifications(() => setRefreshing(true))).toBe(1) + expect(useAppStore.getState().workspacePortScanRefreshing).toBe(true) + expect(countNotifications(() => setRefreshing(false))).toBe(1) + expect(useAppStore.getState().workspacePortScanRefreshing).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts index 26aab77d0cb..7157a243d7a 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts @@ -79,8 +79,13 @@ export function createUiSurfaceActions(set: UISliceSet, _get: UISliceGet): Parti : state.workspacePortScan } }), + // Preserve root identity so no-op writes do not notify every store subscriber. setWorkspacePortScanRefreshing: (refreshing) => - set({ workspacePortScanRefreshing: refreshing }), + set((state) => + state.workspacePortScanRefreshing === refreshing + ? state + : { workspacePortScanRefreshing: refreshing } + ), // Why: default true so enabling experimentalPet shows the pet immediately (persisted; "Hide pet" flips it false). petVisible: true, diff --git a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts index 28530219cce..435eadec4ec 100644 --- a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts +++ b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts @@ -23,14 +23,14 @@ export function createUpdatePendingWorktreeCreation( set((s) => { const entry = s.pendingWorktreeCreations[creationId] if (!entry) { - return {} + return s } // Why: the main process re-emits the same phase; skip no-op writes so the strip and panel don't re-render. const hasChange = (Object.keys(patch) as (keyof typeof patch)[]).some( (key) => patch[key] !== entry[key] ) if (!hasChange) { - return {} + return s } return { pendingWorktreeCreations: { @@ -51,7 +51,7 @@ export function createRemovePendingWorktreeCreation( set((s) => { const entry = s.pendingWorktreeCreations[creationId] if (!entry) { - return {} + return s } removedEntry = entry const { [creationId]: _removed, ...rest } = s.pendingWorktreeCreations @@ -90,7 +90,7 @@ export function createSetActivePendingWorktreeCreation( return (creationId) => { set((s) => { if (creationId !== null && !s.pendingWorktreeCreations[creationId]) { - return {} + return s } return { activePendingCreationId: creationId } }) diff --git a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts index fdf17d38f8f..35464523ba5 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts @@ -261,7 +261,7 @@ export function applyHostedReviewLinkClear( nextWorktrees === s.worktreesByRepo && nextDetectedWorktrees === s.detectedWorktreesByRepo ) { - return {} + return s } return { ...(nextWorktrees !== s.worktreesByRepo diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts index 5e5c38a71e2..414a2ef206a 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts @@ -149,7 +149,7 @@ export function createUpdateWorktreeMeta( shouldApplyUpdate && !shouldApplyUpdate(findKnownWorktreeById(s, worktreeId, executionHostId)) ) { - return {} + return s } didApply = true const nextWorktrees = applyWorktreeUpdates( @@ -204,7 +204,7 @@ export function createUpdateWorktreeMeta( !cacheKey && !prCacheKey ) { - return {} + return s } const nextHostedReviewCache = diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts index 78254535106..8ae7be3ea8b 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts @@ -73,7 +73,7 @@ export function createUpdateWorktreesMeta( } return nextWorktrees === s.worktreesByRepo && nextDetectedWorktrees === s.detectedWorktreesByRepo - ? {} + ? s : { ...(nextWorktrees !== s.worktreesByRepo ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } diff --git a/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts b/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts index 75d6059f309..d14392e72d9 100644 --- a/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts +++ b/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts @@ -14,7 +14,10 @@ export function createMigrateWorktreeIdentity( } // Why: invalidate pre-rename toast actions before publishing the new path, carrying the dismissal forward. migrateHugeRepoWarningDismissal(oldWorktreeId, newWorktreeId) - set((s) => buildWorktreeRenameState(s, oldWorktreeId, newWorktreeId)) + set((s) => { + const patch = buildWorktreeRenameState(s, oldWorktreeId, newWorktreeId) + return Object.keys(patch).length > 0 ? patch : s + }) migrateHostedReviewLinkMutationGeneration(oldWorktreeId, newWorktreeId) } } diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index 31c7e8bbb37..84c85c45a3c 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -217,15 +217,15 @@ export function createSetActiveWorktree( pendingActivationTerminalPrepCancels.delete(worktreeId) set((s) => { if (s.activeWorktreeId !== worktreeId) { - return {} + return s } const tabs = s.tabsByWorktree[worktreeId] ?? [] if (tabs.length === 0) { - return {} + return s } const allDead = tabs.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) if (!allDead && !shouldTagTerminalTabs) { - return {} + return s } return { tabsByWorktree: { diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts b/src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts new file mode 100644 index 00000000000..7fdaea7999f --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestStore } from '../../worktrees-slice-test-harness' +import { makeWorktree } from '../../worktrees-slice-test-fixtures' + +vi.mock('sonner', () => ({ + toast: { warning: vi.fn(), info: vi.fn(), success: vi.fn(), error: vi.fn(), dismiss: vi.fn() } +})) +vi.mock('@/components/worktree-base-fallback-notice', () => ({ + requestWorktreeBaseFallbackNotice: vi.fn() +})) + +describe('worktree no-op notifications', () => { + it('keeps missing creation, recovery, activity, deletion and visit updates silent', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.updatePendingWorktreeCreation('missing', { phase: 'fetching' }) + before.removePendingWorktreeCreation('missing') + before.setActivePendingWorktreeCreation('missing') + before.remountTerminalTabForRecovery('missing') + before.settleTerminalTabRecovery('missing', 1, 'success') + before.markWorktreeUnread('missing') + before.bumpWorktreeActivity('missing') + before.clearWorktreeDeleteState('missing') + before.seedActiveWorktreeLastVisitedIfMissing() + before.pruneLastVisitedTimestamps() + before.migrateWorktreeIdentity('missing-old', 'missing-new') + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) + + it.each(['local', 'ssh:test'] as const)( + 'keeps repeated %s deletion and visit updates silent', + (hostId) => { + const store = createTestStore() + const worktree = makeWorktree({ id: 'repo1::/path/wt', repoId: 'repo1', hostId }) + store.setState({ worktreesByRepo: { repo1: [worktree] } }) + const target = { id: worktree.id, hostId } + store.getState().markWorktreesQueuedForDeletion([target]) + store.getState().markWorktreeVisited(worktree.id, 100, hostId) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.markWorktreesQueuedForDeletion([target]) + before.markWorktreeVisited(worktree.id, 100, hostId) + before.markWorktreeVisited(worktree.id, 99, hostId) + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + + before.markWorktreesDeleting([target]) + expect(listener).toHaveBeenCalledTimes(1) + const deleting = store.getState() + deleting.markWorktreesDeleting([target]) + expect(store.getState()).toBe(deleting) + expect(listener).toHaveBeenCalledTimes(1) + deleting.clearWorktreeDeleteState(worktree.id, hostId) + expect(listener).toHaveBeenCalledTimes(2) + } + ) +}) diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index 78e0e397bca..66e6ddf5b51 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -55,7 +55,7 @@ export function createRemountTerminalTabForRecovery( const { admitted: _admitted, ...decline } = admission result = { remounted: false, ...decline } } - return {} + return s } const { worktreeId, index, tab } = location const nextTabs = s.tabsByWorktree[worktreeId].slice() @@ -110,12 +110,12 @@ export function createSettleTerminalTabRecovery( set((s) => { const location = locateTerminalTab(s.tabsByWorktree, tabId) if (!location) { - return {} + return s } const { worktreeId, index, tab } = location const recovery = settledTerminalRecoveryLedger(tab, generation, outcome) if (!recovery) { - return {} + return s } const nextTabs = s.tabsByWorktree[worktreeId].slice() nextTabs[index] = { ...tab, recovery } diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts index 72d95b6a0a4..bea375b8292 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts @@ -59,7 +59,7 @@ export function createMarkWorktreeUnread( set((s) => { const worktree = findKnownWorktreeById(s, worktreeId) if (!worktree || worktree.isUnread) { - return {} + return s } shouldPersist = true const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { @@ -266,7 +266,7 @@ export function createBumpWorktreeActivity( set((s) => { const worktree = findKnownWorktreeById(s, worktreeId) if (!worktree) { - return {} + return s } shouldPersist = true // Why: skip sortEpoch bump for the active worktree — its PTY events are click side-effects (reorder-on-click bug, PR #209). diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts index 5cfc5eef3d2..7a9d94c89fd 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts @@ -29,7 +29,7 @@ export function createMarkWorktreeVisited( hostId: ownerHostId }) ?? 0 if (!(now > prev)) { - return {} + return s } return { lastVisitedAtByWorktreeId: { @@ -124,7 +124,7 @@ export function createPruneLastVisitedTimestamps( patch.activeWorkspaceExecutionHostId = null } } - return Object.keys(patch).length > 0 ? patch : {} + return Object.keys(patch).length > 0 ? patch : s }) } } @@ -137,12 +137,12 @@ export function createSeedActiveWorktreeLastVisitedIfMissing( set((s) => { const id = s.activeWorktreeId if (!id) { - return {} + return s } const hostId = s.activeWorkspaceExecutionHostId ?? s.getKnownWorktreeById(id)?.hostId const key = getWorktreeVisitKey(id, hostId) if (getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, { id, hostId }) != null) { - return {} + return s } return { lastVisitedAtByWorktreeId: { diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts index 30975b958e6..7e89bb2e34b 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts @@ -78,7 +78,7 @@ export function createMarkWorktreesDeleting( } changed = true } - return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} + return changed ? { deleteStateByWorktreeId: nextDeleteState } : s }) } } @@ -113,7 +113,7 @@ export function createMarkWorktreesQueuedForDeletion( } changed = true } - return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} + return changed ? { deleteStateByWorktreeId: nextDeleteState } : s }) } } @@ -128,7 +128,7 @@ export function createClearWorktreeDeleteState( : worktreeId set((s) => { if (!s.deleteStateByWorktreeId[key]) { - return {} + return s } const next = { ...s.deleteStateByWorktreeId } delete next[key] diff --git a/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts b/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts index d67a382a421..fc3abb5edd6 100644 --- a/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts +++ b/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts @@ -16,7 +16,7 @@ export function createTerminalDisownedPtySourceActions( markPtySourceDisowned: (ptyId) => { set((state) => state.disownedPtyIds[ptyId] - ? {} + ? state : { disownedPtyIds: { ...state.disownedPtyIds, [ptyId]: true } } ) } diff --git a/src/renderer/src/store/terminals/terminal-ephemeral-state.ts b/src/renderer/src/store/terminals/terminal-ephemeral-state.ts index 89a67bd72ae..80de6b6899a 100644 --- a/src/renderer/src/store/terminals/terminal-ephemeral-state.ts +++ b/src/renderer/src/store/terminals/terminal-ephemeral-state.ts @@ -30,7 +30,7 @@ export function createTerminalEphemeralActions( markDefaultTerminalTabsApplied: (worktreeId) => set((s) => { if (s.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { - return {} + return s } return { defaultTerminalTabsAppliedByWorktreeId: { @@ -70,7 +70,7 @@ export function createTerminalEphemeralActions( set((s) => { const current = s.nativeChatLaunchPromptByTabId[tabId] if (!current || current.failed) { - return {} + return s } return { nativeChatLaunchPromptByTabId: { @@ -83,7 +83,7 @@ export function createTerminalEphemeralActions( clearNativeChatLaunchPrompt: (tabId) => { set((s) => { if (!s.nativeChatLaunchPromptByTabId[tabId]) { - return {} + return s } const next = { ...s.nativeChatLaunchPromptByTabId } delete next[tabId] @@ -102,7 +102,7 @@ export function createTerminalEphemeralActions( set((s) => { const current = s.nativeChatLaunchDraftByTabId[tabId] if (!current || current.adopted) { - return {} + return s } return { nativeChatLaunchDraftByTabId: { @@ -121,7 +121,7 @@ export function createTerminalEphemeralActions( current.createdAt !== resolution.createdAt || current.text !== resolution.text ) { - return {} + return s } return { nativeChatLaunchDraftByTabId: { @@ -134,7 +134,7 @@ export function createTerminalEphemeralActions( clearNativeChatLaunchDraft: (tabId) => { set((s) => { if (!s.nativeChatLaunchDraftByTabId[tabId]) { - return {} + return s } const next = { ...s.nativeChatLaunchDraftByTabId } delete next[tabId] @@ -168,7 +168,7 @@ export function createTerminalEphemeralActions( next ??= { ...s.lastTerminalInputAtByPaneKey } next[key] = at } - return next ? { lastTerminalInputAtByPaneKey: next } : {} + return next ? { lastTerminalInputAtByPaneKey: next } : s }) } }) @@ -227,7 +227,7 @@ export function createTerminalEphemeralActions( removeDeferredSshSessionId: (tabId) => set((s) => { if (!s.deferredSshSessionIdsByTabId[tabId]) { - return {} + return s } const next = { ...s.deferredSshSessionIdsByTabId } delete next[tabId] diff --git a/src/renderer/src/store/terminals/terminal-layout-state.ts b/src/renderer/src/store/terminals/terminal-layout-state.ts index 9b5cf8323c2..42ad3dbc659 100644 --- a/src/renderer/src/store/terminals/terminal-layout-state.ts +++ b/src/renderer/src/store/terminals/terminal-layout-state.ts @@ -30,7 +30,7 @@ export function createTerminalLayoutActions( set((s) => { const layout = s.terminalLayoutsByTabId[tabId] if (!layout || layout.ptyIdsByLeafId?.[leafId] === ptyId) { - return {} + return s } return { terminalLayoutsByTabId: { diff --git a/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts b/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts new file mode 100644 index 00000000000..4d68e867770 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + flushTerminalInputActivity, + resetTerminalInputActivityCoalescingForTests +} from '@/lib/terminal-input-activity-coalescing' +import { createTestStore, makeLayout } from '../slices/store-test-helpers' + +afterEach(resetTerminalInputActivityCoalescingForTests) + +describe('terminal no-op subscriber budget', () => { + it('does not publish missing-entry cleanup and restart actions', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + for (let i = 0; i < 25; i += 1) { + const s = store.getState() + s.replaceTerminalLayoutPanePtyId('missing', 'leaf', 'pty') + expect(s.consumeSuppressedPtyExit('missing')).toBe(false) + expect(s.consumePendingCodexPaneRestart('missing')).toBe(false) + s.clearCodexRestartNotice('missing') + s.dismissCodexRestartNotices(['missing']) + s.reopenCodexRestartPrompt('missing') + s.markNativeChatLaunchPromptFailed('missing') + s.clearNativeChatLaunchPrompt('missing') + s.markNativeChatLaunchDraftAdopted('missing') + s.resolveNativeChatLaunchDraft('missing', { text: 'draft', createdAt: 1 }) + s.clearNativeChatLaunchDraft('missing') + s.removeDeferredSshSessionId('missing') + } + + expect(listener).not.toHaveBeenCalled() + expect(store.getState()).toBe(before) + }) + + it('publishes real mutations once and keeps repeated actions silent', () => { + const store = createTestStore() + const draft = { tabId: 'tab', agent: 'codex', text: 'draft', createdAt: 1 } as const + store.getState().seedNativeChatLaunchPrompt(draft) + store.getState().seedNativeChatLaunchDraft(draft) + store.getState().setTabLayout('tab', makeLayout()) + const listener = vi.fn() + store.subscribe(listener) + + const actions = [ + () => store.getState().markDefaultTerminalTabsApplied('folder-workspace'), + () => store.getState().markUnverifiedPtyLoss('tab'), + () => store.getState().markPtySourceDisowned('pty'), + () => store.getState().markNativeChatLaunchPromptFailed('tab'), + () => store.getState().markNativeChatLaunchDraftAdopted('tab'), + () => store.getState().resolveNativeChatLaunchDraft('tab', draft), + () => store.getState().replaceTerminalLayoutPanePtyId('tab', 'leaf', 'pty'), + () => store.getState().clearNativeChatLaunchPrompt('tab'), + () => store.getState().clearNativeChatLaunchDraft('tab') + ] + for (const action of actions) { + listener.mockClear() + action() + expect(listener).toHaveBeenCalledTimes(1) + const before = store.getState() + action() + expect(listener).toHaveBeenCalledTimes(1) + expect(store.getState()).toBe(before) + } + }) + + it('retains draft generations when stale resolutions arrive without notifying', () => { + const store = createTestStore() + const draft = { tabId: 'tab', agent: 'codex', text: 'new draft', createdAt: 2 } as const + store.getState().seedNativeChatLaunchDraft(draft) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + store.getState().resolveNativeChatLaunchDraft('tab', { ...draft, createdAt: 1 }) + store.getState().resolveNativeChatLaunchDraft('tab', { ...draft, text: 'old draft' }) + + expect(listener).not.toHaveBeenCalled() + expect(store.getState()).toBe(before) + expect(store.getState().nativeChatLaunchDraftByTabId.tab).toBe(draft) + }) + + it('consumes real restart entries and leaves repeated consumes silent', () => { + const store = createTestStore() + store.getState().suppressPtyExit('pty') + store.getState().queueCodexPaneRestarts(['pty']) + const listener = vi.fn() + store.subscribe(listener) + + expect(store.getState().consumeSuppressedPtyExit('pty')).toBe(true) + expect(store.getState().consumePendingCodexPaneRestart('pty')).toBe(true) + expect(listener).toHaveBeenCalledTimes(2) + const before = store.getState() + expect(store.getState().consumeSuppressedPtyExit('pty')).toBe(false) + expect(store.getState().consumePendingCodexPaneRestart('pty')).toBe(false) + expect(listener).toHaveBeenCalledTimes(2) + expect(store.getState()).toBe(before) + }) + + it('drops a trailing input flush after pane teardown without publishing', () => { + const store = createTestStore() + store.getState().recordTerminalInput('tab:leaf', 1000) + store.getState().recordTerminalInput('tab:leaf', 1001) + store.setState({ lastTerminalInputAtByPaneKey: {} }) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + flushTerminalInputActivity() + + expect(listener).not.toHaveBeenCalled() + expect(store.getState()).toBe(before) + expect(store.getState().lastTerminalInputAtByPaneKey['tab:leaf']).toBeUndefined() + }) + + it('dismisses, reopens and clears restart notices without replaying no-op notifications', () => { + const store = createTestStore() + store + .getState() + .markCodexRestartNotices([ + { ptyId: 'pty', previousAccountLabel: 'old', nextAccountLabel: 'new' } + ]) + const listener = vi.fn() + store.subscribe(listener) + const actions = [ + () => store.getState().dismissCodexRestartNotices(['pty']), + () => store.getState().reopenCodexRestartPrompt('pty'), + () => store.getState().clearCodexRestartNotice('pty') + ] + for (const [index, action] of actions.entries()) { + if (index === 1) { + store.getState().queueCodexPaneRestarts(['pty']) + } + listener.mockClear() + action() + expect(listener).toHaveBeenCalledTimes(1) + const before = store.getState() + action() + expect(listener).toHaveBeenCalledTimes(1) + expect(store.getState()).toBe(before) + } + expect(store.getState().codexRestartNoticeByPtyId.pty).toBeUndefined() + expect(store.getState().pendingCodexPaneRestartIds.pty).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-restart-state.ts b/src/renderer/src/store/terminals/terminal-restart-state.ts index f1075bdb698..2b712b6ccdb 100644 --- a/src/renderer/src/store/terminals/terminal-restart-state.ts +++ b/src/renderer/src/store/terminals/terminal-restart-state.ts @@ -21,7 +21,7 @@ export function createTerminalRestartActions( let wasSuppressed = false set((s) => { if (!s.suppressedPtyExitIds[ptyId]) { - return {} + return s } wasSuppressed = true const next = { ...s.suppressedPtyExitIds } @@ -68,7 +68,7 @@ export function createTerminalRestartActions( let wasQueued = false set((s) => { if (!s.pendingCodexPaneRestartIds[ptyId]) { - return {} + return s } wasQueued = true const next = { ...s.pendingCodexPaneRestartIds } @@ -144,7 +144,7 @@ export function createTerminalRestartActions( clearCodexRestartNotice: (ptyId) => { set((s) => { if (!s.codexRestartNoticeByPtyId[ptyId]) { - return {} + return s } const next = { ...s.codexRestartNoticeByPtyId } const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } @@ -175,7 +175,7 @@ export function createTerminalRestartActions( changed = true } if (!changed) { - return {} + return s } return { codexRestartNoticeByPtyId: next, @@ -187,7 +187,7 @@ export function createTerminalRestartActions( set((s) => { const notice = s.codexRestartNoticeByPtyId[ptyId] if (!notice?.restartRequested) { - return {} + return s } const { restartRequested: _restartRequested, ...kept } = notice const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } diff --git a/src/renderer/src/store/terminals/terminal-startup-queues.ts b/src/renderer/src/store/terminals/terminal-startup-queues.ts index 0f684e3f8b7..af56050ef23 100644 --- a/src/renderer/src/store/terminals/terminal-startup-queues.ts +++ b/src/renderer/src/store/terminals/terminal-startup-queues.ts @@ -62,7 +62,7 @@ export function createTerminalStartupQueueActions( } set((s) => { if (s.pendingStartupByTabId[tabId] !== pending) { - return {} + return s } const next = { ...s.pendingStartupByTabId } delete next[tabId] diff --git a/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts b/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts index 9f381a02c4e..075d349a528 100644 --- a/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts +++ b/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts @@ -8,7 +8,7 @@ export function createTerminalUnverifiedPtyLossActions( markUnverifiedPtyLoss: (tabId) => { set((state) => state.unverifiedPtyLossTabIds[tabId] - ? {} + ? state : { unverifiedPtyLossTabIds: { ...state.unverifiedPtyLossTabIds, [tabId]: true } } ) } diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts index dcf93839828..889e89b5103 100644 --- a/src/renderer/src/web/preload-api/web-filesystem-api.ts +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -126,6 +126,11 @@ export function createFileApi(): NonNullable['fs']> { }, importExternalPaths: async () => ({ results: [] }), stageExternalPathsForRuntimeUpload: async () => ({ sources: [] }), + // Why: the web client has no local filesystem to stream from, so staging + // never yields a source for this to upload. + uploadExternalFileToRuntime: async () => { + throw new Error('Uploading local files is not supported in the web client') + }, resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), watchWorktree: () => Promise.resolve(), unwatchWorktree: () => Promise.resolve(), diff --git a/src/shared/agent-session-fence-mint-boundary.test.ts b/src/shared/agent-session-fence-mint-boundary.test.ts new file mode 100644 index 00000000000..c38d17c4b23 --- /dev/null +++ b/src/shared/agent-session-fence-mint-boundary.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { resolve } from 'node:path' +import { scanSourceTree, stripComments } from './source-scan/source-tree-scan' + +const BARE_FENCE_MINT = /runtimeFence\s*:\s*[^\n]*\.runtimeFence\s*\+\s*1\b/ + +describe('agent-session fence mint boundary', () => { + it('rejects direct runtimeFence increments in shipped source', () => { + const offenders = scanSourceTree(resolve(__dirname, '..', '..', 'src')) + .filter(({ source }) => BARE_FENCE_MINT.test(stripComments(source))) + .map(({ relativePath }) => relativePath) + + expect( + offenders, + 'New bare runtimeFence mint. Route the assignment through nextAgentSessionFence(...).' + ).toEqual([]) + }) +}) diff --git a/src/shared/agent-session-next-fence.ts b/src/shared/agent-session-next-fence.ts index bf2eb9f50c7..2c05aa5004d 100644 --- a/src/shared/agent-session-next-fence.ts +++ b/src/shared/agent-session-next-fence.ts @@ -7,8 +7,8 @@ // // Recovery records the floor instead of rewriting the current fence, because `live` means a handle // proven at exactly the current fence — moving it would invalidate the very records recovery exists -// to save. Every mint site routes through here so a new transition cannot quietly reintroduce a -// bare `+ 1`; the floor is pinned by a test that drives each transition. +// to save. A source-level ratchet rejects direct `+ 1` mints, while acquisition-transition tests +// pin the one-step bound and floor; an indirected mint is not caught. import type { AgentSessionLease } from './agent-session-record' diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index fb495f7cb8c..b1ad68f0fa3 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -75,6 +75,8 @@ export type AgentSessionTurnActivity = { text: string } +export const AGENT_SESSION_ID_MAX_LENGTH = 512 + /** Backward paging is the client's normal read; 40 matches the page size the * mobile list renders without a visible fill-in. */ export const AGENT_SESSION_HISTORY_DEFAULT_LIMIT = 40 diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index 2b5194bfda8..949768e03b5 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -1,3 +1,4 @@ +import { getPiStateTitleBrand } from './pi-state-title-marker' import { AGY_AGENT_NAME_RE, CLAUDE_IDLE, @@ -67,6 +68,10 @@ function computeAgentLabel(title: string): string | null { ) { return 'Claude Code' } + const piStateBrand = getPiStateTitleBrand(title) + if (piStateBrand) { + return piStateBrand + } if (isGeminiTerminalTitle(title)) { return 'Gemini CLI' } diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts index 2526c94572f..df0a668621a 100644 --- a/src/shared/agent-title-owner.ts +++ b/src/shared/agent-title-owner.ts @@ -1,3 +1,4 @@ +import { rebrandPiStateTitle } from './pi-state-title-marker' import { detectAgentStatusFromTitle, getAgentLabel } from './agent-detection' import type { AgentStatusEntry, AgentType } from './agent-status-types' import { @@ -157,6 +158,10 @@ export function normalizeCompatibleAgentTitleForOwner( ) { return title } + const stateTitle = rebrandPiStateTitle(title, ownerProfile.workingLabel) + if (stateTitle !== null) { + return stateTitle + } // Why: a π-branded title is the agent's own semantic session title (`π > - `; // Orca's injected extension writes the same shape). Swap only the BRAND for the owner's label // so the pane still reads as its launch owner (#6689, #7633, #9077) without discarding the diff --git a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt index 068f9a8a96f..a59e3be4d7c 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -23,7 +23,6 @@ main/emulator/serve-sim-runtime-materializer.ts main/emulator/simctl-simulator-devices.ts main/emulator/simulator-app-visibility.ts main/external-editor-launch.ts -main/hooks.ts main/ipc/app.ts main/ipc/developer-permissions.ts main/ipc/macos-keyboard-layout-snapshot.ts diff --git a/src/shared/child-process/windows-console-visibility.test.ts b/src/shared/child-process/windows-console-visibility.test.ts index 69b0a99eb5b..a15f98b288b 100644 --- a/src/shared/child-process/windows-console-visibility.test.ts +++ b/src/shared/child-process/windows-console-visibility.test.ts @@ -34,7 +34,7 @@ const ALLOWLIST: readonly string[] = readAllowlist( * the allowlist does not bound this: a swap (one file fixed and delisted, one * new file added with its entry) satisfies both membership assertions. */ -const UNHIDDEN_SPAWNER_PIN = 65 +const UNHIDDEN_SPAWNER_PIN = 64 const CHILD_PROCESS_IMPORT = /from\s+['"](?:node:)?child_process['"]|require\(\s*['"](?:node:)?child_process['"]/ diff --git a/src/shared/filesystem-import-result-types.ts b/src/shared/filesystem-import-result-types.ts new file mode 100644 index 00000000000..206570e9a33 --- /dev/null +++ b/src/shared/filesystem-import-result-types.ts @@ -0,0 +1,36 @@ +import type { + StagedRuntimeUploadEntry, + StagedRuntimeUploadSource +} from './runtime-upload-staging-contract' + +export type ImportSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + +export type ResolveDroppedPathsResult = { + resolvedPaths: string[] + skipped: { sourcePath: string; reason: ImportSkipReason }[] + failed: { sourcePath: string; reason: string }[] +} + +export type ImportItemResult = + | { + sourcePath: string + status: 'imported' + destPath: string + kind: 'file' | 'directory' + renamed: boolean + } + | { + sourcePath: string + status: 'skipped' + reason: ImportSkipReason + } + | { + sourcePath: string + status: 'failed' + reason: string + } + +// Why: staging crosses IPC to the renderer and back into the streamer, so the +// shape lives in shared and every layer names the same type. +export type StagedExternalImportSource = StagedRuntimeUploadSource +export type StagedExternalImportEntry = StagedRuntimeUploadEntry diff --git a/src/shared/omp-owner-state-title.test.ts b/src/shared/omp-owner-state-title.test.ts new file mode 100644 index 00000000000..c06e4dbfe78 --- /dev/null +++ b/src/shared/omp-owner-state-title.test.ts @@ -0,0 +1,51 @@ +import { getPiCompatibleTitleSeparatorStatus } from './pi-compatible-synthetic-title' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { detectAgentStatusFromTitle, getAgentLabel } from './agent-detection' +import { normalizeCompatibleAgentTitleForOwner } from './agent-title-owner' +import { clearPiStateWorkingMarker } from './pi-state-title-marker' + +const transcript = readFileSync( + join(__dirname, '..', 'main', 'runtime', '__fixtures__', 'omp-native-title-win32.txt'), + 'utf8' +) +// oxlint-disable-next-line no-control-regex -- The fixture retains actual OSC control bytes. +const titles = [...transcript.matchAll(/\x1b\]0;([^\x07]+)\x07/g)].map((match) => match[1]) + +describe('owner-rewritten OMP titles from captured upstream output', () => { + it('contains the six upstream state frames', () => expect(titles).toHaveLength(6)) + it.each( + titles.map((title, index) => ({ + title, + state: index < 2 ? 'working' : index < 4 ? 'idle' : 'permission' + })) + )('preserves $state and label for $title', ({ title, state }) => { + for (const prefix of ['', 'zsh | ', 'tmux: ']) { + const wrapped = prefix + title + expect(detectAgentStatusFromTitle(wrapped)).toBe(state) + const owned = normalizeCompatibleAgentTitleForOwner(wrapped, 'omp', { ownerIsLaunch: true }) + expect(owned).toBe(prefix + title.replace('π', 'OMP')) + expect(getAgentLabel(owned)).toBe('OMP') + expect(detectAgentStatusFromTitle(owned)).toBe(state) + expect(getPiCompatibleTitleSeparatorStatus(owned)).toBe(state) + expect(normalizeCompatibleAgentTitleForOwner(owned, 'omp')).toBe(owned) + expect(normalizeCompatibleAgentTitleForOwner(owned, 'pi')).toBe( + prefix + title.replace('π', 'Pi') + ) + if (state === 'working') { + expect(detectAgentStatusFromTitle(clearPiStateWorkingMarker(owned) ?? '')).toBe('idle') + } + } + }) + it.each([ + 'omp-harness ready', + '/tmp/OMP : file', + 'lowercase omp : note', + 'Pi: legacy', + 'OMP ready' + ])('does not rewrite neutral or legacy title %s as a working marker', (title) => { + expect(clearPiStateWorkingMarker(title)).toBeNull() + expect(detectAgentStatusFromTitle(title)).not.toBe('working') + }) +}) diff --git a/src/shared/pi-compatible-synthetic-title.ts b/src/shared/pi-compatible-synthetic-title.ts index 7b99235811d..0e9e5d2b196 100644 --- a/src/shared/pi-compatible-synthetic-title.ts +++ b/src/shared/pi-compatible-synthetic-title.ts @@ -1,3 +1,5 @@ +import { getPiStateTitleStatus } from './pi-state-title-marker' + export type PiCompatibleSyntheticAgentLabel = 'Pi' | 'OMP' export type PiCompatibleSyntheticAgentStatus = 'working' | 'permission' | 'idle' @@ -71,6 +73,10 @@ export function isLegacyPiCompatibleTitle(title: string): boolean { export function getPiCompatibleTitleSeparatorStatus( title: string ): PiCompatibleSyntheticAgentStatus | null { + const nativeState = getPiStateTitleStatus(title) + if (nativeState) { + return nativeState + } // Why: a spinner anywhere means the agent is working, and that outranks the separator — // the frame is drawn over the idle separator position while a turn runs. if (containsBrailleSpinner(title)) { diff --git a/src/shared/pi-state-title-marker.ts b/src/shared/pi-state-title-marker.ts index 7f6ece71bd6..3d2ed19a759 100644 --- a/src/shared/pi-state-title-marker.ts +++ b/src/shared/pi-state-title-marker.ts @@ -27,15 +27,17 @@ function escapeForCharacterClass(marker: string): string { return marker.replace(/[\\\]^-]/g, '\\$&') } -// Why: `π` must sit at a token boundary so wrapper prefixes of any shape (`zsh | π : cwd`, +// Why: the brand must sit at a token boundary so wrapper prefixes (`zsh | OMP : cwd`, // `tmux: π : cwd`) still expose the marker, and whitespace must separate the marker so the // legacy no-space `π: cwd` disabled title keeps its historical idle classification. const PI_STATE_TITLE_RE = new RegExp( - `(?:^|[\\s|])π[ \\t]+([${PI_STATE_MARKERS.map(escapeForCharacterClass).join('')}])(?=\\s|$)`, + `(?:^|[\\s|])(π|Pi|OMP)[ \\t]+([${PI_STATE_MARKERS.map(escapeForCharacterClass).join('')}])(?=\\s|$)`, 'u' ) type PiStateTitleMatch = { + brand: string + brandIndex: number marker: PiStateMarker markerIndex: number } @@ -49,8 +51,14 @@ function matchPiStateTitle(title: string): PiStateTitleMatch | null { if (!match) { return null } + const marker = match[2] + if (marker !== ':' && marker !== '!' && marker !== '>') { + return null + } return { - marker: match[1] as PiStateMarker, + brand: match[1], + brandIndex: match.index + match[0].indexOf(match[1]), + marker, markerIndex: match.index + match[0].length - 1 } } @@ -73,3 +81,20 @@ export function clearPiStateWorkingMarker(title: string): string | null { } return `${title.slice(0, match.markerIndex)}${PI_IDLE_MARKER}${title.slice(match.markerIndex + 1)}` } + +/** The state marker owns identity too; its label may mention another agent. */ +export function getPiStateTitleBrand(title: string): 'Pi' | 'OMP' | null { + const match = matchPiStateTitle(title) + return match ? (match.brand === 'OMP' ? 'OMP' : 'Pi') : null +} + +/** Rebrand only the protocol prefix, preserving wrappers and the opaque session label. */ +export function rebrandPiStateTitle(title: string, brand: string): string | null { + const match = matchPiStateTitle(title) + if (!match) { + return null + } + return ( + title.slice(0, match.brandIndex) + brand + title.slice(match.brandIndex + match.brand.length) + ) +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 99908de9b84..6afdf0b3b62 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -175,6 +175,10 @@ export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' export const AGENT_SESSION_TURN_ITEM_CAPABILITY = 'agent-session.turn-item.v1' as const export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = 'agent-session.background-task-stop.v1' as const +// Why: agentSession.cancel has a strict schema, so clients must not send prompt identity to an +// older host that would reject the whole cancellation instead of falling back to turn stop. +export const AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY = + 'agent-session.prompt-cancel.v1' as const // Why: the host now publishes rows for work that is live inside a turn, and such // a row carries `stoppable: false` because no targeted stop can reach it. A // reader that predates the field draws a per-row Stop on every row it is given, @@ -294,6 +298,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, diff --git a/src/shared/repo-types.ts b/src/shared/repo-types.ts index a6980b45d54..9e3e53cf7a3 100644 --- a/src/shared/repo-types.ts +++ b/src/shared/repo-types.ts @@ -52,6 +52,8 @@ export type Repo = { upstream?: GitHubRepositoryIdentity | null addedAt: number kind?: RepoKind + /** Git root proven during folder upgrade; keeps the original checkout locator stable. */ + folderUpgradeGitRootPath?: string gitUsername?: string worktreeBaseRef?: string /** Optional repo-scoped workspace root override. Relative paths resolve from `path`. */ diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 7d2c73afb66..edbda12d6d9 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -2,11 +2,12 @@ import { z } from 'zod' import { isAgentSessionId } from '../agent-session-record' import { normalizeExecutionHostId } from '../execution-host' import { + AGENT_SESSION_ID_MAX_LENGTH, AGENT_SESSION_HISTORY_DIRECTIONS, AGENT_SESSION_HISTORY_MAX_LIMIT } from '../agent-session-wire' -export const MAX_ID_LENGTH = 512 +export const MAX_ID_LENGTH = AGENT_SESSION_ID_MAX_LENGTH // Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded. export const MAX_RESPONSE_OPTION_ID_LENGTH = 1024 @@ -164,11 +165,23 @@ export const CancelParams = z envelope: MutationEnvelope, turnId: Identifier('Invalid turn id'), scope: z.literal('background-tasks').optional(), - taskId: Identifier('Invalid task id').optional() + taskId: Identifier('Invalid task id').optional(), + prompt: z + .object({ + itemId: Identifier('Invalid item id'), + expectedRevision: z.number().int().positive() + }) + .strict() + .optional() }) .strict() - .refine((value) => value.taskId === undefined || value.scope === 'background-tasks', { - message: 'A task id requires background-task scope' + .superRefine((value, ctx) => { + if (value.taskId !== undefined && value.scope !== 'background-tasks') { + ctx.addIssue({ code: 'custom', message: 'A task id requires background-task scope' }) + } + if (value.prompt !== undefined && value.scope === 'background-tasks') { + ctx.addIssue({ code: 'custom', message: 'A prompt cannot use background-task scope' }) + } }) export const RespondParams = z diff --git a/src/shared/runtime-upload-staging-contract.ts b/src/shared/runtime-upload-staging-contract.ts new file mode 100644 index 00000000000..19a3d2f6261 --- /dev/null +++ b/src/shared/runtime-upload-staging-contract.ts @@ -0,0 +1,50 @@ +import type { SshMutationExpectation } from './ssh-types' + +export type RuntimeUploadSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + +/** + * What staging observed about a file, so the uploader can refuse a source that + * was swapped between the two calls. Size alone misses a same-size replacement. + */ +export type StagedRuntimeUploadFileIdentity = { + byteLength: number + /** 0 when the filesystem does not report one; compared only when both sides have it. */ + inode: number + deviceId: number + modifiedAtMs: number +} + +export type StagedRuntimeUploadEntry = + | { relativePath: string; kind: 'directory' } + // Why: file bodies are streamed in slices at upload time, so staging carries + // identity the uploader re-checks against the handle it actually reads. + | ({ relativePath: string; kind: 'file' } & StagedRuntimeUploadFileIdentity) + +export type StagedRuntimeUploadSource = + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: StagedRuntimeUploadEntry[] + } + | { sourcePath: string; status: 'skipped'; reason: RuntimeUploadSkipReason } + | { sourcePath: string; status: 'failed'; reason: string } + +export type StageRuntimeUploadResult = { sources: StagedRuntimeUploadSource[] } + +/** Renderer → main request to pump one staged file's bytes to the runtime. */ +export type RuntimeUploadFileStreamRequest = { + environmentId: string + /** Client-local path of the dropped source (file, or root of a dropped directory). */ + sourceRootPath: string + /** Path of this file within the dropped directory; empty when the source is a file. */ + entryRelativePath: string + /** Identity staging recorded; a source that no longer matches is refused, not streamed. */ + expected: StagedRuntimeUploadFileIdentity + worktree: string + /** Destination path on the runtime, relative to the worktree. */ + relativePath: string + expectedEnvironmentPairingRevision?: number + expectedEnvironmentRuntimeId?: string +} & SshMutationExpectation diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 9ea733e5832..14e197325d8 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -20,8 +20,14 @@ export const DISPATCH_REJECTED_WRITE_FAILED = 'provider_write_failed' -/** Local admission refused the frame before any transport was involved. */ +/** Local admission refused the frame before any transport was involved. Two + * strings rather than one provider-neutral marker because both are already + * durable journal reasons; rewording either would relabel rows on disk. */ export const DISPATCH_REJECTED_QUEUE_FULL = 'claude structured dispatch queue is full' +export const DISPATCH_REJECTED_CODEX_QUEUE_FULL = 'codex structured dispatch queue is full' + +/** The provider confirmed a queued frame was withdrawn before execution. */ +export const DISPATCH_REJECTED_CANCELLED = 'provider_cancelled_before_start' export function dispatchWriteFailureReason(error: unknown): string { const detail = error instanceof Error ? error.message : String(error) @@ -45,6 +51,9 @@ export function dispatchRejectionWasTransportWriteFailure( */ export function dispatchRejectionReasonIsInternal(reason: string | null | undefined): boolean { return ( - dispatchRejectionWasTransportWriteFailure(reason) || reason === DISPATCH_REJECTED_QUEUE_FULL + dispatchRejectionWasTransportWriteFailure(reason) || + reason === DISPATCH_REJECTED_QUEUE_FULL || + reason === DISPATCH_REJECTED_CODEX_QUEUE_FULL || + reason === DISPATCH_REJECTED_CANCELLED ) } diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 0030f17a5b0..80c862f9f02 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -2,6 +2,7 @@ import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-se import { agentSessionRefusalOperationState } from './agent-session-refusal-retry' import type { AgentSessionWireRefusalCode } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' +import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection' export type StructuredAgentSessionOutboxState = 'queued' | 'dispatching' | 'unconfirmed' @@ -102,6 +103,12 @@ export function reconcileStructuredAgentSessionOutbox( if (submission?.dispatchState === 'accepted') { return [] } + if ( + submission?.dispatchState === 'rejected' && + submission.reason === DISPATCH_REJECTED_CANCELLED + ) { + return [] + } if (submission?.dispatchState === 'pending') { return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }] } diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 4ab5c107db0..b567c2a8dd2 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -8,11 +8,13 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' import { dispatchWriteFailureReason, + DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_QUEUE_FULL } from './structured-agent-session-dispatch-rejection' import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition' import { createStructuredAgentSessionOutboxEntry, + reconcileStructuredAgentSessionOutbox, type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' @@ -55,6 +57,15 @@ function notice(reason: string | null): string | null { } describe('what a rejection shows the user', () => { + it('removes a queued message the provider confirms Stop cancelled', () => { + const result = rejectedWith(DISPATCH_REJECTED_CANCELLED) + if (!result.ok) { + throw new Error('expected rejected submission fixture') + } + + expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([]) + }) + it('never puts the transport marker on screen', () => { const shown = notice(dispatchWriteFailureReason(new Error('broken pipe'))) // `provider_write_failed: broken pipe` names nothing a person can act on. diff --git a/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts b/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts new file mode 100644 index 00000000000..417133b7c43 --- /dev/null +++ b/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts @@ -0,0 +1,101 @@ +import { getDefaultWorkspaceSession } from '../../src/shared/constants' +import type { AppState } from '../../src/renderer/src/store/types' +import { getRemovedWorktreeIdsAfterAuthoritativeScan } from '../../src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership' +import { mergeWorktree } from '../../src/main/ipc/worktree-metadata-merge' +import { createFolderWorktree } from '../../src/main/repo-worktrees' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createStore, + makeRepo, + makeTerminalTab, + testState +} from '../../src/main/persistence-test-harness' +import { buildDetectedGitWorktrees } from '../../src/main/ipc/worktrees/listing/ssh-worktree-fallback' +import { resolveRepoWorktreeRows } from '../../src/main/runtime/repo-worktree-row-resolution' + +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-folder-upgrade-store-')) +}) +afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) +}) + +it('retains the folder instance and metadata through upgrade, listing, and Store reload', async () => { + const store = createStore() + const owner = makeRepo({ id: 'folder', path: 'C:\\projects\\draft', kind: 'folder' }) + store.addRepo(owner) + const id = `folder::${owner.path}` + const before = store.setWorktreeMetaForHost(id, 'local', { comment: 'ongoing OMP work' }) + store.setWorktreeMetaForHost(id, 'ssh:builder', { comment: 'other host' }) + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + activeRepoId: owner.id, + activeWorktreeId: id, + activeTabId: 'omp-tab', + tabsByWorktree: { [id]: [makeTerminalTab({ id: 'omp-tab', worktreeId: id })] } + }) + store.updateRepo(owner.id, { kind: 'git', folderUpgradeGitRootPath: 'C:/projects/draft' }) + store.flush() + const reloaded = createStore() + const repo = reloaded.getRepo(owner.id) + expect(repo?.folderUpgradeGitRootPath).toBe('C:/projects/draft') + if (!repo) { + throw new Error('registered repo missing') + } + const worktrees = [ + { path: 'C:/projects/draft', branch: 'main', head: 'abc', isMainWorktree: true, isBare: false } + ] + const detected = buildDetectedGitWorktrees(reloaded, repo, worktrees) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The purge reader only reads these catalog fields when session hydration is complete. + const state = { + repos: [owner], + worktreesByRepo: { [owner.id]: [mergeWorktree(owner.id, createFolderWorktree(owner), before)] }, + detectedWorktreesByRepo: {}, + hasHydratedWorktreePurge: true + } as unknown as AppState + expect( + getRemovedWorktreeIdsAfterAuthoritativeScan( + state, + owner.id, + { repoId: owner.id, authoritative: true, source: 'git', worktrees: detected }, + 'local' + ) + ).toEqual([]) + expect(reloaded.getWorkspaceSession()).toMatchObject({ + activeWorktreeId: id, + activeTabId: 'omp-tab', + tabsByWorktree: { [id]: [{ id: 'omp-tab', worktreeId: id }] } + }) + const runtime = await resolveRepoWorktreeRows( + { + store: reloaded, + scanRepo: async () => ({ ok: true, worktrees }), + listFolderWorkspaces: () => [] + }, + repo, + reloaded.getAllWorktreeMeta(), + new Map() + ) + for (const rows of [detected, runtime]) { + expect(rows[0]).toMatchObject({ + id, + instanceId: before.instanceId, + comment: 'ongoing OMP work', + hostId: 'local' + }) + } + expect(reloaded.getWorktreeMetaForHost(id, 'ssh:builder')?.comment).toBe('other host') + reloaded.flush() + expect(createStore().getWorktreeMetaForHost(id, 'local')?.instanceId).toBe(before.instanceId) +}) + +it('drops upgrade path evidence when execution ownership changes', () => { + const store = createStore() + store.addRepo(makeRepo({ id: 'folder', path: 'C:\\projects\\draft', kind: 'git' })) + store.updateRepo('folder', { folderUpgradeGitRootPath: 'C:/projects/draft' }) + store.updateRepo('folder', { executionHostId: 'ssh:builder' }) + expect(store.getRepo('folder')?.folderUpgradeGitRootPath).toBeUndefined() +}) diff --git a/tests/e2e/markdown-literal-save-reopen.spec.ts b/tests/e2e/markdown-literal-save-reopen.spec.ts new file mode 100644 index 00000000000..745396d8771 --- /dev/null +++ b/tests/e2e/markdown-literal-save-reopen.spec.ts @@ -0,0 +1,119 @@ +import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { switchToWorktree, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupMarkdownFixture, + closeActiveEditorTab, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { waitForPairedClientWorktree } from './helpers/paired-client-host-session' + +const SOURCE = '# Compatibility\n\n[[]] [[a|]]\n\n[**Bold**](https://example.com)\n\nEnd\n' +const TYPED = '[typed](./target.md)' + +for (const workspace of ['git', 'folder', 'paired remote'] as const) { + test(`preserves literal Markdown and formatted links when saving in ${workspace}`, async ({ + orcaPage, + registerPostElectronShutdownCleanup + }, testInfo) => { + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + if (workspace === 'folder') { + const folder = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-markdown-folder-'))) + registerPostElectronShutdownCleanup(async () => + rmSync(folder, { recursive: true, force: true }) + ) + await orcaPage.evaluate(async (folderPath) => { + const repo = await window.__store!.getState().addNonGitFolder(folderPath) + if (!repo) { + throw new Error('Could not add folder workspace') + } + }, folder) + await expect + .poll(async () => (await getActiveWorktreeContext(orcaPage)).rootPath) + .toBe(folder) + } + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + 'markdown-compatibility', + workspace.replaceAll(' ', '-'), + testInfo.workerIndex, + SOURCE + ) + let client: PairedElectronClient | undefined + try { + if (workspace === 'paired remote') { + client = await launchPairedElectronClient( + await createRuntimeDesktopPairingOffer(orcaPage), + testInfo, + 'Markdown save compatibility' + ) + await waitForPairedClientWorktree(client.page, context.worktreeId) + await client.page.evaluate( + ({ worktreeId, environmentId }) => { + window.__store!.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`) + }, + { worktreeId: context.worktreeId, environmentId: client.environmentId } + ) + } + const page = client?.page ?? orcaPage + await openMarkdownFixture(page, context, filePath) + if (client) { + expect( + await page.evaluate(() => { + const state = window.__store!.getState() + return state.openFiles.find((file) => file.id === state.activeFileId) + ?.runtimeEnvironmentId + }) + ).toBe(client.environmentId) + } + const editor = await waitForRichMarkdownEditor(page) + await expect(editor).toContainText('[[]] [[a|]]') + await expect(editor.locator('a strong')).toHaveText('Bold') + await editor.click() + await page.keyboard.press('ControlOrMeta+End') + await page.keyboard.press('Enter') + await page.keyboard.insertText(TYPED) + await expect(editor.locator('a').filter({ hasText: 'typed' })).toHaveCount(0) + await page.keyboard.press('ControlOrMeta+S') + await expect + .poll(() => readFileSync(filePath, 'utf8'), { timeout: 15_000 }) + .toContain('typed') + const saved = readFileSync(filePath, 'utf8') + expect(saved).toContain('[[]] [[a|]]') + expect(saved).toContain('[**Bold**](https://example.com)') + await testInfo.attach('saved-markdown', { body: saved, contentType: 'text/markdown' }) + await closeActiveEditorTab(page, filePath) + // Closing a folder's only tab intentionally returns to the landing screen. + if (workspace === 'folder') { + await switchToWorktree(page, context.worktreeId) + } + await openMarkdownFixture(page, context, filePath) + const reopened = await waitForRichMarkdownEditor(page) + await expect(reopened).toContainText(TYPED) + await expect(reopened).toContainText('[[]] [[a|]]') + await expect(reopened.locator('a strong')).toHaveText('Bold') + await expect(reopened.locator('a').filter({ hasText: 'typed' })).toHaveCount(0) + await testInfo.attach('reopened-editor', { + body: await page.screenshot(), + contentType: 'image/png' + }) + await closeActiveEditorTab(page, filePath) + } finally { + await client?.dispose() + await cleanupMarkdownFixture(filePath) + } + }) +} diff --git a/tests/e2e/omp-title-marker.spec.ts b/tests/e2e/omp-title-marker.spec.ts new file mode 100644 index 00000000000..4f2db0ddbd9 --- /dev/null +++ b/tests/e2e/omp-title-marker.spec.ts @@ -0,0 +1,43 @@ +import { writeFile } from 'node:fs/promises' +import { buildShellCommandFromArgv } from '../../src/shared/tui-agent-startup-shell' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +test('OMP spaced-colon title renders working and clears on idle', async ({ + orcaPage +}, testInfo) => { + test.skip( + process.platform === 'win32', + 'POSIX title replay; Windows formatter bytes have separate coverage' + ) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + const script = testInfo.outputPath('title-replay.cjs') + await writeFile( + script, + ` +process.stdout.write('\\x1b]0;OMP : Image review\\x07') +process.stdin.on('data', () => process.stdout.write('\\x1b]0;OMP > Image review\\x07')) +` + ) + await execInTerminal( + orcaPage, + ptyId, + buildShellCommandFromArgv([process.execPath, script], 'posix') + ) + const working = orcaPage.locator('[aria-label="Working"]') + await expect(working.first()).toBeVisible({ timeout: 15000 }) + await orcaPage.screenshot({ path: testInfo.outputPath('omp-title-working.png') }) + await sendToTerminal(orcaPage, ptyId, '\r') + await expect(working).toHaveCount(0) + await orcaPage.screenshot({ path: testInfo.outputPath('omp-title-idle.png') }) +}) diff --git a/tests/tools/omp-child-history-rendered/README.md b/tests/tools/omp-child-history-rendered/README.md new file mode 100644 index 00000000000..52cd99a8371 --- /dev/null +++ b/tests/tools/omp-child-history-rendered/README.md @@ -0,0 +1,15 @@ +# Child history resume proof + +Run `ORCA_BACKGROUND_LAUNCH=1 node tests/tools/omp-child-history-rendered/run.mjs`. +The hidden Electron fixture renders the production virtual history list and styles with +injected OMP, Claude and empty OMP transcripts. It opens eight generations lazily, +resumes the deepest child into a folder target, and checks that Claude remains view-only. +It verifies parent-row measurement avoids overlap, indentation stops growing, scrolling +away/back restores expansion, and collapse removes descendants. CDP screenshots and +native hidden/unfocused window assertions are saved in `.bench-fixtures`. +This exercises production rendering and callbacks, not the complete terminal launch UI. + +Run `ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-child-session-resume-smoke.mjs /path/to/oh-my-pi` +for a zero-model-call check against real OMP session storage and CLI parsing. The smoke +builds Orca's path-based resume command, creates parent, child and grandchild transcripts, +and verifies OMP selects each descendant's distinct identity in a folder workspace. diff --git a/tests/tools/omp-child-history-rendered/fixture.css b/tests/tools/omp-child-history-rendered/fixture.css new file mode 100644 index 00000000000..30978a0b8da --- /dev/null +++ b/tests/tools/omp-child-history-rendered/fixture.css @@ -0,0 +1,5 @@ +@import '../../../src/renderer/src/assets/main.css'; +@source './fixture.tsx'; +@source '../../../src/renderer/src/components/right-sidebar'; +@source '../../../src/renderer/src/components/ui'; +@source '../../../src/renderer/src/components/AgentStateDot.tsx'; diff --git a/tests/tools/omp-child-history-rendered/fixture.tsx b/tests/tools/omp-child-history-rendered/fixture.tsx new file mode 100644 index 00000000000..b8fb1b54707 --- /dev/null +++ b/tests/tools/omp-child-history-rendered/fixture.tsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react' +import { createRoot } from 'react-dom/client' +import { TooltipProvider } from '../../../src/renderer/src/components/ui/tooltip' +import { AiVaultSessionVirtualList } from '../../../src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import './fixture.css' + +const parent: AiVaultSession = { + id: 'parent', + agent: 'omp', + executionHostId: 'local', + sessionId: 'parent', + title: 'Coordinate the change', + cwd: '/project', + branch: null, + model: null, + filePath: '/sessions/parent.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-09-14T00:00:00Z', + messageCount: 2, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 3, + resumeCommand: 'omp --resume parent', + subagent: null +} +const children: AiVaultSession[] = [ + { + ...parent, + id: 'child', + sessionId: 'child', + title: 'OMP worker with saved conversation', + filePath: '/sessions/parent/child.jsonl', + subagentTranscriptCount: 1, + subagent: { parentSessionId: 'parent', agentType: 'worker', status: 'completed' } + }, + { + ...parent, + id: 'claude', + agent: 'claude', + filePath: '/sessions/parent/claude.jsonl', + subagentTranscriptCount: 0, + title: 'Claude worker (view only)', + subagent: { parentSessionId: 'parent', agentType: 'worker', status: 'completed' } + }, + { + ...parent, + id: 'empty', + sessionId: 'empty', + filePath: '/sessions/parent/empty.jsonl', + subagentTranscriptCount: 0, + messageCount: 0, + title: 'OMP worker without saved turns', + subagent: { parentSessionId: 'parent', agentType: 'worker', status: 'stopped' } + } +] + +const descendants = Array.from({ length: 7 }, (_, index): AiVaultSession => ({ + ...parent, + id: `depth-${index}`, + sessionId: `depth-${index}`, + filePath: `/sessions/parent/child/${Array.from({ length: index + 1 }, () => 'nested').join('/')}.jsonl`, + title: `Research depth ${index + 2}`, + subagentTranscriptCount: index === 6 ? 0 : 1, + subagent: { parentSessionId: 'child', agentType: 'researcher', status: 'completed' } +})) +const requests: string[] = [] +Object.defineProperty(window, 'nestedRequests', { value: requests }) +Object.defineProperty(window, 'api', { + value: { + aiVault: { + listSubagentSessions: async ({ parentFilePath }: { parentFilePath: string }) => { + requests.push(parentFilePath) + await new Promise((resolve) => setTimeout(resolve, 250)) + const index = descendants.findIndex((session) => session.filePath === parentFilePath) + return { + sessions: + parentFilePath === parent.filePath + ? children + : parentFilePath === children[0].filePath + ? [descendants[0]] + : index !== -1 + ? descendants.slice(index + 1, index + 2) + : [], + issues: [] + } + } + } + } +}) +const sessions = [ + parent, + ...Array.from({ length: 100 }, (_, index) => ({ + ...parent, + id: `row-${index}`, + sessionId: `row-${index}`, + title: `Other session ${index}`, + filePath: `/sessions/other-${index}.jsonl`, + subagentTranscriptCount: 0 + })) +] +const ignore = () => {} +function App() { + const [result, setResult] = useState('No resume requested') + return ( + +
+

Agent Session History

+
+ ({ command: session.resumeCommand })} + getOriginalPaneTarget={() => null} + getSessionLiveState={() => null} + getWorktreeInfo={() => null} + getSessionResumeState={() => ({ + blocked: false, + worktreeId: 'folder:project', + usesSessionWorktree: true + })} + getSessionResumeActions={() => ({ + worktree: { worktreeId: 'folder:project', disabled: false }, + newTab: { worktreeId: 'folder:project', disabled: false } + })} + getSessionResumeInChat={() => ({ available: false, reason: 'agent' })} + onToggleGroup={ignore} + onJumpToOriginalPane={ignore} + onJumpToWorktree={ignore} + onResume={(session, target) => setResult(`Resume ${session.sessionId} in ${target}`)} + onContinueInNewSession={ignore} + onResumeInNewChat={ignore} + onCopyResume={ignore} + onCopyId={ignore} + onCopyPath={ignore} + onOpenLog={ignore} + onRevealLog={ignore} + onOpenCwd={ignore} + onRequestDelete={ignore} + /> +
+ {result} +
+
+ ) +} +createRoot(document.getElementById('root')!).render() diff --git a/tests/tools/omp-child-history-rendered/index.html b/tests/tools/omp-child-history-rendered/index.html new file mode 100644 index 00000000000..44793ff9830 --- /dev/null +++ b/tests/tools/omp-child-history-rendered/index.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/tests/tools/omp-child-history-rendered/run.mjs b/tests/tools/omp-child-history-rendered/run.mjs new file mode 100644 index 00000000000..67866ffc985 --- /dev/null +++ b/tests/tools/omp-child-history-rendered/run.mjs @@ -0,0 +1,194 @@ +import { _electron as electron, expect } from '@stablyai/playwright-test' +import { build as buildMain } from 'esbuild' +import { build as buildRenderer } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + throw new Error('Requires ORCA_BACKGROUND_LAUNCH=1') +} +const root = fileURLToPath(new URL('../../../', import.meta.url)) +const parent = path.join(root, '.bench-fixtures') +mkdirSync(parent, { recursive: true }) +const output = mkdtempSync(path.join(parent, 'omp-child-history-')) +const main = path.join(output, 'main.cjs') +await buildMain({ + entryPoints: [path.join(root, 'tests/tools/benchmarks/spinner-rendering/main.ts')], + outfile: main, + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'] +}) +await buildRenderer({ + configFile: false, + root: import.meta.dirname, + base: './', + logLevel: 'silent', + plugins: [react(), tailwindcss()], + resolve: { alias: { '@': path.join(root, 'src/renderer/src') } }, + build: { outDir: path.join(output, 'renderer'), emptyOutDir: true } +}) +const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = process.env +const app = await electron.launch({ args: [main], env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' } }) +const report = { + scope: + 'Production virtual history list and nested rows with injected records; hidden Electron/CDP layout and action targeting, not full launch UI.' +} +try { + const page = await app.firstWindow() + const errors = [] + page.on('pageerror', (error) => { + errors.push(error.message) + console.error(error) + }) + await page.goto(pathToFileURL(path.join(output, 'renderer/index.html')).href) + await page.getByTestId('ai-vault-session-toggle-details').first().click() + await expect(page.getByText('OMP worker with saved conversation')).toBeVisible() + expect(await page.evaluate(() => window.nestedRequests.length)).toBe(1) + const cdp = await page.context().newCDPSession(page) + const capture = async (name) => { + await page.evaluate(async () => { + await Promise.all( + document + .getAnimations() + .filter((animation) => animation.effect?.getComputedTiming().iterations !== Infinity) + .map((animation) => animation.finished.catch(() => {})) + ) + }) + const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }) + writeFileSync(path.join(output, `${name}.png`), Buffer.from(data, 'base64')) + } + await capture('child-resume-affordance') + await page + .getByText('OMP worker with saved conversation') + .locator('..') + .getByRole('button', { name: 'Resume in Worktree' }) + .click() + await expect(page.getByText('Resume child in folder:project')).toBeVisible() + await capture('child-resume-callback') + for (let depth = 2; depth <= 8; depth++) { + const row = + depth === 2 + ? page.getByText('OMP worker with saved conversation') + : page.getByText(`Research depth ${depth - 1}`, { exact: true }) + await row.locator('..').getByRole('button', { name: 'Subagents (1)' }).click() + await expect(page.getByText(`Research depth ${depth}`, { exact: true })).toBeVisible() + if (depth === 2) { + await capture('grandchild-disclosure-dark') + await page.evaluate(() => document.documentElement.classList.remove('dark')) + await capture('grandchild-disclosure-light') + await page.evaluate(() => document.documentElement.classList.add('dark')) + } + } + await page + .getByText('Research depth 8', { exact: true }) + .locator('..') + .getByRole('button', { name: 'Resume in Worktree' }) + .click() + await expect(page.getByText('Resume depth-6 in folder:project')).toBeVisible() + const scroll = page.locator('.overflow-y-auto').first() + const checkLayout = async () => { + const layout = await page.locator('[data-index="1"]').evaluate((element) => { + const next = document.querySelector('[data-index="2"]') + return { + height: element.getBoundingClientRect().height, + bottom: element.getBoundingClientRect().bottom, + nextTop: next?.getBoundingClientRect().top + } + }) + expect(layout.nextTop).toBeGreaterThanOrEqual(layout.bottom - 1) + return layout + } + await expect + .poll(async () => { + const bounds = await page.locator('[data-index="1"]').boundingBox() + const next = await page.locator('[data-index="2"]').boundingBox() + return next.y - bounds.y - bounds.height + }) + .toBeGreaterThanOrEqual(-1) + report.expandedLayout = await checkLayout() + const lefts = await page + .getByText(/^Research depth /) + .evaluateAll((elements) => + elements.map((element) => element.parentElement.getBoundingClientRect().left) + ) + expect(lefts.at(-1)).toBe(lefts.at(-2)) + report.depthLefts = lefts + await capture('nested-expanded') + report.sidebarWidths = [] + for (const width of [280, 350]) { + await page.getByTestId('history-panel').evaluate((element, value) => { + element.style.width = `${value}px` + }, width) + const measurements = await page.getByText(/^Research depth /).evaluateAll((elements) => + elements.map((element) => { + const row = element.parentElement + const bounds = row.getBoundingClientRect() + return { + titleWidth: element.getBoundingClientRect().width, + rowRight: bounds.right, + buttonsRight: Math.max( + ...[...row.querySelectorAll('button')].map( + (button) => button.getBoundingClientRect().right + ) + ) + } + }) + ) + expect( + measurements.every((row) => row.titleWidth >= 40 && row.buttonsRight <= row.rowRight + 1) + ).toBe(true) + report.sidebarWidths.push({ width, measurements }) + await page.getByText('Research depth 4', { exact: true }).scrollIntoViewIfNeeded() + await capture(`nested-width-${width}`) + } + await page.getByTestId('history-panel').evaluate((element) => { + element.style.width = '' + }) + + await scroll.evaluate((element) => { + element.scrollTop = element.scrollHeight + }) + await expect(page.getByText('OMP worker with saved conversation')).toHaveCount(0) + await scroll.evaluate((element) => { + element.scrollTop = 0 + }) + await expect(page.getByText('Research depth 8', { exact: true })).toBeVisible() + await page + .getByText('OMP worker with saved conversation') + .locator('..') + .getByRole('button', { name: 'Subagents (1)' }) + .click() + await expect(page.getByText('Research depth 8', { exact: true })).toHaveCount(0) + await expect + .poll(async () => { + const bounds = await page.locator('[data-index="1"]').boundingBox() + return bounds.height + }) + .toBeLessThan(report.expandedLayout.height) + await expect + .poll(async () => { + const bounds = await page.locator('[data-index="1"]').boundingBox() + const next = await page.locator('[data-index="2"]').boundingBox() + return Math.abs(next.y - bounds.y - bounds.height) + }) + .toBeLessThanOrEqual(1) + report.collapsedLayout = await checkLayout() + report.requests = await page.evaluate(() => window.nestedRequests) + await capture('nested-collapsed') + expect(errors).toEqual([]) + report.windows = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map((window) => ({ + visible: window.isVisible(), + focused: window.isFocused() + })) + ) + expect(report.windows.every((window) => !window.visible && !window.focused)).toBe(true) +} finally { + writeFileSync(path.join(output, 'report.json'), `${JSON.stringify(report, null, 2)}\n`) + console.log(`OMP child history evidence: ${output}`) + await app.close() +} diff --git a/tests/tools/omp-child-session-resume-smoke.mjs b/tests/tools/omp-child-session-resume-smoke.mjs new file mode 100644 index 00000000000..baa7c7d4f19 --- /dev/null +++ b/tests/tools/omp-child-session-resume-smoke.mjs @@ -0,0 +1,82 @@ +// Bun; argv[2] is a read-only OMP checkout. No model requests. +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { buildAiVaultResumeCommand } from '../../src/shared/ai-vault-resume-command.ts' +import { tokenizeStartupCommand } from '../../src/shared/tui-agent-startup-shell.ts' + +assert.ok(process.argv[2], 'Pass a read-only OMP checkout path') +const scratch = await mkdtemp(join(tmpdir(), 'orca-omp-child-resume-')) +process.env.HOME = join(scratch, 'home') +process.env.USERPROFILE = process.env.HOME +process.env.XDG_CONFIG_HOME = join(scratch, 'config') +process.env.XDG_DATA_HOME = join(scratch, 'data') +process.env.XDG_STATE_HOME = join(scratch, 'state') +process.env.PI_CODING_AGENT_DIR = join(scratch, 'agent') +delete process.env.OMP_CODING_AGENT_DIR +delete process.env.PI_CONFIG_DIR +delete process.env.OMP_PROFILE +delete process.env.PI_PROFILE +delete process.env.PI_CONFIG_FILES +const source = (name) => + pathToFileURL(join(resolve(process.argv[2]), 'packages/coding-agent/src', name)).href +const managers = [] +try { + await mkdir(process.env.HOME, { recursive: true }) + const { SessionManager } = await import(source('session/session-manager.ts')) + const { Settings } = await import(source('config/settings.ts')) + const { createSessionManager } = await import(source('main.ts')) + const { parseArgs } = await import(source('cli/args.ts')) + const cwd = join(scratch, 'folder workspace') + await mkdir(cwd) + const parent = SessionManager.create(cwd) + managers.push(parent) + parent.appendMessage({ role: 'user', content: 'coordinate work', timestamp: Date.now() }) + await parent.ensureOnDisk() + await parent.flush() + const child = SessionManager.create(cwd, parent.getSessionFile().replace(/\.jsonl$/, '')) + managers.push(child) + child.appendMessage({ role: 'user', content: 'child task', timestamp: Date.now() }) + await child.ensureOnDisk() + await child.flush() + const grandchild = SessionManager.create(cwd, child.getSessionFile().replace(/\.jsonl$/, '')) + managers.push(grandchild) + grandchild.appendMessage({ role: 'user', content: 'grandchild research', timestamp: Date.now() }) + await grandchild.ensureOnDisk() + await grandchild.flush() + for (const target of [child, grandchild]) { + const command = buildAiVaultResumeCommand({ + agent: 'omp', + sessionId: target.getSessionId(), + resumeFilePath: target.getSessionFile(), + cwd: null, + platform: process.platform, + shell: 'posix' + }) + const tokens = tokenizeStartupCommand(command, 'posix') + assert.ok(tokens.ok) + const args = parseArgs(tokens.tokens.slice(1)) + assert.equal(args.resume, target.getSessionFile()) + const settings = await Settings.init({ cwd }) + const resumed = await createSessionManager(args, cwd, settings) + managers.push(resumed) + assert.equal(resumed.getSessionId(), target.getSessionId()) + assert.notEqual(resumed.getSessionId(), parent.getSessionId()) + } + console.log( + JSON.stringify({ + childPathResumed: true, + grandchildPathResumed: true, + distinctFromParent: true, + folderWorkspace: true, + modelCalls: 0 + }) + ) +} finally { + for (const manager of managers) { + await manager?.close() + } + await rm(scratch, { recursive: true, force: true }) +} diff --git a/tests/tools/omp-history-title-smoke.mjs b/tests/tools/omp-history-title-smoke.mjs new file mode 100644 index 00000000000..7c5d84aaa2c --- /dev/null +++ b/tests/tools/omp-history-title-smoke.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +assert.ok(process.argv[2], 'Pass a read-only OMP checkout') +const orcaRoot = fileURLToPath(new URL('../../', import.meta.url)) +const scratch = await mkdtemp(join(tmpdir(), 'orca-omp-history-title-')) +process.env.HOME = join(scratch, 'home') +process.env.USERPROFILE = process.env.HOME +for (const [key, value] of Object.entries({ + XDG_CONFIG_HOME: 'config', + XDG_DATA_HOME: 'data', + XDG_STATE_HOME: 'state', + XDG_CACHE_HOME: 'cache' +})) { + process.env[key] = join(scratch, value) +} +for (const key of [ + 'OMP_CODING_AGENT_DIR', + 'PI_CODING_AGENT_DIR', + 'OMP_PROFILE', + 'PI_PROFILE', + 'PI_CONFIG_DIR', + 'PI_CONFIG_FILES' +]) { + delete process.env[key] +} +await mkdir(process.env.HOME, { recursive: true }) +const source = (root, path) => pathToFileURL(join(resolve(root), path)).href +const { SessionManager } = await import( + source(process.argv[2], 'packages/coding-agent/src/session/session-manager.ts') +) +const { parseMessageGraphSessionFile } = await import( + source(orcaRoot, 'src/main/ai-vault/session-scanner-graph-parsers.ts') +) +const { createSessionParseStats, parseAgentSessionFileCached } = await import( + source(orcaRoot, 'src/main/ai-vault/session-scanner-parse-cache.ts') +) +const stats = createSessionParseStats() +const manager = SessionManager.create(scratch, join(scratch, 'sessions')) +try { + manager.appendMessage({ role: 'user', content: 'Original first prompt', timestamp: Date.now() }) + await manager.ensureOnDisk() + await manager.flush() + const candidate = async () => { + const details = await stat(manager.getSessionFile()) + return { + agent: 'omp', + codexHome: null, + file: { + path: manager.getSessionFile(), + mtimeMs: details.mtimeMs, + modifiedAt: details.mtime.toISOString(), + sizeBytes: details.size + } + } + } + const initial = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + assert.equal(initial.title, 'Original first prompt') + await manager.setSessionName('Explicit renamed conversation', 'user') + await manager.flush() + const path = manager.getSessionFile() + const details = await stat(path) + const parsed = await parseMessageGraphSessionFile( + 'omp', + { path, mtimeMs: details.mtimeMs, modifiedAt: details.mtime.toISOString() }, + process.platform + ) + const refreshed = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + assert.equal(parsed?.title, manager.getSessionName()) + assert.equal(refreshed?.title, manager.getSessionName()) + assert.equal(stats.fullParses, 1) + assert.equal(stats.incremental, 1) + assert.equal(initial.title, 'Original first prompt') + const reused = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + assert.equal(reused, refreshed) + console.log( + JSON.stringify({ + actualOmpPersistence: true, + renamedTitlePreserved: true, + cachedRenamePreserved: true, + unchangedSnapshotReused: true, + fullParses: stats.fullParses, + incrementalParses: stats.incremental, + modelCalls: 0 + }) + ) +} finally { + await manager.close() + await rm(scratch, { recursive: true, force: true }) +} diff --git a/tests/tools/omp-native-title-capture.mjs b/tests/tools/omp-native-title-capture.mjs new file mode 100644 index 00000000000..9f4be2cc7c0 --- /dev/null +++ b/tests/tools/omp-native-title-capture.mjs @@ -0,0 +1,18 @@ +// Run under Bun through capture-agent-pty-transcript.mjs; sourceRoot is read-only. +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const sourceRoot = process.argv[2] +if (!sourceRoot) { + throw new Error('Expected path to the read-only oh-my-pi checkout') +} +const { buildTerminalTitleWithState } = await import( + pathToFileURL(resolve(sourceRoot, 'packages/coding-agent/src/utils/title-generator.ts')).href +) +for (const state of ['working', 'idle', 'attention']) { + for (const label of ['Run a long task', 'release | π : note | OMP ! action required ✦']) { + // Exercise upstream's explicit Windows argument, independently of the capture host OS. + const title = buildTerminalTitleWithState(label, state, 0, true, 'win32') + process.stdout.write(`\x1b]0;${title}\x07`) + } +} diff --git a/tests/tools/omp-picker-search-rendered/README.md b/tests/tools/omp-picker-search-rendered/README.md new file mode 100644 index 00000000000..653a7bd1c33 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/README.md @@ -0,0 +1,17 @@ +# OMP picker project-name search (#14319) + +Run `ORCA_BACKGROUND_LAUNCH=1 node tests/tools/omp-picker-search-rendered/run.mjs`. +Uses production AgentCombobox, the production catalog and canonical CSS in a hidden +Electron renderer. Rebuilds the existing background-launch harness before execution; +all windows must remain invisible and unfocused. No dependency install is needed. + +The probe types `oh-my-pi`, captures the resulting OMP row over CDP, selects it, +asserts the callback receives `omp`, and checks `oh my pi` too. Reports/screenshots +are local under `.bench-fixtures/omp-picker-search-*`. Before proof uses the baseline +catalog without search aliases and `ORCA_OMP_PICKER_BASELINE=1`, expecting no match. + +Available agents are supplied by the fixture. This does not exercise local/SSH/WSL +PATH detection, disabled-agent settings, terminal launch, or a full workspace form. +Search only ranks entries supplied by each caller; aliases cannot introduce an +agent absent from that list. The broader missing-agent explanation in #14319 is +separate from this reproduced project-name search defect. diff --git a/tests/tools/omp-picker-search-rendered/fixture.css b/tests/tools/omp-picker-search-rendered/fixture.css new file mode 100644 index 00000000000..9c08bc0261c --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/fixture.css @@ -0,0 +1,4 @@ +@import '../../../src/renderer/src/assets/main.css'; +@source './fixture.tsx'; +@source '../../../src/renderer/src/components/agent/AgentCombobox.tsx'; +@source '../../../src/renderer/src/components/ui'; diff --git a/tests/tools/omp-picker-search-rendered/fixture.tsx b/tests/tools/omp-picker-search-rendered/fixture.tsx new file mode 100644 index 00000000000..b318cdee827 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/fixture.tsx @@ -0,0 +1,29 @@ +import React, { useState } from 'react' +import { createRoot } from 'react-dom/client' +import AgentCombobox from '../../../src/renderer/src/components/agent/AgentCombobox' +import { getAgentCatalog } from '../../../src/renderer/src/lib/agent-catalog' +import type { TuiAgent } from '../../../src/shared/tui-agent' +import './fixture.css' +const baseline = new URLSearchParams(window.location.search).get('baseline') === '1' +const agents = getAgentCatalog().map((agent) => + baseline && agent.id === 'omp' ? { ...agent, searchAliases: [] } : agent +) +function App() { + const [selected, setSelected] = useState(null) + return ( +
+

Agent picker

+ +

Selected agent: {selected ?? 'none'}

+
+ ) +} +const root = document.getElementById('root') +if (root) { + createRoot(root).render() +} diff --git a/tests/tools/omp-picker-search-rendered/index.html b/tests/tools/omp-picker-search-rendered/index.html new file mode 100644 index 00000000000..44793ff9830 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/index.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/tests/tools/omp-picker-search-rendered/run.mjs b/tests/tools/omp-picker-search-rendered/run.mjs new file mode 100644 index 00000000000..14bee4a6752 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/run.mjs @@ -0,0 +1,93 @@ +import { _electron as electron, expect } from '@stablyai/playwright-test' +import { build as buildMain } from 'esbuild' +import { build as buildRenderer } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + throw new Error('Requires ORCA_BACKGROUND_LAUNCH=1') +} +const root = fileURLToPath(new URL('../../../', import.meta.url)) +const parent = path.join(root, '.bench-fixtures') +mkdirSync(parent, { recursive: true }) +const output = mkdtempSync(path.join(parent, 'omp-picker-search-')) +const main = path.join(output, 'main.cjs') +await buildMain({ + entryPoints: [path.join(root, 'tests/tools/benchmarks/spinner-rendering/main.ts')], + outfile: main, + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'] +}) +await buildRenderer({ + configFile: false, + root: import.meta.dirname, + base: './', + logLevel: 'silent', + plugins: [react(), tailwindcss()], + resolve: { alias: { '@': path.join(root, 'src/renderer/src') } }, + build: { outDir: path.join(output, 'renderer'), emptyOutDir: true } +}) +const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = process.env +const app = await electron.launch({ args: [main], env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' } }) +const report = { + scope: + 'Production AgentCombobox and agent catalog in hidden Electron; supplied available agents, no PATH detection or terminal launch.' +} +try { + const page = await app.firstWindow() + const errors = [] + page.on('pageerror', (error) => { + errors.push(error.message) + console.error(error) + }) + const baseline = process.env.ORCA_OMP_PICKER_BASELINE === '1' + const fixtureUrl = pathToFileURL(path.join(output, 'renderer/index.html')) + if (baseline) { + fixtureUrl.searchParams.set('baseline', '1') + } + await page.goto(fixtureUrl.href) + await page.locator('button[role=combobox]').click() + const search = page.getByPlaceholder('Search agents...') + await search.fill('oh-my-pi') + await expect( + baseline + ? page.getByText('No agents match your search.') + : page.getByRole('option', { name: 'OMP', exact: true }) + ).toBeVisible() + await page.evaluate(async () => { + await Promise.all( + document.getAnimations().map((animation) => animation.finished.catch(() => {})) + ) + }) + const cdp = await page.context().newCDPSession(page) + const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }) + writeFileSync( + path.join(output, baseline ? 'before.png' : 'after.png'), + Buffer.from(data, 'base64') + ) + if (!baseline) { + await page.getByRole('option', { name: 'OMP', exact: true }).click() + await expect(page.getByText('Selected agent: omp', { exact: true })).toBeVisible() + await expect(search).toBeHidden() + await page.locator('button[role=combobox]').click() + await search.fill('oh my pi') + await expect(page.getByRole('option', { name: 'OMP', exact: true })).toBeVisible() + } + report.baseline = baseline + expect(errors).toEqual([]) + report.windows = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map((window) => ({ + visible: window.isVisible(), + focused: window.isFocused() + })) + ) + expect(report.windows.every((window) => !window.visible && !window.focused)).toBe(true) +} finally { + writeFileSync(path.join(output, 'report.json'), `${JSON.stringify(report, null, 2)}\n`) + console.log(`OMP picker search evidence: ${output}`) + await app.close() +}