mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
+4
-2
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
+7
-5
@@ -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 }
|
||||
}
|
||||
|
||||
+26
@@ -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())
|
||||
|
||||
+10
-1
@@ -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
|
||||
)
|
||||
|
||||
+2
-1
@@ -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>> {
|
||||
|
||||
+2
-1
@@ -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 }
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]))
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user