Merge origin/main into step4-small-a

One conflict, in the mount-adapter register #20662 introduced: both sides added
`adapters/mounted-operation-modules.ts`. Resolved as the union, main's nine
entries plus this branch's five. Main's file was otherwise a subset, so nothing
of main's was dropped.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-14 20:15:18 -04:00
409 changed files with 16464 additions and 3236 deletions
+8 -5
View File
@@ -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"
+13 -9
View File
@@ -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"
@@ -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
+20 -12
View File
@@ -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"
+1
View File
@@ -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 \
@@ -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<Record<string, unknown>>,
@@ -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)
+18 -2
View File
@@ -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<SqlRow[]> {
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()
}
}
@@ -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<void>((resolve) => {
acquired = resolve
})
let release!: () => void
const wait = new Promise<void>((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 }])
})
})
@@ -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()
})
})
@@ -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.
}
}
@@ -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<string> {
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<pg.Client> {
const client = new pg.Client({ connectionString: url, statement_timeout: 2_000 })
await client.connect()
return client
}
async function installed(client: pg.Client): Promise<boolean> {
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()
}
})
})
@@ -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$;
`
+15
View File
@@ -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
@@ -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.
+5 -2
View File
@@ -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)
@@ -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')
})
@@ -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)
})
})
@@ -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',
@@ -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'
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 53m">
<title>downloads: 53m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 54m">
<title>downloads: 54m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">53m</text>
<text x="90" y="14">53m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">54m</text>
<text x="90" y="14">54m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

@@ -0,0 +1,43 @@
# Malformed worktree registration removal
Git can report a linked worktree at `<checkout>/.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.
+34
View File
@@ -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.
+1
View File
@@ -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
+1 -1
View File
@@ -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",
@@ -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<WebSocket, Map<string, ReturnType<typeof setInterval>>>()
@@ -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': {
@@ -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}
@@ -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 })
})
})
@@ -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<boolean>
onCancel?: (prompt?: NonNullable<MobileChatPermission['prompt']>) => Promise<boolean>
}): React.JSX.Element {
const [submitting, setSubmitting] = useState(false)
const submittingRef = useRef(false)
@@ -33,6 +35,17 @@ function MobileNativeChatPermissionImpl({
<View style={styles.header}>
<ShieldQuestion size={16} color={colors.accentBlue} strokeWidth={2} />
<Text style={styles.title}>{permission.title}</Text>
{onCancel ? (
<Pressable
accessibilityLabel="Cancel"
hitSlop={8}
style={styles.cancel}
onPress={() => void onCancel(permission.prompt)}
disabled={submitting}
>
<X size={16} color={colors.textMuted} />
</Pressable>
) : null}
</View>
{permission.detail ? <Text style={styles.detail}>{permission.detail}</Text> : null}
<View style={styles.options}>
@@ -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,
@@ -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<boolean>
onCancelAsk?: () => Promise<boolean>
onCancelPrompt?: (prompt?: NonNullable<MobileChatPermission['prompt']>) => Promise<boolean>
permission?: MobileChatPermission | null
onRespondPermission?: (send: string) => Promise<boolean>
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}
/>
)
}
@@ -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 })
})
})
@@ -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<boolean>
onCancel?: (prompt?: NonNullable<MobileChatQuestion['prompt']>) => Promise<boolean>
}
/** 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<number[]>([])
const [freeText, setFreeText] = useState('')
const [sending, setSending] = useState(false)
@@ -102,6 +107,17 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
<View style={styles.header}>
<CircleHelp size={15} color={colors.accentBlue} strokeWidth={2.2} />
<Text style={styles.question}>{question.question}</Text>
{onCancel ? (
<Pressable
accessibilityLabel="Cancel"
hitSlop={8}
style={styles.cancel}
onPress={() => void onCancel(question.prompt)}
disabled={sending}
>
<X size={16} color={colors.textMuted} />
</Pressable>
) : null}
</View>
{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
},
@@ -122,6 +122,8 @@ type Props = {
* into selector keystrokes (Claude) or pasted label text (other agents). */
onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
onCancelAsk?: () => Promise<boolean>
/** Cancel a structured approval/question with exact item identity when supported. */
onCancelPrompt?: (prompt?: { itemId: string; expectedRevision: number }) => Promise<boolean>
question?: MobileChatQuestion | null
onAnswerQuestion?: (text: string) => Promise<boolean>
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}
@@ -57,6 +57,10 @@ export type MobileNativeChatController = {
selections: AskAnswerSelection[]
) => Promise<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatCancelPrompt?: (prompt?: {
itemId: string
expectedRevision: number
}) => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
@@ -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 }>
}
@@ -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. */
@@ -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)
})
@@ -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,
@@ -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<string, string>
promptCancelSupported: boolean | null
prompt?: PromptIdentity
onSendError: (message: string) => void
}): Promise<boolean> {
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<AgentSessionCancelResult> =
await requestStructuredAgentSessionMutation<AgentSessionCancelResult>({
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
}
@@ -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,
@@ -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.
@@ -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,
@@ -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<typeof useMobileNativeChatSession>[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.
@@ -32,6 +32,11 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina
null
)
const [quickCommandsSupported, setQuickCommandsSupported] = useState<boolean | null>(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,
@@ -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
})
@@ -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.
@@ -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<typeof useMobileStructuredAgentSession>
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)
)
})
})
@@ -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<typeof useMobileStructuredAgentOptions
question: MobileChatQuestion | null
respondPermission: (optionId: string) => Promise<boolean>
respondQuestion: (answer: string) => Promise<boolean>
cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise<boolean>
}
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<string, string>())
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<TValue>({
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<AgentSessionCancelResult>({
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<boolean> =>
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,
+18 -18
View File
@@ -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",
+329 -318
View File
@@ -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
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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<SessionSearchFileRow, 'identity'> & {
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<SessionSearchFileRow, 'identity'> & {
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
}))
)
}
/**
@@ -4,6 +4,8 @@ import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
export type RemoteSessionContent = string | AsyncIterable<string>
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<string> {
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)
@@ -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 {
@@ -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() {
@@ -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
@@ -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
})
})
})
@@ -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() },
@@ -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')
})
})
@@ -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<string, unknown>
): 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 }
}
+224
View File
@@ -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<string, unknown>
suggestions: PermissionUpdate[]
questionIds: readonly string[]
answers: Map<string, string | readonly string[]>
settle: ClaudePromptSettle
turnId?: string | null
}
export type ClaudePromptRegistration = {
requestId: string
toolName: string
toolUseId: string
input: Record<string, unknown>
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<void>
resolve: () => void
}
export function isClaudePromptRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>): Record<string, unknown>[] {
return Array.isArray(input.questions) ? input.questions.filter(isClaudePromptRecord) : []
}
function questionId(question: Record<string, unknown>, 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<string, ClaudePendingPrompt>()
private readonly journalBindings = new Map<string, PromptBinding>()
private readonly claims = new Map<ClaudePendingPrompt, ClaudePromptClaim>()
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<void> | null {
if (!this.ownsClaim(claim)) {
return null
}
let observation = this.cancellationObservations.get(claim.found.prompt)
if (!observation) {
let resolve = (): void => {}
const promise = new Promise<void>((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
}
}
@@ -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<ReturnType<ClaudeSession['connection']['interrupt']>>
@@ -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()
})
})
@@ -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<void> {
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)
}
+37 -10
View File
@@ -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) {
@@ -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<string, unknown> | null) => void
settle: resolve,
turnId: deps.currentTurnId?.() ?? null
})
if (!prompt) {
resolve(denySafeResult(options.toolUseID))
@@ -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<string>
release: () => void
} {
const staged = new Map<string, AgentJournalItemBody>()
const durable = new Map<string, AgentJournalItemBody>()
const appliedSettlements = new Set<string>()
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' } })
})
})
@@ -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<string, ClaudeJournalPromptEntry>()
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<ClaudeStructuredSessionEvent, { type: 'prompt' }>['prompt']
}) => ClaudeQuestionItem[]
}
) {}
handle(event: Extract<ClaudeStructuredSessionEvent, { type: 'prompt' }>): 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
}
}
@@ -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)
})
})
@@ -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<ClaudeJournalPrompts, 'cancel' | 'resolve'>
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<string, ClaudeToolUse>()
const promptItems = new Map<string, AgentJournalItemIdentity[]>()
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()
}
@@ -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)
@@ -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 }
}
})
}
]
}
@@ -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<void>; resolve: () => void } {
let resolve = (): void => {}
const promise = new Promise<void>((finish) => {
resolve = finish
})
return { promise, resolve }
}
function lifecycleRecorder(acceptPromptCancellation = true): {
sink: StructuredAgentSessionEventSink
bodies: Map<string, AgentJournalItemBody>
tombstones: Set<string>
order: string[]
} {
const bodies = new Map<string, AgentJournalItemBody>()
const tombstones = new Set<string>()
const order: string[] = []
const appendTombstone = (
identity: Parameters<StructuredAgentSessionEventSink['appendTombstone']>[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<StructuredAgentSessionEventSink['appendItem']>[0],
body: Parameters<StructuredAgentSessionEventSink['appendItem']>[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<ReturnType<typeof acquired>>,
turnId = 'turn-1'
): Promise<void> {
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<StructuredAgentSessionEventSink['appendTombstone']>[0]) => {
tombstones.push(agentJournalItemKey(identity))
}
)
let rowAdmission = 0
const tryAppendTombstone = vi.fn(
(identity: Parameters<StructuredAgentSessionEventSink['appendTombstone']>[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<StructuredAgentSessionEventSink['tryAppendLifecycleBatch']>
>[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)
})
})
@@ -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<StructuredAgentSessionAdapter['cancelTurn']>[0]
type AnswerInput = Parameters<StructuredAgentSessionAdapter['answerPrompt']>[0]
export function admitClaudePromptCancellation(session: ClaudeSession, promptKey: string): boolean {
const admission = session.translator?.journalPrompts.cancel(promptKey)
return admission?.accepted ?? true
}
function waitForClaudePromptCancellation(
observed: Promise<void>,
timeoutMs = CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | null = null
const deadline = new Promise<never>((_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<string, ClaudeSession>, 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<string, ClaudeSession>
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<string, ClaudeSession>
}): Promise<void> {
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
}
}
@@ -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<string, unknown> | null) => void
export type ClaudePendingPrompt = {
requestId: string
promptKey: string
toolUseId: string
toolName: string
kind: 'approval' | 'question'
input: Record<string, unknown>
suggestions: unknown[]
questionIds: readonly string[]
answers: Map<string, string | readonly string[]>
settle: ClaudePromptSettle
}
export type ClaudePromptRegistration = {
requestId: string
toolName: string
toolUseId: string
input: Record<string, unknown>
suggestions: unknown[]
settle: ClaudePromptSettle
}
type PromptBinding = {
address: string
questionId?: string
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>): Record<string, unknown>[] {
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<string, unknown>, 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<string, ClaudePendingPrompt>()
private readonly journalBindings = new Map<string, PromptBinding>()
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<string, unknown> {
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<string, unknown> | null {
): PermissionResult | null {
const decoded = decodeClaudeQuestionOptionId(optionId)
const decodedQuestionId = decoded
? (questionIdFromAddress(prompt, decoded.questionId) ??
@@ -229,7 +125,11 @@ function questionResponse(
}
const answers: Record<string, string | readonly string[]> = {}
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<string, unknown> | 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<string, string | readonly string[]> = {}
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<string, unknown> | null {
): PermissionResult | null {
if (found.prompt.kind === 'approval') {
return approvalResponse(found.prompt, optionId)
}
@@ -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))
})
@@ -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/)
})
@@ -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<StructuredAgentSessionAdapter['compact']> = (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<StructuredAgentSessionAdapter['readCommands']> = (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 }) =>
@@ -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<ClaudeStructuredLaunch>
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
@@ -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) }
}
+278
View File
@@ -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<string, string>
optionAnswers: ReadonlyMap<string, { questionId: string; answer: string }>
answers: Map<string, string>
}
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<string, CodexPendingPrompt>()
private readonly journalItemIds = new Map<string, string>()
private readonly boundPrompts = new Map<string, CodexPendingPrompt>()
private readonly claims = new Map<CodexPendingPrompt, CodexPromptClaim>()
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<string, { questionId: string; answer: string }>()
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)
}
}
}
@@ -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()
@@ -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<ReturnType<typeof acquiredCodexAdapter>>,
clientMessageId: string
): Promise<unknown> {
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([])
})
})
@@ -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()
})
})
@@ -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<string>()
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<string, unknown>,
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
}
@@ -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<string, unknown> | undefined) => unknown
type FakeConnection = Omit<CodexAppServerConnection, 'closed'> & {
closed: boolean
launch: CodexAppServerLaunch
handlers: CodexAppServerConnectionHandlers
calls: { method: string; params?: Record<string, unknown> }[]
}
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<string, CodexTestRoute> = {}): {
connections: FakeConnection[]
openConnection: typeof openCodexAppServerConnection
routes: Record<string, CodexTestRoute>
} {
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<typeof fakeCodexAppServer>
settlements: LateSettlement[]
sink?: StructuredAgentSessionEventSink
}): Promise<CodexStructuredSessionAdapter> {
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 }
})
}
@@ -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' })
@@ -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<string, unknown>
@@ -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') {
@@ -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<string, CodexPendingJournalPrompt>()
readonly pending = new Map<string, CodexGroupedPendingJournalPrompt>()
constructor(
private readonly deps: Pick<CodexJournalTranslatorDeps, 'sink' | 'bindPromptItemId'>,
@@ -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()
}
@@ -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
@@ -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()
@@ -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<void>; resolve: () => void } {
let resolve = (): void => {}
const promise = new Promise<void>((finish) => {
resolve = finish
})
return { promise, resolve }
}
function registerPrompt(
adapter: Awaited<ReturnType<typeof acquired>>,
codex: ReturnType<typeof fakeCodex>,
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<typeof fakeCodex>,
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<typeof fakeCodex>,
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<string, AgentJournalItemBody>
order: string[]
} {
const bodies = new Map<string, AgentJournalItemBody>()
const order: string[] = []
const settlements = new Set<string>()
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()
})
})
@@ -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<StructuredAgentSessionAdapter['cancelTurn']>[0]
type AnswerInput = Parameters<StructuredAgentSessionAdapter['answerPrompt']>[0]
export async function cancelCodexStructuredTurn(input: {
request: CancelInput
sessions: Map<string, CodexSession>
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<string, CodexSession>
}): Promise<void> {
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
}
}
@@ -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']))
+28 -246
View File
@@ -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<string, string>
optionAnswers: ReadonlyMap<string, { questionId: string; answer: string }>
answers: Map<string, string>
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<string, unknown>)[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<string, CodexPendingPrompt>()
/** Journal item id to thread-scoped prompt address. */
private readonly journalItemIds = new Map<string, string>()
/** Bound prompts survive LRU eviction of the lookup window until answered. */
private readonly boundPrompts = new Map<string, CodexPendingPrompt>()
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<string, { questionId: string; answer: string }>()
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<string, unknown> | 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<string, { answers: string[] }> = {}
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<CodexAppServerConnection, 'respond'>,
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.
@@ -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(

Some files were not shown because too many files have changed in this diff Show More