mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 00:02:34 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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$;
|
||||
`
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+329
-318
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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']))
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user