mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
test: remove 52 low-value plugin, checks, and runtime tests (#38909)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -104,84 +104,4 @@ describe('Claude plugin UI frames', () => {
|
||||
)
|
||||
).toEqual([[{ type: 'text', text: 'PROBE_OK' }]])
|
||||
})
|
||||
|
||||
it('keeps the other plugin UI subtypes off the transcript too', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
for (const [index, subtype] of [
|
||||
'ui_invalidate',
|
||||
'ui_log',
|
||||
'ui_panes',
|
||||
'ui_placed',
|
||||
'ui_scroll',
|
||||
'ui_focus',
|
||||
'ui_future',
|
||||
'future_notice'
|
||||
].entries()) {
|
||||
translator.handle(pluginUi(subtype, `ui-${index}`, {}))
|
||||
}
|
||||
|
||||
translator.handle(
|
||||
pluginUi('ui_panes', 'historical-focus', {
|
||||
panes: [{ id: 'blast-radius', plugin: 'blast-radius' }],
|
||||
focus_requested_id: 'blast-radius'
|
||||
})
|
||||
)
|
||||
expect(providerFrameKinds(state.items)).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['ui_status', 'future_notice', undefined])(
|
||||
'does not flush streamed text for N or 10N %s diagnostics',
|
||||
(subtype) => {
|
||||
const writes = (count: number) => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({
|
||||
sink: state.sink,
|
||||
schedule: () => () => {}
|
||||
})
|
||||
translator.handle(userTurn('user-stream', 'Reply'))
|
||||
let sequence = 0
|
||||
const stream = (event: Record<string, unknown>) =>
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
message: {
|
||||
type: 'stream_event',
|
||||
uuid: `stream-${sequence++}`,
|
||||
session_id: 'claude-session',
|
||||
event
|
||||
}
|
||||
})
|
||||
stream({
|
||||
type: 'message_start',
|
||||
message: { id: 'msg-stream', role: 'assistant', content: [] }
|
||||
})
|
||||
stream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })
|
||||
for (let i = 0; i < count; i++) {
|
||||
stream({
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text: 'a' }
|
||||
})
|
||||
const frame = pluginUi(subtype, `status-${i}`, {
|
||||
text: 'PR 6 · 진행 13 · 대기 6 /board'
|
||||
})
|
||||
translator.handle(frame)
|
||||
}
|
||||
const assistantWrites = () =>
|
||||
state.items.filter(({ body }) => body.kind === 'message' && body.role === 'assistant')
|
||||
expect(assistantWrites()).toHaveLength(0)
|
||||
expect(providerFrameKinds(state.items)).toEqual([])
|
||||
translator.flush()
|
||||
expect(assistantWrites()).toHaveLength(1)
|
||||
expect(assistantWrites()[0].body).toMatchObject({
|
||||
blocks: [{ type: 'text', text: 'a'.repeat(count) }]
|
||||
})
|
||||
translator.dispose()
|
||||
return assistantWrites().length
|
||||
}
|
||||
expect(writes(20)).toBe(writes(200))
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import capture from './__fixtures__/claude-plugin-recovery.json'
|
||||
import { asRecord } from '../ai-vault/session-scanner-values'
|
||||
import { createClaudeJournalTranslator } from './structured/journal/claude-structured-journal-translation'
|
||||
import { agentSessionFailureFact } from '../../shared/agent-session/failures/agent-session-failure'
|
||||
import { agentJournalItemKey } from '../../shared/agent-session/journal/agent-session-journal-item-key'
|
||||
import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution'
|
||||
import { dispatchClaudeTurn } from './structured/claude-structured-dispatch'
|
||||
import {
|
||||
sessionFor,
|
||||
userMessage,
|
||||
userReplayFrame
|
||||
} from './structured/claude-structured-dispatch-test-support'
|
||||
import {
|
||||
SESSION,
|
||||
SID,
|
||||
at,
|
||||
chat,
|
||||
chatTurn,
|
||||
prompt,
|
||||
reply
|
||||
} from './claude-transcript-catch-up-test-support'
|
||||
|
||||
// Claude 2.1.316: background wake and two resumed, interrupted tool calls.
|
||||
const rows: Record<string, unknown>[] = capture.map((row, index) => ({
|
||||
...row,
|
||||
sessionId: SID,
|
||||
...(index === 0 ? { parentUuid: 'a1' } : {})
|
||||
}))
|
||||
const frame = (row: Record<string, unknown>) => ({
|
||||
...row,
|
||||
session_id: SID,
|
||||
parent_tool_use_id: null
|
||||
})
|
||||
const synthetic = rows[3]!
|
||||
const task = rows[0]!
|
||||
const message = (row: Record<string, unknown>) => asRecord(row.message)!
|
||||
const contentParts = (row: Record<string, unknown>) => {
|
||||
const content = message(row).content
|
||||
if (!Array.isArray(content)) {
|
||||
throw new Error('recorded content parts missing')
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
it('imports recorded repair results without inventing outside work or duplicate failed turns', async () => {
|
||||
const c = await chat()
|
||||
await c.live()
|
||||
const translator = createClaudeJournalTranslator({ sink: c.deferred.sink })
|
||||
const live = (row: Record<string, unknown>, startsTurn = false) =>
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: SESSION,
|
||||
message: frame(row),
|
||||
...(startsTurn ? { startsTurn: true } : {}),
|
||||
observedAt: 4_000
|
||||
})
|
||||
live({
|
||||
type: 'system',
|
||||
subtype: 'task_started',
|
||||
task_id: 'bobbnxies',
|
||||
task_type: 'local_bash',
|
||||
description: 'background work'
|
||||
})
|
||||
live({ type: 'system', subtype: 'task_notification', task_id: 'bobbnxies', status: 'completed' })
|
||||
live(task)
|
||||
live(rows[1]!)
|
||||
const end = () =>
|
||||
translator.handle({
|
||||
type: 'ended',
|
||||
sessionId: SESSION,
|
||||
reason: 'crash',
|
||||
failure: agentSessionFailureFact('pluginInputRequired'),
|
||||
observedAt: 5_000
|
||||
})
|
||||
end()
|
||||
const localPrompt = rows[4]!
|
||||
await c.journal.appendSubmission({
|
||||
clientMessageId: 'retry',
|
||||
body: userMessage([{ type: 'text', text: 'retry' }]),
|
||||
payloadFingerprint: 'retry',
|
||||
fence: 1
|
||||
})
|
||||
await c.journal.resolveDispatch({
|
||||
clientMessageId: 'retry',
|
||||
state: 'accepted',
|
||||
providerIdentity: { provider: 'claude', sessionId: SID, uuid: String(localPrompt.uuid) },
|
||||
fence: 1
|
||||
})
|
||||
live(localPrompt, true)
|
||||
live(rows[5]!)
|
||||
end()
|
||||
translator.dispose()
|
||||
await c.deferred.drained()
|
||||
const before = c.journal.snapshot()
|
||||
const turns = before.items.filter((item) => item.body.kind === 'turn')
|
||||
await c.write([...chatTurn, ...rows])
|
||||
expect(await c.open('a1')).toBe(true)
|
||||
const after = c.journal.snapshot()
|
||||
expect(after.items.filter((item) => item.body.kind === 'turn')).toEqual(turns)
|
||||
for (const row of [task, synthetic, rows[7]!]) {
|
||||
expect(
|
||||
c.journal.item(
|
||||
agentJournalItemKey({ provider: 'claude', sessionId: SID, uuid: String(row.uuid) })
|
||||
)
|
||||
).toBeNull()
|
||||
}
|
||||
const tools = after.items.filter((item) => item.body.kind === 'tool-call')
|
||||
expect(tools).toHaveLength(2)
|
||||
for (const tool of tools) {
|
||||
expect(tool.body).toMatchObject({ name: 'Bash', state: 'failed' })
|
||||
const original = before.items.find((item) => item.itemId === tool.itemId)!
|
||||
expect(tool.turnScope).toEqual(original.turnScope)
|
||||
expect(tool.body.kind === 'tool-call' && tool.body.input).toEqual(
|
||||
original.body.kind === 'tool-call' && original.body.input
|
||||
)
|
||||
}
|
||||
expect(c.rows().some((row) => row.startsWith('(continued-outside'))).toBe(false)
|
||||
expect(await c.open('a1')).toBe(true)
|
||||
expect(c.journal.snapshot()).toEqual(after)
|
||||
expect((await c.replay()).snapshot()).toEqual(after)
|
||||
|
||||
const outsideTask = { ...task, uuid: 'outside-task', parentUuid: rows[7]!.uuid }
|
||||
await c.write([
|
||||
outsideTask,
|
||||
reply(
|
||||
'outside-answer',
|
||||
'outside-task',
|
||||
'msg_outside',
|
||||
{ type: 'text', text: 'real outside answer' },
|
||||
10
|
||||
)
|
||||
])
|
||||
expect(await c.open('a1')).toBe(true)
|
||||
expect(c.rows()).toContain('assistant:real outside answer')
|
||||
expect(c.rows().filter((row) => row === '(continued-outside)')).toHaveLength(1)
|
||||
const outside = c.journal
|
||||
.snapshot()
|
||||
.items.find(
|
||||
(item) =>
|
||||
item.body.kind === 'message' &&
|
||||
item.body.role === 'assistant' &&
|
||||
item.body.blocks.some(
|
||||
(block) => block.type === 'text' && block.text === 'real outside answer'
|
||||
)
|
||||
)!
|
||||
const outsideScope = outside.turnScope
|
||||
expect(outsideScope?.kind).toBe('turn')
|
||||
if (outsideScope?.kind !== 'turn') {
|
||||
throw new Error('outside turn missing')
|
||||
}
|
||||
expect(c.journal.item(outsideScope.turnItemId)?.body).toMatchObject({
|
||||
kind: 'turn',
|
||||
state: 'completed'
|
||||
})
|
||||
expect(turns.some((turn) => turn.itemId === outsideScope.turnItemId)).toBe(false)
|
||||
expect(
|
||||
c.journal.snapshot().items.filter((item) => turns.some((turn) => turn.itemId === item.itemId))
|
||||
).toEqual(turns)
|
||||
const caughtUp = c.journal.snapshot()
|
||||
expect(await c.open('a1')).toBe(true)
|
||||
expect(c.journal.snapshot()).toEqual(caughtUp)
|
||||
expect((await c.replay()).snapshot()).toEqual(caughtUp)
|
||||
})
|
||||
|
||||
it.each([false, true])('ignores quiet caveat time with a later prompt: %s', async (laterPrompt) => {
|
||||
const c = await chat()
|
||||
await c.live()
|
||||
await c.write([
|
||||
...chatTurn,
|
||||
prompt('outside', 'a1', 'outside work', 10),
|
||||
reply('answer', 'outside', 'msg_answer', { type: 'text', text: 'done' }, 11),
|
||||
{
|
||||
...prompt('caveat', 'answer', '<local-command-caveat>internal</local-command-caveat>', 60),
|
||||
isMeta: true
|
||||
},
|
||||
...(laterPrompt ? [prompt('next', 'caveat', 'next request', 70)] : [])
|
||||
])
|
||||
expect(await c.open('a1')).toBe(true)
|
||||
const turn = c.journal
|
||||
.snapshot()
|
||||
.items.find((item) => item.body.kind === 'turn' && item.body.turnId === 'outside')
|
||||
expect(turn?.body).toMatchObject({
|
||||
startedAt: Date.parse(at(10)),
|
||||
completedAt: Date.parse(at(11))
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a task boundary without inventing a successor until real output arrives', async () => {
|
||||
const c = await chat()
|
||||
await c.live()
|
||||
const wake = { ...task, ...prompt('wake', 'answer-again', String(message(task).content), 60) }
|
||||
await c.write([
|
||||
...chatTurn,
|
||||
prompt('outside', 'a1', 'outside work', 10),
|
||||
reply('answer', 'outside', 'msg_answer', { type: 'text', text: 'first answer' }, 11),
|
||||
{ ...prompt('quiet', 'answer', 'quiet metadata', 12), isMeta: true },
|
||||
reply('answer-again', 'quiet', 'msg_again', { type: 'text', text: 'continued answer' }, 13),
|
||||
wake
|
||||
])
|
||||
expect(await c.open('a1')).toBe(true)
|
||||
const before = c.journal.snapshot()
|
||||
const turn = before.items.find(
|
||||
(item) => item.body.kind === 'turn' && item.body.turnId === 'outside'
|
||||
)!
|
||||
expect(turn.body).toMatchObject({
|
||||
state: 'completed',
|
||||
startedAt: Date.parse(at(10)),
|
||||
completedAt: Date.parse(at(13))
|
||||
})
|
||||
expect(before.items.filter((item) => item.body.kind === 'turn')).toHaveLength(2)
|
||||
expect(c.rows().filter((row) => row === '(continued-outside)')).toHaveLength(1)
|
||||
const continued = before.items.find(
|
||||
(item) =>
|
||||
item.body.kind === 'message' &&
|
||||
item.body.role === 'assistant' &&
|
||||
item.body.blocks.some((block) => block.type === 'text' && block.text === 'continued answer')
|
||||
)!
|
||||
expect(continued.turnScope).toEqual({ kind: 'turn', turnItemId: turn.itemId })
|
||||
expect(await c.open('a1')).toBe(true)
|
||||
expect(c.journal.snapshot()).toEqual(before)
|
||||
await c.write([
|
||||
reply('wake-answer', 'wake', 'msg_wake', { type: 'text', text: 'wake answer' }, 61)
|
||||
])
|
||||
expect(await c.open('a1')).toBe(true)
|
||||
const after = c.journal.snapshot()
|
||||
const answer = after.items.find(
|
||||
(item) =>
|
||||
item.body.kind === 'message' &&
|
||||
item.body.role === 'assistant' &&
|
||||
item.body.blocks.some((block) => block.type === 'text' && block.text === 'wake answer')
|
||||
)!
|
||||
const scope = answer.turnScope
|
||||
expect(scope?.kind).toBe('turn')
|
||||
if (scope?.kind !== 'turn') {
|
||||
throw new Error('wake turn missing')
|
||||
}
|
||||
expect(scope.turnItemId).not.toBe(turn.itemId)
|
||||
expect(c.journal.item(scope.turnItemId)?.body).toMatchObject({ kind: 'turn', state: 'completed' })
|
||||
expect(c.journal.item(turn.itemId)).toEqual(turn)
|
||||
expect(after.items.filter((item) => item.body.kind === 'turn')).toHaveLength(3)
|
||||
// Each separate catch-up appends one genuine outside stretch.
|
||||
expect(c.rows().filter((row) => row === '(continued-outside)')).toHaveLength(2)
|
||||
expect(await c.open('a1')).toBe(true)
|
||||
expect(c.journal.snapshot()).toEqual(after)
|
||||
expect((await c.replay()).snapshot()).toEqual(after)
|
||||
})
|
||||
|
||||
it('keeps mixed task results with their saved caller before an independent reply', async () => {
|
||||
const c = await chat()
|
||||
await c.live({ tool: true, interrupted: true })
|
||||
const saved = c.journal.snapshot()
|
||||
const tool = saved.items.find((item) => item.body.kind === 'tool-call')!
|
||||
const failed = saved.items.filter((item) => item.body.kind === 'turn')
|
||||
await c.write([
|
||||
...chatTurn,
|
||||
{
|
||||
...task,
|
||||
...prompt('mixed-task', 'a1', '', 60),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: String(message(task).content) },
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'real result' }
|
||||
]
|
||||
}
|
||||
},
|
||||
reply(
|
||||
'mixed-answer',
|
||||
'mixed-task',
|
||||
'msg_mixed',
|
||||
{ type: 'text', text: 'independent answer' },
|
||||
61
|
||||
)
|
||||
])
|
||||
expect(await c.open('a1')).toBe(true)
|
||||
expect(c.journal.item(tool.itemId)).toMatchObject({
|
||||
turnScope: tool.turnScope,
|
||||
body: { name: 'Read', input: { path: 'original' }, state: 'completed' }
|
||||
})
|
||||
expect(
|
||||
c.journal.snapshot().items.filter((item) => failed.some((old) => old.itemId === item.itemId))
|
||||
).toEqual(failed)
|
||||
const answer = c.journal
|
||||
.snapshot()
|
||||
.items.find(
|
||||
(item) =>
|
||||
item.body.kind === 'message' &&
|
||||
item.body.role === 'assistant' &&
|
||||
item.body.blocks.some(
|
||||
(block) => block.type === 'text' && block.text === 'independent answer'
|
||||
)
|
||||
)!
|
||||
expect(answer.turnScope).not.toEqual(tool.turnScope)
|
||||
const scope = answer.turnScope
|
||||
if (scope?.kind !== 'turn') {
|
||||
throw new Error('independent turn missing')
|
||||
}
|
||||
expect(c.journal.item(scope.turnItemId)?.body).toMatchObject({ kind: 'turn', state: 'completed' })
|
||||
})
|
||||
|
||||
describe('internal content keeps live settlement and semantic siblings', () => {
|
||||
it('does not open a turn or acknowledge a pending send, then accepts the real echo/result', async () => {
|
||||
const c = await chat()
|
||||
const translator = createClaudeJournalTranslator({ sink: c.deferred.sink })
|
||||
const live = (row: Record<string, unknown>, startsTurn = false) =>
|
||||
translator.handle({
|
||||
type: 'message',
|
||||
sessionId: SESSION,
|
||||
message: frame(row),
|
||||
...(startsTurn ? { startsTurn: true } : {})
|
||||
})
|
||||
live(synthetic)
|
||||
live(task, true)
|
||||
expect(translator.currentTurnId).toBeNull()
|
||||
const session = sessionFor()
|
||||
session.providerSessionId = SID
|
||||
const sent = await dispatchClaudeTurn(session, {
|
||||
sessionId: SESSION,
|
||||
clientMessageId: 'real-send',
|
||||
body: userMessage([{ type: 'text', text: String(message(task).content) }])
|
||||
})
|
||||
const waiter = session.dispatchWaiters[0]!
|
||||
const internalEcho = { ...frame(task), uuid: waiter.sentUuid }
|
||||
expect(resolveClaudeReplayTurn(session, internalEcho)).toBeNull()
|
||||
expect(session.dispatchWaiters).toHaveLength(1)
|
||||
const realEcho = {
|
||||
...userReplayFrame(waiter.sentUuid, String(message(task).content)),
|
||||
session_id: SID
|
||||
}
|
||||
expect(resolveClaudeReplayTurn(session, realEcho)).toMatchObject({
|
||||
clientMessageId: 'real-send'
|
||||
})
|
||||
expect(sent).toEqual({ state: 'admitted' })
|
||||
live(realEcho, true)
|
||||
live(synthetic)
|
||||
expect(translator.currentTurnId).toBe(waiter.sentUuid)
|
||||
const result = { type: 'result', subtype: 'success', session_id: SID, uuid: 'real-result' }
|
||||
live(result)
|
||||
expect(session.dispatchWaiters).toHaveLength(0)
|
||||
expect(translator.currentTurnId).toBeNull()
|
||||
translator.dispose()
|
||||
await c.deferred.drained()
|
||||
expect(c.rows()).not.toContain('assistant:No response requested.')
|
||||
})
|
||||
|
||||
it('keeps mixed injected tool results and synthetic tool siblings', async () => {
|
||||
const c = await chat()
|
||||
const translator = createClaudeJournalTranslator({ sink: c.deferred.sink })
|
||||
const live = (row: Record<string, unknown>) =>
|
||||
translator.handle({ type: 'message', sessionId: SESSION, message: frame(row) })
|
||||
const call = rows[1]!
|
||||
live({
|
||||
...synthetic,
|
||||
message: {
|
||||
...message(synthetic),
|
||||
content: [...contentParts(synthetic), ...contentParts(call)]
|
||||
}
|
||||
})
|
||||
const result = rows[2]!
|
||||
live({
|
||||
...task,
|
||||
message: {
|
||||
...message(task),
|
||||
content: [{ type: 'text', text: String(message(task).content) }, ...contentParts(result)]
|
||||
}
|
||||
})
|
||||
live({ type: 'system', subtype: 'session_state_changed', state: 'idle' })
|
||||
translator.dispose()
|
||||
await c.deferred.drained()
|
||||
expect(c.rows()).toContain('tool:Bash:failed')
|
||||
expect(c.rows()).not.toContain('assistant:No response requested.')
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['real-model', 'missing-usage', 'nonzero-usage', 'error', 'empty-error'] as const)(
|
||||
'retains %s assistant semantics',
|
||||
async (kind) => {
|
||||
const c = await chat()
|
||||
await c.live()
|
||||
const nested = message(synthetic)
|
||||
const variant: Record<string, unknown> = {
|
||||
...synthetic,
|
||||
parentUuid: 'a1',
|
||||
...(kind === 'error' ? { isApiErrorMessage: true } : {}),
|
||||
...(kind === 'empty-error' ? { error: 'authentication_failed' } : {}),
|
||||
message: {
|
||||
...nested,
|
||||
...(kind === 'empty-error' ? { content: [] } : {}),
|
||||
...(kind === 'real-model' ? { model: 'claude-opus-5-5' } : {}),
|
||||
...(kind === 'missing-usage' ? { usage: undefined } : {}),
|
||||
...(kind === 'nonzero-usage'
|
||||
? { usage: { ...asRecord(nested.usage), output_tokens: 1 } }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
await c.write([...chatTurn, variant])
|
||||
expect(await c.open('a1')).toBe(true)
|
||||
expect(
|
||||
c.journal.item(
|
||||
agentJournalItemKey({ provider: 'claude', sessionId: SID, uuid: String(variant.uuid) })
|
||||
)
|
||||
).not.toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([undefined, { kind: 'human' }, { kind: 'future-origin' }])(
|
||||
'retains canonical human text with origin %j',
|
||||
async (origin) => {
|
||||
const c = await chat()
|
||||
await c.live()
|
||||
const human = { ...prompt('external-human', 'a1', String(message(task).content), 10), origin }
|
||||
await c.write([...chatTurn, human])
|
||||
expect(await c.open('a1')).toBe(true)
|
||||
expect(
|
||||
c.journal.item(
|
||||
agentJournalItemKey({ provider: 'claude', sessionId: SID, uuid: 'external-human' })
|
||||
)?.body
|
||||
).toMatchObject({ kind: 'message', role: 'user' })
|
||||
}
|
||||
)
|
||||
@@ -1,155 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readAgentJournalTurn } from '../../../shared/agent-session/lifecycle/agent-session-turn-record'
|
||||
import type { AgentJournalItemBody } from '../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
|
||||
import {
|
||||
adapterFor,
|
||||
claudeFrame,
|
||||
fakeClaude,
|
||||
identityFor,
|
||||
recordingJournalSink,
|
||||
tick,
|
||||
USER_MESSAGE
|
||||
} from './claude-structured-session-test-support'
|
||||
import { claudeFocusedPlugin } from '../claude-plugin-ui'
|
||||
|
||||
// Claude Code 2.1.303 recorded shape; UUID and session ID are test identities.
|
||||
const FOCUSED_PANE = {
|
||||
type: 'system',
|
||||
subtype: 'ui_panes',
|
||||
panes: [{ id: 'blast-radius', title: 'Blast Radius', plugin: 'blast-radius', rows: 10 }],
|
||||
shown_id: 'blast-radius',
|
||||
focused_id: null,
|
||||
focus_requested_id: 'blast-radius',
|
||||
uuid: '00000000-0000-4000-8000-000000000795'
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const claude = fakeClaude()
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const persisted: unknown[] = []
|
||||
const items: AgentJournalItemBody[] = []
|
||||
const sink = {
|
||||
...recordingJournalSink(),
|
||||
appendItem: (_identity: unknown, body: AgentJournalItemBody) => {
|
||||
items.push(body)
|
||||
},
|
||||
observeExit: vi.fn()
|
||||
}
|
||||
const adapter = adapterFor(claude, {}, events, persisted)
|
||||
const acquire = (fence = 7) =>
|
||||
adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence,
|
||||
spawnToken: `spawn-${fence}`,
|
||||
events: sink
|
||||
})
|
||||
const dispatch = (id = 'client-1', fence = 7) =>
|
||||
adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: id,
|
||||
body: USER_MESSAGE,
|
||||
fence
|
||||
})
|
||||
await acquire()
|
||||
return { claude, events, persisted, sink, items, adapter, acquire, dispatch }
|
||||
}
|
||||
|
||||
describe('focused Claude plugin pane recovery', () => {
|
||||
it.each([null, 'child-tool'])(
|
||||
'closes the held child once without interrupt for parent %s',
|
||||
async (parent) => {
|
||||
const state = await setup()
|
||||
const first = state.claude.connections[0]
|
||||
await state.dispatch()
|
||||
claudeFrame(first, { ...FOCUSED_PANE, parent_tool_use_id: parent })
|
||||
claudeFrame(first, FOCUSED_PANE)
|
||||
await vi.waitFor(() =>
|
||||
expect(state.events.filter((event) => event.type === 'ended')).toHaveLength(1)
|
||||
)
|
||||
expect(first.closeCount).toBe(1)
|
||||
expect(first.calls.some((call) => call.subtype === 'interrupt')).toBe(false)
|
||||
expect(state.events.find((event) => event.type === 'ended')).toMatchObject({
|
||||
failure: {
|
||||
kind: 'pluginInputRequired',
|
||||
detail: { text: 'blast-radius', audience: 'person' }
|
||||
},
|
||||
reason:
|
||||
'Claude stopped because the "blast-radius" plugin is waiting for input in a terminal pane. Disable the plugin for chat sessions, or run the task in a Claude terminal.'
|
||||
})
|
||||
expect(state.sink.observeExit).toHaveBeenCalledOnce()
|
||||
expect(state.persisted).toHaveLength(1)
|
||||
expect(state.items.flatMap((body) => readAgentJournalTurn(body) ?? []).at(-1)).toMatchObject({
|
||||
state: 'interrupted',
|
||||
outcome: 'failure'
|
||||
})
|
||||
await state.adapter.closeAll()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps passive panes running and ignores foreign, idle and settled focus frames', async () => {
|
||||
const state = await setup()
|
||||
const first = state.claude.connections[0]
|
||||
claudeFrame(first, FOCUSED_PANE)
|
||||
await state.dispatch()
|
||||
claudeFrame(first, { ...FOCUSED_PANE, focus_requested_id: null })
|
||||
claudeFrame(first, { type: 'system', subtype: 'ui_invalidate' })
|
||||
claudeFrame(first, { type: 'system', subtype: 'ui_status', text: null })
|
||||
claudeFrame(first, { ...FOCUSED_PANE, session_id: 'foreign-session' })
|
||||
await tick()
|
||||
expect(first.closeCount).toBe(0)
|
||||
claudeFrame(first, { type: 'result', subtype: 'success', is_error: false, result: 'OK' })
|
||||
claudeFrame(first, FOCUSED_PANE)
|
||||
await tick()
|
||||
expect(first.closeCount).toBe(0)
|
||||
expect(state.events.some((event) => event.type === 'ended')).toBe(false)
|
||||
await state.adapter.closeAll()
|
||||
})
|
||||
|
||||
it('waits for close then sends on a fresh connection, ignoring late frames from the old child', async () => {
|
||||
const state = await setup()
|
||||
const first = state.claude.connections[0]
|
||||
const closed = Promise.withResolvers<boolean>()
|
||||
const close = first.close
|
||||
first.close = async () => {
|
||||
await close()
|
||||
return closed.promise
|
||||
}
|
||||
await state.dispatch()
|
||||
claudeFrame(first, FOCUSED_PANE)
|
||||
const next = state.acquire(8)
|
||||
await tick()
|
||||
expect(state.claude.connections).toHaveLength(1)
|
||||
closed.resolve(true)
|
||||
await next
|
||||
await state.dispatch('client-next', 8)
|
||||
const second = state.claude.connections[1]
|
||||
claudeFrame(first, FOCUSED_PANE)
|
||||
expect(second.closeCount).toBe(0)
|
||||
claudeFrame(second, {
|
||||
type: 'assistant',
|
||||
uuid: 'fresh-reply',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'FOLLOW_UP_OK' }] }
|
||||
})
|
||||
claudeFrame(second, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: false,
|
||||
result: 'FOLLOW_UP_OK'
|
||||
})
|
||||
expect(first.sent.filter((message) => message.type === 'user')).toHaveLength(1)
|
||||
expect(second.sent.filter((message) => message.type === 'user')).toHaveLength(1)
|
||||
expect(state.events.filter((event) => event.type === 'ended')).toHaveLength(1)
|
||||
await state.adapter.closeAll()
|
||||
})
|
||||
|
||||
it('uses the focused pane plugin name, falling back to its trimmed ID', () => {
|
||||
expect(claudeFocusedPlugin({ ...FOCUSED_PANE, focus_requested_id: ' blast-radius ' })).toBe(
|
||||
'blast-radius'
|
||||
)
|
||||
expect(
|
||||
claudeFocusedPlugin({ ...FOCUSED_PANE, panes: [], focus_requested_id: ' pane-2 ' })
|
||||
).toBe('pane-2')
|
||||
expect(claudeFocusedPlugin({ ...FOCUSED_PANE, focus_requested_id: ' ' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,96 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GitHubApiRepositoryModule from '../../github-api-repository'
|
||||
|
||||
const { ghExecFileAsyncMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn() }))
|
||||
|
||||
vi.mock('../../gh-utils', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
acquire: vi.fn(async () => undefined),
|
||||
release: vi.fn()
|
||||
}))
|
||||
vi.mock('../../github-repository-identity', () => ({
|
||||
withGitHubRepoExecution: (_r: string, _c: unknown, _l: unknown, run: () => unknown) => run(),
|
||||
githubRepoContext: (repoPath: string) => ({ repoPath }),
|
||||
ghRepoExecOptions: (context: { repoPath: string }) => ({ cwd: context.repoPath })
|
||||
}))
|
||||
vi.mock('../../github-read-execution-scope', () => ({ githubReadExecutionScope: () => 'local' }))
|
||||
vi.mock('../../github-api-repository', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GitHubApiRepositoryModule>()),
|
||||
resolveGitHubRepoExecution: async () => ({
|
||||
ownerRepo: { owner: 'acme', repo: 'widgets' },
|
||||
ghOptions: { cwd: '/repo' }
|
||||
})
|
||||
}))
|
||||
vi.mock('../../rate-limit', () => ({ noteRepositoryRateLimitSpend: vi.fn() }))
|
||||
vi.mock('../lookup/pr-lookup-rate-limit', () => ({ assertRateLimitBudget: vi.fn() }))
|
||||
|
||||
import { getPRChecks } from './get-pr-checks'
|
||||
|
||||
const HEAD = 'a'.repeat(40)
|
||||
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
describe('getPRChecks run times', () => {
|
||||
it('reads the rollup without gh’s disk cache, which ignores the PR head', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
headRefOid: HEAD,
|
||||
commits: { nodes: [{ commit: { oid: HEAD, statusCheckRollup: null } }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
await getPRChecks('/repo', 1, HEAD)
|
||||
expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).not.toContain('--cache')
|
||||
})
|
||||
|
||||
it('keeps REST check-run times through the fallback’s schema', async () => {
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('GraphQL rollup failed'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
total_count: 1,
|
||||
check_runs: [
|
||||
{
|
||||
id: 88,
|
||||
name: 'build',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
html_url: 'https://github.com/acme/widgets/runs/88',
|
||||
details_url: null,
|
||||
started_at: '2026-11-05T10:00:00Z',
|
||||
completed_at: '2026-11-05T10:03:12Z'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ total_count: 0, statuses: [] }) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ total_count: 0, check_suites: [] }) })
|
||||
const [check] = await getPRChecks('/repo', 2, HEAD)
|
||||
expect(check).toMatchObject({
|
||||
startedAt: '2026-11-05T10:00:00Z',
|
||||
completedAt: '2026-11-05T10:03:12Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('asks an older gh without run-time fields for the fields it knows', async () => {
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('GraphQL rollup failed'))
|
||||
.mockRejectedValueOnce(new Error('REST details failed'))
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Command failed'), { stderr: 'Unknown JSON field: "startedAt"' })
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([{ name: 'lint', state: 'PASS', link: '' }])
|
||||
})
|
||||
const checks = await getPRChecks('/repo', 3, HEAD)
|
||||
expect(ghExecFileAsyncMock.mock.calls[3]?.[0]).toContain('name,state,link')
|
||||
expect(checks.map((check) => check.name)).toEqual(['lint'])
|
||||
})
|
||||
})
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
mapGraphQLPRChecksResponse,
|
||||
mapRestCheckRun,
|
||||
mapRestCommitStatus
|
||||
} from './pr-checks-response-mapping'
|
||||
import type { GraphQLPRChecksResponse, GraphQLStatusCheckContext } from './pr-checks-graphql-query'
|
||||
|
||||
const repo = { owner: 'acme', repo: 'widgets' }
|
||||
|
||||
function rollup(contexts: GraphQLStatusCheckContext[]): GraphQLPRChecksResponse {
|
||||
return {
|
||||
data: {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
headRefOid: 'a'.repeat(40),
|
||||
commits: {
|
||||
nodes: [
|
||||
{
|
||||
commit: {
|
||||
oid: 'a'.repeat(40),
|
||||
statusCheckRollup: { contexts: { nodes: contexts } }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('check run times', () => {
|
||||
it('carries when each GraphQL check ran, dropping GitHub’s unset zero time', () => {
|
||||
const checks = mapGraphQLPRChecksResponse(
|
||||
repo,
|
||||
rollup([
|
||||
{
|
||||
__typename: 'CheckRun',
|
||||
name: 'build',
|
||||
status: 'COMPLETED',
|
||||
conclusion: 'FAILURE',
|
||||
startedAt: '2026-11-05T10:00:00Z',
|
||||
completedAt: '2026-11-05T10:03:12Z'
|
||||
},
|
||||
{
|
||||
__typename: 'CheckRun',
|
||||
name: 'lint',
|
||||
status: 'QUEUED',
|
||||
conclusion: null,
|
||||
startedAt: '0001-01-01T00:00:00Z',
|
||||
completedAt: null
|
||||
},
|
||||
{
|
||||
__typename: 'StatusContext',
|
||||
context: 'jenkins',
|
||||
state: 'FAILURE',
|
||||
createdAt: '2026-11-05T09:00:00Z'
|
||||
},
|
||||
{
|
||||
__typename: 'StatusContext',
|
||||
context: 'deploy',
|
||||
state: 'PENDING',
|
||||
createdAt: '2026-11-05T09:30:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
expect(
|
||||
checks?.map(({ name, startedAt, completedAt }) => ({ name, startedAt, completedAt }))
|
||||
).toEqual([
|
||||
{ name: 'build', startedAt: '2026-11-05T10:00:00Z', completedAt: '2026-11-05T10:03:12Z' },
|
||||
{ name: 'lint', startedAt: undefined, completedAt: undefined },
|
||||
// A commit status's one time is when its result was posted; a pending one has no run yet.
|
||||
{ name: 'jenkins', startedAt: undefined, completedAt: '2026-11-05T09:00:00Z' },
|
||||
{ name: 'deploy', startedAt: undefined, completedAt: undefined }
|
||||
])
|
||||
})
|
||||
|
||||
it('carries REST check-run and commit-status times', () => {
|
||||
expect(
|
||||
mapRestCheckRun({
|
||||
name: 'build',
|
||||
status: 'in_progress',
|
||||
conclusion: null,
|
||||
html_url: '',
|
||||
details_url: null,
|
||||
started_at: '2026-11-05T10:00:00Z',
|
||||
completed_at: null
|
||||
})
|
||||
).toMatchObject({ startedAt: '2026-11-05T10:00:00Z' })
|
||||
expect(
|
||||
mapRestCommitStatus({
|
||||
context: 'jenkins',
|
||||
state: 'success',
|
||||
created_at: '2026-11-05T09:00:00Z'
|
||||
})
|
||||
).toMatchObject({ completedAt: '2026-11-05T09:00:00Z' })
|
||||
})
|
||||
})
|
||||
-328
@@ -1,328 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session/lifecycle/agent-session-mutation-envelope'
|
||||
import { ClaudeStructuredSessionAdapter } from '../../claude/structured/claude-structured-session-adapter'
|
||||
import {
|
||||
claudeFrame,
|
||||
fakeClaude,
|
||||
PROVIDER_SESSION_ID,
|
||||
type FakeConnection
|
||||
} from '../../claude/structured/claude-structured-session-test-support'
|
||||
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session/records/agent-session-record-store-test-harness'
|
||||
import { structuredClaudeLifecycleEvent } from '../../runtime/agent-session/installation/structured-claude-runtime-adapter'
|
||||
import {
|
||||
closeTestJournalHostDatabases,
|
||||
openTestJournalHostDatabase
|
||||
} from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import {
|
||||
HOST_TEST_NOW,
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
hostTestAttachParams,
|
||||
hostTestMessage,
|
||||
hostTestOperationId,
|
||||
resetHostTestOperationIds
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
|
||||
// Recorded pane content; ordering before the user echo is a constructed protocol contract.
|
||||
const FOCUS = {
|
||||
type: 'system',
|
||||
subtype: 'ui_panes',
|
||||
panes: [{ id: 'blast-radius', title: 'Blast Radius', plugin: 'blast-radius', rows: 10 }],
|
||||
shown_id: 'blast-radius',
|
||||
focused_id: null,
|
||||
focus_requested_id: 'blast-radius'
|
||||
}
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
let root: string
|
||||
let host: StructuredAgentSessionHost
|
||||
let adapter: ClaudeStructuredSessionAdapter
|
||||
let claude: ReturnType<typeof fakeClaude>
|
||||
let observedAt: number
|
||||
let restoreConnections: (() => void)[]
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-claude-plugin-input-'))
|
||||
resetHostTestOperationIds()
|
||||
observedAt = HOST_TEST_NOW
|
||||
restoreConnections = []
|
||||
claude = fakeClaude({ replayUuid: null })
|
||||
adapter = new ClaudeStructuredSessionAdapter({
|
||||
resolveLaunch: async () => ({
|
||||
pathToClaudeCodeExecutable: 'claude',
|
||||
options: {},
|
||||
cwd: root,
|
||||
claudeConfigDir: join(root, 'claude-home'),
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
resumeLeafUuid: null,
|
||||
resumesTranscript: false,
|
||||
cliFlag: async () => 'supported' as const,
|
||||
continuesChain: claude.connections.length > 0
|
||||
}),
|
||||
onEvent: (event) => {
|
||||
const mapped = structuredClaudeLifecycleEvent(event)
|
||||
if (mapped) {
|
||||
void host.handleAdapterEvent(mapped)
|
||||
}
|
||||
},
|
||||
onDispatchSettledLate: (event) => void host.settleLateDispatch(event),
|
||||
openConnection: claude.openConnection,
|
||||
readProcessStartTime: async () => 1_700_000_000_000,
|
||||
now: () => ++observedAt
|
||||
})
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store: await openTestAgentSessionRecordStore(root),
|
||||
adapter: Object.assign(adapter, { supportsCreate: () => true }),
|
||||
journalDatabase: openTestJournalHostDatabase(root),
|
||||
claimKeyId: 'key-1',
|
||||
mintSpawnToken: () => hostTestOperationId(),
|
||||
now: () => ++observedAt
|
||||
})
|
||||
expect(
|
||||
await host.attach(
|
||||
CALLER,
|
||||
hostTestAttachParams(null, {
|
||||
provider: 'claude',
|
||||
agent: 'claude',
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') },
|
||||
providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null }
|
||||
})
|
||||
)
|
||||
).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(child()?.phase).toBe('ready'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const restore of restoreConnections) {
|
||||
restore()
|
||||
}
|
||||
await adapter.closeAll()
|
||||
await host.flushAllStreamedEvents()
|
||||
await closeTestJournalHostDatabases()
|
||||
claude.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function child() {
|
||||
return host.collaboratorsForTests().sessions.get(SESSION)?.child
|
||||
}
|
||||
|
||||
function envelope(method: string, fields: Record<string, unknown>, id = hostTestOperationId()) {
|
||||
return {
|
||||
sessionId: SESSION,
|
||||
clientOperationId: id,
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method,
|
||||
sessionId: SESSION,
|
||||
fields
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function send(text: string, queued = false) {
|
||||
const fields = {
|
||||
body: hostTestMessage(text),
|
||||
...(queued ? { delivery: 'queue-if-active' as const } : {})
|
||||
}
|
||||
const request = envelope('agentSession.send', fields)
|
||||
const result = await host.send(CALLER, { envelope: request, ...fields, userSend: true })
|
||||
expect(result).toMatchObject({ ok: true })
|
||||
return { id: request.clientOperationId, result }
|
||||
}
|
||||
|
||||
async function snapshot() {
|
||||
await host.flushStreamedEvents(SESSION)
|
||||
return host.journalSnapshot(SESSION)
|
||||
}
|
||||
|
||||
async function notices() {
|
||||
return (await snapshot()).items.filter(
|
||||
(item) => item.body.kind === 'status' && item.body.failure?.kind === 'pluginInputRequired'
|
||||
)
|
||||
}
|
||||
|
||||
async function expectReply(connection: FakeConnection, text: string) {
|
||||
claudeFrame(connection, { ...connection.sent.at(-1)!, uuid: `echo-${text}` })
|
||||
claudeFrame(connection, {
|
||||
type: 'assistant',
|
||||
uuid: `reply-${text}`,
|
||||
message: { role: 'assistant', content: [{ type: 'text', text }] }
|
||||
})
|
||||
claudeFrame(connection, { type: 'result', subtype: 'success', is_error: false, result: text })
|
||||
await vi.waitFor(async () => expect(JSON.stringify((await snapshot()).items)).toContain(text))
|
||||
expect(
|
||||
(await snapshot()).items.findLast((item) => item.body.kind === 'turn')?.body
|
||||
).toMatchObject({ state: 'completed', outcome: 'success' })
|
||||
}
|
||||
|
||||
async function startHeld() {
|
||||
const first = claude.connections[0]!
|
||||
const initial = await send('plugin probe')
|
||||
await vi.waitFor(() => expect(first.sent).toHaveLength(1))
|
||||
return { first, initial }
|
||||
}
|
||||
|
||||
function holdClose(first: FakeConnection) {
|
||||
const proof = Promise.withResolvers<boolean>()
|
||||
const close = first.close
|
||||
first.close = async () => {
|
||||
await close()
|
||||
return proof.promise
|
||||
}
|
||||
restoreConnections.push(() => {
|
||||
proof.resolve(true)
|
||||
first.close = close
|
||||
})
|
||||
return { proof, close }
|
||||
}
|
||||
|
||||
it('fails an unechoed action before close proof; the quick next send joins close and gets a fresh reply', async () => {
|
||||
const { first, initial } = await startHeld()
|
||||
const { proof } = holdClose(first)
|
||||
const originalChild = child()
|
||||
claudeFrame(first, FOCUS)
|
||||
expect(child()).toBe(originalChild)
|
||||
expect(child()?.close?.failure?.kind).toBe('pluginInputRequired')
|
||||
await vi.waitFor(async () => expect(await notices()).toHaveLength(1))
|
||||
const failed = await snapshot()
|
||||
expect(failed.items.findLast((item) => item.body.kind === 'turn')?.body).toMatchObject({
|
||||
state: 'interrupted',
|
||||
outcome: 'failure'
|
||||
})
|
||||
expect(failed.submissions.find((s) => s.clientMessageId === initial.id)?.dispatchState).not.toBe(
|
||||
'accepted'
|
||||
)
|
||||
expect(host.readStatusSummary(SESSION)?.status).not.toBe('working')
|
||||
expect(host.collaboratorsForTests().sessions.get(SESSION)?.lastEndedChild).toBeUndefined()
|
||||
claudeFrame(first, { ...first.sent[0]!, uuid: 'late-failed-echo' })
|
||||
claudeFrame(first, {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: false,
|
||||
result: 'late failure result'
|
||||
})
|
||||
expect(
|
||||
(await snapshot()).submissions.find((s) => s.clientMessageId === initial.id)?.dispatchState
|
||||
).not.toBe('accepted')
|
||||
await send('fresh follow-up')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(claude.connections).toHaveLength(1)
|
||||
expect(first.sent).toHaveLength(1)
|
||||
proof.resolve(true)
|
||||
await vi.waitFor(() => expect(claude.connections[1]?.sent).toHaveLength(1))
|
||||
const second = claude.connections[1]!
|
||||
claudeFrame(first, {
|
||||
type: 'assistant',
|
||||
message: { content: [{ type: 'text', text: 'LATE_OLD_OUTPUT' }] }
|
||||
})
|
||||
claudeFrame(first, { type: 'result', subtype: 'success', is_error: false, result: 'late result' })
|
||||
claudeFrame(first, FOCUS)
|
||||
await expectReply(second, 'FRESH_REPLY')
|
||||
expect(await notices()).toHaveLength(1)
|
||||
expect(JSON.stringify((await snapshot()).items)).not.toContain('LATE_OLD_OUTPUT')
|
||||
expect(second.closeCount).toBe(0)
|
||||
expect(first.calls.some((call) => call.subtype === 'interrupt')).toBe(false)
|
||||
})
|
||||
|
||||
it('retains unproven ownership, then a legitimate next-send wake retries proof and completes delivery', async () => {
|
||||
const { first } = await startHeld()
|
||||
const { proof, close } = holdClose(first)
|
||||
claudeFrame(first, FOCUS)
|
||||
await vi.waitFor(async () => expect(await notices()).toHaveLength(1))
|
||||
proof.resolve(false)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(child()?.close?.failure?.kind).toBe('pluginInputRequired')
|
||||
expect(claude.connections).toHaveLength(1)
|
||||
first.close = close
|
||||
await send('retry after proof failure')
|
||||
await vi.waitFor(() => expect(claude.connections[1]?.sent).toHaveLength(1))
|
||||
await expectReply(claude.connections[1]!, 'RETRY_REPLY')
|
||||
expect(first.closeCount).toBe(2)
|
||||
expect(await notices()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('holds queued cards through semantic and physical failure until explicit Resume dispatches and replies', async () => {
|
||||
const { first } = await startHeld()
|
||||
const queued = await send('queued follow-up', true)
|
||||
expect(queued.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { queued: { messageId: expect.any(String) } }
|
||||
})
|
||||
claudeFrame(first, FOCUS)
|
||||
await vi.waitFor(() => expect(child()).toBeNull())
|
||||
const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal
|
||||
expect(journal.queuedMessages.pauses()).toContainEqual(
|
||||
expect.objectContaining({ reason: 'failed' })
|
||||
)
|
||||
await host.flushStreamedEvents(SESSION)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(claude.connections).toHaveLength(1)
|
||||
expect(first.sent).toHaveLength(1)
|
||||
expect(
|
||||
await host.queuedMessagesResume(CALLER, {
|
||||
envelope: envelope('agentSession.queuedMessagesResume', {})
|
||||
})
|
||||
).toMatchObject({ ok: true, value: { resumed: true } })
|
||||
await vi.waitFor(() => expect(claude.connections[1]?.sent).toHaveLength(1))
|
||||
await expectReply(claude.connections[1]!, 'QUEUED_REPLY')
|
||||
expect(await notices()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'keeps personal Stop precedence when focus arrives during interrupt (echoed=%s)',
|
||||
async (echoed) => {
|
||||
const { first } = await startHeld()
|
||||
if (echoed) {
|
||||
claudeFrame(first, { ...first.sent[0]!, uuid: 'stopped-user-echo' })
|
||||
await vi.waitFor(async () =>
|
||||
expect(
|
||||
(await snapshot()).items.some(
|
||||
(item) => item.body.kind === 'turn' && item.body.state === 'running'
|
||||
)
|
||||
).toBe(true)
|
||||
)
|
||||
}
|
||||
claude.routes.interrupt = () => {
|
||||
claudeFrame(first, FOCUS)
|
||||
}
|
||||
expect(
|
||||
await host.cancel(CALLER, {
|
||||
envelope: envelope('agentSession.cancel', {})
|
||||
})
|
||||
).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(child()).toBeNull())
|
||||
expect(await notices()).toHaveLength(0)
|
||||
expect(
|
||||
(await snapshot()).items.findLast((item) => item.body.kind === 'turn')?.body
|
||||
).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
|
||||
}
|
||||
)
|
||||
|
||||
it('finishes a retained unproven close when its exact process later reports exit', async () => {
|
||||
const { first } = await startHeld()
|
||||
const close = first.close
|
||||
restoreConnections.push(() => {
|
||||
first.close = close
|
||||
})
|
||||
first.close = async () => {
|
||||
await close()
|
||||
return false
|
||||
}
|
||||
claudeFrame(first, FOCUS)
|
||||
await vi.waitFor(async () => expect(await notices()).toHaveLength(1))
|
||||
expect(child()?.close).toBeDefined()
|
||||
first.exitVerdict = { root: 'exited', tree: 'unverifiable' }
|
||||
first.handlers.onExit?.(new Error('late real exit'), { expected: true })
|
||||
await vi.waitFor(() => expect(child()).toBeNull())
|
||||
expect(await notices()).toHaveLength(1)
|
||||
await send('after late exit')
|
||||
await vi.waitFor(() => expect(claude.connections[1]?.sent).toHaveLength(1))
|
||||
await expectReply(claude.connections[1]!, 'LATE_EXIT_REPLY')
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
// #38434: clients judge an agent row's ownership by worktree, so a restored pane's status must name it.
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { partialRuntimeStore } from './orca-runtime-tests/runtime-store-partial-fixture'
|
||||
|
||||
// Preserve side-effect order: module registration, runtime, lifecycle, fixtures.
|
||||
await import('./orca-runtime-tests/runtime-test-module-registration.spec')
|
||||
const { OrcaRuntimeService } = await import('./orca-runtime')
|
||||
await import('./orca-runtime-tests/orca-runtime-test-lifecycle.spec')
|
||||
const {
|
||||
HEADLESS_LEAF_ID,
|
||||
TEST_WORKTREE_ID,
|
||||
makeRuntimeStoreWithWorkspaceSession,
|
||||
makeWorkspaceSessionWithHeadlessTerminal
|
||||
} = await import('./orca-runtime-tests/orca-runtime-test-fixtures.spec')
|
||||
|
||||
// The previous run's hook time; restored rows keep it.
|
||||
const RESTORED_AT = 1_700_000_000_000
|
||||
|
||||
// A minute after the restored row's hook; only the clock is fixed.
|
||||
beforeEach(() => vi.useFakeTimers({ now: RESTORED_AT + 60_000, toFake: ['Date'] }))
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('names the worktree on a restored pane agent whose hook row carries none', async () => {
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal()
|
||||
)
|
||||
// Hydrated from the previous run's cache; the hook that reported it named no worktree.
|
||||
const rows = [
|
||||
{
|
||||
paneKey: `host-tab:${HEADLESS_LEAF_ID}`,
|
||||
agentType: 'codex' as const,
|
||||
providerSession: { key: 'session_id' as const, id: 'codex-session' },
|
||||
prompt: 'ship it',
|
||||
state: 'working' as const,
|
||||
receivedAt: RESTORED_AT,
|
||||
stateStartedAt: RESTORED_AT,
|
||||
connectionId: null,
|
||||
restoredUnconfirmed: true
|
||||
}
|
||||
]
|
||||
const runtime = new OrcaRuntimeService(partialRuntimeStore(runtimeStore), undefined, {
|
||||
getAgentProviderSessionSnapshot: () => rows,
|
||||
getAgentProviderSessionRowsForPane: (paneKey: string) =>
|
||||
rows.filter((row) => row.paneKey === paneKey)
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
const [tab] = (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs
|
||||
|
||||
expect(tab).toMatchObject({
|
||||
status: 'pending-handle',
|
||||
agentStatus: { restoredUnconfirmed: true, worktreeId: TEST_WORKTREE_ID }
|
||||
})
|
||||
})
|
||||
@@ -1,171 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import { parsePtySessionId } from '../../shared/terminal/pty/lifecycle/pty-session-id-format'
|
||||
import { withBoundPane } from './orca-runtime-tests/bind-first-pane.test-fixture'
|
||||
|
||||
// Preserve side-effect order: mocks, lifecycle, fixtures.
|
||||
await import('./orca-runtime-tests/runtime-test-module-registration.spec')
|
||||
const { OrcaRuntimeService } = await import('./orca-runtime')
|
||||
await import('./orca-runtime-tests/orca-runtime-test-lifecycle.spec')
|
||||
const { createFolderWorkspaceRuntimeStore, makeFolderProjectGroup, makeFolderWorkspace } =
|
||||
await import('./orca-runtime-tests/orca-runtime-test-fixtures.spec')
|
||||
|
||||
const FOLDER = process.cwd()
|
||||
const H = '10000000-0000-4000-8000-000000000001'
|
||||
const B = '10000000-0000-4000-8000-000000000002'
|
||||
|
||||
// Same-path folder workspaces on a headless serve host (#36716).
|
||||
function makeServeHost(seed: { ptyId: string; worktreeId?: string }[] = []) {
|
||||
const folders = [H, B].map((id) => makeFolderWorkspace({ id, folderPath: FOLDER }))
|
||||
let session = getDefaultWorkspaceSession()
|
||||
for (const [index, { ptyId, worktreeId }] of seed.entries()) {
|
||||
if (worktreeId) {
|
||||
session = withBoundPane(session, {
|
||||
worktreeId,
|
||||
tabId: `tab-${index}`,
|
||||
leafId: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
|
||||
ptyId
|
||||
})
|
||||
}
|
||||
}
|
||||
const store = {
|
||||
...createFolderWorkspaceRuntimeStore(
|
||||
folders[0],
|
||||
makeFolderProjectGroup({ parentPath: FOLDER })
|
||||
),
|
||||
getFolderWorkspaces: () => folders,
|
||||
getWorkspaceSession: () => session,
|
||||
setWorkspaceSession: (next: typeof session) => {
|
||||
session = next
|
||||
},
|
||||
flushOrThrow: () => {}
|
||||
}
|
||||
const live = new Set(seed.map(({ ptyId }) => ptyId))
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const spawn = vi.fn(
|
||||
async (args: {
|
||||
sessionId?: string
|
||||
worktreeId?: string
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
persistHostSessionBinding?: boolean
|
||||
}) => {
|
||||
if (!args.worktreeId || !args.tabId || !args.leafId) {
|
||||
throw new Error('missing identity')
|
||||
}
|
||||
const id = args.sessionId ?? `${args.worktreeId}@@control`
|
||||
live.add(id)
|
||||
if (args.persistHostSessionBinding) {
|
||||
session = withBoundPane(session, {
|
||||
worktreeId: args.worktreeId,
|
||||
tabId: args.tabId,
|
||||
leafId: args.leafId,
|
||||
ptyId: id
|
||||
})
|
||||
}
|
||||
return { id }
|
||||
}
|
||||
)
|
||||
const stopAndWait = vi.fn(async (id: string) => {
|
||||
live.delete(id)
|
||||
runtime.onPtyExit(id, 0)
|
||||
return true
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
stopAndWait,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
hasPty: (id: string) => live.has(id),
|
||||
getForegroundProcess: async () => null,
|
||||
// Like the daemon, report only the workspace a session id names; every shell shares one cwd.
|
||||
listProcesses: async () =>
|
||||
[...live].map((id) => {
|
||||
const { worktreeId } = parsePtySessionId(id)
|
||||
return {
|
||||
id,
|
||||
incarnationId: `${id}-incarnation`,
|
||||
terminalHandle: `term_${id}`,
|
||||
wslDistro: null,
|
||||
cwd: FOLDER,
|
||||
title: 'shell',
|
||||
...(worktreeId ? { worktreeId } : {})
|
||||
}
|
||||
})
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
const ownerOf = async (ptyId: string) =>
|
||||
(await runtime.listTerminals()).terminals.find((entry) => entry.ptyId === ptyId)?.worktreeId
|
||||
return { runtime, spawn, stopAndWait, live, ownerOf }
|
||||
}
|
||||
|
||||
describe('serve host terminal workspace ownership', () => {
|
||||
it('keeps a paired client terminal in its workspace when a same-path sibling refreshes', async () => {
|
||||
const { runtime, spawn, stopAndWait, live, ownerOf } = makeServeHost()
|
||||
await runtime.createMobileSessionTerminal(`id:folder:${H}`, { activate: false })
|
||||
const spawned = await spawn.mock.results.at(-1)?.value
|
||||
const id = spawned?.id ?? ''
|
||||
expect(id).toMatch(new RegExp(`^folder:${H}@@serve-`))
|
||||
|
||||
await runtime.listMobileSessionTabs(`id:folder:${B}`)
|
||||
expect((await runtime.listTerminals(`id:folder:${B}`)).terminals).toEqual([])
|
||||
expect(await ownerOf(id)).toBe(`folder:${H}`)
|
||||
|
||||
await runtime.stopTerminalsForWorktree(`id:folder:${B}`)
|
||||
expect(stopAndWait).not.toHaveBeenCalled()
|
||||
expect(live.has(id)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a restored workspace-less serve terminal with its persisted binding', async () => {
|
||||
const legacy = 'serve-00000000-0000-4000-8000-0000000000aa'
|
||||
const { runtime, stopAndWait, live, ownerOf } = makeServeHost([
|
||||
{ ptyId: legacy, worktreeId: `folder:${H}` }
|
||||
])
|
||||
|
||||
await runtime.listMobileSessionTabs(`id:folder:${B}`)
|
||||
expect((await runtime.listTerminals(`id:folder:${B}`)).terminals).toEqual([])
|
||||
expect(await ownerOf(legacy)).toBe(`folder:${H}`)
|
||||
|
||||
await runtime.stopTerminalsForWorktree(`id:folder:${B}`)
|
||||
expect(stopAndWait).not.toHaveBeenCalled()
|
||||
expect(live.has(legacy)).toBe(true)
|
||||
})
|
||||
|
||||
it('never stops an unbound terminal that a workspace only claims by cwd', async () => {
|
||||
const unbound = 'unbound-shell'
|
||||
const { runtime, stopAndWait, live } = makeServeHost([{ ptyId: unbound }])
|
||||
|
||||
await runtime.listMobileSessionTabs(`id:folder:${B}`)
|
||||
const listed = await runtime.listTerminals(`id:folder:${B}`)
|
||||
expect(listed.terminals.map((entry) => entry.ptyId)).toEqual([unbound])
|
||||
|
||||
await runtime.stopTerminalsForWorktree(`id:folder:${B}`)
|
||||
await runtime.sleepTerminalsForWorktree(`id:folder:${B}`)
|
||||
expect(stopAndWait).not.toHaveBeenCalled()
|
||||
expect(live.has(unbound)).toBe(true)
|
||||
})
|
||||
|
||||
it('stops a cwd-filed terminal with its workspace once a pane adopts it', async () => {
|
||||
const orphan = 'unbound-shell'
|
||||
const { runtime, stopAndWait, live } = makeServeHost([{ ptyId: orphan }])
|
||||
const before = await runtime.listTerminals(`id:folder:${B}`)
|
||||
|
||||
await runtime.adoptTerminalOrphans({
|
||||
worktree: `id:folder:${B}`,
|
||||
expectedTopologyRevision: before.topologyRevisions?.[`folder:${B}`] ?? 0,
|
||||
claims: [
|
||||
{
|
||||
terminal: `term_${orphan}`,
|
||||
ptyId: orphan,
|
||||
incarnationId: `${orphan}-incarnation`,
|
||||
tabId: 'adopted-tab',
|
||||
leafId: '00000000-0000-4000-8000-0000000000ad'
|
||||
}
|
||||
]
|
||||
})
|
||||
await runtime.stopTerminalsForWorktree(`id:folder:${B}`)
|
||||
|
||||
expect(stopAndWait).toHaveBeenCalledWith(orphan)
|
||||
expect(live.has(orphan)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
// A recipe VM's suspend and resume never overlap, and one reopening runs its resume once.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { EphemeralVmRuntimeRecord } from '../../../../shared/ephemeral-vm-runtimes'
|
||||
import { WorkspaceSleepCoordinator, type WorkspaceSleepRuntime } from './workspace-sleep-runtime'
|
||||
import type { WorkspaceSleepPlatform } from './workspace-sleep-platform'
|
||||
import type { WorkspaceLifecycleQueue } from '../lifecycle/workspace-lifecycle-queue'
|
||||
@@ -15,20 +14,6 @@ function gate(): Gate {
|
||||
return { opened, open }
|
||||
}
|
||||
|
||||
/** A lifecycle queue that holds every command until the test releases it. */
|
||||
function heldLifecycleQueue() {
|
||||
const held: (() => void)[] = []
|
||||
const hold = (_key: unknown, command: { run: () => Promise<unknown> }) =>
|
||||
new Promise((resolve, reject) => held.push(() => void command.run().then(resolve, reject)))
|
||||
const queue = { submit: hold, submitResolving: hold, pausedKeys: () => [] }
|
||||
return {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sleep and wake paths only submit commands.
|
||||
queue: queue as unknown as WorkspaceLifecycleQueue,
|
||||
held,
|
||||
releaseAll: () => held.splice(0).forEach((release) => release())
|
||||
}
|
||||
}
|
||||
|
||||
function coordinator(
|
||||
vm: Pick<WorkspaceSleepPlatform, 'suspendVm' | 'resumeVm'>,
|
||||
workspaceLifecycle: WorkspaceLifecycleQueue | null = null
|
||||
@@ -94,63 +79,6 @@ describe('WorkspaceSleepCoordinator VM wake', () => {
|
||||
expect(order).toEqual(['suspend:start', 'suspend:end', 'resume'])
|
||||
})
|
||||
|
||||
it("runs the recipe's resume once for two quick clicks, the second waiting for the first", async () => {
|
||||
const resuming = gate()
|
||||
let suspended = true
|
||||
let resumes = 0
|
||||
// Like the recipe resume: it only runs on a suspended VM.
|
||||
const resumeVm = async (): Promise<EphemeralVmRuntimeRecord | null> => {
|
||||
if (!suspended) {
|
||||
return null
|
||||
}
|
||||
resumes += 1
|
||||
await resuming.opened
|
||||
suspended = false
|
||||
return null
|
||||
}
|
||||
const workspaceSleep = coordinator({ suspendVm: async () => {}, resumeVm })
|
||||
|
||||
const first = workspaceSleep.wakeVm(WORKTREE_ID)
|
||||
const second = workspaceSleep.wakeVm(WORKTREE_ID)
|
||||
resuming.open()
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(resumes).toBe(1)
|
||||
})
|
||||
|
||||
it("resumes the VM while the workspace's lifecycle queue waits on the host it runs", async () => {
|
||||
const lifecycle = heldLifecycleQueue()
|
||||
const resumeVm = async (): Promise<EphemeralVmRuntimeRecord | null> => null
|
||||
const workspaceSleep = coordinator({ suspendVm: async () => {}, resumeVm }, lifecycle.queue)
|
||||
|
||||
await expect(workspaceSleep.wakeVm(WORKTREE_ID)).resolves.toBeNull()
|
||||
expect(lifecycle.held).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the VM up when the workspace is opened while its sleep waits its turn', async () => {
|
||||
const lifecycle = heldLifecycleQueue()
|
||||
const order: string[] = []
|
||||
const workspaceSleep = coordinator(
|
||||
{
|
||||
suspendVm: async () => {
|
||||
order.push('suspend')
|
||||
},
|
||||
resumeVm: async () => {
|
||||
order.push('resume')
|
||||
return null
|
||||
}
|
||||
},
|
||||
lifecycle.queue
|
||||
)
|
||||
|
||||
const slept = sleep(workspaceSleep)
|
||||
await workspaceSleep.wakeVm(WORKTREE_ID)
|
||||
lifecycle.releaseAll()
|
||||
await slept
|
||||
|
||||
expect(order).toEqual(['resume'])
|
||||
})
|
||||
|
||||
it('keeps the VM up when the workspace is opened while its suspend waits behind a wake', async () => {
|
||||
const order: string[] = []
|
||||
const resuming = gate()
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import { agentSessionFailureFact } from '../../../../shared/agent-session/failures/agent-session-failure'
|
||||
import { NativeChatNoticeRow } from './NativeChatNoticeRow'
|
||||
|
||||
function renderFailure(failure: ReturnType<typeof agentSessionFailureFact>, text: string) {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<NativeChatNoticeRow
|
||||
agentName="Claude"
|
||||
block={{ type: 'text', tone: 'error', text, failure }}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
it('renders a plugin input failure with its recovery in English and French', async () => {
|
||||
const failure = agentSessionFailureFact('pluginInputRequired', {
|
||||
detail: { text: 'blast-radius', audience: 'person' }
|
||||
})
|
||||
const text =
|
||||
'Claude stopped because the "blast-radius" plugin is waiting for input in a terminal pane. Disable the plugin for chat sessions, or run the task in a Claude terminal.'
|
||||
renderFailure(failure, text)
|
||||
expect(screen.getByText(text)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Details')).not.toBeInTheDocument()
|
||||
cleanup()
|
||||
await i18n.changeLanguage('fr')
|
||||
renderFailure(failure, text)
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Claude s’est arrêté car le plugin « blast-radius » attend une saisie dans un volet de terminal. Désactivez le plugin pour les sessions de chat ou exécutez la tâche dans un terminal Claude.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('Détails')).not.toBeInTheDocument()
|
||||
cleanup()
|
||||
renderFailure(failure, 'Custom host recovery advice.')
|
||||
expect(screen.getByText('Custom host recovery advice.')).toBeInTheDocument()
|
||||
})
|
||||
@@ -70,32 +70,6 @@ describe('useNativeChatSteerChordPreview', () => {
|
||||
return { props, view, field, button, hold, preview: () => button.dataset.preview }
|
||||
}
|
||||
|
||||
it('previews steering the head card while Cmd is held alone on an empty composer', () => {
|
||||
const { props, field, button, hold, preview } = setup()
|
||||
hold()
|
||||
expect(preview()).toBe('steer-queued')
|
||||
fireEvent.click(button)
|
||||
expect(props.steerHead).toHaveBeenCalledTimes(1)
|
||||
fireEvent.keyUp(field, { key: 'Meta' })
|
||||
expect(preview()).toBe('')
|
||||
})
|
||||
|
||||
it('names what Cmd+Enter does to a typed draft, and a click sends it that way', () => {
|
||||
const { props, button, hold, preview } = setup({ alternateSend: 'queue', hasDraft: true })
|
||||
hold()
|
||||
expect(preview()).toBe('queue')
|
||||
fireEvent.click(button)
|
||||
expect(props.send).toHaveBeenCalledWith({ alternate: true })
|
||||
expect(props.steerHead).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows nothing when Shift is already down, since Cmd+Shift+Enter is another chord', () => {
|
||||
const { field, preview } = setup({ hasDraft: true })
|
||||
fireEvent.keyDown(field, { key: 'Meta', shiftKey: true })
|
||||
act(() => vi.advanceTimersByTime(NATIVE_CHAT_STEER_CHORD_HOLD_MS))
|
||||
expect(preview()).toBe('')
|
||||
})
|
||||
|
||||
it('never flashes for a quick chord such as Cmd+C', () => {
|
||||
const { field, preview } = setup()
|
||||
fireEvent.keyDown(field, { key: 'Meta' })
|
||||
@@ -119,14 +93,4 @@ describe('useNativeChatSteerChordPreview', () => {
|
||||
act(() => vi.advanceTimersByTime(NATIVE_CHAT_STEER_CHORD_HOLD_MS))
|
||||
expect(preview()).toBe('')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the chord queues', { alternateSend: 'queue' as const }],
|
||||
['there is no card to steer', { steerHead: undefined }],
|
||||
['a picker takes Enter', { pickerOpen: true }]
|
||||
])('shows nothing on an empty composer where %s', (_case, overrides) => {
|
||||
const { hold, preview } = setup(overrides)
|
||||
hold()
|
||||
expect(preview()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { PRCheckDetail } from '../../../../../../shared/github/check-types'
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const formatCalls = vi.hoisted(() => ({ count: 0 }))
|
||||
|
||||
// Counts how often labels rebuild their exact times, the per-row text that never depends on the clock.
|
||||
vi.mock('@/components/github/pr-check-presentation', () => ({
|
||||
formatCheckTimestamp: (input: string | null | undefined) => {
|
||||
formatCalls.count += 1
|
||||
return input ?? null
|
||||
}
|
||||
}))
|
||||
|
||||
import { CheckRunTime } from './check-run-time'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
function rows(timed: number, untimed: number): PRCheckDetail[] {
|
||||
const base = { status: 'completed', conclusion: 'success', url: null } as const
|
||||
return [
|
||||
...Array.from({ length: timed }, (_, i) => ({
|
||||
...base,
|
||||
name: `timed-${i}`,
|
||||
startedAt: '2026-11-05T09:57:00Z',
|
||||
completedAt: '2026-11-05T10:00:12Z'
|
||||
})),
|
||||
...Array.from({ length: untimed }, (_, i) => ({ ...base, name: `untimed-${i}` }))
|
||||
]
|
||||
}
|
||||
|
||||
function formatsPerTick(timed: number, untimed: number): number {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
{rows(timed, untimed).map((check) => (
|
||||
<CheckRunTime key={check.name} check={check} />
|
||||
))}
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
formatCalls.count = 0
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000)
|
||||
})
|
||||
return formatCalls.count
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-11-05T12:00:00Z'))
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
formatCalls.count = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('CheckRunTime clock cost', () => {
|
||||
it('rebuilds no fixed text on a clock tick, at N and 10N rows', () => {
|
||||
// A tick can only move ages; exact times, durations and copy depend on data and language alone.
|
||||
expect(formatsPerTick(10, 10)).toBe(0)
|
||||
act(() => root.unmount())
|
||||
root = createRoot(container)
|
||||
expect(formatsPerTick(100, 100)).toBe(0)
|
||||
})
|
||||
|
||||
it('still moves each visible age on a tick', () => {
|
||||
formatsPerTick(3, 0)
|
||||
const before = container.textContent
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60 * 60_000)
|
||||
})
|
||||
expect(container.textContent).not.toBe(before)
|
||||
})
|
||||
})
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatCheckRunAge, getCheckRunTimeFacts } from './check-run-time'
|
||||
import type { PRCheckDetail } from '../../../../../../shared/github/check-types'
|
||||
|
||||
const NOW = Date.parse('2026-11-05T12:00:00Z')
|
||||
|
||||
function facts(overrides: Partial<PRCheckDetail>) {
|
||||
return getCheckRunTimeFacts({ status: 'completed', ...overrides })
|
||||
}
|
||||
|
||||
function age(overrides: Partial<PRCheckDetail>): string | null {
|
||||
const result = facts(overrides)
|
||||
return result ? formatCheckRunAge(result, NOW) : null
|
||||
}
|
||||
|
||||
describe('check run time facts', () => {
|
||||
it('dates a finished check by its finish and shows how long it took', () => {
|
||||
const finished = facts({
|
||||
startedAt: '2026-11-05T09:57:00Z',
|
||||
completedAt: '2026-11-05T10:00:12Z'
|
||||
})
|
||||
expect(finished?.whenMs).toBe(Date.parse('2026-11-05T10:00:12Z'))
|
||||
expect(finished?.fixedText).not.toBe('')
|
||||
})
|
||||
|
||||
it('dates a running check by its start, with no duration yet', () => {
|
||||
const running = facts({ status: 'in_progress', startedAt: '2026-11-05T11:55:00Z' })
|
||||
expect(running?.whenMs).toBe(Date.parse('2026-11-05T11:55:00Z'))
|
||||
expect(running?.running).toBe(true)
|
||||
expect(running?.fixedText).toBe('')
|
||||
})
|
||||
|
||||
it('reads a provider clock ahead of ours as now and drops sub-second runs', () => {
|
||||
const ahead = facts({ startedAt: '2026-11-05T12:00:20Z', completedAt: '2026-11-05T12:00:20Z' })
|
||||
expect(ahead?.fixedText).toBe('')
|
||||
expect(age({ completedAt: '2026-11-05T12:00:20Z' })).toBe(
|
||||
age({ completedAt: '2026-11-05T12:00:00Z' })
|
||||
)
|
||||
})
|
||||
|
||||
it('shows nothing for a finished check without a finish time', () => {
|
||||
expect(facts({ startedAt: '2026-11-05T09:57:00Z' })).toBeNull()
|
||||
expect(facts({})).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an earlier-commit note even when the run has no time', () => {
|
||||
const note = facts({ notLatestCommitSha: 'a'.repeat(40) })
|
||||
expect(note?.fixedText).not.toBe('')
|
||||
expect(age({ notLatestCommitSha: 'a'.repeat(40) })).toBeNull()
|
||||
})
|
||||
})
|
||||
-154
@@ -1,154 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../../shared/repo-types'
|
||||
import { GITLAB_MR_INVOLVEMENT_RUNTIME_CAPABILITY } from '../../../../../shared/source-control/runtime/source-control-runtime-capabilities'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
|
||||
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
|
||||
import { isRuntimeEnvironmentPairingChangedError } from '@/runtime/runtime-environment-pairing-refresh'
|
||||
import { createCompatibleRuntimeStatusResponse } from '@/runtime/runtime-compatibility-test-fixture'
|
||||
import { useTaskPageGitLabLoading } from './use-task-page-gitlab-loading'
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } }))
|
||||
|
||||
const environmentCall = vi.fn()
|
||||
|
||||
function statusResponse(supportsFilter: boolean) {
|
||||
const response = createCompatibleRuntimeStatusResponse('runtime-1')
|
||||
return {
|
||||
...response,
|
||||
result: {
|
||||
...response.result,
|
||||
capabilities: (response.result.capabilities ?? []).filter(
|
||||
(capability) => supportsFilter || capability !== GITLAB_MR_INVOLVEMENT_RUNTIME_CAPABILITY
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function callsTo(method: string) {
|
||||
return environmentCall.mock.calls.filter(([request]) => request.method === method)
|
||||
}
|
||||
|
||||
function renderMineOnServer(repos: Repo[]) {
|
||||
const setGitlabLoading = vi.fn()
|
||||
const setGitlabError = vi.fn()
|
||||
const view = renderHook(
|
||||
({ nonce }) =>
|
||||
useTaskPageGitLabLoading({
|
||||
selectedRepos: repos,
|
||||
selectedReposKey: `server-${repos.length}`,
|
||||
primaryRepo: repos[0],
|
||||
taskSource: 'gitlab',
|
||||
setGitlabItems: vi.fn(),
|
||||
setGitlabLoading,
|
||||
setGitlabError,
|
||||
gitlabRefreshNonce: nonce,
|
||||
gitlabView: 'mrs',
|
||||
setGitlabTodos: vi.fn(),
|
||||
setGitlabTodosLoading: vi.fn(),
|
||||
activeGitlabFilter: 'opened',
|
||||
gitlabMRInvolvement: 'authored',
|
||||
gitlabItems: [],
|
||||
gitlabError: null,
|
||||
gitlabLoading: false,
|
||||
displayedGitLabItems: [],
|
||||
gitlabListQueryKey: null,
|
||||
setGitlabListQueryKey: vi.fn()
|
||||
}),
|
||||
{ initialProps: { nonce: 0 } }
|
||||
)
|
||||
const settled = async () => {
|
||||
await waitFor(() => expect(setGitlabLoading).toHaveBeenLastCalledWith(false))
|
||||
setGitlabLoading.mockClear()
|
||||
}
|
||||
return { ...view, settled, setGitlabError }
|
||||
}
|
||||
|
||||
function serverRepos(count: number): Repo[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `server-repo-${index}`,
|
||||
path: `/srv/app-${index}`,
|
||||
displayName: `app-${index}`,
|
||||
badgeColor: '',
|
||||
addedAt: 1,
|
||||
executionHostId: 'runtime:build-box'
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'build-box', createdAt: 1 }])
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
environmentCall.mockReset()
|
||||
Object.assign(window, { api: { runtimeEnvironments: { call: environmentCall } } })
|
||||
})
|
||||
|
||||
describe('GitLab MR filter capability checks', () => {
|
||||
it.each([12, 120])(
|
||||
'asks an older server for its status once per refresh across %s projects',
|
||||
async (projectCount) => {
|
||||
environmentCall.mockImplementation(async (request: { method: string }) =>
|
||||
request.method === 'status.get'
|
||||
? statusResponse(false)
|
||||
: { id: 'list', ok: true, result: { items: [] } }
|
||||
)
|
||||
const { rerender, settled, setGitlabError } = renderMineOnServer(serverRepos(projectCount))
|
||||
|
||||
await settled()
|
||||
expect(setGitlabError).toHaveBeenLastCalledWith(expect.any(String))
|
||||
// The compatibility check and the capability read share one status probe on a cold client.
|
||||
expect(callsTo('status.get')).toHaveLength(1)
|
||||
expect(callsTo('gitlab.listMRs')).toHaveLength(0)
|
||||
|
||||
rerender({ nonce: 1 })
|
||||
await settled()
|
||||
expect(callsTo('status.get')).toHaveLength(2)
|
||||
expect(callsTo('gitlab.listMRs')).toHaveLength(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('shows a failed check as a failure and lists on the checked runtime once it recovers', async () => {
|
||||
let statusFails = true
|
||||
environmentCall.mockImplementation(async (request: { method: string }) => {
|
||||
if (request.method === 'status.get') {
|
||||
if (statusFails) {
|
||||
throw new Error('Connection lost')
|
||||
}
|
||||
return statusResponse(true)
|
||||
}
|
||||
return { id: 'list', ok: true, result: { items: [], page: 1, perPage: 50 } }
|
||||
})
|
||||
const { rerender, settled, setGitlabError } = renderMineOnServer(serverRepos(2))
|
||||
|
||||
await settled()
|
||||
expect(setGitlabError).toHaveBeenLastCalledWith(expect.stringContaining('Connection lost'))
|
||||
expect(callsTo('gitlab.listMRs')).toHaveLength(0)
|
||||
|
||||
statusFails = false
|
||||
rerender({ nonce: 1 })
|
||||
await settled()
|
||||
expect(callsTo('gitlab.listMRs')).toHaveLength(2)
|
||||
for (const [request] of callsTo('gitlab.listMRs')) {
|
||||
expect(request).toMatchObject({
|
||||
params: expect.objectContaining({ involvement: 'authored' }),
|
||||
expectedEnvironmentRuntimeId: 'runtime-1'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores an unsupported answer from a pairing replaced while it was checked', async () => {
|
||||
environmentCall.mockImplementation(async (request: { method: string }) => {
|
||||
if (request.method === 'status.get') {
|
||||
replaceRuntimeEnvironmentRevisions([{ id: 'build-box', createdAt: 2 }])
|
||||
return statusResponse(false)
|
||||
}
|
||||
return { id: 'list', ok: true, result: { items: [] } }
|
||||
})
|
||||
const { settled, setGitlabError } = renderMineOnServer(serverRepos(1))
|
||||
|
||||
await settled()
|
||||
expect(isRuntimeEnvironmentPairingChangedError(setGitlabError.mock.lastCall?.[0])).toBe(true)
|
||||
expect(callsTo('gitlab.listMRs')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
-203
@@ -1,203 +0,0 @@
|
||||
/** A server that restarted publishes its restored agents without their worktree, and restored panes
|
||||
* wait for a stream handle. When that server closes such a tab, a paired desktop must drop the tab
|
||||
* and its agent row like any other retraction, not keep a greyed retained row for a pane that is gone. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import { makePaneKey } from '../../../shared/workspace-layout/stable-pane-id'
|
||||
import { toWebTerminalSurfaceTabId } from '../../../shared/workspace-layout/terminal-surface-id'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import type { AppState } from '../store/types'
|
||||
import { createTestStore, makeWorktree, seedStore } from '../store/slices/store-test-helpers'
|
||||
import { applyFreshWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api'
|
||||
import { resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync/tracking-lifecycle'
|
||||
import {
|
||||
buildRetainedAgentsSyncSnapshot,
|
||||
collectRetainedAgentsOnDisappear
|
||||
} from '../components/dashboard/useRetainedAgents'
|
||||
import { buildWorktreeAgentRows } from '../components/sidebar/workspace-card/activity/worktree-agent-rows'
|
||||
import {
|
||||
selectLiveAgentStatusEntriesForWorktree,
|
||||
selectRetainedAgentEntriesForWorktree
|
||||
} from '../components/sidebar/workspace-card/activity/worktree-agent-row-selectors'
|
||||
import {
|
||||
selectLivePtyIdsForWorktree,
|
||||
selectRuntimePaneTitlesForWorktree
|
||||
} from '../components/sidebar/workspace-card/activity/worktree-card-status-inputs'
|
||||
|
||||
// Mirror imports app store singleton; redirect it to this createTestStore harness.
|
||||
vi.mock('../store', () => ({
|
||||
useAppStore: {
|
||||
setState: vi.fn(),
|
||||
getState: vi.fn(() => ({})),
|
||||
subscribe: vi.fn(() => () => {})
|
||||
}
|
||||
}))
|
||||
|
||||
const WT = 'repo1::/path/wt1'
|
||||
const ENV = 'web-env-1'
|
||||
const T0 = 1_700_000_000_000
|
||||
const KEEP_TAB = 'host-tab-live'
|
||||
const CLOSED_TAB = 'host-tab-finished-worker'
|
||||
const LEAF: Record<string, string> = {
|
||||
[KEEP_TAB]: '11111111-1111-4111-8111-111111111111',
|
||||
[CLOSED_TAB]: '22222222-2222-4222-8222-222222222222'
|
||||
}
|
||||
const paneKeyOf = (hostTabId: string) =>
|
||||
makePaneKey(toWebTerminalSurfaceTabId(hostTabId), LEAF[hostTabId]!)
|
||||
const KEEP_PANE_KEY = paneKeyOf(KEEP_TAB)
|
||||
const CLOSED_PANE_KEY = paneKeyOf(CLOSED_TAB)
|
||||
|
||||
type TestStore = ReturnType<typeof createTestStore>
|
||||
type RetainedSyncAgents = ReturnType<typeof buildRetainedAgentsSyncSnapshot>['currentAgents']
|
||||
|
||||
/** A restarted server's frame: restored panes wait for a handle and their agents name no worktree. */
|
||||
function restoredHostSnapshot(
|
||||
snapshotVersion: number,
|
||||
hostTabIds: readonly string[]
|
||||
): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: WT,
|
||||
publicationEpoch: 'headless-hydrated:restarted',
|
||||
snapshotVersion,
|
||||
activeGroupId: 'host-group-1',
|
||||
activeTabId: hostTabIds[0] ? `${hostTabIds[0]}::${LEAF[hostTabIds[0]]}` : null,
|
||||
activeTabType: hostTabIds.length > 0 ? 'terminal' : null,
|
||||
tabs: hostTabIds.map((hostTabId, index) => ({
|
||||
type: 'terminal' as const,
|
||||
id: `${hostTabId}::${LEAF[hostTabId]}`,
|
||||
title: hostTabId,
|
||||
parentTabId: hostTabId,
|
||||
leafId: LEAF[hostTabId]!,
|
||||
isActive: index === 0,
|
||||
ptyId: `${WT}@@pty-${index + 1}`,
|
||||
status: 'pending-handle' as const,
|
||||
terminal: null,
|
||||
agentStatus: {
|
||||
state: 'done' as const,
|
||||
prompt: `work in ${hostTabId}`,
|
||||
updatedAt: T0 - 60_000,
|
||||
stateStartedAt: T0 - 120_000,
|
||||
agentType: 'codex',
|
||||
paneKey: makePaneKey(hostTabId, LEAF[hostTabId]!),
|
||||
tabId: hostTabId,
|
||||
restoredUnconfirmed: true,
|
||||
stateHistory: []
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function applyHostSnapshot(
|
||||
store: TestStore,
|
||||
snapshot: RuntimeMobileSessionTabsResult,
|
||||
now: number
|
||||
) {
|
||||
vi.setSystemTime(now)
|
||||
const state = store.getState()
|
||||
const patch = applyFreshWebSessionTabsSnapshot(state, snapshot, ENV, now)
|
||||
expect(patch, 'host snapshot must pass the freshness gate').not.toBe(state)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the gate returned a store patch for this state (checked above); the test applies it as the store would.
|
||||
store.setState(patch as Partial<AppState>)
|
||||
}
|
||||
|
||||
/** useRetainedAgentsSync's effect body, minus React. */
|
||||
function replayRetainedAgentsSync(
|
||||
store: TestStore,
|
||||
previousAgents: RetainedSyncAgents,
|
||||
now: number
|
||||
): RetainedSyncAgents {
|
||||
const state = store.getState()
|
||||
const { currentAgents, existingWorktreeIds, tabIndex } = buildRetainedAgentsSyncSnapshot({
|
||||
repos: state.repos,
|
||||
worktreesByRepo: state.worktreesByRepo,
|
||||
folderWorkspaces: state.folderWorkspaces,
|
||||
tabsByWorktree: state.tabsByWorktree,
|
||||
agentStatusByPaneKey: state.agentStatusByPaneKey,
|
||||
now
|
||||
})
|
||||
const { toRetain, consumedSuppressedPaneKeys } = collectRetainedAgentsOnDisappear({
|
||||
previousAgents,
|
||||
currentAgents,
|
||||
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey,
|
||||
retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys,
|
||||
recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds,
|
||||
recentlyRetiredAgentStatusPaneKeys: state.recentlyRetiredAgentStatusPaneKeys,
|
||||
tabIndex
|
||||
})
|
||||
store.getState().retainAgents(toRetain)
|
||||
store.getState().pruneRetainedAgents(existingWorktreeIds)
|
||||
if (consumedSuppressedPaneKeys.length > 0) {
|
||||
store.getState().clearRetentionSuppressedPaneKeys(consumedSuppressedPaneKeys)
|
||||
}
|
||||
return currentAgents
|
||||
}
|
||||
|
||||
/** The sidebar card's agent rows and the tab strip, as useWorktreeAgentRows builds them. */
|
||||
function observeDesktop(store: TestStore, now: number) {
|
||||
const state = store.getState()
|
||||
const tabs = state.tabsByWorktree[WT] ?? []
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs,
|
||||
entries: selectLiveAgentStatusEntriesForWorktree(state, WT),
|
||||
retained: selectRetainedAgentEntriesForWorktree(state, WT),
|
||||
runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(state, WT),
|
||||
ptyIdsByTabId: selectLivePtyIdsForWorktree(state, WT),
|
||||
terminalLayoutsByTabId: Object.fromEntries(
|
||||
tabs.map((tab) => [tab.id, state.terminalLayoutsByTabId[tab.id]])
|
||||
),
|
||||
now
|
||||
})
|
||||
return {
|
||||
tabIds: tabs.map((tab) => tab.id),
|
||||
rowPaneKeys: rows.filter((row) => row.rowSource !== 'subagent').map((row) => row.paneKey),
|
||||
retainedPaneKeys: Object.keys(state.retainedAgentsByPaneKey)
|
||||
}
|
||||
}
|
||||
|
||||
function seedPairedDesktop(): TestStore {
|
||||
const store = createTestStore()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial seed; the session-tab sync reads only these fields.
|
||||
seedStore(store, {
|
||||
settings: getDefaultSettings('/tmp'),
|
||||
worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] },
|
||||
activeWorktreeId: WT
|
||||
} as Partial<AppState>)
|
||||
return store
|
||||
}
|
||||
|
||||
describe('a paired desktop following a restarted server', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(T0)
|
||||
resetWebSessionTabsSnapshotFreshnessForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('drops restored terminals the server closes, with their agent rows', () => {
|
||||
const store = seedPairedDesktop()
|
||||
let previousAgents: RetainedSyncAgents = new Map()
|
||||
applyHostSnapshot(store, restoredHostSnapshot(1, [KEEP_TAB, CLOSED_TAB]), T0)
|
||||
previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 500)
|
||||
expect(observeDesktop(store, T0 + 500).rowPaneKeys).toContain(CLOSED_PANE_KEY)
|
||||
|
||||
applyHostSnapshot(store, restoredHostSnapshot(2, [KEEP_TAB]), T0 + 2_000)
|
||||
previousAgents = replayRetainedAgentsSync(store, previousAgents, T0 + 2_500)
|
||||
expect(observeDesktop(store, T0 + 3_000)).toEqual({
|
||||
tabIds: [toWebTerminalSurfaceTabId(KEEP_TAB)],
|
||||
rowPaneKeys: [KEEP_PANE_KEY],
|
||||
retainedPaneKeys: []
|
||||
})
|
||||
|
||||
// The last tab's retraction publishes an empty worktree.
|
||||
applyHostSnapshot(store, restoredHostSnapshot(3, []), T0 + 4_000)
|
||||
replayRetainedAgentsSync(store, previousAgents, T0 + 4_500)
|
||||
expect(observeDesktop(store, T0 + 5_000)).toEqual({
|
||||
tabIds: [],
|
||||
rowPaneKeys: [],
|
||||
retainedPaneKeys: []
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isPtySessionIdOwnedByWorktree,
|
||||
isServeMintedPtySessionId,
|
||||
mintServePtySessionId,
|
||||
parsePtySessionId
|
||||
} from './pty-session-id-format'
|
||||
|
||||
const UUID = '10000000-0000-4000-8000-0000000000aa'
|
||||
|
||||
// The parsers are unchanged since v1.4.243, so released clients read serve ids the same way (#36716).
|
||||
describe('serve-minted pty session ids', () => {
|
||||
it.each([
|
||||
['folder', 'folder:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
|
||||
['POSIX worktree', 'repo-1::/Users/me/src/app:v2/wt'],
|
||||
['SSH worktree', 'repo-ssh::/home/dev/a@b/wt'],
|
||||
['Windows worktree', 'repo-2::C:\\Users\\me\\wt'],
|
||||
['WSL worktree', 'repo-3::\\\\wsl.localhost\\Ubuntu\\home\\me\\wt']
|
||||
])('round-trips a %s workspace id', (_kind, worktreeId) => {
|
||||
const id = mintServePtySessionId(worktreeId, UUID)
|
||||
|
||||
expect(parsePtySessionId(id)).toEqual({ worktreeId })
|
||||
expect(isServeMintedPtySessionId(id)).toBe(true)
|
||||
expect(isPtySessionIdOwnedByWorktree(id, worktreeId)).toBe(true)
|
||||
expect(isPtySessionIdOwnedByWorktree(id, `${worktreeId}-sibling`)).toBe(false)
|
||||
})
|
||||
|
||||
it('still recognizes the workspace-less ids older hosts minted', () => {
|
||||
const legacy = `serve-${UUID}`
|
||||
|
||||
expect(isServeMintedPtySessionId(legacy)).toBe(true)
|
||||
expect(parsePtySessionId(legacy)).toEqual({ worktreeId: null })
|
||||
})
|
||||
|
||||
it('does not treat a workspace path containing serve- as a serve id', () => {
|
||||
expect(isServeMintedPtySessionId('repo::/srv/serve-app@@deadbeef')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -7,22 +7,6 @@ import { SUPPORTED, openStubChat, test } from './helpers/structured-claude-stub'
|
||||
test.describe('native chat plugin UI frames', () => {
|
||||
test.skip(!SUPPORTED, 'the Claude stub wrapper is POSIX-only')
|
||||
|
||||
test('a turn with plugin status and toast frames shows only the reply', async ({
|
||||
orcaPage: page,
|
||||
claudeStub
|
||||
}) => {
|
||||
const { reply } = await openStubChat(page, claudeStub.command)
|
||||
await page.keyboard.type('plugin-ui probe')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(reply).toBeVisible({ timeout: 30_000 })
|
||||
const chat = page.locator('[data-native-chat-root="true"]')
|
||||
// Unknown frames land inside the turn's work section, so open it before looking.
|
||||
await chat.getByText(/Worked for/).click()
|
||||
await expect(chat.getByText('PR 6 · 진행 13 · 대기 6', { exact: false })).toHaveCount(0)
|
||||
await expect(chat.getByText('Board synced')).toHaveCount(0)
|
||||
await expect(reply).toBeVisible()
|
||||
})
|
||||
|
||||
test('a focused plugin pane fails once and a follow-up completes on a fresh process', async ({
|
||||
orcaPage: page,
|
||||
claudeStub
|
||||
|
||||
Reference in New Issue
Block a user