Merge remote-tracking branch 'origin/main' into brennanb2025/nc-reasoning-row

Conflict in agent-session-journal-schemas.ts: both sides extracted a schema. Use main's
AgentSessionFailureFactSchema and the branch's AgentJournalThreadGoalStateSchema (each identical to
the inline schema it replaces), keep main's answered-turn import and the branch's message state.
This commit is contained in:
Brennan Benson
2026-10-05 18:00:59 -07:00
249 changed files with 12091 additions and 1755 deletions
+2
View File
@@ -315,6 +315,7 @@ jobs:
. != "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts" and
. != "tests/e2e/ssh-cold-activation-restore.spec.ts" and
. != "tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts" and
. != "tests/e2e/ssh-emptied-worktree-reactivation.spec.ts" and
. != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and
. != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and
. != "tests/e2e/ssh-docker-half-open-link.spec.ts" and
@@ -386,6 +387,7 @@ jobs:
contains(inputs.test_files, 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-cold-activation-restore.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-half-open-link.spec.ts') ||
@@ -48,9 +48,9 @@ export const STRUCTURED_CHAT_LANES = [
{ directory: ['src', 'main', 'codex'] },
{ directory: ['src', 'shared'] },
{ directory: ['src', 'main', 'runtime'], basename: /^(?:structured-|agent-session-)/ },
// Allowed absent until they land; every other lane throws if missing, so a rename can't empty it.
{ directory: ['src', 'main', 'acp'], mayBeAbsent: true },
{ directory: ['src', 'main', 'provider-process'], mayBeAbsent: true }
{ directory: ['src', 'main', 'provider-process'] },
// Allowed absent until it lands; every other lane throws if missing, so a rename can't empty it.
{ directory: ['src', 'main', 'acp'], mayBeAbsent: true }
]
export function collectStructuredChatEntryPoints(root = ROOT) {
@@ -32,12 +32,13 @@ describe('structured chat coverage', () => {
return root
}
// Every lane that must exist; acp/ and provider-process/ may be absent until they land.
// Every lane that must exist; acp/ may be absent until it lands.
const requiredLanes = {
'src/main/native-chat/reader.ts': 'export {}',
'src/main/claude/claude-session.ts': 'export {}',
'src/main/codex/codex-session.ts': 'export {}',
'src/main/runtime/structured-agent-session-host.ts': 'export {}',
'src/main/provider-process/provider-process-teardown.ts': 'export {}',
'src/shared/agent-session-record.ts': 'export {}'
}
@@ -89,7 +90,7 @@ describe('structured chat coverage', () => {
Object.entries(requiredLanes).filter(([file]) => !file.startsWith(`${lane}/`))
)
expect(() => collectStructuredChatEntryPoints(fixture(without))).toThrow(`${lane} is missing`)
expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(5)
expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(6)
}
)
@@ -128,7 +129,7 @@ describe('the default entry points', () => {
}
})
// Retires the temporary flag: the PR that adds acp/ or provider-process/ must make it required.
// Retires the temporary flag: the PR that adds acp/ must make it required.
it('lets only directories that have not landed yet be absent', () => {
for (const lane of STRUCTURED_CHAT_LANES.filter((candidate) => candidate.mayBeAbsent)) {
expect(
+1
View File
@@ -8,6 +8,7 @@ export const DOCKER_SSH_E2E_SPECS = [
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts',
'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts',
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts',
'tests/e2e/ssh-docker-half-open-link.spec.ts',
+1
View File
@@ -60,6 +60,7 @@ const result = spawnSync(
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts',
'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts',
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts',
'tests/e2e/ssh-docker-half-open-link.spec.ts',
+42
View File
@@ -65,3 +65,45 @@ Method (reproducible):
ko 색인화. R3 sign-off clean on all locales (R2 fixes byte-verified,
full placeholder parity 0 mismatches, 0 missing keys).
## Scan 2026-10-05
- HEAD scanned: d3943c6d81 (origin/main; branch rebased onto it). Base: a15a5c8c1a6.
Window: a15a5c8c1a6 .. HEAD (~1008 commits). Note: origin/main was still advancing
during the scan; the one en key added in the last 13 commits (browser markup
`...tool.eraser`) already shipped translated upstream in all five locales.
- Gates before fix: catalog verify PASS (all five locales missing the SAME 87 keys),
coverage --check PASS (13 allowlisted), extraction PASS, runtime-catalog PASS.
So no unkeyed strings and en.json in sync; the gap was target-locale coverage only.
- Delta since base: 87 new en keys (csv editor x34, provider credit balance x11,
activity x31, jira assignee x3, workspace-delete change checks x5, nested-worktree
removal x10) plus 18 changed en values (Gemini CLI legacy, structured-chat copy/scope,
markdown notes, unverifiable -> "no recent update", onboarding casing, arrow spacing).
es had fully caught up to fr/ja/ko/zh, so all five shared the same 87-key gap.
- **11 of the 87 are intentionally untranslated**: the provider credit-balance keys
(`auto.components.status.bar.{StatusBar.*,tooltip.*,provider.extra.usage.section.*}`)
are pinned as sparse-fallback by `provider-credit-balance-locales.test.ts` (#25408),
which asserts every target catalog OMITS them and falls back to English at runtime.
Translated the remaining 76 new keys (x5) and left the 11 balance keys absent.
- Changed-value review: 15 of the 18 already matched the new English upstream; only 3
were stale (AccountsPane Gemini legacy description + label, onboarding arrow spacing).
- Applied 76 new keys x5 via apply-translation-delta.mjs (pipeline repaired ja
open->オープン, zh 智能体->代理 xN, ko refreshFailed spacing) + 3 stale updates x5 via
the same repairTranslatedValue gate. Strictly additive: +380 new keys, 0 removed.
- Gates after fix: catalog verify PASS (all five 15268/15279; the 11 omissions are the
pinned balance keys), coverage --check PASS (13 allowlisted), extraction PASS,
runtime-catalog PASS, locale vitest suites PASS (34 files, 288 tests).
- Reviews (3 of max 4 rounds; round 0 = 5 parallel translators, then 5 reviewers/round):
R1 applied 11 fixes (es structuredCopy "Off"->"Si está desactivado", structuredScope
semicolon lowercase, 5f02e6fb21 review->"de revisión"; fr nestedRemoval pronoun
agreement; ja f80603d293 compound-word space + csv.indexProgress->インデックス作成中;
ko f80603d293 agent->에이전트 + structuredScope fallback + structuredCopy 지원->기반;
zh f80603d293 切换->交接/注释->笔记 + threadListOptionsActive->个生效).
R1 rejected 2 as repo-convention conflicts: ja TaskPage half-width 例: (28 existing
occurrences) and ko showMoreAgents (pinned Latin "agents" by
config/scripts/locale-ko-key-overrides.json, so no locale change).
R2 applied 3 (es structuredScope unified "chat de terminal"; zh structuredCopy
->基于终端的 + activity hideAutomation/CLI-created re-add 工作区 to match ja/ko);
ko re-flagged the pinned override, rejected again.
R3 sign-off clean on all five locales (0 errors, 0 warns; placeholders byte-identical,
0 stale changed keys, 0 generic-term regressions).
@@ -133,9 +133,9 @@ describe('the turn Codex answered a send into but has not opened', () => {
it('is the turn the latest armed send was answered into', () => {
const echoes = createCodexDispatchEchoes()
echoes.arm('client-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start')
echoes.arm('client-2')
echoes.bindTurn('client-2', 'thread-1', 'turn-2')
echoes.bindTurn('client-2', 'thread-1', 'turn-2', 'start')
expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBe('turn-2')
})
@@ -143,7 +143,7 @@ describe('the turn Codex answered a send into but has not opened', () => {
it('is none once Codex opened that turn', () => {
const echoes = createCodexDispatchEchoes()
echoes.arm('client-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start')
expect(echoes.answeredUnopenedTurn('thread-1', new Set(['turn-1']))).toBeNull()
})
@@ -151,7 +151,7 @@ describe('the turn Codex answered a send into but has not opened', () => {
it('is none once that turn ended, even with its send still armed for an echo', () => {
const echoes = createCodexDispatchEchoes()
echoes.arm('client-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start')
// A completed end leaves its unechoed send armed.
expect(echoes.endTurn('thread-1', 'turn-1', { status: 'completed' })).toEqual([])
@@ -161,9 +161,9 @@ describe('the turn Codex answered a send into but has not opened', () => {
it('skips a turn a wait left unopened, and still names an earlier one', () => {
const echoes = createCodexDispatchEchoes()
echoes.arm('client-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start')
echoes.arm('client-2')
echoes.bindTurn('client-2', 'thread-1', 'turn-2')
echoes.bindTurn('client-2', 'thread-1', 'turn-2', 'start')
echoes.leftUnopened('thread-1', 'turn-2')
@@ -174,7 +174,7 @@ describe('the turn Codex answered a send into but has not opened', () => {
const echoes = createCodexDispatchEchoes()
echoes.arm('client-1')
echoes.arm('client-2')
echoes.bindTurn('client-2', 'thread-2', 'turn-2')
echoes.bindTurn('client-2', 'thread-2', 'turn-2', 'start')
expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBeNull()
})
@@ -1,5 +1,8 @@
import type { ProviderDiagnostic } from '../../shared/agent-session-failure'
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import type {
AgentJournalItemIdentity,
AgentJournalTurnJoin
} from '../../shared/agent-session-journal-types'
/** Sends awaiting their echo. One bound to a turn that ended without taking it settles from that
* end; any other whose echo never arrives is retired by the journal's recovery on exit. */
@@ -35,10 +38,15 @@ export type CodexDispatchEchoes = {
/** Drops an armed send whose write never reached the provider. */
disarm: (clientMessageId: string) => void
/**
* Binds a send to the turn Codex answered it into. Returns that turn's end when the answer is
* read after it; a send that end settles is no longer armed.
* Binds a send to the turn Codex answered it into, and how it joined that turn. Returns that
* turn's end when the answer is read after it; a send that end settles is no longer armed.
*/
bindTurn: (clientMessageId: string, threadId: string, turnId: string) => CodexTurnEnd | null
bindTurn: (
clientMessageId: string,
threadId: string,
turnId: string,
via: AgentJournalTurnJoin
) => CodexTurnEnd | null
/** The turn the latest armed send was answered into that is neither in `openTurnIds`, ended, nor
* left unopened through a wait: one Codex has picked for the send but not opened. */
answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet<string>) => string | null
@@ -52,7 +60,11 @@ export type CodexDispatchEchoes = {
* completed, which echoes its pending input first, so one it never echoed waits for recovery. An
* interrupt withdraws an un-echoed send, steered or the turn's own input: neither reached history.
*/
endTurn: (threadId: string, turnId: string, end: CodexTurnEnd) => string[]
endTurn: (
threadId: string,
turnId: string,
end: CodexTurnEnd
) => { clientMessageId: string; via: AgentJournalTurnJoin }[]
/** Submission origin for this exact send, retained until its echo settles it. */
requestOrigin: (clientMessageId: string) => CodexDispatchRequestOrigin | null
/** Highest causal sequence assigned to a dispatch in this session. */
@@ -64,7 +76,11 @@ export type CodexDispatchEchoes = {
export function createCodexDispatchEchoes(): CodexDispatchEchoes {
const armed = new Map<
string,
{ requestedAt: number | null; sequence: number; turn?: { threadId: string; turnId: string } }
{
requestedAt: number | null
sequence: number
turn?: { threadId: string; turnId: string; via: AgentJournalTurnJoin }
}
>()
const endedTurns = new Map<string, CodexTurnEnd>()
const unopenedTurns = new Set<string>()
@@ -101,12 +117,12 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes {
},
settle: (clientMessageId) => armed.delete(clientMessageId),
disarm: (clientMessageId) => void armed.delete(clientMessageId),
bindTurn: (clientMessageId, threadId, turnId) => {
bindTurn: (clientMessageId, threadId, turnId, via) => {
const entry = armed.get(clientMessageId)
if (!entry) {
return null
}
entry.turn = { threadId, turnId }
entry.turn = { threadId, turnId, via }
const end = endedTurns.get(turnKey(threadId, turnId)) ?? null
if (end && settles(end)) {
armed.delete(clientMessageId)
@@ -141,10 +157,10 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes {
}
const settled = [...armed].flatMap(([clientMessageId, entry]) =>
entry.turn && turnKey(entry.turn.threadId, entry.turn.turnId) === turn
? [clientMessageId]
? [{ clientMessageId, via: entry.turn.via }]
: []
)
for (const clientMessageId of settled) {
for (const { clientMessageId } of settled) {
armed.delete(clientMessageId)
}
return settled
@@ -130,7 +130,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
}
if (event.type === 'notification') {
// Only an admitted turn end settles; a refused one settles on the retry that lands.
settleCodexSendsInEndedTurn(session, event.method, event.params, (settlement) =>
settleCodexSendsInEndedTurn(session, event, (settlement) =>
this.deps.onDispatchSettledLate?.({ sessionId: event.sessionId, ...settlement })
)
// After the admission check, so a refused frame is observed by the child records
@@ -1,5 +1,6 @@
import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
import type {
AgentJournalAnsweredTurnIdentity,
AgentJournalItemIdentity,
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
@@ -97,7 +98,10 @@ export type CodexStructuredSessionAdapterDeps = {
onDispatchSettledLate?: (
input: { sessionId: string; clientMessageId: string } & (
| { providerIdentity: AgentJournalItemIdentity }
| ({ state: 'rejected' } & AgentJournalDispatchRejection)
| ({
state: 'rejected'
answeredInTurn: AgentJournalAnsweredTurnIdentity
} & AgentJournalDispatchRejection)
)
) => void
/** Codex reported its thread not running with no turn open: a send whose
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection'
import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo'
import {
@@ -20,11 +24,17 @@ async function turnEndRig() {
Object.assign(codex.routes, turns.routes)
const settlements: LateSettlement[] = []
const bodies: AgentJournalItemBody[] = []
const turnRecords: AgentJournalItemIdentity[] = []
const adapter = await acquiredCodexAdapter({
codex,
settlements,
sink: {
appendItem: (_identity, body) => bodies.push(body),
appendItem: (identity, body) => {
bodies.push(body)
if (body.kind === 'turn') {
turnRecords.push(identity)
}
},
appendTombstone: () => {},
publish: () => {}
}
@@ -46,7 +56,18 @@ async function turnEndRig() {
const settledIds = () => settlements.map(({ clientMessageId }) => clientMessageId)
const categoryOf = (settlement: LateSettlement | undefined) =>
settlement && 'state' in settlement ? classifyDispatchRejection(settlement).category : null
return { codex, turns, adapter, send, sendAndOpen, settlements, settledIds, categoryOf, bodies }
return {
codex,
turns,
adapter,
send,
sendAndOpen,
settlements,
settledIds,
categoryOf,
bodies,
turnRecords
}
}
describe('a Codex send its turn ended without echoing', () => {
@@ -286,13 +307,82 @@ describe('a Codex send its turn ended without echoing', () => {
})
})
describe('the turn a withdrawn Codex send names', () => {
it('is the record of the turn it opened, when that turn is interrupted', async () => {
const rig = await turnEndRig()
await rig.sendAndOpen('client-1')
rig.turns.end('interrupted')
expect(rig.turnRecords.length).toBeGreaterThan(0)
expect(new Set(rig.turnRecords.map(agentJournalItemKey)).size).toBe(1)
expect(rig.settlements).toEqual([
expect.objectContaining({
clientMessageId: 'client-1',
answeredInTurn: { turn: rig.turnRecords[0], via: 'start' }
})
])
})
it('is the running turn for a send steered into it', async () => {
const rig = await turnEndRig()
await rig.sendAndOpen('client-1')
await rig.send('client-2')
rig.turns.end('interrupted')
expect(rig.settlements.map((settlement) => [settlement.clientMessageId, settlement])).toEqual([
[
'client-1',
expect.objectContaining({ answeredInTurn: { turn: rig.turnRecords[0], via: 'start' } })
],
[
'client-2',
expect.objectContaining({ answeredInTurn: { turn: rig.turnRecords[0], via: 'steer' } })
]
])
})
it('is the ended turn whose end was read before the answer', async () => {
const rig = await turnEndRig()
const release = rig.turns.holdNextAnswer()
const sending = rig.send('client-1')
await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1'))
rig.turns.start()
rig.turns.end('interrupted')
release()
await expect(sending).resolves.toMatchObject({
state: 'rejected',
answeredInTurn: { turn: rig.turnRecords[0], via: 'start' }
})
})
it('is not named when its turn completes without echoing it: the send stays pending', async () => {
const rig = await turnEndRig()
const release = rig.turns.holdNextAnswer()
const sending = rig.send('client-1')
await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1'))
rig.turns.start()
rig.turns.end('completed')
release()
await expect(sending).resolves.toEqual({ state: 'admitted' })
await rig.sendAndOpen('client-2')
rig.turns.end('completed')
expect(rig.settlements).toEqual([])
})
})
describe('a send bound to a turn', () => {
it('dies with the settlement its turn end makes', () => {
const echoes = createCodexDispatchEchoes()
echoes.arm('client-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start')
expect(echoes.endTurn('thread-1', 'turn-1', { status: 'interrupted' })).toEqual(['client-1'])
expect(echoes.endTurn('thread-1', 'turn-1', { status: 'interrupted' })).toEqual([
{ clientMessageId: 'client-1', via: 'start' }
])
expect(echoes.size).toBe(0)
expect(echoes.settle('client-1')).toBe(false)
})
@@ -300,20 +390,20 @@ describe('a send bound to a turn', () => {
it('dies with its child, which forgets recorded turn ends too', () => {
const echoes = createCodexDispatchEchoes()
echoes.arm('client-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start')
echoes.endTurn('thread-2', 'turn-2', { status: 'interrupted' })
echoes.clear()
expect(echoes.size).toBe(0)
echoes.arm('client-2')
expect(echoes.bindTurn('client-2', 'thread-2', 'turn-2')).toBeNull()
expect(echoes.bindTurn('client-2', 'thread-2', 'turn-2', 'start')).toBeNull()
})
it('is matched by thread as well as turn id', () => {
const echoes = createCodexDispatchEchoes()
echoes.arm('client-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1')
echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start')
expect(echoes.endTurn('thread-2', 'turn-1', { status: 'interrupted' })).toEqual([])
expect(echoes.size).toBe(1)
@@ -17,7 +17,9 @@ import {
agentSessionFailureWords,
type AgentJournalDispatchRejection
} from '../../shared/agent-session-failure-words'
import type { AgentJournalAnsweredTurnIdentity } from '../../shared/agent-session-journal-types'
import type { CodexTurnEnd } from './codex-structured-dispatch-echo'
import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns'
import type { CodexSession } from './codex-structured-session-state'
import {
readCodexThreadId,
@@ -41,6 +43,8 @@ export function codexDispatchRejection(
export type CodexTurnEndSettlement = {
clientMessageId: string
state: 'rejected'
/** The turn Codex answered the send into, whose end settled it, and how the send joined it. */
answeredInTurn: AgentJournalAnsweredTurnIdentity
} & AgentJournalDispatchRejection
function errorDetail(params: unknown): ProviderDiagnostic | undefined {
@@ -80,19 +84,27 @@ export function codexTurnEndRejection(end: CodexTurnEnd): AgentJournalDispatchRe
/** Settles the sends bound to the turn this admitted notification ended. */
export function settleCodexSendsInEndedTurn(
session: Pick<CodexSession, 'threadId' | 'dispatchEchoes'>,
method: string,
params: unknown,
frame: { sessionId: string; method: string; params: unknown },
settle: (settlement: CodexTurnEndSettlement) => void
): void {
const turnId = readCodexTurnId(params)
const end = readCodexTurnEnd(method, params)
if (!turnId || !end || (readCodexThreadId(params) ?? session.threadId) !== session.threadId) {
const turnId = readCodexTurnId(frame.params)
const end = readCodexTurnEnd(frame.method, frame.params)
if (
!turnId ||
!end ||
(readCodexThreadId(frame.params) ?? session.threadId) !== session.threadId
) {
return
}
const rejection = codexTurnEndRejection(end)
for (const clientMessageId of session.dispatchEchoes.endTurn(session.threadId, turnId, end)) {
const turn = codexTurnLifecycleIdentity(frame.sessionId, turnId)
for (const { clientMessageId, via } of session.dispatchEchoes.endTurn(
session.threadId,
turnId,
end
)) {
if (rejection) {
settle({ clientMessageId, state: 'rejected', ...rejection })
settle({ clientMessageId, state: 'rejected', answeredInTurn: { turn, via }, ...rejection })
}
}
}
+32 -9
View File
@@ -1,5 +1,8 @@
import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import type {
AgentJournalMessageItem,
AgentJournalTurnJoin
} from '../../shared/agent-session-journal-types'
import type { NativeChatBlock } from '../../shared/native-chat-types'
import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import {
@@ -8,6 +11,7 @@ import {
} from './codex-app-server-connection'
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo'
import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns'
import { readCodexTurnId } from './codex-structured-thread-facts'
import {
codexRunningOrOpeningTurn,
@@ -107,7 +111,7 @@ async function steerCodexTurn(
host: CodexTurnHost,
expectedTurnId: string,
input: { clientMessageId: string; body: AgentJournalMessageItem; timeoutMs?: number }
): Promise<{ turnId: string } | null> {
): Promise<{ turnId: string; via: 'steer' } | null> {
try {
const answer = await host.connection.request(
'turn/steer',
@@ -119,7 +123,7 @@ async function steerCodexTurn(
},
{ timeoutMs: input.timeoutMs }
)
return { turnId: readCodexTurnId(answer) ?? expectedTurnId }
return { turnId: readCodexTurnId(answer) ?? expectedTurnId, via: 'steer' }
} catch (error) {
if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) {
return null
@@ -141,7 +145,7 @@ export async function startCodexTurn(
requestedAt?: number
timeoutMs?: number
}
): Promise<{ turnId: string | null } | false> {
): Promise<{ turnId: string | null; via: AgentJournalTurnJoin } | false> {
// Armed before the write: the echo and `turn/started` can both land while the
// response is in flight, and the start must snapshot this send in its frontier.
if (!host.dispatchEchoes.arm(input.clientMessageId, input.requestedAt)) {
@@ -167,7 +171,7 @@ export async function startCodexTurn(
},
{ timeoutMs: input.timeoutMs }
)
return { turnId: readCodexTurnId(answer) }
return { turnId: readCodexTurnId(answer), via: 'start' }
}
/**
@@ -178,10 +182,15 @@ export async function startCodexTurn(
*/
export async function dispatchCodexTurn(
session: CodexTurnHost,
input: { clientMessageId: string; body: AgentJournalMessageItem; requestedAt?: number },
input: {
sessionId: string
clientMessageId: string
body: AgentJournalMessageItem
requestedAt?: number
},
timeoutMs: number | undefined
): Promise<AgentSessionDispatchOutcome> {
let answer: { turnId: string | null } | false
let answer: { turnId: string | null; via: AgentJournalTurnJoin } | false
try {
answer = await startCodexTurn(session, { ...input, timeoutMs })
} catch (error) {
@@ -205,8 +214,22 @@ export async function dispatchCodexTurn(
}
// An answer read after the turn it names already ended is settled by that end.
const endedFirst = answer.turnId
? session.dispatchEchoes.bindTurn(input.clientMessageId, session.threadId, answer.turnId)
? session.dispatchEchoes.bindTurn(
input.clientMessageId,
session.threadId,
answer.turnId,
answer.via
)
: null
const rejection = endedFirst ? codexTurnEndRejection(endedFirst) : null
return rejection ? { state: 'rejected', ...rejection } : { state: 'admitted' }
return rejection && answer.turnId
? {
state: 'rejected',
answeredInTurn: {
turn: codexTurnLifecycleIdentity(input.sessionId, answer.turnId),
via: answer.via
},
...rejection
}
: { state: 'admitted' }
}
@@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBe(39)
expect(PROTOCOL_VERSION).toBe(40)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
@@ -22,7 +22,7 @@ import {
describe('daemon protocol version', () => {
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
expect(PROTOCOL_VERSION).toBe(39)
expect(PROTOCOL_VERSION).toBe(40)
expect(COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION).toBe(38)
expect(CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION).toBe(37)
expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36)
@@ -38,7 +38,7 @@ describe('daemon protocol version', () => {
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
Array.from({ length: 38 }, (_, index) => index + 1)
Array.from({ length: 39 }, (_, index) => index + 1)
)
})
+4 -3
View File
@@ -1,6 +1,7 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
// v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS; older owners stay attachable.
export const PROTOCOL_VERSION = 39
// v40 rolls the #25130/#24636 shell-wrapper changes and the wider agent list (jcode, qoder-cn,
// dsb; resume claims for qoder-cn/qwen-code/cursor/jcode) into a fresh daemon; older owners stay attachable.
export const PROTOCOL_VERSION = 40
export const CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION = 39
// Why: older daemons reject `setColorQueryReplyColors` as an unknown request type.
export const COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION = 38
@@ -34,7 +35,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38
28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39
] as const
export function supportsColorQueryReplyColors(protocolVersion: number): boolean {
@@ -52,7 +52,7 @@ describe('DaemonPtyAdapter startup delivery', () => {
await vi.advanceTimersByTimeAsync(299)
expect(lastSubprocess.write).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith('codex\n')
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith('codex\r')
expect(lastSpawnOpts).not.toEqual(
expect.objectContaining({ startupCommandDelivery: 'shell-ready' })
)
@@ -82,7 +82,7 @@ describe('DaemonPtyAdapter startup delivery', () => {
)
lastSubprocess._simulateData('\x1b]777;orca-shell-ready\x07\r\nuser@host $ ')
await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0)
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith('codex\n')
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith('codex\r')
}
)
@@ -102,6 +102,6 @@ describe('DaemonPtyAdapter startup delivery', () => {
lastSubprocess._simulateData('\r\nuser@host $ ')
await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0)
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith(`${startup.command}\n`)
expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith(`${startup.command}\r`)
})
})
@@ -197,11 +197,9 @@ async function spawnAndPublishSession(
// Diagnostics must never turn a live PTY into a failed create.
}
if (startupCommandWritten && opts.command) {
const submit = process.platform === 'win32' ? '\r' : '\n'
// Why: only Orca-wrapped shells advertise the paste-safe startup barrier.
session.write(
buildStartupCommandSubmission(opts.command, {
submit,
bracketedPasteSafe: shellReadySupported
})
)
@@ -19,11 +19,8 @@ function mockSubprocess(): SubprocessHandle {
} as SubprocessHandle
}
// Why: Windows shells (PowerShell/cmd.exe) submit on CR, not LF. Without CR
// the startup command sits typed at the prompt but unexecuted — forcing the
// user to press Enter after "claude" (or a setup script) is injected.
// POSIX shells (bash/zsh) keep the LF behaviour. A caller-supplied terminator
// must not be doubled.
// Why: without CR (Enter) the startup command sits typed at the prompt but
// unexecuted (#23250). A caller-supplied terminator is replaced, not doubled.
describe('TerminalHost startup command terminator', () => {
const origPlatform = process.platform
afterEach(() => {
@@ -39,9 +36,11 @@ describe('TerminalHost startup command terminator', () => {
it.each([
['win32', 'claude', 'claude\r'],
['darwin', 'claude', 'claude\n'],
['darwin', 'claude', 'claude\r'],
['linux', 'claude', 'claude\r'],
['win32', 'claude\r', 'claude\r'],
['darwin', 'claude\n', 'claude\n']
['darwin', 'claude\n', 'claude\r'],
['win32', 'claude\r\n', 'claude\r']
])('submits startup with correct terminator on %s', async (platform, cmd, sent) => {
Object.defineProperty(process, 'platform', { value: platform })
await host.createOrAttach({
@@ -128,6 +127,6 @@ describe('TerminalHost startup command delivery logging', () => {
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
).resolves.toMatchObject({ isNew: true })
expect(sub.write).toHaveBeenCalledWith(`codex${process.platform === 'win32' ? '\r' : '\n'}`)
expect(sub.write).toHaveBeenCalledWith('codex\r')
})
})
+5 -10
View File
@@ -189,9 +189,7 @@ describe('TerminalHost', () => {
lastSubprocess._onDataCb?.('\r\nuser@host $ ')
await new Promise((r) => setTimeout(r, 40))
expect(lastSubprocess.write).toHaveBeenCalledWith(
process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n'
)
expect(lastSubprocess.write).toHaveBeenCalledWith('echo hello\r')
})
it('uses the short daemon settle path when marker and prompt arrive together', async () => {
@@ -211,9 +209,7 @@ describe('TerminalHost', () => {
expect(lastSubprocess.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(lastSubprocess.write).toHaveBeenCalledWith(
process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n'
)
expect(lastSubprocess.write).toHaveBeenCalledWith('echo hello\r')
} finally {
vi.useRealTimers()
}
@@ -242,9 +238,7 @@ describe('TerminalHost', () => {
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
expect(lastSubprocess.write).toHaveBeenCalledWith(
process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n'
)
expect(lastSubprocess.write).toHaveBeenCalledWith('echo hello\r')
})
it('does not bracketed-paste-wrap multiline commands for a fallback shell without paste mode', async () => {
@@ -272,7 +266,8 @@ describe('TerminalHost', () => {
const written = (lastSubprocess.write as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]
expect(written).not.toContain('\x1b[200~')
expect(written).toContain('line one\nline two')
// Why CR between the lines: without bracketed paste each break submits its own line.
expect(written).toContain('line one\rline two')
})
it('keeps the shell-ready barrier when the spawned shell supports the marker', async () => {
@@ -233,7 +233,7 @@ describe('registerPtyHandlers', () => {
mockProc.emitData('\x1b]133;A\x07% ')
await Promise.resolve()
vi.runAllTimers()
expect(mockProc.proc.write).toHaveBeenCalledWith('claude\n')
expect(mockProc.proc.write).toHaveBeenCalledWith('claude\r')
} finally {
vi.useRealTimers()
}
@@ -396,7 +396,7 @@ describe('registerPtyHandlers', () => {
vi.advanceTimersByTime(1)
await Promise.resolve()
vi.runAllTimers()
expect(mockProc.proc.write).toHaveBeenCalledWith('printf "hello"\n')
expect(mockProc.proc.write).toHaveBeenCalledWith('printf "hello"\r')
} finally {
vi.useRealTimers()
}
@@ -278,7 +278,7 @@ describe('registerPtyHandlers', () => {
await Promise.resolve()
vi.runAllTimers()
expect(mockProc.proc.write).toHaveBeenCalledWith(`${command}\n`)
expect(mockProc.proc.write).toHaveBeenCalledWith(`${command}\r`)
expect(spawned.agentResumeUnavailable).toBeUndefined()
} finally {
vi.useRealTimers()
@@ -0,0 +1,78 @@
// How a stored dispatch row's answered turn reads onto its submission: a named turn, none stated,
// or absent on a row written before the field existed.
import { describe, expect, it } from 'vitest'
import { applyJournalRow, createJournalReducerState } from './journal-reducer'
import type { JournalRow } from './journal-row-schema'
const EPOCH = 'epoch-1'
function base(seq: number): { v: number; epoch: string; seq: number; fence: number; ts: number } {
return { v: 1, epoch: EPOCH, seq, fence: 1, ts: 1_000 + seq }
}
/** The submission after a send and one dispatch row, stored with the given extra keys. */
function readAfter(dispatch: Partial<Extract<JournalRow, { kind: 'dispatch' }>>) {
const state = createJournalReducerState('session-1', EPOCH)
applyJournalRow(state, {
kind: 'submission',
clientMessageId: 'cm_1',
payloadFingerprint: 'fp_1',
providerHandle: { kind: 'codex', threadId: 'thread-1' },
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
...base(1)
})
applyJournalRow(state, {
kind: 'dispatch',
clientMessageId: 'cm_1',
state: 'rejected',
providerItemId: null,
reason: 'This message was withdrawn before the agent started it.',
...base(2),
...dispatch
})
return state.submissions.get('cm_1')
}
describe("a rejected dispatch row's answered turn", () => {
it('is the turn it names', () => {
expect(readAfter({ answeredInTurn: { turnItemId: 'turn-1', via: 'steer' } })).toMatchObject({
answeredInTurn: { turnItemId: 'turn-1', via: 'steer' }
})
})
it('is none when the row states none', () => {
expect(readAfter({ answeredInTurn: null })).toMatchObject({ answeredInTurn: null })
})
it('is absent on a row written before the field existed', () => {
const submission = readAfter({})
expect(submission).toMatchObject({ dispatchState: 'rejected' })
expect(submission).not.toHaveProperty('answeredInTurn')
})
it('reads as none, keeping the row, when it names a way of joining this build does not know', () => {
expect(readAfter({ answeredInTurn: { turnItemId: 'turn-1', via: 'resume' } })).toMatchObject({
dispatchState: 'rejected',
answeredInTurn: null
})
})
it('reads as none, keeping the row, when it names no turn id', () => {
expect(readAfter({ answeredInTurn: { turnItemId: '', via: 'start' } })).toMatchObject({
dispatchState: 'rejected',
answeredInTurn: null
})
})
it('is absent on any other dispatch state', () => {
const submission = readAfter({
state: 'unknown',
answeredInTurn: { turnItemId: 'turn-1', via: 'start' }
})
expect(submission).toMatchObject({ dispatchState: 'unknown' })
expect(submission).not.toHaveProperty('answeredInTurn')
})
})
@@ -6,6 +6,7 @@ import {
type UnreadAgentSessionFailureFact
} from '../../../shared/agent-session-failure'
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import type { AgentJournalAnsweredTurn } from '../../../shared/agent-session-journal-types'
import { journalDispatchRowApplies } from './journal-dispatch-settlement'
import type { JournalReducerState } from './journal-reducer'
import {
@@ -34,6 +35,11 @@ export function applyJournalDispatchRow(
} else {
delete submission.rejection
}
if (row.state === 'rejected' && row.answeredInTurn !== undefined) {
submission.answeredInTurn = readAnsweredTurn(row.answeredInTurn)
} else {
delete submission.answeredInTurn
}
submission.resolvedAt = row.state === 'pending' ? null : row.ts
if (row.state === 'pending') {
submission.handedOverAt = row.ts
@@ -61,6 +67,19 @@ export function applyJournalDispatchRow(
})
}
/** A stored answered turn. One malformed, or naming a way of joining this build does not know, is
* read as no turn: it was written knowing the field, so it is not an older row. */
function readAnsweredTurn(value: unknown): AgentJournalAnsweredTurn | null {
if (typeof value !== 'object' || value === null) {
return null
}
const turnItemId = 'turnItemId' in value ? value.turnItemId : undefined
const via = 'via' in value ? value.via : undefined
return typeof turnItemId === 'string' && turnItemId && (via === 'start' || via === 'steer')
? { turnItemId, via }
: null
}
/** A stored rejection fact, read where it can be placed; a kind it cannot place is kept as
* written, so the classifier still knows a fact was there without this build claiming what it
* says. Shared with the queued-draft table, whose returned card mirrors its submission. */
@@ -33,6 +33,7 @@ import {
type QueuedMessageHoldReason,
type QueuedMessageRow
} from './queued-message-table'
import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source'
import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo'
import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention'
import {
@@ -110,6 +111,7 @@ export class JournalQueuedMessages {
fingerprint: string
hostInstance: string
carriedFrom?: string
source: AgentSessionMessageSource
},
receipt?: JournalOperationReceipt
): Promise<QueuedMessageRow> {
@@ -112,6 +112,17 @@ export function journalDispatchRowBuilder(
providerItemId,
reason: boundedDispatchReason(input),
...(input.state === 'rejected' ? { rejection: input.rejection } : {}),
// Every rejection states its turn, null for none, so a reader tells it from an older row.
...(input.state === 'rejected'
? {
answeredInTurn: input.answeredInTurn
? {
turnItemId: agentJournalItemKey(input.answeredInTurn.turn),
via: input.answeredInTurn.via
}
: null
}
: {}),
...journalRowBase(state().epoch, seq, input.fence, ts),
...(input.recovered ? { recovered: input.recovered } : {}),
...(input.state === 'pending' ? { turnScope: input.turnScope } : {})
@@ -160,6 +160,11 @@ export type JournalDispatchRow = JournalRowBase & {
/** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is
* dropped when read, never the row. */
rejection?: AgentSessionFailureFact
/** On `rejected`: the turn a Codex send was answered into, and how it joined it, when that
* turn's end settled the send; null on every other rejection. Absent on other rows and on rows
* written before it. `via` stays a string: a newer build may write another. Older readers keep
* the key and ignore it; one this build cannot read is read as null, never dropping the row. */
answeredInTurn?: { turnItemId: string; via: string } | null
}
/** An item mutation may name its own producer, because one batch can CREATE
@@ -1,5 +1,6 @@
import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words'
import type {
AgentJournalAnsweredTurnIdentity,
AgentJournalCursor,
AgentJournalItemBody,
AgentJournalItemIdentity,
@@ -40,7 +41,10 @@ export type ResolveDispatchInput = {
| { state: 'pending'; turnScope: AgentJournalTurnScope }
/** `reason` is what released clients print, `rejection` what newer ones read: both from
* `agentSessionFailureWords`, never written by hand. */
| ({ state: 'rejected' } & AgentJournalDispatchRejection)
| ({
state: 'rejected'
answeredInTurn?: AgentJournalAnsweredTurnIdentity
} & AgentJournalDispatchRejection)
| { state: 'unknown'; reason?: string | null }
)
@@ -25,6 +25,7 @@ export function applyJournalSubmission(
reason: null,
submittedAt: row.ts,
resolvedAt: null,
submittedSequence: row.seq,
...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}),
// A malformed stored link is dropped, never the row.
...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0
@@ -0,0 +1,171 @@
// Each submission carries where the journal wrote its row. A rejected send's own row moves to its
// rejection, so this is the only journal-order record of where it was sent. Derived on every fold: a
// replay of the same rows gives the same value, and a history page carries it. A rejection a turn's
// end made also names that turn.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
import {
agentJournalItemKey,
agentJournalSubmissionKey
} from '../../../shared/agent-session-journal-item-key'
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalMessageItem,
type AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
import { readAgentSessionHydrationPage } from '../agent-session-wire/agent-session-history-page'
import { createTrackedJournalOpener } from './journal-host-database-test-support'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'workspace-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: codexProviderHandle('thread-1')
}
const BODY: AgentJournalMessageItem = {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'look around' }]
}
let root: string | null = null
const journals = createTrackedJournalOpener()
afterEach(async () => {
await journals.closeAll()
if (root) {
await rm(root, { recursive: true, force: true })
root = null
}
})
/** A send accepted for later handover, handed over, then taken back. */
async function sendHandedOverThenWithdrawn() {
root = await mkdtemp(join(tmpdir(), 'orca-submission-positions-'))
const journal = await journals.open({ identity: IDENTITY, stateDirectory: root })
const submitted = await journal.appendSubmission({
clientMessageId: 'send-1',
payloadFingerprint: 'send-1',
body: BODY,
fence: 1,
handoverRecorded: true
})
const handedOver = await journal.resolveDispatch({
clientMessageId: 'send-1',
state: 'pending',
fence: 1,
turnScope: AGENT_JOURNAL_THREAD_SCOPE
})
const pending = { ...journal.submission('send-1') }
const withdrawn = await journal.resolveDispatch({
clientMessageId: 'send-1',
state: 'rejected',
...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }),
fence: 1,
recovered: true
})
return { journal, submitted, handedOver, pending, withdrawn }
}
const TURN = {
provider: 'legacy',
agent: 'codex',
sessionId: 'session-1',
recordId: 'turn-lifecycle:turn-1'
} as const
/** A send its turn ended without taking: the rejection names that turn. */
async function sendWithdrawnByItsTurnEnd() {
root = await mkdtemp(join(tmpdir(), 'orca-submission-positions-'))
const journal = await journals.open({ identity: IDENTITY, stateDirectory: root })
await journal.appendSubmission({
clientMessageId: 'send-1',
payloadFingerprint: 'send-1',
body: BODY,
fence: 1
})
await journal.resolveDispatch({
clientMessageId: 'send-1',
state: 'rejected',
...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }),
answeredInTurn: { turn: TURN, via: 'start' },
fence: 1
})
return journal
}
describe('the turn a rejected submission was answered into', () => {
it('is the turn record its rejection named, on the snapshot, a replay, and the page', async () => {
const journal = await sendWithdrawnByItsTurnEnd()
const turnItemId = agentJournalItemKey(TURN)
expect(journal.submission('send-1')).toMatchObject({
dispatchState: 'rejected',
answeredInTurn: { turnItemId, via: 'start' }
})
expect(readAgentSessionHydrationPage(journal).submissions).toEqual([
expect.objectContaining({ answeredInTurn: { turnItemId, via: 'start' } })
])
await journals.closeAll()
const replayed = await journals.open({ identity: IDENTITY, stateDirectory: root! })
expect(replayed.submission('send-1')).toMatchObject({
answeredInTurn: { turnItemId, via: 'start' }
})
})
it('is stated as none on a take-back that names no turn, so it reads apart from an older row', async () => {
const { journal } = await sendHandedOverThenWithdrawn()
expect(journal.submission('send-1')).toMatchObject({
dispatchState: 'rejected',
answeredInTurn: null
})
expect(readAgentSessionHydrationPage(journal).submissions[0]).toMatchObject({
answeredInTurn: null
})
})
})
describe("a submission's journal position", () => {
it('is its own row, which a take-back does not move', async () => {
const { journal, submitted, handedOver, pending, withdrawn } =
await sendHandedOverThenWithdrawn()
expect(pending).toMatchObject({ submittedSequence: submitted.sequence })
expect(handedOver.sequence).toBeGreaterThan(submitted.sequence)
expect(journal.submission('send-1')).toMatchObject({
dispatchState: 'rejected',
submittedSequence: submitted.sequence
})
// Anti-vacuous: the send's own row moved to its rejection, so only this field says where it was sent.
expect(
journal.snapshot().items.find((item) => item.itemId === agentJournalSubmissionKey('send-1'))
?.sequence
).toBe(withdrawn.sequence)
})
it('comes back the same from a replay of the stored rows', async () => {
const { journal, submitted } = await sendHandedOverThenWithdrawn()
await journals.closeAll()
const replayed = await journals.open({ identity: IDENTITY, stateDirectory: root! })
expect(replayed.submission('send-1')).toMatchObject({ submittedSequence: submitted.sequence })
expect(journal).not.toBe(replayed)
})
it('reaches a client on the history page', async () => {
const { journal, submitted } = await sendHandedOverThenWithdrawn()
expect(readAgentSessionHydrationPage(journal).submissions).toEqual([
expect.objectContaining({ clientMessageId: 'send-1', submittedSequence: submitted.sequence })
])
})
})
@@ -51,7 +51,8 @@ async function handOff(journal: AgentSessionJournal, draftId: string, submission
messageId: draftId,
body: BODY,
fingerprint: 'fp',
hostInstance: 'p'
hostInstance: 'p',
source: { kind: 'user' }
})
await journal.appendSubmission(
{ clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 },
@@ -100,7 +101,8 @@ describe('the submission names the queued draft it hands off', () => {
messageId: 'draft-1',
body: BODY,
fingerprint: 'fp',
hostInstance: 'p'
hostInstance: 'p',
source: { kind: 'user' }
})
await expect(
journal.appendSubmission(
@@ -65,7 +65,8 @@ async function queueAndConsume(journal: AgentSessionJournal, messageId: string):
messageId,
body,
fingerprint: `fp-${messageId}`,
hostInstance: 'proc-1'
hostInstance: 'proc-1',
source: { kind: 'user' }
})
await journal.appendSubmission(
{
@@ -172,7 +173,8 @@ describe('draft bookkeeping inside a journal append', () => {
messageId: 'draft-1',
body: BODY,
fingerprint: 'fp-draft-1',
hostInstance: 'proc-1'
hostInstance: 'proc-1',
source: { kind: 'user' }
})
expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }])
const commit = failNextCommit()
@@ -77,7 +77,8 @@ async function handOffAndReject(
messageId: 'draft-1',
body,
fingerprint,
hostInstance: 'p'
hostInstance: 'p',
source: { kind: 'user' }
})
await journal.appendSubmission(
{
@@ -63,6 +63,7 @@ function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom
body: message(messageId),
fingerprint: `fp-${messageId}`,
hostInstance: HOST,
source: { kind: 'user' },
...(carriedFrom ? { carriedFrom } : {})
})
}
@@ -495,7 +496,8 @@ describe("a restart's pause", () => {
messageId: 'draft-restart',
body: message('written before the restart'),
fingerprint: 'fp-draft-restart',
hostInstance: 'proc-0'
hostInstance: 'proc-0',
source: { kind: 'user' }
})
expect(reason(journal)).toBe('restarted')
await queueDraft(journal, 'draft-legacy')
@@ -12,7 +12,8 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [
['consumed_as', 'TEXT'],
['carried_from', 'TEXT'],
['queued_epoch', 'TEXT'],
['queued_sequence', 'INTEGER']
['queued_sequence', 'INTEGER'],
['source_json', 'TEXT']
]
/**
@@ -43,6 +44,7 @@ CREATE TABLE IF NOT EXISTS queued_messages (
carried_from TEXT,
queued_epoch TEXT,
queued_sequence INTEGER,
source_json TEXT,
PRIMARY KEY (session_id, message_id)
);
`)
@@ -22,6 +22,7 @@ import {
QueuedMessageNotConsumableError
} from './journal-queued-messages'
import type { AgentSessionJournal } from './journal-store'
import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source'
import {
closeTestJournalHostDatabases,
createTrackedJournalOpener
@@ -81,7 +82,8 @@ async function queueDraft(journal: AgentSessionJournal, messageId: string, text
messageId,
body: message(text),
fingerprint: `fp-${messageId}`,
hostInstance: 'proc-1'
hostInstance: 'proc-1',
source: { kind: 'user' }
})
}
@@ -174,6 +176,53 @@ describe('draft rows', () => {
expect(journal.queuedMessages.list()).toHaveLength(1)
})
it("keeps who queued a card across reopen; a card with no readable sender is the person's", async () => {
const agent: AgentSessionMessageSource = {
kind: 'agent',
senders: [
{
party: {
address: 'structworker_1',
terminalHandle: 'structworker_1',
orcaSessionId: null
}
}
],
orchestration: {
message: 'mail-notice',
mailbox: 'run:r1',
dispatchId: 'd1',
messages: [{ messageId: 'm1', runId: 'r1', from: 'structworker_1' }]
}
}
const first = await open()
await first.queuedMessages.insert({
messageId: 'agent-card',
body: message('You have 1 orchestration message. Run `orca orchestration check --run r1`.'),
fingerprint: 'fp-agent-card',
hostInstance: 'proc-1',
source: agent
})
await queueDraft(first, 'before-the-column')
await queueDraft(first, 'unreadable')
await first.close()
closeTestJournalHostDatabases()
const db = new Database(journalDatabasePath(root))
db.prepare('UPDATE queued_messages SET source_json = NULL WHERE message_id = ?').run(
'before-the-column'
)
db.prepare('UPDATE queued_messages SET source_json = \'{"v":9}\' WHERE message_id = ?').run(
'unreadable'
)
db.close()
const reopened = await open()
expect(reopened.queuedMessages.list().map((row) => [row.messageId, row.source])).toEqual([
['agent-card', agent],
['before-the-column', { kind: 'user' }],
['unreadable', { kind: 'user' }]
])
})
it('drafts survive epoch replacement, which deletes only journal rows', async () => {
const journal = await open()
await queueDraft(journal, 'draft-1')
@@ -13,6 +13,11 @@ import type {
AgentJournalCursor,
AgentJournalMessageItem
} from '../../../shared/agent-session-journal-types'
import {
readAgentSessionMessageSource,
serializeAgentSessionMessageSource,
type AgentSessionMessageSource
} from '../../../shared/agent-session-message-source'
import { rejectedDraftSettlement } from './journal-dispatch-settlement'
import { readStoredRejectionFact } from './journal-dispatch-reducer'
@@ -59,10 +64,12 @@ export type QueuedMessageRow = {
/** Where the journal stood when it was queued: a Stop's pause holds only cards queued before
* it. Null on rows from builds before it was recorded, which read as queued before any Stop. */
queuedAt: AgentJournalCursor | null
/** Who it is from: the person, or another agent through Orca. */
source: AgentSessionMessageSource
}
const COLUMNS =
'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence'
'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence, source_json'
export function insertQueuedMessage(
db: Database.Database,
@@ -74,6 +81,7 @@ export function insertQueuedMessage(
hostInstance: string
carriedFrom?: string
queuedAt: AgentJournalCursor
source: AgentSessionMessageSource
now: number
}
): QueuedMessageRow {
@@ -84,7 +92,7 @@ export function insertQueuedMessage(
const position = Number(highest?.p ?? 0) + 1
db.prepare(
`INSERT INTO queued_messages (${COLUMNS})
VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)`
VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)`
).run(
input.sessionId,
input.messageId,
@@ -95,7 +103,8 @@ export function insertQueuedMessage(
input.hostInstance,
input.carriedFrom ?? null,
input.queuedAt.epoch,
input.queuedAt.sequence
input.queuedAt.sequence,
serializeAgentSessionMessageSource(input.source)
)
return {
sessionId: input.sessionId,
@@ -113,7 +122,8 @@ export function insertQueuedMessage(
settledByOp: null,
consumedAs: null,
carriedFrom: input.carriedFrom ?? null,
queuedAt: input.queuedAt
queuedAt: input.queuedAt,
source: input.source
}
}
@@ -295,6 +305,7 @@ function toStoredRow(row: unknown): QueuedMessageRow | null {
carried_from: string | null
queued_epoch: string | null
queued_sequence: number | null
source_json: string | null
}
let body: AgentJournalMessageItem
try {
@@ -333,10 +344,21 @@ function toStoredRow(row: unknown): QueuedMessageRow | null {
queuedAt:
record.queued_epoch !== null && typeof record.queued_sequence === 'number'
? { epoch: record.queued_epoch, sequence: record.queued_sequence }
: null
: null,
source: storedSource(record.source_json)
}
}
function storedSource(json: string | null): AgentSessionMessageSource {
let stored: unknown = null
try {
stored = json === null ? null : JSON.parse(json)
} catch {
// An unreadable value is read as no value; the source reader decides what that means.
}
return readAgentSessionMessageSource(stored)
}
function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null {
if (json === null) {
return null
@@ -12,6 +12,7 @@ import type {
import type { AgentSessionBackgroundTaskStops } from '../../../shared/agent-child-work-stop-targets'
import type {
AgentJournalAnsweredTurnIdentity,
AgentJournalItemIdentity,
AgentJournalItemBody,
AgentJournalMessageItem,
@@ -176,8 +177,12 @@ export type AgentSessionDispatchOutcome =
* anything and never promotes this to `unknown`.
*/
| { state: 'admitted' }
/** Words from `agentSessionFailureWords`, never written by hand. */
| ({ state: 'rejected' } & AgentJournalDispatchRejection)
/** Words from `agentSessionFailureWords`, never written by hand. `answeredInTurn`: the turn the
* provider answered the send into, which ended before the answer was read. */
| ({
state: 'rejected'
answeredInTurn?: AgentJournalAnsweredTurnIdentity
} & AgentJournalDispatchRejection)
/** The call did not settle. Never re-send on the user's behalf. */
| { state: 'unknown'; reason: string }
@@ -37,6 +37,7 @@ import {
setOptionPlan
} from './structured-agent-session-mutation-plans'
import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send'
import type { AgentMessageSource } from '../../../shared/agent-session-message-source'
import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel'
import { mutateWithChatStop } from './structured-agent-session-chat-stop'
export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context'
@@ -60,6 +61,9 @@ export function sendStructuredAgentSessionTurn(
* Orchestration mail, a restart continuation and `agent.launch`'s host-sent
* prompt never set it. */
userSend?: true
/** Host-local, never on the wire: who a host-side `queue-if-active` send queues for, recorded
* on its card. A client's send is always its person's (`userSend`). */
source?: AgentMessageSource
beforeRun?: () => void
},
arrival?: Parameters<typeof sendPreparation>[2]
@@ -1,4 +1,7 @@
import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types'
import type {
AgentJournalAnsweredTurnIdentity,
AgentJournalItemIdentity
} from '../../../shared/agent-session-journal-types'
import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words'
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
@@ -12,7 +15,10 @@ export async function settleStructuredAgentSessionLateDispatch(
clientMessageId: string
} & (
| { providerIdentity: AgentJournalItemIdentity }
| ({ state: 'rejected' } & AgentJournalDispatchRejection)
| ({
state: 'rejected'
answeredInTurn?: AgentJournalAnsweredTurnIdentity
} & AgentJournalDispatchRejection)
| { state: 'unknown'; reason: string }
)
): Promise<void> {
@@ -43,6 +49,7 @@ export async function settleStructuredAgentSessionLateDispatch(
state: 'rejected',
reason: input.reason,
rejection: input.rejection,
...(input.answeredInTurn ? { answeredInTurn: input.answeredInTurn } : {}),
fence
}
)
@@ -10,6 +10,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure-
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire'
import type { AgentMessageSource } from '../../../shared/agent-session-message-source'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
@@ -30,6 +31,7 @@ import { createStructuredAgentSessionLogger } from './structured-agent-session-l
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
export const QUEUED_RIG_CALLER = { callerKey: 'client-1' }
type RigSendOptions = { internal?: true; source?: AgentMessageSource }
export function eventually(assertion: () => void | Promise<void>): Promise<void> {
return vi.waitFor(assertion, { timeout: 10_000 })
@@ -140,16 +142,16 @@ export async function createQueuedMessageTestRig(
}
/** A client's send, as the `agentSession.send` RPC hands it to the host;
* `internal` is a host-side sender (orchestration mail, a restart continuation). */
function send(text: string, delivery?: 'queue-if-active', options?: { internal?: true }) {
* `internal` is a host-side sender (orchestration mail, a restart continuation), and `source`
* who it is from. */
function send(text: string, delivery?: 'queue-if-active', options?: RigSendOptions) {
const body = hostTestMessage(text)
const clientOperationId = hostTestOperationId()
const fields = { body, ...(delivery ? { delivery } : {}) }
const result = host.send(QUEUED_RIG_CALLER, {
envelope: envelope(fields, 'agentSession.send', clientOperationId),
body,
...(delivery ? { delivery } : {}),
...(options?.internal ? {} : { userSend: true as const })
...fields,
...(options?.internal ? { source: options.source } : { userSend: true as const })
})
return { id: clientOperationId, result }
}
@@ -12,6 +12,7 @@ import {
type AgentSessionSubscribeEvent
} from '../../../shared/agent-session-wire'
import { ConversationCommandParams } from '../../../shared/rpc-contract/structured-agent-session-params'
import type { AgentMessageSource } from '../../../shared/agent-session-message-source'
import { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages'
import {
@@ -636,6 +637,31 @@ describe('/clear', () => {
})
})
it('carries who each card is from', async () => {
const notice = {
message: 'mail-notice',
mailbox: 'run:r1',
dispatchId: null,
messages: []
} as const
const source: AgentMessageSource = { kind: 'agent', senders: [], orchestration: notice }
const working = await workingSend()
await send('pointer', 'queue-if-active', { internal: true, source }).result
await send('typed', 'queue-if-active').result
await stop()
await settleAccepted(working, 'a')
const cleared = await clear(hostTestOperationId())
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
if (!replacementId) {
throw new Error('expected a replacement session')
}
const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal
expect(journal?.queuedMessages.list().map((row) => row.source)).toEqual([
source,
{ kind: 'user' }
])
})
it("the replacement's 'cleared' pause lifts through Resume exactly like a Stop's", async () => {
const [firstId] = await pausedDrafts()
const cleared = await clear(hostTestOperationId())
@@ -8,6 +8,10 @@
import { randomUUID } from 'node:crypto'
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import {
USER_MESSAGE_SOURCE,
type AgentMessageSource
} from '../../../shared/agent-session-message-source'
import {
QUEUED_MESSAGE_PAUSED_SEND_FAILED,
type AgentSessionSendResult,
@@ -191,6 +195,10 @@ export async function maybeQueueStructuredAgentSessionSend(
envelope: { clientOperationId: string }
body: AgentJournalMessageItem
delivery?: 'queue-if-active'
/** A person's send at a chat surface; it outranks any `source`. */
userSend?: true
/** Who a host-side send is from. */
source?: AgentMessageSource
}
): Promise<
| { ok: true; value: AgentSessionSendResult }
@@ -231,7 +239,8 @@ export async function maybeQueueStructuredAgentSessionSend(
messageId: clientMessageId,
body: params.body,
fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body),
hostInstance: structuredAgentSessionHostInstance()
hostInstance: structuredAgentSessionHostInstance(),
source: params.userSend ? USER_MESSAGE_SOURCE : (params.source ?? USER_MESSAGE_SOURCE)
},
ctx.operationReceipt
)
@@ -117,7 +117,8 @@ export async function carryQueuedMessagesToClearReplacement(
body: row.body,
fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body),
hostInstance: structuredAgentSessionHostInstance(),
carriedFrom: ctx.sessionId
carriedFrom: ctx.sessionId,
source: row.source
})
}
await withdrawQueuedMessagesForOperation(ctx.journal, {
@@ -4,6 +4,7 @@
import type { AgentSessionSendResult } from '../../../shared/agent-session-wire'
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import type { AgentMessageSource } from '../../../shared/agent-session-message-source'
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages'
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
@@ -16,6 +17,7 @@ export async function runQueueableStructuredAgentSessionSend(
body: AgentJournalMessageItem
delivery?: 'queue-if-active'
userSend?: true
source?: AgentMessageSource
},
immediate: () => Promise<TurnOutcome<AgentSessionSendResult>>
): Promise<TurnOutcome<AgentSessionSendResult>> {
@@ -519,7 +519,8 @@ describe('startup restore of chats still in their per-chat files', () => {
messageId: 'draft-1',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] },
fingerprint: 'fp-draft-1',
hostInstance: 'proc-1'
hostInstance: 'proc-1',
source: { kind: 'user' }
})
expect(journal.importPending).toBe(false)
@@ -231,6 +231,7 @@ export async function handOverSubmission(
state: 'rejected',
reason: outcome.reason,
rejection: outcome.rejection,
...(outcome.answeredInTurn ? { answeredInTurn: outcome.answeredInTurn } : {}),
fence: ctx.fence
}
: { clientMessageId, state: 'unknown', reason: outcome.reason, fence: ctx.fence }
+13
View File
@@ -92,6 +92,19 @@ describe('Store', () => {
expect(ui.dismissedUpdateVersion).toBeNull()
})
it.each([false, true])(
'restores sidebarOpen=%s from disk without changing the right sidebar',
async (sidebarOpen) => {
const store = await createStore()
store.updateUI({ sidebarOpen, rightSidebarOpen: false })
store.flush()
const reloaded = await createStore()
expect(reloaded.getUI().sidebarOpen).toBe(sidebarOpen)
expect(reloaded.getUI().rightSidebarOpen).toBe(false)
}
)
it('round-trips and normalizes the host-qualified manual repo order', async () => {
const store = await createStore()
store.updateUI({
@@ -60,6 +60,32 @@ describe('Pi async subagent roster', () => {
vi.useRealTimers()
})
it('settles again when an async child starts after the turn settled', async () => {
const harness = createAgentStatusExtensionHarness({ kind: 'pi' })
await harness.callHook('agent_start')
await endTurn(harness)
startChild(harness, 'late-child', 'tool-call-1')
complete(harness, 'late-child')
await vi.advanceTimersByTimeAsync(0)
expect(agentEndCount(harness)).toBe(2)
})
it("does not spend a run's done on a child that starts before it is posted", async () => {
const harness = createAgentStatusExtensionHarness({ kind: 'pi' })
let idle = false
const context = { isIdle: () => idle }
await harness.callHook('agent_start')
await harness.callHook('agent_end', {}, context)
startChild(harness, 'quick-child', 'tool-call-1')
complete(harness, 'quick-child')
await harness.callHook('tool_execution_start', { toolName: 'bash' }, context)
idle = true
await vi.advanceTimersByTimeAsync(1_000)
expect(postedHookNames(harness).at(-1)).toBe('agent_end')
})
it('settles after an async workflow whose awaited children never report completion', async () => {
const harness = createAgentStatusExtensionHarness({ kind: 'pi' })
await harness.callHook('agent_start')
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness'
import {
createAgentStatusExtensionHarness,
type AgentStatusExtensionHarness
} from './agent-status-extension-test-harness'
function postedHookNames(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map(
@@ -152,3 +155,143 @@ describe('OMP agent_end contract', () => {
}
})
})
function ompSession(id: string, parentSession?: string) {
return {
sessionManager: {
getSessionId: () => id,
getSessionFile: () => `/sessions/${id}.jsonl`,
getHeader: () => ({ parentSession })
}
}
}
describe('OMP subagent settlement', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
async function lifecycle(harness: AgentStatusExtensionHarness, id: string, status: string) {
harness.emitPiEvent('task:subagent:lifecycle', { id, status })
await vi.advanceTimersByTimeAsync(0)
}
async function hook(harness: AgentStatusExtensionHarness, name: string) {
await harness.callHook(name)
await vi.advanceTimersByTimeAsync(0)
}
it.each(OMP_RUNTIME_CASES)(
'settles %s again when a child starts after the run ended',
async (_name, args) => {
const harness = createAgentStatusExtensionHarness(args)
await hook(harness, 'agent_start')
await hook(harness, 'agent_end')
await lifecycle(harness, 'wake-1', 'started')
await lifecycle(harness, 'wake-1', 'completed')
expect(postedHookNames(harness.fetchMock)).toEqual([
'agent_start',
'agent_end',
'agent_start',
'agent_end'
])
}
)
it.each(OMP_RUNTIME_CASES)(
'keeps %s working while a child outlives the next run',
async (_name, args) => {
const harness = createAgentStatusExtensionHarness(args)
await hook(harness, 'agent_start')
await lifecycle(harness, 'helper', 'started')
await hook(harness, 'agent_end')
await hook(harness, 'agent_start')
await hook(harness, 'agent_end')
expect(postedHookNames(harness.fetchMock)).not.toContain('agent_end')
await lifecycle(harness, 'helper', 'completed')
expect(postedHookNames(harness.fetchMock).at(-1)).toBe('agent_end')
}
)
it.each(OMP_RUNTIME_CASES)(
'keeps %s working while a late child outlives the next run',
async (_name, args) => {
const harness = createAgentStatusExtensionHarness(args)
await hook(harness, 'agent_start')
await hook(harness, 'agent_end')
await lifecycle(harness, 'wake-1', 'started')
await hook(harness, 'agent_start')
await hook(harness, 'agent_end')
expect(postedHookNames(harness.fetchMock).at(-1)).not.toBe('agent_end')
await lifecycle(harness, 'wake-1', 'completed')
expect(postedHookNames(harness.fetchMock).at(-1)).toBe('agent_end')
}
)
it('does not settle OMP when a child finishes mid-run', async () => {
const harness = createAgentStatusExtensionHarness({ kind: 'omp' })
await hook(harness, 'agent_start')
await lifecycle(harness, 'child-1', 'started')
await lifecycle(harness, 'child-1', 'completed')
expect(postedHookNames(harness.fetchMock)).not.toContain('agent_end')
})
it('settles a child woken before the resumed root has run a turn', async () => {
const harness = createAgentStatusExtensionHarness({ kind: 'omp' })
await harness.callHook('session_start', {}, ompSession('root'))
await lifecycle(harness, 'revived', 'started')
await lifecycle(harness, 'revived', 'completed')
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end'])
})
it("ignores children seen by an OMP task session's copy of the extension", async () => {
const harness = createAgentStatusExtensionHarness({ kind: 'omp' })
await harness.callHook('agent_start', {}, ompSession('child', '/sessions/root.jsonl'))
await lifecycle(harness, 'grandchild', 'started')
await lifecycle(harness, 'grandchild', 'completed')
expect(harness.fetchMock).not.toHaveBeenCalled()
})
it('does not settle a reload that lands while the root run is still in flight', async () => {
const harness = createAgentStatusExtensionHarness({ kind: 'omp' })
await harness.callHook('session_start', {}, ompSession('root'))
await hook(harness, 'agent_start')
harness.reload()
await lifecycle(harness, 'child-1', 'started')
await lifecycle(harness, 'child-1', 'completed')
expect(postedHookNames(harness.fetchMock)).not.toContain('agent_end')
await hook(harness, 'agent_end')
expect(postedHookNames(harness.fetchMock).at(-1)).toBe('agent_end')
})
it('keeps OMP pane ownership across an extension reload', async () => {
const harness = createAgentStatusExtensionHarness({ kind: 'omp' })
await harness.callHook('session_start', {}, ompSession('root'))
await hook(harness, 'agent_start')
await lifecycle(harness, 'child-1', 'started')
await hook(harness, 'agent_end')
harness.reload()
expect(postedHookNames(harness.fetchMock)).not.toContain('agent_end')
await lifecycle(harness, 'child-1', 'completed')
expect(postedHookNames(harness.fetchMock).at(-1)).toBe('agent_end')
})
})
+4 -1
View File
@@ -136,7 +136,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
...getPiSubagentRosterSetupSourceLines(),
...(kind !== 'pi'
? [
" pi.on('session_shutdown', () => { lifecycleState.active.clear(); lifecycleState.exited?.clear(); lifecycleState.waiting = false; resetPostQueue(); clearPendingAgentEndCheck() })"
" pi.on('session_shutdown', () => { lifecycleState.active.clear(); lifecycleState.exited?.clear(); lifecycleState.waiting = false; lifecycleState.rootRunInFlight = false; resetPostQueue(); clearPendingAgentEndCheck() })"
]
: []),
...(kind !== 'prime-agent'
@@ -146,6 +146,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
' lifecycleState.active.clear()',
' lifecycleState.exited?.clear()',
' lifecycleState.waiting = false',
' lifecycleState.rootRunInFlight = false',
' resetPostQueue()',
' clearPendingAgentEndCheck()',
' updateRuntimeOmpSessionMetadata(ctx)',
@@ -165,6 +166,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
...captureSessionMetadata,
' clearPendingAgentEndCheck()',
' lifecycleState.waiting = false',
' lifecycleState.rootRunInFlight = true',
' runGeneration += 1',
// Why: a turn cannot begin under a dialog holding input focus, so this is the one
// boundary that can recover a modal whose close never arrived.
@@ -287,6 +289,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
' clearPendingAgentEndCheck()',
' return',
' }',
' lifecycleState.rootRunInFlight = false',
' endedRunGeneration = runGeneration',
' if (isOmpRuntime()) {',
' postAgentEndOnce()',
@@ -5,7 +5,7 @@
export function getPiSubagentRosterSetupSourceLines(): string[] {
return [
' const piEventBus = (pi as { events?: { on?: (name: string, handler: (event: unknown) => void) => void } }).events',
' const lifecycleState = (piEventBus as { __orcaPiSubagents?: { active: Set<string>; exited?: Set<string>; waiting: boolean; onEvent?: (event: unknown, forcedStatus?: string) => void; listener?: (event: unknown) => void; onRunnerExit?: (event: unknown) => void; runnerExitListener?: (event: unknown) => void } } | undefined)?.__orcaPiSubagents ?? { active: new Set<string>(), waiting: false }',
' const lifecycleState = (piEventBus as { __orcaPiSubagents?: { active: Set<string>; exited?: Set<string>; waiting: boolean; ownsPane?: boolean; rootRunInFlight?: boolean; onEvent?: (event: unknown, forcedStatus?: string) => void; listener?: (event: unknown) => void; onRunnerExit?: (event: unknown) => void; runnerExitListener?: (event: unknown) => void } } | undefined)?.__orcaPiSubagents ?? { active: new Set<string>(), waiting: false }',
' if (piEventBus) (piEventBus as { __orcaPiSubagents?: unknown }).__orcaPiSubagents = lifecycleState',
' if (piEventBus?.on && !(lifecycleState as { listener?: unknown }).listener) {',
' const listener = (event: unknown) => lifecycleState.onEvent?.(event)',
@@ -23,8 +23,8 @@ export function getPiSubagentRosterSetupSourceLines(): string[] {
]
}
// Expects post() and postAgentEndOnce() from the handler scope; the latter prunes
// exited runners before deciding whether children still hold the pane.
// Expects post(), the run generations and postAgentEndOnce() from the handler scope;
// the latter prunes exited runners before deciding whether children still hold the pane.
export function getPiSubagentRosterEventSourceLines(): string[] {
return [
// Why: a run that reports its own completion does so ~150ms after its runner exits;
@@ -37,7 +37,20 @@ export function getPiSubagentRosterEventSourceLines(): string[] {
" const id = typeof record.id === 'string' && record.id ? record.id : typeof record.runId === 'string' ? record.runId : ''",
' const status = forcedStatus ?? (event as { status?: unknown }).status',
' if (!id) return',
" if (status === 'started') { lifecycleState.active.add(id); post('agent_start'); return }",
// Why: each OMP task session runs its own copy on its own bus; only the pane's bus tracks children.
' if (isOmpRuntime() && !lifecycleState.ownsPane) return',
" if (status === 'started') {",
' lifecycleState.active.add(id)',
// Why: a child starting after the run's done owes a fresh done. Under OMP the same holds
// before the first turn of this factory run (a resumed root, or a reload), but only when no
// root run is in flight -- a reload mid-turn resets these counters while the root still works.
' if (completionPostedGeneration === runGeneration || (isOmpRuntime() && runGeneration === 0 && !lifecycleState.rootRunInFlight)) {',
' lifecycleState.waiting = true',
' completionPostedGeneration = -1',
' }',
" post('agent_start')",
' return',
' }',
" if (status !== 'completed' && status !== 'failed' && status !== 'aborted') return",
' lifecycleState.active.delete(id)',
' lifecycleState.exited?.delete(id)',
@@ -55,6 +55,7 @@ export function getOmpSessionOwnerHandlerSourceLines(): string[] {
' function onStatus(name, handler): void {',
' pi.on(name, (event, ctx) => {',
' if (!ownsSessionStatus(ctx)) return',
' lifecycleState.ownsPane = true',
' return handler(event, ctx)',
' })',
' }',
@@ -217,7 +217,7 @@ describe('LocalPtyProvider', () => {
expect(mockProc.write).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(200)
expect(mockProc.write).toHaveBeenCalledWith(`${command}\n`)
expect(mockProc.write).toHaveBeenCalledWith(`${command}\r`)
} finally {
vi.useRealTimers()
}
@@ -269,7 +269,7 @@ describe('LocalPtyProvider', () => {
vi.advanceTimersByTime(1)
await Promise.resolve()
expect(mockProc.write).toHaveBeenCalledWith("printf 'linked issue context'\n")
expect(mockProc.write).toHaveBeenCalledWith("printf 'linked issue context'\r")
} finally {
vi.useRealTimers()
}
@@ -298,7 +298,7 @@ describe('LocalPtyProvider', () => {
vi.advanceTimersByTime(200)
await Promise.resolve()
expect(mockProc.write).toHaveBeenCalledWith('printf ready\n')
expect(mockProc.write).toHaveBeenCalledWith('printf ready\r')
} finally {
vi.useRealTimers()
}
@@ -58,8 +58,8 @@ describe('writeStartupCommandWhenShellReady', () => {
Object.defineProperty(process, 'platform', { value: origPlatform })
})
it('appends LF on POSIX so bash/zsh submit the line', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
it.each(['darwin', 'linux', 'win32'])('submits with CR on %s', async (platform) => {
Object.defineProperty(process, 'platform', { value: platform })
const proc = createMockProc()
const ready = Promise.resolve()
writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {})
@@ -69,39 +69,23 @@ describe('writeStartupCommandWhenShellReady', () => {
vi.advanceTimersByTime(30)
await Promise.resolve()
expect(proc._writes).toEqual(['claude\n'])
expect(proc._writes).toEqual(['claude\r'])
})
it('appends CR on Windows so PowerShell/cmd.exe submit the line', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
it('replaces a caller-supplied LF terminator with CR', async () => {
const proc = createMockProc()
const ready = Promise.resolve()
writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {})
writeStartupCommandWhenShellReady(ready, proc, 'claude\n', () => {})
await ready
proc._emitData('\r\nPS> ')
proc._emitData('\r\nuser@host % ')
vi.advanceTimersByTime(30)
await Promise.resolve()
expect(proc._writes).toEqual(['claude\r'])
})
it('does not re-append a submit byte if the command already ends in CR or LF', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
const proc = createMockProc()
const ready = Promise.resolve()
writeStartupCommandWhenShellReady(ready, proc, 'claude\n', () => {})
await ready
proc._emitData('\r\nPS> ')
vi.advanceTimersByTime(30)
await Promise.resolve()
expect(proc._writes).toEqual(['claude\n'])
})
it('keeps the no-prompt fallback conservative to avoid duplicate shell echo', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const proc = createMockProc()
const ready = Promise.resolve()
writeStartupCommandWhenShellReady(ready, proc, 'codex', () => {})
@@ -115,11 +99,10 @@ describe('writeStartupCommandWhenShellReady', () => {
vi.advanceTimersByTime(150)
await Promise.resolve()
expect(proc._writes).toEqual(['codex\n'])
expect(proc._writes).toEqual(['codex\r'])
})
it('uses the short settle delay when marker scan already observed post-marker bytes', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const proc = createMockProc()
const ready = Promise.resolve({ postMarkerBytesObserved: true })
writeStartupCommandWhenShellReady(ready, proc, 'codex', () => {})
@@ -131,12 +114,11 @@ describe('writeStartupCommandWhenShellReady', () => {
vi.advanceTimersByTime(1)
await Promise.resolve()
expect(proc._writes).toEqual(['codex\n'])
expect(proc._writes).toEqual(['codex\r'])
})
// Why: multiline startup commands must be bracketed-paste wrapped (ESC[200~ … ESC[201~) so shells insert them literally instead of treating each LF as Enter.
it('wraps a multiline startup command in bracketed paste when the shell supports it', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const proc = createMockProc()
const ready = Promise.resolve()
const command = "claude '--dangerously-skip-permissions' 'line one\nline two'"
@@ -149,11 +131,10 @@ describe('writeStartupCommandWhenShellReady', () => {
vi.advanceTimersByTime(30)
await Promise.resolve()
expect(proc._writes).toEqual([`\x1b[200~${command}\x1b[201~\n`])
expect(proc._writes).toEqual([`\x1b[200~${command}\x1b[201~\r`])
})
it('leaves a single-line command on the raw submit path even when bracketed paste is safe', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const proc = createMockProc()
const ready = Promise.resolve()
writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {}, {
@@ -165,14 +146,14 @@ describe('writeStartupCommandWhenShellReady', () => {
vi.advanceTimersByTime(30)
await Promise.resolve()
expect(proc._writes).toEqual(['claude\n'])
expect(proc._writes).toEqual(['claude\r'])
})
it('does not bracket-wrap a multiline command when the shell lacks bracketed paste', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const proc = createMockProc()
const ready = Promise.resolve()
const command = 'echo one\necho two'
// Why CR between the lines: without bracketed paste each break submits its own line.
// Why: bracketedPasteSafe defaults false, so keep the raw path to avoid echoing ESC[200~ on shells without bracketed paste.
writeStartupCommandWhenShellReady(ready, proc, command, () => {})
@@ -181,6 +162,6 @@ describe('writeStartupCommandWhenShellReady', () => {
vi.advanceTimersByTime(30)
await Promise.resolve()
expect(proc._writes).toEqual([`${command}\n`])
expect(proc._writes).toEqual(['echo one\recho two\r'])
})
})
@@ -46,12 +46,9 @@ export function writeStartupCommandWhenShellReady(
postReadyTimer = null
}
// Why: run in the same interactive shell (not `shell -c`) so the session survives after the agent exits.
// Why CR on Windows: PSReadLine/cmd.exe submit on `\r`, not LF; POSIX treats either as Enter under ICRNL.
const submit = process.platform === 'win32' ? '\r' : '\n'
// Why: single write after the ready barrier avoids incremental-paste char drops; multiline is bracketed-paste wrapped so newlines don't submit early.
proc.write(
buildStartupCommandSubmission(startupCommand, {
submit,
bracketedPasteSafe: options.bracketedPasteSafe === true
})
)
@@ -113,7 +113,7 @@ async function renderPreamble(worker: 'chat' | 'terminal'): Promise<string> {
/** The turn text the structured lane sends a chat for one message on `mailbox`. */
async function renderChatPointer(mailbox: string): Promise<string> {
const texts: string[] = []
db.insertMessage({ from: 'term_peer', to: mailbox, subject: 'hi' })
const message = db.insertMessage({ from: 'term_peer', to: mailbox, subject: 'hi' })
const delivery = new OrchestrationStructuredMailboxPointerDelivery({
getDb: () => db,
getMessageWaiters: () => undefined,
@@ -121,7 +121,7 @@ async function renderChatPointer(mailbox: string): Promise<string> {
// The runtime's wiring of the structured lane.
getCliCommand: localOrchestrationCliCommand,
host: {
readGateFacts: async () => ({ turnRunning: false, awaitingHuman: false, submissions: [] }),
readSessionFacts: async () => ({ submissions: [] }),
currentFence: () => 1,
send: async (input) => {
for (const block of input.body.blocks) {
@@ -133,6 +133,8 @@ async function renderChatPointer(mailbox: string): Promise<string> {
})
delivery.deliverForHandle(mailbox)
await vi.waitFor(() => expect(texts).toHaveLength(1))
// Read, as the agent's `check` reads it, so this mailbox's next mail is pointed too.
db.markAsRead([message.id])
return texts[0]!
}
@@ -2,23 +2,10 @@ import type { RunRow } from './types'
import { isEquivalentPaneKey } from './db/pane-key-match'
import { currentRunCoordinatorOrcaSessionId } from './db/runs/run-coordinator-orca-session'
import { formatOrcaSessionAddress, type OrcaSessionId } from '../../../shared/orca-session-address'
import type { OrchestrationPartyIdentity } from '../../../shared/orchestration-party-identity'
/**
* Who an orchestration caller is, as Run binding and mail routing match it.
*
* A PTY agent is its terminal: a handle and a pane key, no Orca session id. An agent that is a
* structured session is its Orca session id, addressed as `orca_session_id:<id>`; a structured worker also
* has the handle and pane key it was minted, and an ordinary chat has neither. Methods pass this
* through whole and never branch on which fields are set; the lookups below own that.
*/
export type OrchestrationCallerIdentity = Readonly<{
/** Mailbox address the caller sends from and reads: its terminal handle, else its session address. */
address: string
terminalHandle: string | null
paneKey: string | null
/** The bare Orca session id the caller is addressed by; mail spells it `orca_session_id:<id>`. */
orcaSessionId: OrcaSessionId | null
}>
/** Who an orchestration caller is; shared so a queued message can name its sender the same way. */
export type OrchestrationCallerIdentity = OrchestrationPartyIdentity
/** The part of a caller a Run binding stores and matches. */
export type OrchestrationCoordinatorKey = Pick<
@@ -2,6 +2,7 @@
// host's own admission: a fingerprint over other fields than the send carries is refused there.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { AgentMessageSource } from '../../../shared/agent-session-message-source'
import {
createQueuedMessageTestRig,
eventually,
@@ -20,6 +21,25 @@ import {
let rig: QueuedMessageTestRig
const MAIL_SOURCE: AgentMessageSource = {
kind: 'agent',
senders: [
{
party: {
address: 'term_peer',
terminalHandle: 'term_peer',
orcaSessionId: null
}
}
],
orchestration: {
message: 'mail-notice',
mailbox: 'dispatch:d1',
dispatchId: 'd1',
messages: [{ messageId: 'm1', runId: 'r1', from: 'term_peer' }]
}
}
beforeEach(async () => {
rig = await createQueuedMessageTestRig()
})
@@ -36,7 +56,12 @@ function sendTurn(
host,
sessionId: SESSION,
callerKey: 'trusted-local:orchestration:d1',
turn: { body: hostTestMessage('mail'), delivery, operationId, expectedRuntimeFence: 1 }
turn: {
body: hostTestMessage('mail'),
operationId,
expectedRuntimeFence: 1,
...(delivery === 'queue' ? { delivery, source: MAIL_SOURCE } : { delivery })
}
})
}
@@ -47,6 +72,15 @@ describe('sendAgentTurn through the real host', () => {
kind: 'queued',
queued: { position: 1, state: 'waiting' }
})
// Stored with the card, read back whole: who it is from survives the round trip.
expect(
rig.host
.collaboratorsForTests()
.sessions.get(SESSION)
?.journal.queuedMessages.list()
.map(({ state, source }) => ({ state, source }))
).toEqual([{ state: 'waiting', source: MAIL_SOURCE }])
// Shown in the chat's queue like the person's own card.
expect(await rig.drafts()).toMatchObject([{ state: 'waiting' }])
})
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type { AgentSessionSendResult } from '../../../shared/agent-session-wire'
import type { AgentMessageSource } from '../../../shared/agent-session-message-source'
import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../shared/orchestration-timing-budgets'
import { dispatchPreambleSendOptions } from './preamble'
import {
@@ -52,6 +53,17 @@ function structuredHost(answer: HostSendAnswer, settled?: AgentJournalSubmission
return { host, send, waitForSendSettlement }
}
const MAIL_SOURCE: AgentMessageSource = {
kind: 'agent',
senders: [],
orchestration: {
message: 'mail-notice',
mailbox: 'dispatch:d1',
dispatchId: 'd1',
messages: [{ messageId: 'm1', runId: 'r1', from: 'term_peer' }]
}
}
const turn: StructuredSessionTurn = {
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
delivery: 'now',
@@ -142,7 +154,7 @@ describe('sendAgentTurn to a structured session', () => {
})
)
await expect(
sendAgentTurn(structured(fake.host, { ...turn, delivery: 'queue' }))
sendAgentTurn(structured(fake.host, { ...turn, delivery: 'queue', source: MAIL_SOURCE }))
).resolves.toEqual({
kind: 'queued',
clientMessageId: 'op-1',
@@ -158,7 +170,9 @@ describe('sendAgentTurn to a structured session', () => {
payloadFingerprint: hostFingerprint({ body: turn.body, delivery: 'queue-if-active' })
},
body: turn.body,
delivery: 'queue-if-active'
delivery: 'queue-if-active',
// Host-local: who the card is from rides beside the envelope, outside its fingerprint.
source: MAIL_SOURCE
}
)
expect(fake.waitForSendSettlement).not.toHaveBeenCalled()
@@ -172,7 +186,7 @@ describe('sendAgentTurn to a structured session', () => {
})
)
await expect(
sendAgentTurn(structured(fake.host, { ...turn, delivery: 'queue' }))
sendAgentTurn(structured(fake.host, { ...turn, delivery: 'queue', source: MAIL_SOURCE }))
).resolves.toMatchObject({ kind: 'queued', queued: { state: 'returned' } })
})
})
@@ -17,6 +17,7 @@ import {
type AgentSessionQueuedSendReceipt
} from '../../../shared/agent-session-wire'
import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals'
import type { AgentMessageSource } from '../../../shared/agent-session-message-source'
import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../shared/orchestration-timing-budgets'
import { structuredAgentSessionMessageSendMutation } from '../../../shared/structured-agent-session-send-mutation'
import type { StructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-host'
@@ -36,11 +37,14 @@ export type StructuredAgentTurnHost = Pick<
export type StructuredSessionTurn = {
body: AgentJournalMessageItem
delivery: AgentTurnDelivery
/** Reused on a retry, so the host replays its recorded answer instead of sending twice. */
operationId: string
expectedRuntimeFence: number
}
} & (
| { delivery: 'now' }
/** A queued card records who it is from. */
| { delivery: 'queue'; source: AgentMessageSource }
)
export type StructuredSessionTurnSend = {
kind: 'structured-session'
@@ -123,15 +127,17 @@ async function sendStructuredSessionTurn(
send: StructuredSessionTurnSend
): Promise<StructuredSessionTurnOutcome> {
const { turn } = send
const message = structuredAgentSessionMessageSendMutation({
sessionId: send.sessionId,
clientOperationId: turn.operationId,
expectedRuntimeFence: turn.expectedRuntimeFence,
body: turn.body,
delivery: turn.delivery === 'queue' ? 'queue-if-active' : undefined
})
const result = await send.host.send(
{ callerKey: send.callerKey },
structuredAgentSessionMessageSendMutation({
sessionId: send.sessionId,
clientOperationId: turn.operationId,
expectedRuntimeFence: turn.expectedRuntimeFence,
body: turn.body,
delivery: turn.delivery === 'queue' ? 'queue-if-active' : undefined
})
// The source is host-local and outside the fingerprint: a retry under the same id replays.
turn.delivery === 'queue' ? { ...message, source: turn.source } : message
)
if (!result.ok) {
return { kind: 'refused', refusal: result.refusal }
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { structuredMailSource } from './structured-mail-source'
const SESSION = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37'
describe('who delivered mail is from', () => {
it("names each sender once, without the pane key that would open its mailbox, and each message's own sender", () => {
// No database: a terminal handle and a session address name their party by themselves.
const source = structuredMailSource({
db: null,
mailboxHandle: 'run:r1',
dispatchId: null,
batch: [
{ id: 'm1', from_handle: 'term_a', run_id: 'r1' },
{ id: 'm2', from_handle: `orca_session_id:${SESSION}`, run_id: 'r2' },
{ id: 'm3', from_handle: 'term_a', run_id: 'r1' }
]
})
expect(source).toEqual({
kind: 'agent',
senders: [
{ party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null } },
{
party: {
address: `orca_session_id:${SESSION}`,
terminalHandle: null,
orcaSessionId: SESSION
}
}
],
orchestration: {
message: 'mail-notice',
mailbox: 'run:r1',
dispatchId: null,
messages: [
{ messageId: 'm1', runId: 'r1', from: 'term_a' },
{ messageId: 'm2', runId: 'r2', from: `orca_session_id:${SESSION}` },
{ messageId: 'm3', runId: 'r1', from: 'term_a' }
]
}
})
})
})
@@ -0,0 +1,53 @@
/**
* Who the mail a chat is pointed at is from: every distinct sender, named the
* way orchestration names a party, and each message's own sender and records. The run, dispatch
* and message ids join back to orchestration's own rows while those exist.
*/
import { parseOrcaSessionAddress } from '../../../shared/orca-session-address'
import type {
AgentMessageSource,
AgentMessageSender
} from '../../../shared/agent-session-message-source'
import type { MessageRow, OrchestrationDb } from './db'
import { resolveOrchestrationParty } from './orchestration-party'
export type MailSourceMessage = Pick<MessageRow, 'id' | 'from_handle' | 'run_id'>
export function structuredMailSource(input: {
db: OrchestrationDb | null
mailboxHandle: string
dispatchId: string | null
batch: readonly MailSourceMessage[]
}): AgentMessageSource {
const senders = new Map<string, AgentMessageSender>()
for (const { from_handle: address } of input.batch) {
if (!senders.has(address)) {
senders.set(address, { party: senderParty(address, input.db) })
}
}
return {
kind: 'agent',
senders: [...senders.values()],
orchestration: {
message: 'mail-notice',
mailbox: input.mailboxHandle,
dispatchId: input.dispatchId,
messages: input.batch.map((message) => ({
messageId: message.id,
runId: message.run_id,
from: message.from_handle
}))
}
}
}
function senderParty(address: string, db: OrchestrationDb | null): AgentMessageSender['party'] {
try {
const { paneKey: _credential, ...party } = resolveOrchestrationParty(address, db)
return party
} catch {
// A worker this host lost the identity of: what the address itself says.
return { address, terminalHandle: null, orcaSessionId: parseOrcaSessionAddress(address) }
}
}
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
import {
OrchestrationStructuredMailboxPointerDelivery,
type StructuredMailboxPointerHost
@@ -9,7 +8,6 @@ import {
structuredPointerBatchFingerprint,
type StructuredPointerSubmission
} from './structured-pointer-operation-id'
import { structuredSessionGateFacts } from './structured-session-pointer-delivery'
import type { StructuredWorkerIdentity } from '../structured-worker-identity'
const IDENTITY: StructuredWorkerIdentity = {
@@ -22,63 +20,12 @@ const IDENTITY: StructuredWorkerIdentity = {
hostScope: { kind: 'local', hostId: 'local' }
}
function idleJournal(): AgentJournalRenderItem[] {
return [
{
itemId: 'i1',
observedAt: 1,
body: { kind: 'status', text: 'done', turnLifecycle: { state: 'completed', turnId: 't1' } }
} as unknown as AgentJournalRenderItem
]
}
function runningJournal(): AgentJournalRenderItem[] {
return [
{
itemId: 'i1',
observedAt: 1,
body: { kind: 'status', text: 'working', turnLifecycle: { state: 'running', turnId: 't1' } }
} as unknown as AgentJournalRenderItem
]
}
/** What a worker's journal looks like once it has finished a substantial turn: history, and no
* turnLifecycle row anywhere, because settlement tombstones it. */
function settledLongJournal(): AgentJournalRenderItem[] {
return Array.from(
{ length: 120 },
(_unused, index) =>
({
itemId: `tool-${index}`,
observedAt: index,
body: { kind: 'tool-call', name: 'Bash', input: {}, state: 'completed' }
}) as unknown as AgentJournalRenderItem
)
}
/** A prompt raised at the very start of a long turn, far outside any bounded tail window. */
function staleAttentionJournal(): AgentJournalRenderItem[] {
return [...attentionJournal(), ...settledLongJournal()]
}
function attentionJournal(): AgentJournalRenderItem[] {
return [
{
itemId: 'i1',
observedAt: 1,
body: {
kind: 'question',
question: 'which?',
options: [],
resolution: { state: 'pending' }
}
} as unknown as AgentJournalRenderItem
]
}
function harness(options: {
journal: AgentJournalRenderItem[] | null
/** False: the session cannot be read (not attached). */
attached?: boolean
dispatchState?: 'accepted' | 'rejected' | 'unknown'
/** The chat was busy: its queue holds the pointer as a card. */
queued?: true
/** The coordinator of this worker's Run is mid-batch: it checked and has not acked yet. */
outstandingRunDelivery?: boolean
outstandingOwnDelivery?: boolean
@@ -88,14 +35,24 @@ function harness(options: {
}) {
const mailbox = options.mailbox ?? 'dispatch:d1'
const dispatchId = options.dispatchId === undefined ? 'd1' : options.dispatchId
let journal = options.journal
let attached = options.attached ?? true
// The session's recorded sends, as its journal reports them.
let submissions: StructuredPointerSubmission[] = []
const markAsDelivered = vi.fn()
const send: StructuredMailboxPointerHost['send'] = vi.fn(async () => ({
kind: 'sent' as const,
state: options.dispatchState ?? ('accepted' as const)
}))
// The mailbox's unread mail; a pointed message is no longer selected for a pointer.
const mail = [
{ id: 'm1', type: 'status', sequence: 3, from_handle: 'term_coord', run_id: 'run_1' }
]
const pointed = new Set<string>()
const markAsDelivered = vi.fn((ids: string[]) => {
for (const id of ids) {
pointed.add(id)
}
})
const send: StructuredMailboxPointerHost['send'] = vi.fn(async () =>
options.queued
? { kind: 'queued' as const }
: { kind: 'sent' as const, state: options.dispatchState ?? ('accepted' as const) }
)
const sendMock = vi.mocked(send)
const stored = new Map<string, StructuredPointerOperationRow>()
const db = {
@@ -103,7 +60,7 @@ function harness(options: {
hasOutstandingMailboxDelivery: (handle: string) =>
((options.outstandingRunDelivery ?? false) && handle.startsWith('run:')) ||
((options.outstandingOwnDelivery ?? false) && !handle.startsWith('run:')),
getUndeliveredUnreadMessages: () => [{ id: 'm1', type: 'status', sequence: 3 }],
getUndeliveredUnreadMessages: () => mail.filter((message) => !pointed.has(message.id)),
markAsDelivered,
getStructuredPointerOperation: (key: string) => stored.get(key),
putStructuredPointerOperation: (row: StructuredPointerOperationRow) =>
@@ -117,8 +74,7 @@ function harness(options: {
mailboxHandle === mailbox ? { sessionId: IDENTITY.sessionId, dispatchId } : null,
getCliCommand: () => 'orca-dev',
host: {
readGateFacts: async () =>
journal === null ? null : { ...structuredSessionGateFacts(journal), submissions },
readSessionFacts: async () => (attached ? { submissions } : null),
currentFence: () => 4,
send
}
@@ -128,8 +84,11 @@ function harness(options: {
markAsDelivered,
send: sendMock,
stored,
setJournal: (next: AgentJournalRenderItem[] | null) => {
journal = next
setAttached: (next: boolean) => {
attached = next
},
receive: (id: string, sequence: number) => {
mail.push({ id, type: 'status', sequence, from_handle: 'term_coord', run_id: 'run_1' })
},
setSubmissions: (next: StructuredPointerSubmission[]) => {
submissions = next
@@ -141,13 +100,13 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
describe('structured mailbox pointer delivery', () => {
it('claims only mailboxes whose assignee is a structured worker', () => {
const { delivery } = harness({ journal: idleJournal() })
const { delivery } = harness({})
expect(delivery.deliverForHandle('dispatch:d1')).toBe(true)
expect(delivery.deliverForHandle('run:run_1')).toBe(false)
})
it('sends the pointer as a turn and consumes mail on an accepted dispatch', async () => {
const { delivery, markAsDelivered, send } = harness({ journal: idleJournal() })
const { delivery, markAsDelivered, send } = harness({})
delivery.deliverForHandle('dispatch:d1')
await flush()
expect(send).toHaveBeenCalledTimes(1)
@@ -157,7 +116,6 @@ describe('structured mailbox pointer delivery', () => {
it('nudges through the worker`s own handle for direct peer mail outside a dispatch', async () => {
const { delivery, send, markAsDelivered } = harness({
journal: idleJournal(),
mailbox: IDENTITY.handle,
dispatchId: null
})
@@ -176,7 +134,6 @@ describe('structured mailbox pointer delivery', () => {
it('retains mail when the dispatch settles unknown', async () => {
const { delivery, markAsDelivered } = harness({
journal: idleJournal(),
dispatchState: 'unknown'
})
delivery.deliverForHandle('dispatch:d1')
@@ -184,40 +141,8 @@ describe('structured mailbox pointer delivery', () => {
expect(markAsDelivered).not.toHaveBeenCalled()
})
it('retains mail while a turn is running', async () => {
const { delivery, send, markAsDelivered } = harness({ journal: runningJournal() })
delivery.deliverForHandle('dispatch:d1')
await flush()
expect(send).not.toHaveBeenCalled()
expect(markAsDelivered).not.toHaveBeenCalled()
})
it('retains mail while a prompt is waiting for a human', async () => {
const { delivery, send } = harness({ journal: attentionJournal() })
delivery.deliverForHandle('dispatch:d1')
await flush()
expect(send).not.toHaveBeenCalled()
})
it('delivers to a worker whose finished turn left a long history and no lifecycle row', async () => {
// The steady state after a worker's first substantial turn. Gating on a bounded tail page read
// this as permanently busy, so every later nudge parked forever and the worker went unnudged.
const { delivery, send, markAsDelivered } = harness({ journal: settledLongJournal() })
delivery.deliverForHandle('dispatch:d1')
await flush()
expect(send).toHaveBeenCalledTimes(1)
expect(markAsDelivered).toHaveBeenCalledWith(['m1'])
})
it('retains mail for a prompt that scrolled out of the tail window', async () => {
const { delivery, send } = harness({ journal: staleAttentionJournal() })
delivery.deliverForHandle('dispatch:d1')
await flush()
expect(send).not.toHaveBeenCalled()
})
it('retains mail when the session is not attached', async () => {
const { delivery, send } = harness({ journal: null })
const { delivery, send } = harness({ attached: false })
delivery.deliverForHandle('dispatch:d1')
await flush()
expect(send).not.toHaveBeenCalled()
@@ -226,27 +151,54 @@ describe('structured mailbox pointer delivery', () => {
it('redrives a detached session when the journal replays on re-attach', async () => {
// A transient detach parks nothing to be woken unless `session-not-attached` waits for the
// journal edge, and the dispatch preamble tells the worker not to poll.
const { delivery, send, setJournal, markAsDelivered } = harness({ journal: null })
const { delivery, send, setAttached, markAsDelivered } = harness({ attached: false })
delivery.deliverForHandle('dispatch:d1')
await flush()
expect(send).not.toHaveBeenCalled()
setJournal(idleJournal())
setAttached(true)
delivery.onJournalActivity('session-1')
await flush()
expect(send).toHaveBeenCalledTimes(1)
expect(markAsDelivered).toHaveBeenCalledWith(['m1'])
})
it('retries a parked pointer when the journal moves', async () => {
const { delivery, send, setJournal, markAsDelivered } = harness({ journal: runningJournal() })
it('sends the pointer through the chat, with who it is from, and counts it pointed once queued', async () => {
const { delivery, send, markAsDelivered, stored } = harness({ queued: true })
delivery.deliverForHandle('dispatch:d1')
await flush()
expect(send).not.toHaveBeenCalled()
setJournal(idleJournal())
delivery.onJournalActivity('session-1')
expect(send).toHaveBeenCalledTimes(1)
expect(send.mock.calls[0]![0].body.blocks[0]).toMatchObject({
text: expect.stringContaining('orca-dev orchestration check')
})
expect(send.mock.calls[0]![0].source).toMatchObject({
kind: 'agent',
senders: [{ party: { address: 'term_coord' } }],
orchestration: {
message: 'mail-notice',
mailbox: 'dispatch:d1',
messages: [{ messageId: 'm1', runId: 'run_1', from: 'term_coord' }]
}
})
// The chat's queue holds it now, as it holds the person's: the same mail is not pointed again.
expect(markAsDelivered).toHaveBeenCalledWith(['m1'])
expect(stored.has('dispatch:d1')).toBe(false)
})
it('points mail that arrives while earlier pointed mail is still unread, counting only the new mail', async () => {
const { delivery, send, receive } = harness({})
delivery.deliverForHandle('dispatch:d1')
await flush()
expect(send).toHaveBeenCalledTimes(1)
expect(markAsDelivered).toHaveBeenCalledWith(['m1'])
delivery.deliverForHandle('dispatch:d1')
await flush()
expect(send).toHaveBeenCalledTimes(1)
receive('m2', 4)
delivery.deliverForHandle('dispatch:d1')
await flush()
expect(send).toHaveBeenCalledTimes(2)
expect(send.mock.calls[1]![0].body.blocks[0]).toMatchObject({
text: expect.stringContaining('You have 1 orchestration message.')
})
})
it('nudges the worker while its coordinator holds an unacked Run delivery', async () => {
@@ -255,7 +207,6 @@ describe('structured mailbox pointer delivery', () => {
// coordinator's `run:` delivery is invisible here — gating the WORKER's dispatch mailbox on it
// dropped the nudge with nothing parked, and the worker sat idle on mail it was never told of.
const { delivery, send, markAsDelivered } = harness({
journal: idleJournal(),
outstandingRunDelivery: true
})
delivery.deliverForHandle('dispatch:d1')
@@ -267,7 +218,7 @@ describe('structured mailbox pointer delivery', () => {
it('does not re-nudge a mailbox still holding its own unacked batch', async () => {
// The other half of the same gate: the consumer already has this batch, so a second nudge
// spends a whole provider turn telling it something it was told.
const { delivery, send } = harness({ journal: idleJournal(), outstandingOwnDelivery: true })
const { delivery, send } = harness({ outstandingOwnDelivery: true })
delivery.deliverForHandle('dispatch:d1')
await flush()
expect(send).not.toHaveBeenCalled()
@@ -278,7 +229,6 @@ describe('structured mailbox pointer delivery', () => {
// stranded the worker until unrelated mail happened to arrive. The retry keeps the id: the host
// replays a recorded refusal rather than starting the agent again.
const { delivery, send, markAsDelivered } = harness({
journal: idleJournal(),
dispatchState: 'rejected'
})
delivery.deliverForHandle('dispatch:d1')
@@ -294,7 +244,6 @@ describe('structured mailbox pointer delivery', () => {
it('points again under a new id once a later send ran', async () => {
const { delivery, send, setSubmissions } = harness({
journal: idleJournal(),
dispatchState: 'unknown'
})
delivery.deliverForHandle('dispatch:d1')
@@ -312,7 +261,6 @@ describe('structured mailbox pointer delivery', () => {
it('points once more under a new id for a send an earlier process left in doubt', async () => {
const { delivery, send, stored, setSubmissions } = harness({
journal: idleJournal(),
dispatchState: 'unknown'
})
stored.set('dispatch:d1', {
@@ -344,7 +292,6 @@ describe('structured mailbox pointer delivery', () => {
vi.useFakeTimers({ toFake: ['Date'] })
try {
const { delivery, send, setSubmissions } = harness({
journal: idleJournal(),
dispatchState: 'unknown'
})
// The wall clock steps back an hour after the lane started: its own row is still its own.
@@ -374,7 +321,6 @@ describe('structured mailbox pointer delivery', () => {
vi.useFakeTimers({ toFake: ['Date'] })
try {
const { delivery, send, setSubmissions } = harness({
journal: idleJournal(),
dispatchState: 'unknown'
})
const personTurn = {
@@ -408,7 +354,6 @@ describe('structured mailbox pointer delivery', () => {
it('stamps a pointer whose echo arrived after the lane stopped waiting, sending nothing more', async () => {
const { delivery, send, markAsDelivered, stored, setSubmissions } = harness({
journal: idleJournal(),
dispatchState: 'unknown'
})
delivery.deliverForHandle('dispatch:d1')
@@ -428,7 +373,6 @@ describe('structured mailbox pointer delivery', () => {
it('reuses one operation id for the same batch and re-mints when it grows', async () => {
const { delivery, send, stored } = harness({
journal: idleJournal(),
dispatchState: 'unknown'
})
delivery.deliverForHandle('dispatch:d1')
@@ -445,10 +389,10 @@ describe('structured mailbox pointer delivery', () => {
})
describe('forgetting one settled worker', () => {
/** Two workers, each mid-turn and so each parked on its OWN session's journal edge. */
/** Two workers, each detached and so each parked on its OWN session's journal edge. */
function twoWorkerHarness() {
let resolves = true
let journal = runningJournal()
let attached = false
const sessionByMailbox: Record<string, string> = {
'dispatch:d1': 'session-1',
'dispatch:d2': 'session-2'
@@ -460,7 +404,9 @@ describe('forgetting one settled worker', () => {
const db = {
getDispatchContextById: () => ({ run_id: 'run_1' }),
hasOutstandingMailboxDelivery: () => false,
getUndeliveredUnreadMessages: () => [{ id: 'm1', type: 'status', sequence: 3 }],
getUndeliveredUnreadMessages: () => [
{ id: 'm1', type: 'status', sequence: 3, from_handle: 'term_coord', run_id: 'run_1' }
],
markAsDelivered: vi.fn(),
getStructuredPointerOperation: () => undefined,
putStructuredPointerOperation: () => {},
@@ -477,7 +423,7 @@ describe('forgetting one settled worker', () => {
},
getCliCommand: () => 'orca',
host: {
readGateFacts: async () => ({ ...structuredSessionGateFacts(journal), submissions: [] }),
readSessionFacts: async () => (attached ? { submissions: [] } : null),
currentFence: () => 4,
send
}
@@ -485,8 +431,8 @@ describe('forgetting one settled worker', () => {
return {
delivery,
send: vi.mocked(send),
goIdle: () => {
journal = idleJournal()
attach: () => {
attached = true
},
stopResolving: () => {
resolves = false
@@ -501,7 +447,7 @@ describe('forgetting one settled worker', () => {
// The bug: `forgetSession` re-resolved every parked mailbox and pruned the ones that answered
// null. A momentarily null DB reference or a session mid-teardown made that EVERY worker, so
// the sibling's mail stayed durable but lost the edge that would have woken it.
const { delivery, send, goIdle, stopResolving, resumeResolving } = twoWorkerHarness()
const { delivery, send, attach, stopResolving, resumeResolving } = twoWorkerHarness()
delivery.deliverForHandle('dispatch:d1')
delivery.deliverForHandle('dispatch:d2')
await flush()
@@ -511,7 +457,7 @@ describe('forgetting one settled worker', () => {
delivery.forgetSession('session-1')
resumeResolving()
goIdle()
attach()
delivery.onJournalActivity('session-2')
await flush()
expect(send).toHaveBeenCalledTimes(1)
@@ -519,7 +465,7 @@ describe('forgetting one settled worker', () => {
})
it('still drops what the settled worker itself had parked', async () => {
const { delivery, send, goIdle, stopResolving } = twoWorkerHarness()
const { delivery, send, attach, stopResolving } = twoWorkerHarness()
delivery.deliverForHandle('dispatch:d1')
await flush()
expect(send).not.toHaveBeenCalled()
@@ -529,7 +475,7 @@ describe('forgetting one settled worker', () => {
stopResolving()
delivery.forgetSession('session-1')
goIdle()
attach()
delivery.onJournalActivity('session-1')
await flush()
expect(send).not.toHaveBeenCalled()
@@ -3,9 +3,9 @@
*
* The PTY lane types the nudge into a live pane and reads the idle edge off the terminal title.
* Neither exists here, so this is a sibling of `OrchestrationMailboxPointerDelivery` rather than a
* branch inside it: batch selection is literally shared (`selectOrchestrationPointerBatch`), and
* everything below it is different — the nudge is a session turn, the idle edge is the journal,
* and only an `accepted` dispatch may consume mail.
* branch inside it: batch selection and the pointer text are literally shared, and everything
* below it is different — the nudge goes through the chat's own send, as a person's message does,
* and the retry edge is the journal.
*
* Coordinators are in scope here, unlike the PTY lane's reasoning: a PTY coordinator blocks in
* `check --wait`, where a waiter preempts pointer delivery, but a structured coordinator is a chat
@@ -13,7 +13,8 @@
*/
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import type { OrchestrationDb } from './db'
import type { AgentMessageSource } from '../../../shared/agent-session-message-source'
import type { MessageRow, OrchestrationDb } from './db'
import { formatMessagePointer } from './formatter'
import type { OrchestrationCliCommand } from './cli-command'
import {
@@ -24,13 +25,12 @@ import {
resolveStructuredPointerOperation,
type StructuredPointerSubmission
} from './structured-pointer-operation-id'
import { structuredMailSource } from './structured-mail-source'
import {
decideStructuredSessionPointerDelivery,
retainReasonForDispatch,
structuredDispatchDelivered,
type StructuredDispatchState,
type StructuredPointerRetainReason,
type StructuredSessionGateFacts
type StructuredPointerRetainReason
} from './structured-session-pointer-delivery'
export type StructuredPointerTarget = {
@@ -50,22 +50,25 @@ type ParkedPointerDelivery = {
export type StructuredPointerSendOutcome =
| { kind: 'sent'; state: StructuredDispatchState }
/** The chat's queue took it, as it takes a person's message. */
| { kind: 'queued' }
| { kind: 'unattached' }
export type StructuredPointerGateFacts = StructuredSessionGateFacts & {
export type StructuredPointerSessionFacts = {
/** Every send the session recorded, oldest first: what the lane's own sends settled as. */
submissions: readonly StructuredPointerSubmission[]
}
export type StructuredMailboxPointerHost = {
/** The idle gate, read off the session's full reduced timeline; `null` when it cannot be read. */
readGateFacts: (sessionId: string) => Promise<StructuredPointerGateFacts | null>
/** `null` when the session cannot be read. */
readSessionFacts: (sessionId: string) => Promise<StructuredPointerSessionFacts | null>
send: (input: {
sessionId: string
dispatchId: string | null
operationId: string
expectedRuntimeFence: number
body: AgentJournalMessageItem
source: AgentMessageSource
}) => Promise<StructuredPointerSendOutcome>
/** Current lease fence; `null` when no record backs the session any more. */
currentFence: (sessionId: string) => number | null
@@ -194,14 +197,13 @@ export class OrchestrationStructuredMailboxPointerDelivery<
db: OrchestrationDb,
mailboxHandle: string,
target: StructuredPointerTarget,
unread: readonly { id: string; type: string; sequence: number }[],
unread: readonly MessageRow[],
reservedTypes: ReadonlySet<string> | undefined
): Promise<void> {
const sessionId = target.sessionId
const session = await this.deps.host.readGateFacts(sessionId)
const decision = decideStructuredSessionPointerDelivery({ session })
if (!decision.deliver) {
this.retain(mailboxHandle, sessionId, decision.retain, reservedTypes)
const session = await this.deps.host.readSessionFacts(sessionId)
if (!session) {
this.retain(mailboxHandle, sessionId, 'session-not-attached', reservedTypes)
return
}
const fence = this.deps.host.currentFence(sessionId)
@@ -225,7 +227,7 @@ export class OrchestrationStructuredMailboxPointerDelivery<
mailboxHandle,
sessionId,
messageIds: staged,
submissions: session?.submissions ?? [],
submissions: session.submissions,
sentByThisProcess: this.sentOperationIds.get(mailboxHandle)
})
if (operation.kind === 'stamp') {
@@ -245,13 +247,20 @@ export class OrchestrationStructuredMailboxPointerDelivery<
dispatchId: target.dispatchId,
operationId: operation.operationId,
expectedRuntimeFence: fence,
body
body,
source: structuredMailSource({
db,
mailboxHandle,
dispatchId: target.dispatchId,
batch: unread
})
})
if (outcome.kind === 'unattached') {
this.retain(mailboxHandle, sessionId, 'session-not-attached', reservedTypes)
return
}
if (!structuredDispatchDelivered(outcome.state)) {
// A queued pointer is the chat's queue's to send, as a person's queued message is.
if (outcome.kind === 'sent' && !structuredDispatchDelivered(outcome.state)) {
// The row stays: resending under its id replays this verdict and starts nothing.
this.retain(mailboxHandle, sessionId, retainReasonForDispatch(outcome.state), reservedTypes)
return
@@ -264,7 +273,8 @@ export class OrchestrationStructuredMailboxPointerDelivery<
}
/**
* No `markAsUndelivered` is owed: rows are marked delivered only after an accepted dispatch.
* No `markAsUndelivered` is owed: rows are marked delivered only after an accepted dispatch, or
* once the chat's queue holds the pointer.
*
* Every reason parks for the session's next journal edge. `unknown` may mean the nudge already
* sits in the provider's input queue, so an immediate retry can stack duplicate nudges;
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
import type { AgentMessageSource } from '../../../shared/agent-session-message-source'
const hostRef: { current: unknown } = { current: null }
@@ -9,6 +10,7 @@ vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry'
const {
createStructuredMailboxPointerHost,
readStructuredSessionGateFacts,
structuredPointerCallerKey,
structuredSessionPointerCallerKey
} = await import('./structured-mailbox-pointer-host')
@@ -33,6 +35,12 @@ function transcript(count: number): AgentJournalRenderItem[] {
)
}
const NOTICE_SOURCE: AgentMessageSource = {
kind: 'agent',
senders: [],
orchestration: { message: 'mail-notice', mailbox: 'dispatch:d1', dispatchId: 'd1', messages: [] }
}
describe('structured mailbox pointer host', () => {
beforeEach(() => {
hostRef.current = null
@@ -41,30 +49,33 @@ describe('structured mailbox pointer host', () => {
it('reads the gate facts from the FULL timeline, never a bounded tail', async () => {
// The defect this pins: a running turn is announced by ONE lifecycle item, and settlement
// tombstones it rather than rewriting it. A long tool-calling turn pushes that item arbitrarily
// far from the tail, so any page-sized read reports a busy worker as idle — and the pointer is
// then delivered mid-turn, which Codex coalesces into the running turn and Claude folds into
// it -- either way folded into work already in flight rather than read as a new instruction.
// far from the tail, so any page-sized read reports a busy worker as idle — and `@idle` then
// wakes it mid-turn.
const items = [runningTurn(), ...transcript(500)]
const submissions = [{ clientMessageId: 'op1', dispatchState: 'unknown' }]
hostRef.current = { journalSnapshot: () => ({ items, submissions }) }
// The recorded sends ride along: the lane reads what its own operation id settled as.
expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toEqual({
hostRef.current = { journalSnapshot: () => ({ items, submissions: [] }) }
expect(await readStructuredSessionGateFacts('s1')).toEqual({
turnRunning: true,
awaitingHuman: false,
awaitingHuman: false
})
})
it("reads what the session's sends settled as", async () => {
const submissions = [{ clientMessageId: 'op1', dispatchState: 'unknown' }]
hostRef.current = { journalSnapshot: () => ({ items: [], submissions }) }
expect(await createStructuredMailboxPointerHost().readSessionFacts('s1')).toEqual({
submissions
})
})
it('answers null rather than idle when the session cannot be read', async () => {
// Null retains the pointer; `{turnRunning:false}` would deliver a nudge into a session this
// runtime cannot see at all.
expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull()
it('answers null rather than nothing recorded when the session cannot be read', async () => {
// Null retains the pointer; an empty answer would send into a session this runtime cannot see.
expect(await createStructuredMailboxPointerHost().readSessionFacts('s1')).toBeNull()
hostRef.current = {
journalSnapshot: () => {
throw new Error('agent_session_ownership_unknown')
}
}
expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull()
expect(await createStructuredMailboxPointerHost().readSessionFacts('s1')).toBeNull()
})
it('reports an unattached host rather than a rejection when nothing can be sent', async () => {
@@ -108,25 +119,31 @@ describe('structured mailbox pointer host', () => {
expect(send.mock.calls[0]![1]!.retryUnknown).toBeUndefined()
})
it('reads a queued answer as unknown, so the pointer is retained', async () => {
hostRef.current = {
send: async () => ({
it('asks a busy chat to queue the pointer as a card, with who it is from', async () => {
const send = vi.fn(
async (_caller: unknown, _payload: { delivery?: string; source?: unknown }) => ({
ok: true,
value: {
clientMessageId: 'op1',
queued: { messageId: 'op1', position: 0, state: 'waiting' }
}
})
}
)
hostRef.current = { send }
await expect(
createStructuredMailboxPointerHost().send({
sessionId: 's1',
dispatchId: 'd1',
operationId: 'op1',
expectedRuntimeFence: 1,
body: { kind: 'message', role: 'user', blocks: [] }
} as never)
).resolves.toEqual({ kind: 'sent', state: 'unknown' })
body: { kind: 'message', role: 'user', blocks: [] },
source: NOTICE_SOURCE
})
).resolves.toEqual({ kind: 'queued' })
expect(send.mock.calls[0]![1]).toMatchObject({
delivery: 'queue-if-active',
source: NOTICE_SOURCE
})
})
it('consumes mail once an accepted nudge is delivered while the worker starts (W10)', async () => {
@@ -10,7 +10,7 @@ import { AGENT_SESSION_NOT_ATTACHED } from '../../native-chat/agent-session-wire
import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry'
import type {
StructuredMailboxPointerHost,
StructuredPointerGateFacts
StructuredPointerSessionFacts
} from './structured-mailbox-pointer-delivery'
import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types'
import {
@@ -36,12 +36,12 @@ export function structuredSessionPointerCallerKey(sessionId: string): string {
}
/**
* The idle gate for a structured session, read off its FULL reduced timeline.
* Whether a structured session is idle, for group addressing (`@idle`), read off its FULL reduced
* timeline.
*
* Never a bounded page. A settled turn's lifecycle item is revised in place, so on any tail window
* an idle session and a busy one whose lifecycle item scrolled off look identical — and
* idle-with-history is the normal steady state of a working agent. Shared so the pointer lane and
* group addressing cannot disagree about it.
* idle-with-history is the normal steady state of a working agent.
*/
export async function readStructuredSessionGateFacts(
sessionId: string
@@ -50,12 +50,12 @@ export async function readStructuredSessionGateFacts(
return snapshot ? structuredSessionGateFacts(snapshot.items) : null
}
/** The pointer lane's gate: the shared idle facts, plus what each recorded send settled as. */
async function readPointerGateFacts(sessionId: string): Promise<StructuredPointerGateFacts | null> {
/** What each recorded send settled as. */
async function readPointerSessionFacts(
sessionId: string
): Promise<StructuredPointerSessionFacts | null> {
const snapshot = await readSessionJournal(sessionId)
return snapshot
? { ...structuredSessionGateFacts(snapshot.items), submissions: snapshot.submissions }
: null
return snapshot ? { submissions: snapshot.submissions } : null
}
async function readSessionJournal(sessionId: string): Promise<AgentJournalSnapshot | null> {
@@ -77,8 +77,8 @@ async function readSessionJournal(sessionId: string): Promise<AgentJournalSnapsh
export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHost {
return {
readGateFacts(sessionId) {
return readPointerGateFacts(sessionId)
readSessionFacts(sessionId) {
return readPointerSessionFacts(sessionId)
},
currentFence(sessionId) {
@@ -101,7 +101,9 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo
: structuredSessionPointerCallerKey(input.sessionId),
turn: {
body: input.body,
delivery: 'now',
// As a person's message is: a busy chat queues it as a card, sent when the turn ends.
delivery: 'queue',
source: input.source,
operationId: input.operationId,
expectedRuntimeFence: input.expectedRuntimeFence
}
@@ -112,9 +114,7 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo
? { kind: 'unattached' }
: { kind: 'sent', state: 'rejected' }
case 'queued':
// Never for a `now` send. A draft would hand off under a fresh id, which this lane's
// operation row cannot see, so reading it needs its own rule before this lane queues.
return { kind: 'sent', state: 'unknown' }
return { kind: 'queued' }
case 'sent': {
// `pending` is not yet an acknowledgement; only `accepted` may consume mail. A send still
// pending after the wait parks for the next journal edge.
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
import {
decideStructuredSessionPointerDelivery,
retainReasonForDispatch,
structuredDispatchDelivered,
structuredSessionGateFacts
@@ -80,37 +79,6 @@ describe('structured session gate facts', () => {
})
})
describe('decideStructuredSessionPointerDelivery', () => {
it('delivers to an attached, idle session', () => {
expect(decideStructuredSessionPointerDelivery({ session: IDLE })).toEqual({
deliver: true
})
})
it('retains when the session is not attached on this host', () => {
expect(decideStructuredSessionPointerDelivery({ session: null })).toEqual({
deliver: false,
retain: 'session-not-attached'
})
})
it('retains mid-turn rather than delegating the race to the provider', () => {
expect(
decideStructuredSessionPointerDelivery({
session: { turnRunning: true, awaitingHuman: false }
})
).toEqual({ deliver: false, retain: 'turn-unsettled' })
})
it('names the human prompt ahead of the turn, so the retain reason is the actionable one', () => {
expect(
decideStructuredSessionPointerDelivery({
session: { turnRunning: true, awaitingHuman: true }
})
).toEqual({ deliver: false, retain: 'awaiting-human' })
})
})
describe('dispatch outcome classification', () => {
it('marks mail delivered only on an accepted dispatch', () => {
expect(structuredDispatchDelivered('accepted')).toBe(true)
@@ -1,13 +1,10 @@
/**
* Delivery decisions for an orchestration mail pointer aimed at a host-owned
* structured ("native") agent session.
* What orchestration mail delivery reads of a host-owned structured ("native") agent session.
*
* A structured session has no PTY the pointer can be typed into, so the nudge
* travels as a session turn instead of as bytes. Everything here is pure: the
* caller supplies the session's gate facts, and gets back a decision it can
* act on. Orchestration's database stays the source of truth —
* no decision here ever consumes mail, it only says whether the nudge may be
* attempted now.
* A structured session has no PTY the pointer can be typed into, so the nudge travels as a session
* turn instead of as bytes, and a busy session's own queue holds it until the turn ends.
* Everything here is pure. Orchestration's database stays the source of truth: nothing here
* consumes mail.
*/
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
@@ -20,22 +17,18 @@ import {
export type StructuredPointerRetainReason =
| 'session-not-attached'
| 'turn-unsettled'
| 'awaiting-human'
| 'dispatch-rejected'
| 'dispatch-unknown'
export type StructuredPointerDecision =
| { deliver: true }
| { deliver: false; retain: StructuredPointerRetainReason }
/** The dispatch states both provider adapters converge on. */
export type StructuredDispatchState = 'accepted' | 'rejected' | 'unknown'
/**
* What the delivery gate needs to know about a session, read once per attempt.
* Whether a session is busy, in the vocabulary group addressing (`@idle`) matches on.
*
* Deliberately two booleans rather than the journal: the caller reads the FULL reduced timeline
* (see `readGateFacts`), so nothing downstream can be tempted to re-derive them from a page.
* (see `readStructuredSessionGateFacts`), so nothing downstream can be tempted to re-derive them
* from a page.
*/
export type StructuredSessionGateFacts = {
turnRunning: boolean
@@ -46,7 +39,7 @@ export type StructuredSessionGateFacts = {
/**
* Projects the gate facts off a session's live items.
*
* Reuses the projection the chat view already reads, so the delivery gate and the visible
* Reuses the projection the chat view already reads, so `@idle` and the visible
* "working" state can never disagree. Both must be answered from the fully reduced timeline: a
* settled turn is TOMBSTONED rather than rewritten to `completed`, so on a bounded tail page an
* idle session and a running turn whose lifecycle item was pushed off the end look identical —
@@ -61,37 +54,6 @@ export function structuredSessionGateFacts(
}
}
/**
* Decide whether the nudge may be sent right now.
*
* Mid-turn delivery is refused for both providers rather than delegated to
* them. Neither refuses the frame: Codex COALESCES a mid-turn `turn/start` into
* the running turn -- measured on codex-cli 0.147.0, 0.150.1 and 0.153.4, none
* of which refuse it and none of which fire a second `turn/started` -- and
* Claude folds it into the running turn (or runs it as the next turn when the
* turn ends first). Both therefore
* fold the nudge into work already in flight, where it reads as part of the
* running turn rather than a new instruction. Waiting for the turn to settle is
* the one contract that holds for both, and it preserves orchestration's
* existing idle-edge-only delivery policy.
*/
export function decideStructuredSessionPointerDelivery(input: {
session: StructuredSessionGateFacts | null
}): StructuredPointerDecision {
if (!input.session) {
return { deliver: false, retain: 'session-not-attached' }
}
// Checked before the turn gate: a pending prompt has no running turn, so the turn test alone
// reads it as idle, and sending there queues a nudge behind something only a human can clear.
if (input.session.awaitingHuman) {
return { deliver: false, retain: 'awaiting-human' }
}
if (input.session.turnRunning) {
return { deliver: false, retain: 'turn-unsettled' }
}
return { deliver: true }
}
/**
* Only an accepted dispatch may mark mail delivered.
*
@@ -46,6 +46,7 @@ describe('client UI RPC pairing-local field seams', () => {
{ hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' }
],
workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'],
sidebarOpen: false,
agentsVisibleHostIds: ['runtime:web-11111111-2222-3333-4444-555555555555'],
agentsFilterRepoIds: ['repo-a'],
agentsHideWorkspacesFromOtherDevices: true,
@@ -345,6 +345,79 @@ describe('a Codex send its turn ended without taking it', () => {
})
})
describe('the turn a withdrawn Codex send was answered into', () => {
async function answeredInto(clientMessageId: string) {
await host.flushStreamedEvents(SESSION)
const snapshot = await host.journalSnapshot(SESSION)
const page = await host.history({ sessionId: SESSION, direction: 'tail' })
const onPage = page.ok
? page.page.submissions.find((entry) => entry.clientMessageId === clientMessageId)
: undefined
return {
turnRecords: snapshot.items.flatMap((item) =>
item.body.kind === 'turn' ? [item.itemId] : []
),
named: snapshot.submissions.find((entry) => entry.clientMessageId === clientMessageId)
?.answeredInTurn,
onPage: onPage?.answeredInTurn
}
}
it("is that turn's record, started by the send that opened it and steered by a later one", async () => {
const opening = await send('look around')
await vi.waitFor(() => expect(answers).toBe(1))
turns.start()
const steered = await send('and check the tests')
await vi.waitFor(() => expect(steers).toBe(1))
await stop('turn-1')
await vi.waitFor(async () =>
expect(verdictOf((await settled()).submissions, steered)).toBe('withdrawn')
)
const { turnRecords } = await answeredInto(opening)
expect(turnRecords).toHaveLength(1)
const started = { turnItemId: turnRecords[0], via: 'start' }
const steeredIn = { turnItemId: turnRecords[0], via: 'steer' }
expect(await answeredInto(opening)).toEqual({ turnRecords, named: started, onPage: started })
expect(await answeredInto(steered)).toEqual({
turnRecords,
named: steeredIn,
onPage: steeredIn
})
})
it('is not named on a send the turn took', async () => {
const opening = await send('look around')
await vi.waitFor(() => expect(answers).toBe(1))
turns.start()
turns.echo(opening)
await stop('turn-1')
await vi.waitFor(async () =>
expect(verdictOf((await settled()).submissions, opening)).toBe('accepted')
)
expect(await answeredInto(opening)).toMatchObject({ named: undefined, onPage: undefined })
})
it('is named when the answer is read after that turn ended', async () => {
const release = turns.holdNextAnswer()
const sent = await send('look around')
await vi.waitFor(() => expect(turns.turnId).toBe('turn-1'))
turns.start()
turns.end('interrupted')
release()
await vi.waitFor(async () =>
expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn')
)
const { turnRecords, named } = await answeredInto(sent)
expect(turnRecords).toHaveLength(1)
expect(named).toEqual({ turnItemId: turnRecords[0], via: 'start' })
})
})
describe('a queued card sent now into the turn a Stop ends', () => {
async function handoffs(messageId: string): Promise<AgentJournalSubmission[]> {
return (await settled()).submissions.filter((entry) => entry.queuedMessageId === messageId)
@@ -0,0 +1,139 @@
import './rpc/unused-default-rpc-methods.test-fixture'
// A busy structured chat holds the orchestration pointer as a card in its own queue, sent when the
// turn ends, as it holds a message the person sends then; the queue does nothing else with it. End
// to end on the coordinator-mail rig.
import { describe, expect, it, vi } from 'vitest'
import type { FakeConnection } from './structured-chat-coordinator-fake-codex-fixture'
import { idOf } from './rpc/orchestration-session-caller-test-fixture'
import {
COORDINATOR,
WORKER_2_PANE,
WAIT,
call,
coordinatorRunAndTask,
db,
finishWorker,
host,
openChat,
ptyPointer,
queuedCardTexts,
runtime,
sendUserMessage,
settleTurn,
turnText
} from './structured-chat-coordinator-mail-rig.test-fixture'
/** The person's turn, started and still running; resolves to its end. */
async function runningUserTurn(chat: FakeConnection): Promise<() => Promise<void>> {
expect(await sendUserMessage(COORDINATOR, 'go')).toMatchObject({ ok: true })
await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT)
const notify = (method: string, params: unknown) => chat.handlers.onNotification?.(method, params)
notify('turn/started', { turn: { id: 'turn-1' } })
notify('item/completed', {
item: {
type: 'userMessage',
id: 'echo-go',
clientId: chat.turns[0]!.clientUserMessageId,
content: [{ type: 'text', text: 'go' }]
}
})
await host.flushStreamedEvents(COORDINATOR)
return async () => {
notify('turn/completed', { turn: { id: 'turn-1' } })
await host.flushStreamedEvents(COORDINATOR)
}
}
/** Idle edges with nothing owed: whatever they would send gets the time to show. */
async function idleEdgesSettled(): Promise<void> {
for (let edge = 0; edge < 3; edge += 1) {
runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' })
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
/** The chat's queue as its journal stores it. */
function queuedRows() {
return host.collaboratorsForTests().sessions.get(COORDINATOR)?.journal.queuedMessages.list() ?? []
}
/** A second task, for a second worker result. */
async function secondTask(): Promise<string> {
return idOf(
(await call('orchestration.taskCreate', { spec: 'more' }, { sessionId: COORDINATOR })).task
)
}
describe("a busy chat's orchestration pointer waits in its queue", () => {
it('queues the pointer as a card, with who it is from, and sends it once when the turn ends', async () => {
const chat = await openChat(COORDINATOR)
const { runId, taskId } = await coordinatorRunAndTask()
const endTurn = await runningUserTurn(chat)
await finishWorker(taskId)
await vi.waitFor(
async () => expect(await queuedCardTexts()).toEqual([ptyPointer(`run:${runId}`)]),
WAIT
)
expect(chat.turns).toHaveLength(1)
const [card] = queuedRows()
const [mail] = db.getAllMessages(`run:${runId}`)
expect(card?.source).toEqual({
kind: 'agent',
senders: [
{ party: { address: 'term_worker', terminalHandle: 'term_worker', orcaSessionId: null } }
],
orchestration: {
message: 'mail-notice',
mailbox: `run:${runId}`,
dispatchId: null,
messages: [{ messageId: mail!.id, runId, from: 'term_worker' }]
}
})
await endTurn()
await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT)
expect(turnText(chat.turns[1]!)).toBe(ptyPointer(`run:${runId}`))
expect(await queuedCardTexts()).toEqual([])
await settleTurn(COORDINATOR, 1)
await idleEdgesSettled()
expect(chat.turns).toHaveLength(2)
})
it('queues a second card for mail that arrives while the first waits, each counting its own mail', async () => {
const chat = await openChat(COORDINATOR)
const { runId, taskId } = await coordinatorRunAndTask()
const second = await secondTask()
const endTurn = await runningUserTurn(chat)
await finishWorker(taskId)
await vi.waitFor(async () => expect(await queuedCardTexts()).toHaveLength(1), WAIT)
await finishWorker(second, { handle: 'term_worker_2', paneKey: WORKER_2_PANE })
const pointer = ptyPointer(`run:${runId}`)
await vi.waitFor(async () => expect(await queuedCardTexts()).toEqual([pointer, pointer]), WAIT)
await endTurn()
await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT)
await settleTurn(COORDINATOR, 1)
await vi.waitFor(() => expect(chat.turns).toHaveLength(3), WAIT)
expect(turnText(chat.turns[2]!)).toBe(pointer)
await settleTurn(COORDINATOR, 2)
await idleEdgesSettled()
expect(chat.turns).toHaveLength(3)
expect(await queuedCardTexts()).toEqual([])
})
it("leaves the chat's own `check` as it is: the mail stays readable, and the card stays", async () => {
const chat = await openChat(COORDINATOR)
const { runId, taskId } = await coordinatorRunAndTask()
const endTurn = await runningUserTurn(chat)
await finishWorker(taskId)
await vi.waitFor(async () => expect(await queuedCardTexts()).toHaveLength(1), WAIT)
const [mail] = db.getAllMessages(`run:${runId}`)
expect(await call('orchestration.check', {}, { sessionId: COORDINATOR })).toMatchObject({
count: 1,
messages: [{ id: mail!.id }]
})
expect(await queuedCardTexts()).toEqual([ptyPointer(`run:${runId}`)])
await endTurn()
})
})
@@ -0,0 +1,324 @@
// The coordinator-mail rig, shared by every suite that drives a worker's result into a structured
// chat end to end in one process.
//
// Real: the structured agent-session host, its record store, journal, lease and Codex adapter; the
// orchestration database, RPC dispatcher and methods; the runtime's pointer lanes. Fake: only the
// Codex app-server child, which answers the JSON-RPC calls the real one does. Importing it
// registers the rig's own beforeEach/afterEach for the importing file.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types'
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version'
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
import { OrcaRuntimeService } from './orca-runtime'
import { OrchestrationDb } from './orchestration/db'
import { localOrchestrationCliCommand } from './orchestration/cli-command'
import { formatMessagePointer } from './orchestration/formatter'
import { RpcDispatcher } from './rpc/dispatcher'
import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration'
import { idOf, isRecord, resultOf } from './rpc/orchestration-session-caller-test-fixture'
import {
ensureStructuredAgentSessionHost,
stopStructuredAgentSessionRuntime
} from './structured-agent-session-runtime'
import { createCoordinatorMailObservationClock } from './structured-chat-coordinator-observation-clock.test-fixture'
import {
attachParams,
fakeCodex,
operationId,
resetProviderFaults,
type FakeConnection
} from './structured-chat-coordinator-fake-codex-fixture'
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
export const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37'
export const PEER_CHAT = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64'
export const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
export const WORKER_2_PANE = 'tab_worker2:cccccccc-cccc-4ccc-8ccc-cccccccccccc'
export let codex: ReturnType<typeof fakeCodex>
export let root: string
export let runtime: OrcaRuntimeService
export let db: OrchestrationDb
export let host: StructuredAgentSessionHost
export let dispatcher: RpcDispatcher
export let requests = 0
export const observationClock = createCoordinatorMailObservationClock(() => host, COORDINATOR)
export function request(
method: string,
params: Record<string, unknown>,
options: { sessionId?: string } = {}
): Parameters<RpcDispatcher['dispatch']>[0] {
requests += 1
return {
id: `rpc-${requests}`,
authToken: 'test',
method,
params,
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
orchestrationRequestId: `req-${requests}`,
...(options.sessionId
? { orchestrationCompatibilityEvidence: { agentSessionId: options.sessionId } }
: {})
}
}
export async function call(
method: string,
params: Record<string, unknown>,
options?: { sessionId?: string }
): Promise<Record<string, unknown>> {
const response = await dispatcher.dispatch(request(method, params, options))
if (!response.ok) {
throw new Error(`${method} failed: ${JSON.stringify(response)}`)
}
return resultOf(response)
}
export async function openChat(sessionId: string): Promise<FakeConnection> {
const attached = await host.attach({ callerKey: 'test-surface' }, attachParams(sessionId))
expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true })
await host.setSessionTabVisibility(sessionId, true)
threadBySession.set(sessionId, codex.connections.at(-1)!.threadId!)
return connectionFor(sessionId)
}
export const threadBySession = new Map<string, string>()
export function connectionFor(sessionId: string): FakeConnection {
// A cleared chat's successor starts on its first message; its record then names its thread.
const head = agentSessionProviderHandleChainHead(
host.deps.store.getRecord(sessionId)?.providerHandleChain ?? []
)
const thread = threadBySession.get(sessionId) ?? head?.handle.nativeId
const connection = codex.connections.findLast((candidate) => candidate.threadId === thread)
if (!connection) {
throw new Error(`no app-server for ${sessionId}`)
}
return connection
}
/** Codex's own sequence for a turn: it starts, echoes the user message, and completes. */
export async function settleTurn(sessionId: string, turnIndex: number): Promise<void> {
const connection = connectionFor(sessionId)
const turn = connection.turns[turnIndex]!
const turnId = `turn-${turnIndex + 1}`
const notify = (method: string, params: unknown) =>
connection.handlers.onNotification?.(method, params)
notify('turn/started', { turn: { id: turnId } })
notify('item/completed', {
item: {
type: 'userMessage',
id: `echo-${turn.clientUserMessageId}`,
clientId: turn.clientUserMessageId,
content: [{ type: 'text', text: 'pointer' }]
}
})
notify('turn/completed', { turn: { id: turnId } })
await host.flushStreamedEvents(sessionId)
}
/** A user message typed into the chat, as the chat surface sends it. */
export function sendUserMessage(sessionId: string, text: string) {
const body = {
kind: 'message' as const,
role: 'user' as const,
blocks: [{ type: 'text' as const, text }]
}
return host.send(
{ callerKey: 'test-surface' },
{
envelope: {
sessionId,
clientOperationId: operationId(),
expectedRuntimeFence: host.deps.store.getRecord(sessionId)!.lease.runtimeFence,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId,
fields: { body }
})
},
body
}
)
}
export async function userTexts(sessionId: string): Promise<string[]> {
return (await host.journalSnapshot(sessionId)).items.flatMap((item: AgentJournalRenderItem) =>
item.body?.kind === 'message' && item.body.role === 'user'
? item.body.blocks.map((block) => (block.type === 'text' ? block.text : ''))
: []
)
}
/** A supervised terminal worker under the coordinator's Run, and its worker_done. */
export async function finishWorker(
taskId: string,
worker: { handle: string; paneKey: string } = { handle: 'term_worker', paneKey: WORKER_PANE }
): Promise<void> {
const started = db.createStartingWorkerDispatch({
creator: { kind: 'system' },
maxDepth: Number.MAX_SAFE_INTEGER,
taskId,
startOptions: {}
})
db.prepareStartingWorkerAuthority({
dispatchId: started.dispatch.id,
handle: worker.handle,
paneKey: worker.paneKey,
processIncarnation: `runtime_test:${worker.handle}:1`,
worktreeId: 'repo::worker',
effects: [],
setupState: 'not_applicable'
})
db.markWorkerDispatchReady(started.dispatch.id)
await call('orchestration.send', {
from: worker.handle,
subject: 'Done',
type: 'worker_done',
payload: JSON.stringify({ taskId, dispatchId: started.dispatch.id, outcome: 'succeeded' })
})
}
export async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: string }> {
const created = await call(
'orchestration.runCreate',
{ objective: 'ship' },
{
sessionId: COORDINATOR
}
)
const runId = idOf(created.run)
const task = await call(
'orchestration.taskCreate',
{ spec: 'build it' },
{
sessionId: COORDINATOR
}
)
return { runId, taskId: idOf(task.task) }
}
/** `/clear` as the chat surface runs it: the conversation continues in a new session. */
export async function clearChat(sessionId: string): Promise<string> {
const command = 'clear' as const
const cleared = await host.conversationCommand(
{ callerKey: 'test-surface' },
{
command,
envelope: {
sessionId,
clientOperationId: operationId(),
expectedRuntimeFence: host.deps.store.getRecord(sessionId)!.lease.runtimeFence,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.conversationCommand',
sessionId,
fields: { command }
})
}
}
)
const successor = cleared.ok ? cleared.value.replacementSessionId : undefined
if (!successor) {
throw new Error(`clear failed: ${JSON.stringify(cleared)}`)
}
// The surface swaps the tab over to the session that continues the chat.
await host.setSessionTabVisibility(sessionId, false)
await host.setSessionTabVisibility(successor, true)
return successor
}
/** A cleared chat's successor runs once the user writes to it; only then can its agent act. */
export async function startSuccessor(successor: string): Promise<void> {
expect(await sendUserMessage(successor, 'hello')).toMatchObject({ ok: true })
await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(1), WAIT)
await settleTurn(successor, 0)
}
beforeEach(async () => {
resetProviderFaults()
root = await mkdtemp(join(tmpdir(), 'orca-structured-coordinator-mail-'))
codex = fakeCodex()
db = new OrchestrationDb(':memory:')
runtime = startRuntime()
host = await ensureStructuredAgentSessionHost({
logger: createStructuredAgentSessionLogger(),
stateDirectory: root,
hostId: 'local',
claimKeyId: 'key-1',
resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`,
resolveCodexCommand: () => '/usr/local/bin/codex',
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
resolveEnvironment: async () => ({ PATH: '/usr/bin' }),
openCodexConnection: codex.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
// The same calls the runtime's own host install makes.
onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary)
})
dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS })
})
/** The runtime over the shared database; a second call is what an Orca restart leaves behind. */
export function startRuntime(): OrcaRuntimeService {
const started = new OrcaRuntimeService()
started.setOrchestrationDb(db)
vi.spyOn(started, 'ensureStructuredAgentSessionHost').mockResolvedValue()
vi.spyOn(started, 'getTerminalPaneKey').mockImplementation((handle) =>
handle === 'term_worker' ? WORKER_PANE : handle === 'term_worker_2' ? WORKER_2_PANE : null
)
return started
}
afterEach(async () => {
try {
await stopStructuredAgentSessionRuntime()
db.close()
await observationClock.drainClosedDatabaseRepair()
vi.restoreAllMocks()
await rm(root, { recursive: true, force: true })
} finally {
observationClock.restore()
}
})
// Pointers are sent on asynchronous edges; the default 1s wait is too tight under a loaded parallel run.
export const WAIT = { timeout: 10_000 }
export const POINTER =
/You have 1 orchestration message\. Run `orca(-dev)? orchestration check --run run_\w+`\./
/** The text the PTY lane types into a local terminal for this mailbox, byte for byte. */
export function ptyPointer(mailboxHandle: string): string {
return formatMessagePointer(1, mailboxHandle, localOrchestrationCliCommand()).trim()
}
/** The text of a turn the fake provider received. */
export function turnText(turn: { text: string }): string {
const input: unknown = JSON.parse(turn.text)
return Array.isArray(input)
? input.map((item: unknown) => (isRecord(item) ? String(item.text) : '')).join('')
: ''
}
/** What an Orca restart leaves behind: a new runtime over the same database and host. */
export function restartRuntime(): void {
runtime = startRuntime()
dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS })
}
/** The text of every card the chat lists in its queue, as the person sees it. */
export async function queuedCardTexts(sessionId = COORDINATOR): Promise<string[]> {
const page = await host.history({ sessionId, direction: 'tail' })
if (!page.ok) {
throw new Error('history refused')
}
return (page.page.queuedMessages ?? []).flatMap((card) =>
card.body.blocks.map((block) => (block.type === 'text' ? block.text : ''))
)
}
@@ -1,319 +1,51 @@
import './rpc/unused-default-rpc-methods.test-fixture'
// A worker's result reaching the structured chat that coordinates it, end to end in one process.
//
// Real: the structured agent-session host, its record store, journal, lease and Codex adapter; the
// orchestration database, RPC dispatcher and methods; the runtime's pointer lanes. Fake: only the
// Codex app-server child, which answers the JSON-RPC calls the real one does.
// A worker's result reaching the structured chat that coordinates it, end to end in one process,
// on the coordinator-mail rig.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types'
import { describe, expect, it, vi } from 'vitest'
import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key'
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version'
import {
AgentSessionAcquisitionRefusal,
AgentSessionPreSpawnError
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../shared/agent-session-host-authority'
import { refuse } from '../../shared/agent-session-wire-refusals'
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
import { OrcaRuntimeService } from './orca-runtime'
import { OrchestrationDb } from './orchestration/db'
import { localOrchestrationCliCommand } from './orchestration/cli-command'
import { formatMessagePointer } from './orchestration/formatter'
import { currentRunCoordinatorOrcaSessionId } from './orchestration/db/runs/run-coordinator-orca-session'
import { RpcDispatcher } from './rpc/dispatcher'
import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration'
import { idOf, isRecord, resultOf } from './rpc/orchestration-session-caller-test-fixture'
import { idOf } from './rpc/orchestration-session-caller-test-fixture'
import { operationId, providerFaults } from './structured-chat-coordinator-fake-codex-fixture'
import {
ensureStructuredAgentSessionHost,
stopStructuredAgentSessionRuntime
} from './structured-agent-session-runtime'
import { createCoordinatorMailObservationClock } from './structured-chat-coordinator-observation-clock.test-fixture'
import {
attachParams,
fakeCodex,
operationId,
providerFaults,
resetProviderFaults,
type FakeConnection
} from './structured-chat-coordinator-fake-codex-fixture'
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37'
const PEER_CHAT = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64'
const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const WORKER_2_PANE = 'tab_worker2:cccccccc-cccc-4ccc-8ccc-cccccccccccc'
let codex: ReturnType<typeof fakeCodex>
let root: string
let runtime: OrcaRuntimeService
let db: OrchestrationDb
let host: StructuredAgentSessionHost
let dispatcher: RpcDispatcher
let requests = 0
const observationClock = createCoordinatorMailObservationClock(() => host, COORDINATOR)
function request(
method: string,
params: Record<string, unknown>,
options: { sessionId?: string } = {}
): Parameters<RpcDispatcher['dispatch']>[0] {
requests += 1
return {
id: `rpc-${requests}`,
authToken: 'test',
method,
params,
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
orchestrationRequestId: `req-${requests}`,
...(options.sessionId
? { orchestrationCompatibilityEvidence: { agentSessionId: options.sessionId } }
: {})
}
}
async function call(
method: string,
params: Record<string, unknown>,
options?: { sessionId?: string }
): Promise<Record<string, unknown>> {
const response = await dispatcher.dispatch(request(method, params, options))
if (!response.ok) {
throw new Error(`${method} failed: ${JSON.stringify(response)}`)
}
return resultOf(response)
}
async function openChat(sessionId: string): Promise<FakeConnection> {
const attached = await host.attach({ callerKey: 'test-surface' }, attachParams(sessionId))
expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true })
await host.setSessionTabVisibility(sessionId, true)
threadBySession.set(sessionId, codex.connections.at(-1)!.threadId!)
return connectionFor(sessionId)
}
const threadBySession = new Map<string, string>()
function connectionFor(sessionId: string): FakeConnection {
// A cleared chat's successor starts on its first message; its record then names its thread.
const head = agentSessionProviderHandleChainHead(
host.deps.store.getRecord(sessionId)?.providerHandleChain ?? []
)
const thread = threadBySession.get(sessionId) ?? head?.handle.nativeId
const connection = codex.connections.findLast((candidate) => candidate.threadId === thread)
if (!connection) {
throw new Error(`no app-server for ${sessionId}`)
}
return connection
}
/** Codex's own sequence for a turn: it starts, echoes the user message, and completes. */
async function settleTurn(sessionId: string, turnIndex: number): Promise<void> {
const connection = connectionFor(sessionId)
const turn = connection.turns[turnIndex]!
const turnId = `turn-${turnIndex + 1}`
const notify = (method: string, params: unknown) =>
connection.handlers.onNotification?.(method, params)
notify('turn/started', { turn: { id: turnId } })
notify('item/completed', {
item: {
type: 'userMessage',
id: `echo-${turn.clientUserMessageId}`,
clientId: turn.clientUserMessageId,
content: [{ type: 'text', text: 'pointer' }]
}
})
notify('turn/completed', { turn: { id: turnId } })
await host.flushStreamedEvents(sessionId)
}
/** A user message typed into the chat, as the chat surface sends it. */
function sendUserMessage(sessionId: string, text: string) {
const body = {
kind: 'message' as const,
role: 'user' as const,
blocks: [{ type: 'text' as const, text }]
}
return host.send(
{ callerKey: 'test-surface' },
{
envelope: {
sessionId,
clientOperationId: operationId(),
expectedRuntimeFence: host.deps.store.getRecord(sessionId)!.lease.runtimeFence,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId,
fields: { body }
})
},
body
}
)
}
async function userTexts(sessionId: string): Promise<string[]> {
return (await host.journalSnapshot(sessionId)).items.flatMap((item: AgentJournalRenderItem) =>
item.body?.kind === 'message' && item.body.role === 'user'
? item.body.blocks.map((block) => (block.type === 'text' ? block.text : ''))
: []
)
}
/** A supervised terminal worker under the coordinator's Run, and its worker_done. */
async function finishWorker(
taskId: string,
worker: { handle: string; paneKey: string } = { handle: 'term_worker', paneKey: WORKER_PANE }
): Promise<void> {
const started = db.createStartingWorkerDispatch({
creator: { kind: 'system' },
maxDepth: Number.MAX_SAFE_INTEGER,
taskId,
startOptions: {}
})
db.prepareStartingWorkerAuthority({
dispatchId: started.dispatch.id,
handle: worker.handle,
paneKey: worker.paneKey,
processIncarnation: `runtime_test:${worker.handle}:1`,
worktreeId: 'repo::worker',
effects: [],
setupState: 'not_applicable'
})
db.markWorkerDispatchReady(started.dispatch.id)
await call('orchestration.send', {
from: worker.handle,
subject: 'Done',
type: 'worker_done',
payload: JSON.stringify({ taskId, dispatchId: started.dispatch.id, outcome: 'succeeded' })
})
}
async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: string }> {
const created = await call(
'orchestration.runCreate',
{ objective: 'ship' },
{
sessionId: COORDINATOR
}
)
const runId = idOf(created.run)
const task = await call(
'orchestration.taskCreate',
{ spec: 'build it' },
{
sessionId: COORDINATOR
}
)
return { runId, taskId: idOf(task.task) }
}
/** `/clear` as the chat surface runs it: the conversation continues in a new session. */
async function clearChat(sessionId: string): Promise<string> {
const command = 'clear' as const
const cleared = await host.conversationCommand(
{ callerKey: 'test-surface' },
{
command,
envelope: {
sessionId,
clientOperationId: operationId(),
expectedRuntimeFence: host.deps.store.getRecord(sessionId)!.lease.runtimeFence,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.conversationCommand',
sessionId,
fields: { command }
})
}
}
)
const successor = cleared.ok ? cleared.value.replacementSessionId : undefined
if (!successor) {
throw new Error(`clear failed: ${JSON.stringify(cleared)}`)
}
// The surface swaps the tab over to the session that continues the chat.
await host.setSessionTabVisibility(sessionId, false)
await host.setSessionTabVisibility(successor, true)
return successor
}
/** A cleared chat's successor runs once the user writes to it; only then can its agent act. */
async function startSuccessor(successor: string): Promise<void> {
expect(await sendUserMessage(successor, 'hello')).toMatchObject({ ok: true })
await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(1), WAIT)
await settleTurn(successor, 0)
}
beforeEach(async () => {
resetProviderFaults()
root = await mkdtemp(join(tmpdir(), 'orca-structured-coordinator-mail-'))
codex = fakeCodex()
db = new OrchestrationDb(':memory:')
runtime = startRuntime()
host = await ensureStructuredAgentSessionHost({
logger: createStructuredAgentSessionLogger(),
stateDirectory: root,
hostId: 'local',
claimKeyId: 'key-1',
resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`,
resolveCodexCommand: () => '/usr/local/bin/codex',
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
resolveEnvironment: async () => ({ PATH: '/usr/bin' }),
openCodexConnection: codex.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
// The same call the runtime's own host install makes on every status change.
onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary)
})
dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS })
})
/** The runtime over the shared database; a second call is what an Orca restart leaves behind. */
function startRuntime(): OrcaRuntimeService {
const started = new OrcaRuntimeService()
started.setOrchestrationDb(db)
vi.spyOn(started, 'ensureStructuredAgentSessionHost').mockResolvedValue()
vi.spyOn(started, 'getTerminalPaneKey').mockImplementation((handle) =>
handle === 'term_worker' ? WORKER_PANE : handle === 'term_worker_2' ? WORKER_2_PANE : null
)
return started
}
afterEach(async () => {
try {
await stopStructuredAgentSessionRuntime()
db.close()
await observationClock.drainClosedDatabaseRepair()
vi.restoreAllMocks()
await rm(root, { recursive: true, force: true })
} finally {
observationClock.restore()
}
})
// Pointers are sent on asynchronous edges; the default 1s wait is too tight under a loaded parallel run.
const WAIT = { timeout: 10_000 }
const POINTER =
/You have 1 orchestration message\. Run `orca(-dev)? orchestration check --run run_\w+`\./
/** The text the PTY lane types into a local terminal for this mailbox, byte for byte. */
function ptyPointer(mailboxHandle: string): string {
return formatMessagePointer(1, mailboxHandle, localOrchestrationCliCommand()).trim()
}
/** The text of a turn the fake provider received. */
function turnText(turn: { text: string }): string {
const input: unknown = JSON.parse(turn.text)
return Array.isArray(input)
? input.map((item: unknown) => (isRecord(item) ? String(item.text) : '')).join('')
: ''
}
COORDINATOR,
PEER_CHAT,
WORKER_2_PANE,
codex,
runtime,
db,
host,
dispatcher,
observationClock,
request,
call,
openChat,
connectionFor,
settleTurn,
sendUserMessage,
userTexts,
finishWorker,
coordinatorRunAndTask,
clearChat,
startSuccessor,
WAIT,
POINTER,
ptyPointer,
turnText,
queuedCardTexts,
restartRuntime
} from './structured-chat-coordinator-mail-rig.test-fixture'
describe('a worker result reaches the structured chat that coordinates it', () => {
it('lands as a turn in the coordinator journal, and a flagless check returns the worker_done', async () => {
@@ -522,8 +254,7 @@ describe('a worker result reaches the structured chat that coordinates it', () =
// The next process: a fresh runtime over the same database redrives restored mail. The
// provider still dies, so exactly one start proves it is pointed once, not in a loop.
runtime = startRuntime()
dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS })
restartRuntime()
const before = providerFaults.starts
await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(2), WAIT)
await observationClock.observe(1_500)
@@ -658,9 +389,10 @@ describe('a worker result reaches the structured chat that coordinates it', () =
}
})
it('holds mail a refused turn left in doubt until the next result, then points it once', async () => {
it("holds mail a refused turn left in doubt, then queues the next pointer behind it as the person's message would wait", async () => {
// A failed turn/start cannot prove the turn never started, so the host records it `unknown`
// and a resend under its id replays that; new mail is a new send.
// and a resend under its id replays that. A live doubt counts as work still owed, so the next
// result's pointer waits in the chat's queue, as a message the person sent then would.
const chat = await openChat(COORDINATOR)
const { runId, taskId } = await coordinatorRunAndTask()
const second = await call(
@@ -676,10 +408,14 @@ describe('a worker result reaches the structured chat that coordinates it', () =
expect(chat.turns).toHaveLength(0)
await finishWorker(idOf(second.task), { handle: 'term_worker_2', paneKey: WORKER_2_PANE })
await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT)
expect(turnText(chat.turns[0]!)).toBe(
formatMessagePointer(2, `run:${runId}`, localOrchestrationCliCommand()).trim()
await vi.waitFor(
async () =>
expect(await queuedCardTexts()).toEqual([
formatMessagePointer(2, `run:${runId}`, localOrchestrationCliCommand()).trim()
]),
WAIT
)
expect(chat.turns).toHaveLength(0)
expect(codex.connections.length).toBe(before)
})
@@ -879,10 +615,11 @@ describe('a /clear keeps the chat its orchestration address', () => {
expect(sent).toMatchObject({ message: { to_handle: `orca_session_id:${PEER_CHAT}` } })
await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(index + 1), WAIT)
await settleTurn(successor, index)
// Read each ping before the next is sent, so each check holds exactly one.
const checked = await call('orchestration.check', {}, { sessionId: successor })
expect(checked).toMatchObject({ count: 1, messages: [{ subject: `ping ${index}` }] })
await call('orchestration.check', { ack: checked.deliveryId }, { sessionId: successor })
}
await expect(call('orchestration.check', {}, { sessionId: successor })).resolves.toMatchObject({
count: 3
})
})
})
@@ -109,6 +109,9 @@ describe('dashboard popout clipboard access', () => {
expect(() => handlers.get('clipboard:readFilePaths')?.(popoutEvent)).toThrow(
'Unauthorized clipboard IPC sender'
)
expect(() =>
handlers.get('clipboard:restoreNativeChatPastes')?.(popoutEvent, ['/etc/passwd'])
).toThrow('Unauthorized clipboard IPC sender')
expect(() =>
handlers.get('clipboard:writeFile')?.(popoutEvent, {
filePath: '/tmp/copied-file.txt',
@@ -1,12 +1,16 @@
import { dirname, join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { writeFileMock, getPathMock, writeFileBase64Mock } = vi.hoisted(() => ({
const { writeFileMock, mkdirMock, getPathMock, writeFileBase64Mock } = vi.hoisted(() => ({
writeFileMock: vi.fn(),
getPathMock: vi.fn(() => '/var/folders/ab/T'),
mkdirMock: vi.fn(),
getPathMock: vi.fn((name: string) =>
name === 'temp' ? '/os/temp' : '/Users/me/Library/Application Support/orca'
),
writeFileBase64Mock: vi.fn()
}))
vi.mock('node:fs/promises', () => ({ default: { writeFile: writeFileMock } }))
vi.mock('node:fs/promises', () => ({ default: { writeFile: writeFileMock, mkdir: mkdirMock } }))
vi.mock('node:crypto', () => ({ randomUUID: () => 'uuid-1' }))
vi.mock('../../shared/app-environment', () => ({
getAppEnvironment: () => ({ getPath: getPathMock })
@@ -25,10 +29,28 @@ beforeEach(() => {
})
describe('saveClipboardImageBufferAsTempFile', () => {
it('writes the pasted image to the local temp folder', async () => {
it('keeps a terminal, editor or phone paste in OS temp, as before', async () => {
const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1, 2, 3]))
expect(savedPath.startsWith('/var/folders/ab/T')).toBe(true)
expect(getPathMock).toHaveBeenCalledWith('temp')
expect(getPathMock).not.toHaveBeenCalledWith('userData')
expect(mkdirMock).not.toHaveBeenCalled()
expect(dirname(savedPath)).toBe('/os/temp')
expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3]))
})
it('writes a native-chat composer paste into the paste folder, where its draft can find it', async () => {
const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1, 2, 3]), {
forNativeChatDraft: true
})
expect(mkdirMock).toHaveBeenCalledWith(
join('/Users/me/Library/Application Support/orca', 'native-chat-pastes'),
{ recursive: true }
)
expect(dirname(savedPath)).toBe(
join('/Users/me/Library/Application Support/orca', 'native-chat-pastes')
)
expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3]))
})
+12 -2
View File
@@ -2,14 +2,17 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { getAppEnvironment } from '../../shared/app-environment'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { getAppEnvironment } from '../../shared/app-environment'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image'
import { nativeChatPasteFolder } from './native-chat-paste-files'
export type SaveClipboardImageAsTempFileArgs = {
connectionId?: string | null
runtimeEnvironmentId?: string | null
/** A native-chat composer paste: kept in Orca's paste folder so its draft can bring it back. */
forNativeChatDraft?: boolean
}
const REMOTE_CLIPBOARD_IMAGE_TEMP_DIR = '/tmp'
@@ -39,7 +42,14 @@ export async function saveClipboardImageBufferAsTempFile(
return remotePath
}
const tempPath = path.join(getAppEnvironment().getPath('temp'), fileName)
// Why only a composer paste goes to the paste folder: its draft can bring it back after a
// restart, while terminal, editor and phone pastes stay in OS temp, as they always have.
let folder = getAppEnvironment().getPath('temp')
if (args?.forNativeChatDraft === true) {
folder = nativeChatPasteFolder()
await fs.mkdir(folder, { recursive: true })
}
const tempPath = path.join(folder, fileName)
await fs.writeFile(tempPath, buffer)
return tempPath
}
@@ -84,9 +84,7 @@ vi.mock('node:fs/promises', () => ({
stat: fsStatMock,
realpath: vi.fn(), // unused here; only satisfies filesystem-path-containment's named import
writeFile: fsWriteFileMock,
default: {
writeFile: fsWriteFileMock
}
default: { writeFile: fsWriteFileMock, mkdir: fsMkdirMock }
}))
vi.mock('../ipc/filesystem-auth', () => ({
@@ -44,6 +44,7 @@ import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths'
import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail'
import { writeClipboardTextAndVerify } from './clipboard-text-write-verify'
import { isDashboardPopoutRenderer } from './dashboard-popout-window'
import { restoreNativeChatPastes, sweepExpiredNativeChatPastes } from './native-chat-paste-files'
let trustedClipboardRendererWebContentsId: number | null = null
@@ -101,8 +102,10 @@ export function registerClipboardHandlers(store: Store): void {
ipcMain.removeHandler('clipboard:readImageThumbnail')
ipcMain.removeHandler('clipboard:hasImage')
ipcMain.removeHandler('clipboard:readFilePaths')
ipcMain.removeHandler('clipboard:restoreNativeChatPastes')
void cleanupExpiredRemoteClipboardFiles()
void sweepExpiredNativeChatPastes()
scheduleLegacyRemoteClipboardFileCleanup()
ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => {
@@ -116,6 +119,10 @@ export function registerClipboardHandlers(store: Store): void {
return assertClipboardTextWithinLimitWithYield(clipboard.readText('selection'), options)
}
)
ipcMain.handle('clipboard:restoreNativeChatPastes', (event, paths: unknown) => {
assertTrustedClipboardSender(event)
return restoreNativeChatPastes(paths)
})
// Why: an unanswered paste reads as a dropped paste, so the composer probes
// the clipboard in memory before the (slower) save lands.
ipcMain.handle('clipboard:readImageThumbnail', (event): ClipboardImageThumbnail | null => {
@@ -39,7 +39,7 @@ vi.mock('node:fs/promises', () => ({
stat: vi.fn(),
realpath: vi.fn(),
writeFile: fsWriteFileMock,
default: { writeFile: fsWriteFileMock }
default: { writeFile: fsWriteFileMock, mkdir: vi.fn() }
}))
vi.mock('../ipc/filesystem-auth', () => ({
PATH_ACCESS_DENIED_MESSAGE: 'denied',
@@ -0,0 +1,290 @@
import {
existsSync,
lutimesSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../shared/agent-session-host-authority'
import type { Store } from '../persistence'
import { resolveLocalFileRequestPath } from '../ipc/local-file-access-resolution'
import { readLocalFileContent } from '../ipc/filesystem/filesystem-file-content-inspection'
import {
NATIVE_CHAT_PASTE_TTL_MS,
isInsideNativeChatPasteFolder,
restoreNativeChatPastes,
sweepExpiredNativeChatPastes
} from './native-chat-paste-files'
// A store with no projects: nothing but an access kind decides what a read may reach.
const NO_PROJECTS: Store = Object.assign(Object.create(null), {
getRepos: () => [],
getProjects: () => [],
getProjectGroups: () => [],
getFolderWorkspaces: () => [],
getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' })
})
/** Whether the composer preview's chat-image access can read `target`. */
async function chatImageReadable(target: string): Promise<boolean> {
try {
await readLocalFileContent(
await resolveLocalFileRequestPath(target, { kind: 'chat-image' }, NO_PROJECTS)
)
return true
} catch {
return false
}
}
describe('isInsideNativeChatPasteFolder', () => {
const posixFolder = '/data/native-chat-pastes'
const winFolder = 'C:\\Users\\Me\\AppData\\Roaming\\Orca\\native-chat-pastes'
it.each([
['a file inside', `${posixFolder}/orca-paste-1.png`, true],
['a name that only starts with dots', `${posixFolder}/..orca-paste-1.png`, true],
['the folder itself', posixFolder, false],
['the parent', '/data', false],
['a sibling reached through ..', `${posixFolder}/../secret.png`, false],
['a sibling folder sharing the prefix', '/data/native-chat-pastes-evil/x.png', false],
['an unrelated absolute path', '/etc/passwd', false]
])('posix: %s', (_label, target, inside) => {
expect(isInsideNativeChatPasteFolder(posixFolder, target, path.posix, 'darwin')).toBe(inside)
})
it.each([
['a file inside', `${winFolder}\\orca-paste-1.png`, true],
['a file inside in other letter case', `${winFolder.toLowerCase()}\\ORCA-PASTE-1.PNG`, true],
['a \\\\?\\ prefixed file inside', `\\\\?\\${winFolder}\\orca-paste-1.png`, true],
['another drive', 'D:\\native-chat-pastes\\orca-paste-1.png', false],
['a \\\\?\\UNC share', '\\\\?\\UNC\\server\\share\\orca-paste-1.png', false],
['a sibling reached through ..', `${winFolder}\\..\\secret.png`, false],
['the folder itself', winFolder, false]
])('win32: %s', (_label, target, inside) => {
expect(isInsideNativeChatPasteFolder(winFolder, target, path.win32, 'win32')).toBe(inside)
})
it('compares a \\\\?\\ prefixed folder like its plain form', () => {
expect(
isInsideNativeChatPasteFolder(
`\\\\?\\${winFolder}`,
`${winFolder}\\orca-paste-1.png`,
path.win32,
'win32'
)
).toBe(true)
})
})
describe('native-chat paste folder on disk', () => {
let root: string
let folder: string
beforeEach(() => {
root = mkdtempSync(path.join(tmpdir(), 'orca-native-chat-pastes-'))
folder = path.join(root, 'native-chat-pastes')
mkdirSync(folder)
installFakeAppEnvironment({ getPath: () => root })
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
it('keeps only files really inside the folder, and never throws on a bad path', async () => {
const kept = path.join(folder, 'orca-paste-1.png')
writeFileSync(kept, 'png')
const outside = path.join(root, 'outside.png')
writeFileSync(outside, 'png')
const linkOut = path.join(folder, 'orca-paste-2.png')
symlinkSync(outside, linkOut)
mkdirSync(path.join(folder, 'orca-paste-dir.png'))
const results = await restoreNativeChatPastes([
kept,
linkOut,
path.join(folder, '..', 'outside.png'),
path.join(folder, 'orca-paste-dir.png'),
path.join(folder, 'orca-paste-missing.png'),
'relative/orca-paste-3.png',
'',
42
])
expect(results).toEqual([
{ path: kept, kept: true, exists: true },
{ path: linkOut, kept: false, exists: false },
{ path: path.join(folder, '..', 'outside.png'), kept: false, exists: false },
{ path: path.join(folder, 'orca-paste-dir.png'), kept: false, exists: false },
{ path: path.join(folder, 'orca-paste-missing.png'), kept: false, exists: false },
{ path: 'relative/orca-paste-3.png', kept: false, exists: false },
{ path: '', kept: false, exists: false }
])
await expect(restoreNativeChatPastes('not a list')).resolves.toEqual([])
})
it('leaves a kept paste readable by the composer preview, with no grant', async () => {
const kept = path.join(folder, 'orca-paste-1.png')
writeFileSync(kept, 'png')
await expect(restoreNativeChatPastes([kept])).resolves.toEqual([
{ path: kept, kept: true, exists: true }
])
const readable = await resolveLocalFileRequestPath(kept, { kind: 'chat-image' }, NO_PROJECTS)
await expect(readLocalFileContent(readable)).resolves.toMatchObject({ mimeType: 'image/png' })
})
it('keeps a paste reached through a symlinked alias of the folder, as /var is of /private/var', async () => {
const kept = path.join(folder, 'orca-paste-1.png')
writeFileSync(kept, 'png')
const alias = path.join(tmpdir(), `orca-native-chat-pastes-alias-${process.pid}`)
rmSync(alias, { force: true })
symlinkSync(root, alias)
try {
installFakeAppEnvironment({ getPath: () => alias })
const viaAlias = path.join(alias, 'native-chat-pastes', 'orca-paste-1.png')
await expect(restoreNativeChatPastes([viaAlias, realpathSync(kept)])).resolves.toEqual([
{ path: viaAlias, kept: true, exists: true },
{ path: realpathSync(kept), kept: true, exists: true }
])
// The preview reads by the stored spelling, through chat-image access.
expect(await chatImageReadable(viaAlias)).toBe(true)
} finally {
rmSync(alias, { force: true })
}
})
it('refuses a path that names an outside file as text while its real path is inside', async () => {
const secret = path.join(root, 'outside', 'id_rsa')
mkdirSync(path.dirname(secret), { recursive: true })
writeFileSync(secret, 'PRIVATE KEY')
const paste = path.join(folder, 'orca-paste-1.png')
writeFileSync(paste, 'png')
// `s/..` resolves through a link for real, but by text it climbs to the secret.
const workspace = path.join(root, 'ws')
const depth = workspace.split(path.sep).filter(Boolean).length + 1
const deep = path.join(workspace, ...Array.from({ length: depth }, (_, i) => `d${i}`))
mkdirSync(deep, { recursive: true })
symlinkSync(deep, path.join(workspace, 's'))
const tail = secret.slice(1)
mkdirSync(path.dirname(path.join(workspace, tail)), { recursive: true })
symlinkSync(paste, path.join(workspace, tail))
const crafted = `${workspace}/s/${'../'.repeat(depth)}${tail}`
expect(path.resolve(crafted)).toBe(secret)
await expect(restoreNativeChatPastes([crafted])).resolves.toEqual([
{ path: crafted, kept: false, exists: false }
])
// An outside file never becomes readable: nothing is granted, and chat-image reads only images.
expect(await chatImageReadable(secret)).toBe(false)
expect(await chatImageReadable(crafted)).toBe(false)
})
it('keeps a paste by its real path, and never makes the file its stored spelling names readable', async () => {
const secret = path.join(root, 'outside', 'id_rsa')
mkdirSync(path.dirname(secret), { recursive: true })
writeFileSync(secret, 'PRIVATE KEY')
// `folder/link/../y` reaches a real paste through `link`, while `folder/y` by text is a link out.
const sub = path.join(folder, 'sub')
mkdirSync(sub)
mkdirSync(path.join(sub, 'deeper'))
writeFileSync(path.join(sub, 'orca-paste-y.png'), 'png')
symlinkSync(path.join(sub, 'deeper'), path.join(folder, 'link'))
symlinkSync(secret, path.join(folder, 'orca-paste-y.png'))
const restored = `${folder}/link/../orca-paste-y.png`
expect(realpathSync.native(restored)).toBe(realpathSync(path.join(sub, 'orca-paste-y.png')))
await expect(restoreNativeChatPastes([restored])).resolves.toEqual([
{ path: restored, kept: true, exists: true }
])
expect(await chatImageReadable(secret)).toBe(false)
expect(await chatImageReadable(realpathSync(secret))).toBe(false)
expect(await chatImageReadable(path.join(folder, 'orca-paste-y.png'))).toBe(false)
})
it('neither restores from nor sweeps a paste folder that is itself a link', async () => {
const outside = path.join(root, 'Documents')
mkdirSync(outside)
const old = (Date.now() - NATIVE_CHAT_PASTE_TTL_MS - 60_000) / 1000
for (const name of ['orca-paste-1.png', 'tax-return.pdf']) {
writeFileSync(path.join(outside, name), 'x')
utimesSync(path.join(outside, name), old, old)
}
rmSync(folder, { recursive: true })
symlinkSync(outside, folder)
await expect(restoreNativeChatPastes([path.join(folder, 'orca-paste-1.png')])).resolves.toEqual(
[{ path: path.join(folder, 'orca-paste-1.png'), kept: false, exists: false }]
)
await sweepExpiredNativeChatPastes()
expect(existsSync(path.join(outside, 'orca-paste-1.png'))).toBe(true)
expect(existsSync(path.join(outside, 'tax-return.pdf'))).toBe(true)
})
it('expires only Orca paste files, whatever else is in the folder', async () => {
const old = (Date.now() - NATIVE_CHAT_PASTE_TTL_MS - 60_000) / 1000
for (const name of ['orca-paste-old.png', 'notes.txt']) {
writeFileSync(path.join(folder, name), 'x')
utimesSync(path.join(folder, name), old, old)
}
await sweepExpiredNativeChatPastes()
expect(existsSync(path.join(folder, 'orca-paste-old.png'))).toBe(false)
expect(existsSync(path.join(folder, 'notes.txt'))).toBe(true)
})
it('reports nothing kept when the folder does not exist yet', async () => {
rmSync(folder, { recursive: true })
await expect(restoreNativeChatPastes([path.join(folder, 'orca-paste-1.png')])).resolves.toEqual(
[{ path: path.join(folder, 'orca-paste-1.png'), kept: false, exists: false }]
)
})
it('expires old pastes only, and never follows a symlink or enters a folder', async () => {
const now = Date.now()
const old = (now - NATIVE_CHAT_PASTE_TTL_MS - 60_000) / 1000
const oldPaste = path.join(folder, 'orca-paste-old.png')
const newPaste = path.join(folder, 'orca-paste-new.png')
writeFileSync(oldPaste, 'png')
writeFileSync(newPaste, 'png')
utimesSync(oldPaste, old, old)
const outsideOld = path.join(root, 'outside-old.png')
writeFileSync(outsideOld, 'png')
utimesSync(outsideOld, old, old)
symlinkSync(outsideOld, path.join(folder, 'orca-paste-link.png'))
lutimesSync(path.join(folder, 'orca-paste-link.png'), old, old)
const nested = path.join(folder, 'nested')
mkdirSync(nested)
const nestedOld = path.join(nested, 'orca-paste-nested.png')
writeFileSync(nestedOld, 'png')
utimesSync(nestedOld, old, old)
await sweepExpiredNativeChatPastes(now)
expect(existsSync(oldPaste)).toBe(false)
expect(existsSync(newPaste)).toBe(true)
expect(existsSync(outsideOld)).toBe(true)
expect(existsSync(path.join(folder, 'orca-paste-link.png'))).toBe(true)
expect(existsSync(nestedOld)).toBe(true)
})
it('does nothing, and does not throw, when the folder is missing', async () => {
rmSync(folder, { recursive: true })
await expect(sweepExpiredNativeChatPastes()).resolves.toBeUndefined()
})
it('keeps a paste longer than one send id stays valid on the host', () => {
expect(NATIVE_CHAT_PASTE_TTL_MS).toBeGreaterThan(AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS)
})
})
+138
View File
@@ -0,0 +1,138 @@
// Local native-chat pastes live in an Orca-owned folder, so a restored draft can show and send them:
// a restore keeps only files that really are inside it, and old files expire.
import { lstat, readdir, realpath, stat, unlink } from 'node:fs/promises'
import path from 'node:path'
import { getAppEnvironment } from '../../shared/app-environment'
import { NATIVE_CHAT_PASTE_FOLDER } from '../../shared/native-chat-paste-folder'
// Why 30 days: no age bounds what can still name a paste (a queued send is retried with a new id
// after the host's 24 h id window), so this is a judgment. A draft or outbox entry kept longer meets
// its image as a placeholder or a failed send, and a sent paste's file lingers until then.
export const NATIVE_CHAT_PASTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
const PASTE_FILE_NAME = /^orca-paste-.+\.png$/i
const MAX_RESTORED_PASTES = 256
type PathApi = typeof path.posix
export type RestoredNativeChatPaste = { path: string; kept: boolean; exists: boolean }
export function nativeChatPasteFolder(): string {
return path.join(getAppEnvironment().getPath('userData'), NATIVE_CHAT_PASTE_FOLDER)
}
/** A path as compared for containment: no `\\?\` prefix, and case-folded where the platform is. */
function comparablePath(value: string, pathApi: PathApi, platform: string): string {
const unprefixed = value.replace(/^\\\\\?\\UNC\\/i, '\\\\').replace(/^\\\\\?\\/, '')
const normalized = pathApi.normalize(unprefixed)
return platform === 'win32' ? normalized.toLowerCase() : normalized
}
/** True when `target` names something strictly inside `folder`; both must already be real paths. */
export function isInsideNativeChatPasteFolder(
folder: string,
target: string,
pathApi: PathApi = path,
platform: string = process.platform
): boolean {
const relative = pathApi.relative(
comparablePath(folder, pathApi, platform),
comparablePath(target, pathApi, platform)
)
return (
relative !== '' &&
relative !== '..' &&
!relative.startsWith(`..${pathApi.sep}`) &&
!pathApi.isAbsolute(relative)
)
}
/**
* For each restored local paste: kept only when its real path is a file inside the real paste
* folder (symlinks and junctions resolved). Anything else comes back as a placeholder. Nothing is
* granted: the preview reads a paste with chat-image access. Never throws.
*/
export async function restoreNativeChatPastes(paths: unknown): Promise<RestoredNativeChatPaste[]> {
if (!Array.isArray(paths)) {
return []
}
const folders = await realPasteFolder()
return Promise.all(
paths
.slice(0, MAX_RESTORED_PASTES)
.flatMap((value) =>
typeof value === 'string' ? [restoreNativeChatPaste(folders, value)] : []
)
)
}
/** The paste folder as configured and as real path; null when missing or itself a link. */
async function realPasteFolder(): Promise<{ named: string; real: string } | null> {
try {
const named = path.resolve(nativeChatPasteFolder())
const info = await lstat(named)
return info.isDirectory() && !info.isSymbolicLink()
? { named, real: await realpath(named) }
: null
} catch {
return null
}
}
async function restoreNativeChatPaste(
folders: { named: string; real: string } | null,
restored: string
): Promise<RestoredNativeChatPaste> {
const refused = { path: restored, kept: false, exists: false }
if (folders === null || restored === '' || !path.isAbsolute(restored)) {
return refused
}
// Why both: the text the draft stores and the file it really names must each be inside.
const named = path.resolve(restored)
if (
!isInsideNativeChatPasteFolder(folders.named, named) &&
!isInsideNativeChatPasteFolder(folders.real, named)
) {
return refused
}
try {
const real = await realpath(restored)
if (!isInsideNativeChatPasteFolder(folders.real, real) || !(await stat(real)).isFile()) {
return refused
}
return { path: restored, kept: true, exists: true }
} catch {
// Missing or unreadable: not kept, and nothing about an outside path is reported.
return refused
}
}
/** Deletes pastes older than the TTL: only Orca's paste files, never a link, a folder, or anything
* in a paste folder that is itself a link. Failures are logged and never block startup. */
export async function sweepExpiredNativeChatPastes(now = Date.now()): Promise<void> {
const folders = await realPasteFolder()
if (!folders) {
return
}
const folder = folders.named
let entries
try {
entries = await readdir(folder, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (!entry.isFile() || !PASTE_FILE_NAME.test(entry.name)) {
continue
}
const file = path.join(folder, entry.name)
try {
const info = await lstat(file)
if (info.isFile() && now - info.mtimeMs > NATIVE_CHAT_PASTE_TTL_MS) {
await unlink(file)
}
} catch (error) {
console.warn('[native-chat-pastes] could not expire a paste:', error)
}
}
}
@@ -93,9 +93,15 @@ export const uiClipboardAndWindowControlsApi = {
saveClipboardImageAsTempFile: (args?: {
connectionId?: string | null
runtimeEnvironmentId?: string | null
/** A native-chat composer paste, kept where its draft can bring it back. */
forNativeChatDraft?: boolean
}): Promise<string | null> => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args),
clipboardHasImage: (): Promise<boolean> => ipcRenderer.invoke('clipboard:hasImage'),
readClipboardFilePaths: (): Promise<string[]> => ipcRenderer.invoke('clipboard:readFilePaths'),
restoreNativeChatPastes: (
paths: string[]
): Promise<{ path: string; kept: boolean; exists: boolean }[]> =>
ipcRenderer.invoke('clipboard:restoreNativeChatPastes', paths),
readClipboardImageThumbnail: (): Promise<ClipboardImageThumbnail | null> =>
ipcRenderer.invoke('clipboard:readImageThumbnail'),
writeClipboardText: (text: string): Promise<void> =>
+6
View File
@@ -12,10 +12,16 @@ export type UiWindowApi = {
saveClipboardImageAsTempFile: (args?: {
connectionId?: string | null
runtimeEnvironmentId?: string | null
/** A native-chat composer paste, kept where its draft can bring it back. */
forNativeChatDraft?: boolean
}) => Promise<string | null>
clipboardHasImage: () => Promise<boolean | null>
/** Paths of files a file manager copied; empty when there are none or the host cannot list them. */
readClipboardFilePaths: () => Promise<string[]>
/** Which restored draft pastes are still kept: files really in Orca's paste folder. */
restoreNativeChatPastes: (
paths: string[]
) => Promise<{ path: string; kept: boolean; exists: boolean }[]>
readClipboardImageThumbnail: () => Promise<ClipboardImageThumbnail | null>
writeClipboardText: (text: string) => Promise<void>
writeTerminalClipboardText: (text: string) => Promise<void>
@@ -89,8 +89,7 @@ describe('PtyHandler', () => {
expect(term.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
const submit = process.platform === 'win32' ? '\r' : '\n'
expect(term.write).toHaveBeenCalledWith(`echo provider-owned${submit}`)
expect(term.write).toHaveBeenCalledWith('echo provider-owned\r')
expect(handler.retainedStartupCommandCount).toBe(0)
})
@@ -364,7 +363,7 @@ describe('PtyHandler', () => {
expect(term.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(term.write).toHaveBeenCalledWith('echo after-ready\n')
expect(term.write).toHaveBeenCalledWith('echo after-ready\r')
expect(handler.retainedStartupCommandCount).toBe(0)
vi.advanceTimersByTime(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
@@ -421,7 +420,7 @@ describe('PtyHandler', () => {
promptOptions.onPromptReady()
await vi.advanceTimersByTimeAsync(50)
expect(term.write).toHaveBeenCalledWith('echo after-exec\n')
expect(term.write).toHaveBeenCalledWith('echo after-exec\r')
expect(handler.retainedStartupCommandCount).toBe(0)
}
)
@@ -635,7 +634,7 @@ describe('PtyHandler', () => {
dataCallback?.('\x1b]777;orca-shell-ready')
vi.advanceTimersByTime(1500)
expect(term.write).toHaveBeenCalledWith('echo fallback\n')
expect(term.write).toHaveBeenCalledWith('echo fallback\r')
vi.advanceTimersByTime(8)
expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
id: PTY_1,
-2
View File
@@ -1025,10 +1025,8 @@ export class PtyHandler {
if (heldBytes) {
managed.startupIngress?.accept(heldBytes)
}
const submit = process.platform === 'win32' ? '\r' : '\n'
// Why: only the shell-ready wrapper arms bracketed-paste; other shells use raw submit so ESC[200~ markers aren't echoed.
const payload = buildStartupCommandSubmission(startup.command, {
submit,
bracketedPasteSafe: startup.waitForShellReady
})
managed.startupCommand = undefined
+3
View File
@@ -24,6 +24,7 @@ import { useAppChromeLayout } from './app-shell/use-app-chrome-layout'
import { useAppSessionPersistence } from './app-shell/use-app-session-persistence'
import { useAppShellServices } from './app-shell/use-app-shell-services'
import { useAppStartupHydration } from './app-shell/use-app-startup-hydration'
import { startNativeChatDraftLoad } from './app-shell/native-chat-draft-startup'
import { useDocumentAppearance } from './app-shell/use-document-appearance'
import { useFloatingWorkspacePanel } from './app-shell/use-floating-workspace-panel'
import { useGlobalKeybindings } from './app-shell/use-global-keybindings'
@@ -43,6 +44,8 @@ function App(): React.JSX.Element {
useAppShellServices({
floatingPanelVisible: floatingWorkspace.enabled && floatingWorkspace.open
})
// Why before the startup chain: its effect runs first, and no startup step can skip the load.
useEffect(startNativeChatDraftLoad, [])
useAppStartupHydration(onboardingGate.applyStartupOnboardingState)
useAppSessionPersistence()
useRuntimeGraphSync()
@@ -0,0 +1,25 @@
import { useAppStore } from '../store'
import { resolveNativeChatDraftOwner } from '../lib/native-chat-draft-owner'
import {
hydrateNativeChatComposerDrafts,
setNativeChatComposerDraftOwnerResolver,
waitForNativeChatComposerDrafts
} from '@/components/native-chat/native-chat-composer-draft-store'
// Why bounded: loading drafts is bookkeeping and must never hold startup; a slower load still
// fills in every draft not edited meanwhile when it lands.
const DRAFT_LOAD_WAIT_MS = 1_500
/** Before any startup step, so a step that fails can't leave the drafts unloaded. */
export function startNativeChatDraftLoad(): void {
setNativeChatComposerDraftOwnerResolver((scopeKey) =>
resolveNativeChatDraftOwner(useAppStore.getState(), scopeKey)
)
void hydrateNativeChatComposerDrafts()
}
/** Startup waits for the drafts alongside the session read, so a composer shows its draft from
* its first frame. */
export function waitForNativeChatDraftsAtStartup(): Promise<void> {
return waitForNativeChatComposerDrafts(DRAFT_LOAD_WAIT_MS)
}
@@ -5,6 +5,7 @@ import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-p
import { useAppStore } from '../store'
import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models'
import { useStartupActions } from './use-app-startup-actions'
import { waitForNativeChatDraftsAtStartup } from './native-chat-draft-startup'
import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees'
import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep'
import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-hydration'
@@ -180,7 +181,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
// Why: wait for both writers to settle before recovery so neither can mutate hydrated state afterward.
const [sessionOutcome, catalogOutcome] = await Promise.allSettled([
hydrationSessionChain,
localCatalogChain
localCatalogChain,
timeRendererStartupStep('native-chat-drafts', waitForNativeChatDraftsAtStartup)
])
if (sessionOutcome.status === 'rejected') {
throw sessionOutcome.reason
@@ -145,6 +145,7 @@ export function usePersistedUIWriter(): void {
const ui = useAppStore(
useShallow((s): PersistedUIWriteBaseline => ({
sidebarWidth: s.sidebarWidth,
sidebarOpen: s.sidebarOpen,
rightSidebarOpen: s.rightSidebarOpen,
rightSidebarTab: s.rightSidebarTab,
rightSidebarExplorerView: s.rightSidebarExplorerView,
@@ -270,6 +270,40 @@ describe('workspace view preferences: cross-client persistence (STA-5781)', () =
expect(after.hideCliCreatedWorkspaces).toBe(before.hideCliCreatedWorkspaces)
})
it('persists a left sidebar close across an unrelated sync and restores it on startup', async () => {
act(() => {
store.getState().toggleSidebar()
authority.set({ sidebarWidth: 320 })
})
deliverBroadcasts()
expect(store.getState().sidebarOpen).toBe(false)
await flushDesktopDebounce()
expect(authority.get().sidebarOpen).toBe(false)
const restarted = createUIStore()
restarted.getState().hydratePersistedUI(authority.get(), 'startup')
expect(restarted.getState().sidebarOpen).toBe(false)
expect(restarted.getState().sidebarWidth).toBe(320)
})
it('persists a left sidebar reopen while the close acknowledgement is still pending', async () => {
holdAcks = true
act(() => store.getState().toggleSidebar())
await flushDesktopDebounce()
expect(authority.get().sidebarOpen).toBe(false)
act(() => store.getState().toggleSidebar())
deliverBroadcasts()
expect(store.getState().sidebarOpen).toBe(true)
await resolveAcks()
await flushDesktopDebounce()
await resolveAcks()
deliverBroadcasts()
expect(authority.get().sidebarOpen).toBe(true)
expect(store.getState().sidebarOpen).toBe(true)
expect(store.getState().persistedUIWriteInFlightCounts).toEqual({})
})
it('a mobile tap must not revert a desktop change the mobile mirror has not seen', async () => {
const mobile = createMobileClient(authority)
mobile.sync()
@@ -196,7 +196,10 @@ export default function GrabConfirmationSheet({
</div>
{target.accessibility.accessibleName ? (
<div className="mt-1 text-muted-foreground">
{translate('auto.components.browser.pane.GrabConfirmationSheet.eb98a0971a', '"')}
{translate(
'auto.components.browser.pane.GrabConfirmationSheet.accessibleNameOpenQuote',
'"'
)}
<EscapedText text={target.accessibility.accessibleName} />
{translate('auto.components.browser.pane.GrabConfirmationSheet.eb98a0971a', '"')}
</div>
@@ -0,0 +1,71 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MarkupOverlay } from './MarkupOverlay'
afterEach(cleanup)
function renderOverlay() {
const view = render(
<MarkupOverlay
baseImage={{ dataUrl: 'data:image/png;base64,', width: 10, height: 10 }}
busy={false}
onComplete={vi.fn()}
onCancel={vi.fn()}
/>
)
const canvas = view.container.querySelector('canvas')
if (!canvas) {
throw new Error('markup canvas not rendered')
}
canvas.setPointerCapture = vi.fn()
const undoButton = view.getByRole('button', { name: 'Undo' })
const redoButton = view.getByRole('button', { name: 'Redo' })
return { canvas, undoButton, redoButton }
}
// Every ending is followed by the lost capture the browser fires after it.
function stroke(
canvas: HTMLCanvasElement,
end: 'pointerUp' | 'pointerCancel' | 'lostPointerCapture'
): void {
act(() => {
fireEvent.pointerDown(canvas, { pointerId: 1, button: 0, clientX: 0, clientY: 0 })
fireEvent.pointerMove(canvas, { pointerId: 1, clientX: 50, clientY: 0 })
if (end !== 'lostPointerCapture') {
fireEvent[end](canvas, { pointerId: 1, clientX: 50, clientY: 0 })
}
fireEvent.lostPointerCapture(canvas, { pointerId: 1 })
})
}
describe('MarkupOverlay canvas pointer wiring', () => {
it('commits a stroke on release', () => {
const { canvas, undoButton } = renderOverlay()
stroke(canvas, 'pointerUp')
expect(undoButton).toHaveProperty('disabled', false)
})
it('commits a stroke whose release never arrived once the canvas loses the pointer', () => {
const { canvas, undoButton, redoButton } = renderOverlay()
stroke(canvas, 'lostPointerCapture')
act(() => {
fireEvent.click(undoButton)
})
// Only a committed stroke leaves something to redo; a still-held one is just dropped.
expect(redoButton).toHaveProperty('disabled', false)
})
it('discards a stroke whose pointer was cancelled', () => {
const { canvas, undoButton } = renderOverlay()
stroke(canvas, 'pointerCancel')
expect(undoButton).toHaveProperty('disabled', true)
})
})
@@ -66,7 +66,9 @@ export function MarkupOverlay({
onPointerDown={editor.onPointerDown}
onPointerMove={editor.onPointerMove}
onPointerUp={editor.onPointerUp}
onPointerCancel={editor.onPointerUp}
onPointerCancel={editor.onPointerCancel}
// Why: commits a gesture whose release never arrived, so one can never be left open.
onLostPointerCapture={editor.onPointerUp}
/>
{pendingText ? (
@@ -121,7 +123,8 @@ export function MarkupOverlay({
) : null}
<div className="pointer-events-none absolute inset-x-0 bottom-3 flex flex-col items-center gap-2 px-3">
<div className="pointer-events-auto">
{/* Why: cap at the overlay width so the toolbar wraps in a narrow viewport instead of clipping. */}
<div className="pointer-events-auto max-w-full">
<MarkupToolbar
tool={editor.tool}
onToolChange={editor.setTool}
@@ -2,6 +2,7 @@ import React from 'react'
import {
ArrowUpRight,
Circle,
Eraser,
Highlighter,
Pencil,
Redo2,
@@ -59,6 +60,11 @@ function toolItems(): ToolItem[] {
kind: 'text',
icon: Type,
label: translate('auto.components.browser-pane.markup.tool.text', 'Text')
},
{
kind: 'eraser',
icon: Eraser,
label: translate('auto.components.browser-pane.markup.tool.eraser', 'Eraser')
}
]
}
@@ -96,7 +102,7 @@ export const MarkupToolbar = React.memo(function MarkupToolbar({
}: MarkupToolbarProps) {
return (
<TooltipProvider delayDuration={300}>
<div className="flex items-center gap-1 rounded-md border border-border bg-card/95 px-1.5 py-1 shadow-md backdrop-blur">
<div className="flex flex-wrap items-center justify-center gap-1 rounded-md border border-border bg-card/95 px-1.5 py-1 shadow-md backdrop-blur">
{toolItems().map((item) => (
<IconButton
key={item.kind}
@@ -29,8 +29,8 @@ function sceneDevicePixels(
}
// Rasterizes the committed shapes into an offscreen layer. Called only when the
// committed shape list, the size, or the dpr changes — never on the pointermove
// path.
// visible shape list, the size, or the dpr changes — on the pointermove path only
// when an erase drag hits a new mark.
export function renderCommittedLayer(
layer: HTMLCanvasElement,
shapes: readonly MarkupShape[],
@@ -5,9 +5,9 @@
export type MarkupToolKind = 'pen' | 'highlight' | 'arrow' | 'rect' | 'ellipse' | 'text'
// Toolbar selection. Draw-only: markup is a throwaway scribble the user copies
// once, so there is no select/move/restyle cursor.
export type MarkupTool = MarkupToolKind
// Toolbar selection. Marks can be drawn or erased whole: markup is a throwaway
// scribble the user copies once, so there is no select/move/restyle cursor.
export type MarkupTool = MarkupToolKind | 'eraser'
export type MarkupPoint = { x: number; y: number }
@@ -211,3 +211,13 @@ export function arrowHeadGeometry(
export function highlightWidth(width: number): number {
return width * HIGHLIGHT_WIDTH_MULTIPLIER
}
// Radius of the dot a single-point stroke (a tap) leaves.
export function strokeDotRadius(width: number): number {
return Math.max(width / 2, 1)
}
// Line width of the contrasting halo stroked around text.
export function textHaloWidth(fontSize: number): number {
return Math.max(fontSize / 6, 2)
}
@@ -0,0 +1,477 @@
import { describe, expect, it } from 'vitest'
import {
clearShapes,
commitShape,
createMarkupDocument,
redoShape,
undoShape,
type MarkupPoint,
type PenShape,
type TextShape
} from './markup-drawing-model'
import {
applyDocumentCommand,
beginDrawGesture,
beginEraseGesture,
cancelGesture,
canUndoMarkup,
endGesture,
moveGesture,
redoMarkup,
undoMarkup,
type DraggedShape,
type MarkupEditorState
} from './markup-gesture'
const noText = () => null
// A short horizontal pen stroke at height `y`, 100px wide.
function line(id: string, y: number): PenShape {
return {
id,
kind: 'pen',
color: '#ef4444',
width: 2,
points: [
{ x: 0, y },
{ x: 100, y }
]
}
}
function editorWith(...shapes: PenShape[]): MarkupEditorState {
return { doc: shapes.reduce(commitShape, createMarkupDocument()), gesture: null }
}
function erase(state: MarkupEditorState, path: MarkupPoint[], pointerId = 1): MarkupEditorState {
const [first, ...rest] = path
const begun = beginEraseGesture(state, pointerId, first, noText)
return rest.reduce((current, point) => moveGesture(current, pointerId, point, noText), begun)
}
// A rectangle, ellipse or arrow as pressed: no drag yet, so from === to.
function pressedShape(kind: 'rect' | 'ellipse' | 'arrow', at: MarkupPoint): DraggedShape {
return { id: kind, kind, color: '#ef4444', width: 4, from: at, to: at }
}
const ids = (state: MarkupEditorState) => state.doc.shapes.map((shape) => shape.id)
const erasedIds = (state: MarkupEditorState) =>
state.gesture?.kind === 'erase' ? state.gesture.erasedIds : undefined
describe('erase gesture', () => {
it('removes only the clicked mark, leaving the ones drawn after it', () => {
const state = endGesture(
erase(editorWith(line('a', 0), line('b', 100), line('c', 200)), [{ x: 50, y: 0 }]),
1
)
expect(ids(state)).toEqual(['b', 'c'])
expect(state.gesture).toBeNull()
})
it('erases only the newest of two overlapping marks on a click', () => {
const before = editorWith(line('older', 0), line('newer', 0))
const pressed = erase(before, [{ x: 50, y: 0 }])
// The pointer is still down at the press point: only what a release commits is hidden.
expect(erasedIds(pressed)).toEqual(new Set(['newer']))
const after = endGesture(pressed, 1)
expect(ids(after)).toEqual(['older'])
expect(undoShape(after.doc).shapes.map((shape) => shape.id)).toEqual(['older', 'newer'])
})
it('keeps a click a click when a tap jitters by a fraction of a pixel', () => {
const pressed = erase(editorWith(line('older', 0), line('newer', 0)), [{ x: 50, y: 0 }])
const jittered = moveGesture(pressed, 1, { x: 50.6, y: 0.4 }, noText)
expect(jittered).toBe(pressed)
expect(ids(endGesture(jittered, 1))).toEqual(['older'])
})
it('keeps a click a click when the pointer wanders inside the slop and back', () => {
const before = editorWith(line('older', 0), line('newer', 0), line('near', 12))
// `near` is out of reach of the press point but within reach 3.5px below it.
const wandered = erase(before, [
{ x: 50, y: 0 },
{ x: 50, y: 3.5 },
{ x: 50, y: 0 }
])
expect(erasedIds(wandered)).toEqual(new Set(['newer']))
expect(ids(endGesture(wandered, 1))).toEqual(['older', 'near'])
})
it('becomes a drag once the pointer leaves the slop', () => {
const dragged = erase(editorWith(line('older', 0), line('newer', 0)), [
{ x: 50, y: 0 },
{ x: 54, y: 0 }
])
expect(erasedIds(dragged)).toEqual(new Set(['older', 'newer']))
})
it('erases every mark under the press point once the click becomes a drag', () => {
const before = editorWith(line('older', 0), line('newer', 0), line('away', 200))
// The drag leaves the marks behind straight away, so only the press point touched them.
const dragged = erase(before, [
{ x: 50, y: 0 },
{ x: 50, y: 60 }
])
expect(erasedIds(dragged)).toEqual(new Set(['older', 'newer']))
const after = endGesture(dragged, 1)
expect(ids(after)).toEqual(['away'])
expect(undoShape(after.doc).shapes.map((shape) => shape.id)).toEqual(['older', 'newer', 'away'])
})
it('records a whole drag as one undo step that redo replays', () => {
const before = editorWith(line('a', 0), line('b', 100), line('c', 200))
const after = endGesture(
erase(before, [
{ x: 50, y: -50 },
{ x: 50, y: 150 }
]),
1
)
expect(ids(after)).toEqual(['c'])
const undone = undoShape(after.doc)
expect(undone.shapes.map((shape) => shape.id)).toEqual(['a', 'b', 'c'])
expect(redoShape(undone).shapes.map((shape) => shape.id)).toEqual(['c'])
})
it('leaves the document and its history untouched when nothing was hit', () => {
const before = editorWith(line('a', 0))
const after = endGesture(erase(before, [{ x: 50, y: 300 }]), 1)
expect(after.doc).toBe(before.doc)
})
it('does not erase a mark the pointer curved around between events', () => {
// Down left of the stroke, around its end, to its far side: the chord from the
// first to the last point crosses it, but no swept segment does.
const after = endGesture(
erase(editorWith(line('a', 0)), [
{ x: 50, y: -40 },
{ x: 160, y: -40 },
{ x: 160, y: 40 },
{ x: 50, y: 40 }
]),
1
)
expect(ids(after)).toEqual(['a'])
})
it('keeps the same erased set while a drag hits nothing new', () => {
const begun = beginEraseGesture(editorWith(line('a', 0)), 1, { x: 50, y: 0 }, noText)
const moved = moveGesture(begun, 1, { x: 50, y: 300 }, noText)
expect(erasedIds(begun)).toEqual(new Set(['a']))
expect(erasedIds(moved)).toBe(erasedIds(begun))
})
it('erases a text label the drag passes through', () => {
const label: TextShape = {
id: 'label',
kind: 'text',
color: '#111827',
at: { x: 200, y: 200 },
text: 'note',
fontSize: 18
}
const inkBox = () => ({ x: 200, y: 200, width: 60, height: 20 })
const before: MarkupEditorState = {
doc: commitShape(createMarkupDocument(), label),
gesture: null
}
const begun = beginEraseGesture(before, 1, { x: 100, y: 210 }, inkBox)
const after = endGesture(moveGesture(begun, 1, { x: 230, y: 210 }, inkBox), 1)
expect(erasedIds(begun)).toEqual(new Set())
expect(ids(after)).toEqual([])
})
it('ignores a second pointer while a gesture is in flight', () => {
const first = erase(editorWith(line('a', 0), line('b', 100)), [{ x: 50, y: 0 }], 1)
const secondDown = beginEraseGesture(first, 2, { x: 50, y: 100 }, noText)
expect(secondDown).toBe(first)
expect(moveGesture(first, 2, { x: 50, y: 100 }, noText)).toBe(first)
expect(endGesture(first, 2)).toBe(first)
expect(ids(endGesture(first, 1))).toEqual(['b'])
})
it('settles an erase whose release was lost when the same pointer presses again', () => {
const swept = erase(editorWith(line('a', 0), line('b', 100), line('c', 200)), [{ x: 50, y: 0 }])
// No release for pointer 1; it presses again below every mark and drags a little.
const after = endGesture(
erase(swept, [
{ x: 50, y: 300 },
{ x: 60, y: 300 }
]),
1
)
// The new press must not sweep from the stale point, which would cross b and c.
expect(ids(after)).toEqual(['b', 'c'])
expect(undoShape(after.doc).shapes.map((shape) => shape.id)).toEqual(['a', 'b', 'c'])
})
it('commits against the document as it is on release', () => {
const swept = erase(editorWith(line('a', 0)), [{ x: 50, y: 0 }])
// The document moved under the gesture and no longer holds the mark.
const without = { ...swept, doc: undoShape(swept.doc) }
const after = endGesture(without, 1)
expect(after.doc).toBe(without.doc)
expect(after.gesture).toBeNull()
})
})
describe('draw gesture', () => {
it('keeps every point of a freehand stroke', () => {
const begun = beginDrawGesture(editorWith(), 1, {
id: 'pen',
kind: 'highlight',
color: '#eab308',
width: 4,
points: [{ x: 0, y: 0 }]
})
const moved = [
{ x: 10, y: 5 },
{ x: 20, y: 0 }
].reduce((state, point) => moveGesture(state, 1, point, noText), begun)
expect(endGesture(moved, 1).doc.shapes).toMatchObject([
{
points: [
{ x: 0, y: 0 },
{ x: 10, y: 5 },
{ x: 20, y: 0 }
]
}
])
})
it('commits a stroke whose release was lost and starts afresh on the next press', () => {
const stale = moveGesture(
beginDrawGesture(editorWith(), 1, line('old', 0)),
1,
{ x: 120, y: 0 },
noText
)
const next = beginDrawGesture(stale, 1, {
id: 'new',
kind: 'pen',
color: '#ef4444',
width: 2,
points: [{ x: 0, y: 300 }]
})
const ended = endGesture(moveGesture(next, 1, { x: 50, y: 300 }, noText), 1)
expect(ended.doc.shapes.map((shape) => shape.id)).toEqual(['old', 'new'])
expect(ended.doc.shapes[1]).toMatchObject({
points: [
{ x: 0, y: 300 },
{ x: 50, y: 300 }
]
})
})
it('extends the shape on move and commits it once on release', () => {
const begun = beginDrawGesture(editorWith(), 1, {
id: 'new',
kind: 'rect',
color: '#ef4444',
width: 4,
from: { x: 10, y: 10 },
to: { x: 10, y: 10 }
})
// A second pointer landing mid-stroke must not replace it.
expect(beginDrawGesture(begun, 2, line('other', 0))).toBe(begun)
const moved = moveGesture(begun, 1, { x: 60, y: 40 }, noText)
const ended = endGesture(moved, 1)
expect(ended.doc.shapes).toEqual([
{
id: 'new',
kind: 'rect',
color: '#ef4444',
width: 4,
from: { x: 10, y: 10 },
to: { x: 60, y: 40 }
}
])
// A stray second release has no gesture left to commit.
expect(endGesture(ended, 1)).toBe(ended)
})
it.each(['rect', 'ellipse', 'arrow'] as const)(
'saves no %s released without being dragged, so it adds no undo step',
(kind) => {
const before = editorWith(line('a', 0))
const pressed = beginDrawGesture(before, 1, pressedShape(kind, { x: 50, y: 0 }))
// Undo with only the unmoved press held goes straight to the last mark.
expect(ids(undoMarkup(pressed))).toEqual([])
expect(endGesture(pressed, 1).doc).toBe(before.doc)
}
)
it('lets an eraser click take a mark where a shape was pressed without dragging', () => {
const at = { x: 50, y: 0 }
const clicked = endGesture(
beginDrawGesture(editorWith(line('a', 0)), 1, pressedShape('rect', at)),
1
)
expect(ids(endGesture(erase(clicked, [at]), 1))).toEqual([])
})
it('still saves a pen tap, which leaves a visible dot', () => {
const tapped = endGesture(
beginDrawGesture(editorWith(), 1, { ...line('dot', 0), points: [{ x: 50, y: 0 }] }),
1
)
expect(ids(tapped)).toEqual(['dot'])
})
})
describe('cancelled pointer', () => {
it('discards a stroke in progress without adding history', () => {
const before = editorWith(line('a', 0))
const held = moveGesture(
beginDrawGesture(before, 1, line('held', 300)),
1,
{ x: 50, y: 300 },
noText
)
const cancelled = cancelGesture(held, 1)
expect(cancelled.doc).toBe(before.doc)
expect(cancelled.gesture).toBeNull()
})
it('discards an erase, so the marks it hid stay, and a later lost capture does nothing', () => {
const before = editorWith(line('a', 0), line('b', 100))
const held = erase(before, [
{ x: 50, y: -50 },
{ x: 50, y: 150 }
])
expect(erasedIds(held)).toEqual(new Set(['a', 'b']))
const cancelled = cancelGesture(held, 1)
expect(cancelled.doc).toBe(before.doc)
// lostpointercapture follows pointercancel and ends the same pointer's gesture.
expect(endGesture(cancelled, 1)).toBe(cancelled)
})
it('ignores a cancel from a pointer that does not own the gesture', () => {
const held = erase(editorWith(line('a', 0)), [{ x: 50, y: 0 }], 1)
expect(cancelGesture(held, 2)).toBe(held)
})
})
describe('history commands mid-gesture', () => {
// A stroke from (0, 300) to (50, 300), still held down.
function drawing(state: MarkupEditorState): MarkupEditorState {
return moveGesture(beginDrawGesture(state, 1, line('held', 300)), 1, { x: 50, y: 300 }, noText)
}
// The rest of the held drag, which must neither draw nor erase.
function finishDrag(state: MarkupEditorState): MarkupEditorState {
return endGesture(moveGesture(state, 1, { x: 50, y: 100 }, noText), 1)
}
it('undo mid-stroke drops only the stroke, and the next undo takes the last mark', () => {
const before = editorWith(line('a', 0), line('b', 100))
const held = drawing(before)
expect(canUndoMarkup(held)).toBe(true)
const undone = undoMarkup(held)
expect(undone.doc).toBe(before.doc)
expect(undone.gesture).toBeNull()
const released = finishDrag(undone)
expect(released.doc).toBe(before.doc)
expect(released.gesture).toBeNull()
expect(ids(undoMarkup(released))).toEqual(['a'])
})
it('undo mid-erase restores the hidden marks and leaves the document alone', () => {
const before = editorWith(line('a', 0), line('b', 100))
const held = erase(before, [{ x: 50, y: 0 }])
expect(erasedIds(held)).toEqual(new Set(['a']))
const released = finishDrag(undoMarkup(held))
expect(released.doc).toBe(before.doc)
expect(ids(undoMarkup(released))).toEqual(['a'])
})
it('undo during an erase that hides nothing undoes the last mark and ends the erase', () => {
const before = editorWith(line('a', 0), line('b', 100))
const held = erase(before, [{ x: 50, y: 300 }])
expect(erasedIds(held)).toEqual(new Set())
expect(canUndoMarkup(held)).toBe(true)
const undone = undoMarkup(held)
expect(ids(undone)).toEqual(['a'])
expect(undone.gesture).toBeNull()
// The rest of the drag crosses the restored `b`, but must not erase it.
expect(ids(finishDrag(undone))).toEqual(['a'])
})
it('undo during an erase that hides nothing, with no history, keeps the erase', () => {
const held = erase(editorWith(), [{ x: 50, y: 300 }])
expect(canUndoMarkup(held)).toBe(false)
expect(undoMarkup(held)).toBe(held)
})
it('an unmoved shape press on an empty canvas leaves undo disabled and is kept', () => {
const held = beginDrawGesture(editorWith(), 1, pressedShape('ellipse', { x: 50, y: 0 }))
expect(canUndoMarkup(held)).toBe(false)
expect(undoMarkup(held)).toBe(held)
// Dragging it out still draws the ellipse.
const ended = endGesture(moveGesture(held, 1, { x: 90, y: 40 }, noText), 1)
expect(ids(ended)).toEqual(['ellipse'])
})
it('a second undo while the pointer is still down undoes the last committed mark', () => {
const held = undoMarkup(drawing(editorWith(line('a', 0), line('b', 100))))
const again = undoMarkup(held)
expect(ids(again)).toEqual(['a'])
expect(ids(finishDrag(again))).toEqual(['a'])
})
it('redo mid-gesture cancels the gesture, then redoes', () => {
const undone = undoMarkup(editorWith(line('a', 0), line('b', 100)))
const redone = redoMarkup(drawing(undone))
expect(ids(redone)).toEqual(['a', 'b'])
expect(ids(finishDrag(redone))).toEqual(['a', 'b'])
})
it('redo with nothing to redo keeps the held gesture', () => {
const held = erase(editorWith(line('a', 0), line('b', 100)), [{ x: 50, y: 0 }])
expect(redoMarkup(held)).toBe(held)
expect(ids(endGesture(held, 1))).toEqual(['b'])
})
it('clear mid-erase cancels the erase, then clears as one undo step', () => {
const held = erase(editorWith(line('a', 0), line('b', 100)), [{ x: 50, y: 0 }])
const released = finishDrag(applyDocumentCommand(held, clearShapes))
expect(ids(released)).toEqual([])
expect(ids(undoMarkup(released))).toEqual(['a', 'b'])
})
})
@@ -0,0 +1,213 @@
// The markup editor's document plus its in-flight pointer gesture, advanced by
// pure transitions. Keeping both in one value lets a release commit from the
// latest state, and keeps the transitions safe to run twice under StrictMode.
import {
canRedo,
canUndo,
commitShape,
redoShape,
setShapes,
undoShape,
type MarkupDocument,
type MarkupPoint,
type MarkupShape,
type TextShape
} from './markup-drawing-model'
import {
shapesTouchedBySweep,
topmostShapeAt,
type TextInkBoxMeasurer
} from './markup-shape-hit-test'
export type DraggedShape = Exclude<MarkupShape, TextShape>
// Why pointerId: a second pointer (another finger) must not restart or steer a
// gesture the first one owns.
export type MarkupGesture =
| { kind: 'draw'; pointerId: number; shape: DraggedShape }
| {
kind: 'erase'
pointerId: number
origin: MarkupPoint
// A 'pressed' erase is still a click; it becomes 'dragging' past ERASER_CLICK_SLOP.
phase: 'pressed' | 'dragging'
last: MarkupPoint
erasedIds: ReadonlySet<string>
}
export type MarkupEditorState = { doc: MarkupDocument; gesture: MarkupGesture | null }
// How far (CSS px) an erase press may travel and stay a click: touch and pen taps jitter.
const ERASER_CLICK_SLOP = 4
export function beginDrawGesture(
state: MarkupEditorState,
pointerId: number,
shape: DraggedShape
): MarkupEditorState {
const settled = settleMissedRelease(state, pointerId)
return settled.gesture ? settled : { ...settled, gesture: { kind: 'draw', pointerId, shape } }
}
export function beginEraseGesture(
state: MarkupEditorState,
pointerId: number,
point: MarkupPoint,
measureTextInkBox: TextInkBoxMeasurer
): MarkupEditorState {
const settled = settleMissedRelease(state, pointerId)
if (settled.gesture) {
return settled
}
// Why: a click takes only the mark on top. Leaving the slop makes it a drag,
// and that sweep starts at the press point, so it takes the rest under it too.
const topmost = topmostShapeAt(settled.doc.shapes, point, measureTextInkBox)
return {
...settled,
gesture: {
kind: 'erase',
pointerId,
origin: point,
phase: 'pressed',
last: point,
erasedIds: new Set(topmost ? [topmost.id] : [])
}
}
}
// Why: a pointer cannot press twice without releasing, so a press from the
// gesture's own pointer means its release was lost; settle it as that release
// would have, instead of letting it block the new press and steer from a stale point.
function settleMissedRelease(state: MarkupEditorState, pointerId: number): MarkupEditorState {
return state.gesture?.pointerId === pointerId ? endGesture(state, pointerId) : state
}
export function moveGesture(
state: MarkupEditorState,
pointerId: number,
point: MarkupPoint,
measureTextInkBox: TextInkBoxMeasurer
): MarkupEditorState {
const { gesture } = state
if (gesture?.pointerId !== pointerId) {
return state
}
if (gesture.kind === 'erase') {
if (gesture.phase === 'pressed') {
const { origin } = gesture
if (Math.hypot(point.x - origin.x, point.y - origin.y) < ERASER_CLICK_SLOP) {
return state
}
// `last` is still the origin here, so the first sweep starts at the press point.
const dragging: EraseGesture = { ...gesture, phase: 'dragging' }
return {
...state,
gesture: sweepEraser(dragging, state.doc.shapes, point, measureTextInkBox)
}
}
return { ...state, gesture: sweepEraser(gesture, state.doc.shapes, point, measureTextInkBox) }
}
return { ...state, gesture: { ...gesture, shape: dragShapeTo(gesture.shape, point) } }
}
// Commits the gesture as one undoable step. A gesture with no visible result
// leaves history untouched so Undo never has a step with no visible effect.
export function endGesture(state: MarkupEditorState, pointerId: number): MarkupEditorState {
const { doc, gesture } = state
if (gesture?.pointerId !== pointerId) {
return state
}
if (gesture.kind === 'draw') {
return { doc: hasNoSize(gesture.shape) ? doc : commitShape(doc, gesture.shape), gesture: null }
}
const remaining = doc.shapes.filter((shape) => !gesture.erasedIds.has(shape.id))
return {
doc: remaining.length === doc.shapes.length ? doc : setShapes(doc, remaining),
gesture: null
}
}
// Why: a cancelled pointer (an OS gesture or palm rejection took it) was not a
// deliberate release, so its gesture is discarded rather than committed.
export function cancelGesture(state: MarkupEditorState, pointerId: number): MarkupEditorState {
return state.gesture?.pointerId === pointerId ? { ...state, gesture: null } : state
}
// Why: Undo mid-gesture takes back only that gesture, as the newest step, so the
// next Undo takes back the last committed mark rather than both at once. A gesture
// that shows nothing yet (an erase hiding nothing, an unmoved shape press) is no
// step, so Undo goes to the document. Either way the gesture is dropped, so the
// rest of that drag does nothing until the next press.
export function undoMarkup(state: MarkupEditorState): MarkupEditorState {
if (gestureHasEffect(state.gesture)) {
return { ...state, gesture: null }
}
return canUndo(state.doc) ? { doc: undoShape(state.doc), gesture: null } : state
}
export function canUndoMarkup(state: MarkupEditorState): boolean {
return gestureHasEffect(state.gesture) || canUndo(state.doc)
}
// Whether releasing the gesture would change the document.
function gestureHasEffect(gesture: MarkupGesture | null): boolean {
if (gesture === null) {
return false
}
return gesture.kind === 'draw' ? !hasNoSize(gesture.shape) : gesture.erasedIds.size > 0
}
// Why: a rectangle, ellipse or arrow pressed without dragging paints nothing; saved,
// it would be an invisible topmost mark that soaks up the next eraser click there.
function hasNoSize(shape: DraggedShape): boolean {
return (
shape.kind !== 'pen' &&
shape.kind !== 'highlight' &&
shape.from.x === shape.to.x &&
shape.from.y === shape.to.y
)
}
// Why: with nothing to redo the document stays put, so a held gesture is kept.
export function redoMarkup(state: MarkupEditorState): MarkupEditorState {
return canRedo(state.doc) ? applyDocumentCommand(state, redoShape) : state
}
// Redo and Clear replace the document, so the gesture made against it is dropped.
export function applyDocumentCommand(
state: MarkupEditorState,
command: (doc: MarkupDocument) => MarkupDocument
): MarkupEditorState {
return { doc: command(state.doc), gesture: null }
}
type EraseGesture = Extract<MarkupGesture, { kind: 'erase' }>
function sweepEraser(
gesture: EraseGesture,
shapes: readonly MarkupShape[],
point: MarkupPoint,
measureTextInkBox: TextInkBoxMeasurer
): EraseGesture {
const touched = shapesTouchedBySweep(
shapes.filter((shape) => !gesture.erasedIds.has(shape.id)),
gesture.last,
point,
measureTextInkBox
)
// Why: keep the same Set when nothing new was hit so the cached canvas layer
// is not re-rasterized on every pointermove.
const erasedIds =
touched.length === 0
? gesture.erasedIds
: new Set([...gesture.erasedIds, ...touched.map((shape) => shape.id)])
return { ...gesture, last: point, erasedIds }
}
function dragShapeTo(shape: DraggedShape, point: MarkupPoint): DraggedShape {
if (shape.kind === 'pen' || shape.kind === 'highlight') {
return { ...shape, points: [...shape.points, point] }
}
return { ...shape, to: point }
}
@@ -0,0 +1,204 @@
import { describe, expect, it } from 'vitest'
import { arrowHeadGeometry, type MarkupPoint, type MarkupShape } from './markup-drawing-model'
import { shapesTouchedBySweep, topmostShapeAt } from './markup-shape-hit-test'
// Pinned here on purpose: how close the pointer must come is part of the behaviour.
const ERASER_RADIUS = 8
const base = { id: 's', color: '#ef4444' }
const noText = () => null
function touches(shape: MarkupShape, from: MarkupPoint, to: MarkupPoint = from): boolean {
return shapesTouchedBySweep([shape], from, to, noText).length === 1
}
describe('shapesTouchedBySweep', () => {
it('hits a pen stroke within its half-width plus the eraser radius, and not beyond', () => {
const pen: MarkupShape = {
...base,
kind: 'pen',
width: 4,
points: [
{ x: 0, y: 0 },
{ x: 100, y: 0 }
]
}
const reach = 2 + ERASER_RADIUS
expect(touches(pen, { x: 50, y: reach })).toBe(true)
expect(touches(pen, { x: 50, y: reach + 1 })).toBe(false)
// Past the end of the stroke the round cap still counts.
expect(touches(pen, { x: 100 + reach, y: 0 })).toBe(true)
expect(touches(pen, { x: 100 + reach + 1, y: 0 })).toBe(false)
// A sweep that passes the end without crossing the stroke or ending near it.
expect(touches(pen, { x: 105, y: -50 }, { x: 105, y: 50 })).toBe(true)
expect(touches(pen, { x: -5, y: -50 }, { x: -5, y: 50 })).toBe(true)
// A drag that stops beside the stroke without crossing it.
expect(touches(pen, { x: 50, y: -50 }, { x: 50, y: -reach })).toBe(true)
expect(touches(pen, { x: 50, y: -50 }, { x: 50, y: -reach - 1 })).toBe(false)
})
it('hits a thin stroke that a fast drag crosses between two pointer events', () => {
const pen: MarkupShape = {
...base,
kind: 'pen',
width: 2,
points: [
{ x: 50, y: -100 },
{ x: 50, y: 100 }
]
}
expect(touches(pen, { x: 0, y: 0 }, { x: 100, y: 0 })).toBe(true)
expect(touches(pen, { x: 0, y: 0 })).toBe(false)
expect(touches(pen, { x: 100, y: 0 })).toBe(false)
})
it('hits the dot a single tap leaves', () => {
const dot: MarkupShape = { ...base, kind: 'pen', width: 8, points: [{ x: 10, y: 10 }] }
expect(touches(dot, { x: 10 + 4 + ERASER_RADIUS, y: 10 })).toBe(true)
expect(touches(dot, { x: 10 + 4 + ERASER_RADIUS + 1, y: 10 })).toBe(false)
})
it('treats a highlight as the fat stroke it is drawn as', () => {
const points = [
{ x: 0, y: 0 },
{ x: 100, y: 0 }
]
const highlight: MarkupShape = { ...base, kind: 'highlight', width: 4, points }
// A width-4 highlight is drawn 16px wide, so its ink reaches 8px from the centre line.
const edge = { x: 50, y: 8 + ERASER_RADIUS }
expect(touches(highlight, edge)).toBe(true)
expect(touches(highlight, { x: 50, y: 8 + ERASER_RADIUS + 1 })).toBe(false)
expect(touches({ ...base, kind: 'pen', width: 4, points }, edge)).toBe(false)
})
it('hits an arrow on its head wings as well as its shaft', () => {
const from = { x: 0, y: 0 }
const to = { x: 200, y: 0 }
const arrow: MarkupShape = { ...base, kind: 'arrow', width: 8, from, to }
const head = arrowHeadGeometry(from, to, 8)
expect(head).not.toBeNull()
// The wing tips sit well off the shaft, so only the head geometry can match them.
for (const wing of head ? [head.left, head.right] : []) {
expect(Math.abs(wing.y)).toBeGreaterThan(4 + ERASER_RADIUS)
expect(touches(arrow, wing)).toBe(true)
}
expect(touches(arrow, { x: 100, y: 4 + ERASER_RADIUS })).toBe(true)
expect(touches(arrow, { x: 100, y: 4 + ERASER_RADIUS + 1 })).toBe(false)
})
it('hits a rectangle on its border but not in its hollow interior', () => {
const rect: MarkupShape = {
...base,
kind: 'rect',
width: 4,
from: { x: 300, y: 200 },
to: { x: 100, y: 100 }
}
expect(touches(rect, { x: 100 - 2 - ERASER_RADIUS, y: 150 })).toBe(true)
expect(touches(rect, { x: 100 - 2 - ERASER_RADIUS - 1, y: 150 })).toBe(false)
for (const onEdge of [
{ x: 200, y: 100 },
{ x: 300, y: 150 },
{ x: 200, y: 200 }
]) {
expect(touches(rect, onEdge)).toBe(true)
}
expect(touches(rect, { x: 200, y: 150 })).toBe(false)
expect(touches(rect, { x: 150, y: 150 }, { x: 250, y: 150 })).toBe(false)
})
it('hits a large ellipse anywhere on its outline but not inside it', () => {
const ellipse: MarkupShape = {
...base,
kind: 'ellipse',
width: 2,
from: { x: 200, y: 100 },
to: { x: 1400, y: 700 }
}
const outline = (degrees: number, grow = 0): MarkupPoint => {
const angle = (degrees * Math.PI) / 180
return { x: 800 + (600 + grow) * Math.cos(angle), y: 400 + (300 + grow) * Math.sin(angle) }
}
// Odd angles on purpose: a coarse polygon approximation drifts furthest from
// the true outline between its vertices.
for (const degrees of [0, 11.25, 33.75, 101.25, 137, 222, 303.75]) {
expect(touches(ellipse, outline(degrees))).toBe(true)
}
expect(touches(ellipse, outline(0, 1 + ERASER_RADIUS))).toBe(true)
expect(touches(ellipse, outline(0, 1 + ERASER_RADIUS + 1))).toBe(false)
expect(touches(ellipse, { x: 800, y: 400 })).toBe(false)
expect(touches(ellipse, outline(0, -30))).toBe(false)
})
it('follows the curve of a small ellipse', () => {
const circle: MarkupShape = {
...base,
kind: 'ellipse',
width: 2,
from: { x: 0, y: 0 },
to: { x: 20, y: 20 }
}
const reach = 1 + ERASER_RADIUS
// 30 degrees is mid-side for the hexagon the chord limit alone would give this circle.
const at = (radius: number): MarkupPoint => ({
x: 10 + radius * Math.cos(Math.PI / 6),
y: 10 + radius * Math.sin(Math.PI / 6)
})
expect(touches(circle, at(10 + reach - 0.5))).toBe(true)
expect(touches(circle, at(10 + reach + 0.5))).toBe(false)
})
it('hits text anywhere in its measured ink box, including a sweep that ends inside it', () => {
const text: MarkupShape = {
...base,
kind: 'text',
at: { x: 100, y: 100 },
text: 'note',
fontSize: 18
}
const box = () => ({ x: 98, y: 97, width: 60, height: 26 })
const hit = (from: MarkupPoint, to: MarkupPoint = from) =>
shapesTouchedBySweep([text], from, to, box).length === 1
expect(hit({ x: 128, y: 110 })).toBe(true)
expect(hit({ x: 0, y: 110 }, { x: 128, y: 110 })).toBe(true)
expect(hit({ x: 98 + 60 + ERASER_RADIUS, y: 110 })).toBe(true)
expect(hit({ x: 98 + 60 + ERASER_RADIUS + 1, y: 110 })).toBe(false)
expect(hit({ x: 98 - ERASER_RADIUS - 1, y: 110 })).toBe(false)
expect(hit({ x: 128, y: 97 - ERASER_RADIUS - 1 })).toBe(false)
expect(hit({ x: 128, y: 97 + 26 + ERASER_RADIUS + 1 })).toBe(false)
// Without a canvas to measure on there is no drawn ink to hit.
expect(touches(text, { x: 128, y: 110 })).toBe(false)
})
it('returns every touched shape, not only the topmost', () => {
const points = [
{ x: 0, y: 0 },
{ x: 100, y: 0 }
]
const shapes: MarkupShape[] = [
{ id: 'under', color: '#eab308', kind: 'highlight', width: 4, points },
{ id: 'over', color: '#ef4444', kind: 'pen', width: 2, points },
{ id: 'away', color: '#ef4444', kind: 'pen', width: 2, points: [{ x: 0, y: 300 }] }
]
const ids = shapesTouchedBySweep(shapes, { x: 50, y: 0 }, { x: 50, y: 0 }, noText).map(
(shape) => shape.id
)
expect(ids).toEqual(['under', 'over'])
})
})
describe('topmostShapeAt', () => {
it('returns the mark drawn last among those under the point', () => {
const points = [
{ x: 0, y: 0 },
{ x: 100, y: 0 }
]
const shapes: MarkupShape[] = [
{ id: 'under', color: '#eab308', kind: 'highlight', width: 4, points },
{ id: 'over', color: '#ef4444', kind: 'pen', width: 2, points },
{ id: 'away', color: '#ef4444', kind: 'pen', width: 2, points: [{ x: 0, y: 300 }] }
]
expect(topmostShapeAt(shapes, { x: 50, y: 0 }, noText)?.id).toBe('over')
expect(topmostShapeAt(shapes, { x: 50, y: 150 }, noText)).toBeUndefined()
})
})

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