diff --git a/src/main/claude/claude-plugin-ui-frames.test.ts b/src/main/claude/claude-plugin-ui-frames.test.ts index 3973f0ffda8..c8c5a3eafd7 100644 --- a/src/main/claude/claude-plugin-ui-frames.test.ts +++ b/src/main/claude/claude-plugin-ui-frames.test.ts @@ -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) => - 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)) - } - ) }) diff --git a/src/main/claude/claude-transcript-internal-content.test.ts b/src/main/claude/claude-transcript-internal-content.test.ts deleted file mode 100644 index 10618e845ba..00000000000 --- a/src/main/claude/claude-transcript-internal-content.test.ts +++ /dev/null @@ -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[] = capture.map((row, index) => ({ - ...row, - sessionId: SID, - ...(index === 0 ? { parentUuid: 'a1' } : {}) -})) -const frame = (row: Record) => ({ - ...row, - session_id: SID, - parent_tool_use_id: null -}) -const synthetic = rows[3]! -const task = rows[0]! -const message = (row: Record) => asRecord(row.message)! -const contentParts = (row: Record) => { - 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, 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', 'internal', 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, 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) => - 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 = { - ...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' }) - } -) diff --git a/src/main/claude/structured/claude-structured-session-plugin-ui.test.ts b/src/main/claude/structured/claude-structured-session-plugin-ui.test.ts deleted file mode 100644 index a91ed37e5a7..00000000000 --- a/src/main/claude/structured/claude-structured-session-plugin-ui.test.ts +++ /dev/null @@ -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() - 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() - }) -}) diff --git a/src/main/github/client/check/get-pr-checks.test.ts b/src/main/github/client/check/get-pr-checks.test.ts deleted file mode 100644 index 15d4472b777..00000000000 --- a/src/main/github/client/check/get-pr-checks.test.ts +++ /dev/null @@ -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()), - 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']) - }) -}) diff --git a/src/main/github/client/check/pr-checks-response-mapping.test.ts b/src/main/github/client/check/pr-checks-response-mapping.test.ts deleted file mode 100644 index 16d6580dc1c..00000000000 --- a/src/main/github/client/check/pr-checks-response-mapping.test.ts +++ /dev/null @@ -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' }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-plugin-input.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-plugin-input.test.ts deleted file mode 100644 index c678b5367e5..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-plugin-input.test.ts +++ /dev/null @@ -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 -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, 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() - 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') -}) diff --git a/src/main/runtime/restored-pane-agent-status-worktree.test.ts b/src/main/runtime/restored-pane-agent-status-worktree.test.ts deleted file mode 100644 index c876840c164..00000000000 --- a/src/main/runtime/restored-pane-agent-status-worktree.test.ts +++ /dev/null @@ -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 } - }) -}) diff --git a/src/main/runtime/serve-terminal-workspace-binding.test.ts b/src/main/runtime/serve-terminal-workspace-binding.test.ts deleted file mode 100644 index 4574ba54dc0..00000000000 --- a/src/main/runtime/serve-terminal-workspace-binding.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/main/runtime/workspaces/sleep/workspace-sleep-vm-wake.test.ts b/src/main/runtime/workspaces/sleep/workspace-sleep-vm-wake.test.ts index dfb20dfa6f0..8c869799c86 100644 --- a/src/main/runtime/workspaces/sleep/workspace-sleep-vm-wake.test.ts +++ b/src/main/runtime/workspaces/sleep/workspace-sleep-vm-wake.test.ts @@ -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 }) => - 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, 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 => { - 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 => 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() diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.plugin-input.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.plugin-input.test.tsx deleted file mode 100644 index 3dfc098dc15..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.plugin-input.test.tsx +++ /dev/null @@ -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, text: string) { - render( - - - - ) -} - -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() -}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-steer-chord-preview.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-steer-chord-preview.test.tsx index 91126a3c670..b5fcf366971 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-steer-chord-preview.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-steer-chord-preview.test.tsx @@ -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('') - }) }) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/presentation/check-run-time.perf.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/presentation/check-run-time.perf.test.tsx deleted file mode 100644 index 110ec0e2de4..00000000000 --- a/src/renderer/src/components/right-sidebar/checks-panel/presentation/check-run-time.perf.test.tsx +++ /dev/null @@ -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( - - {rows(timed, untimed).map((check) => ( - - ))} - - ) - }) - 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) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/presentation/check-run-time.test.ts b/src/renderer/src/components/right-sidebar/checks-panel/presentation/check-run-time.test.ts deleted file mode 100644 index 79c545123a6..00000000000 --- a/src/renderer/src/components/right-sidebar/checks-panel/presentation/check-run-time.test.ts +++ /dev/null @@ -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) { - return getCheckRunTimeFacts({ status: 'completed', ...overrides }) -} - -function age(overrides: Partial): 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() - }) -}) diff --git a/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading-capability.test.tsx b/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading-capability.test.tsx deleted file mode 100644 index 58852547a20..00000000000 --- a/src/renderer/src/components/task-page/gitlab/use-task-page-gitlab-loading-capability.test.tsx +++ /dev/null @@ -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) - }) -}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-restored-host-agent-retraction.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-restored-host-agent-retraction.test.ts deleted file mode 100644 index b778257a742..00000000000 --- a/src/renderer/src/runtime/web-session-tabs-sync-restored-host-agent-retraction.test.ts +++ /dev/null @@ -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 = { - [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 -type RetainedSyncAgents = ReturnType['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) -} - -/** 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) - 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: [] - }) - }) -}) diff --git a/src/shared/terminal/pty/lifecycle/pty-session-id-format.test.ts b/src/shared/terminal/pty/lifecycle/pty-session-id-format.test.ts deleted file mode 100644 index ec123969dc3..00000000000 --- a/src/shared/terminal/pty/lifecycle/pty-session-id-format.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/tests/e2e/native-chat-plugin-ui-frames.spec.ts b/tests/e2e/native-chat-plugin-ui-frames.spec.ts index 71e68b180df..17c2f2c0d82 100644 --- a/tests/e2e/native-chat-plugin-ui-frames.spec.ts +++ b/tests/e2e/native-chat-plugin-ui-frames.spec.ts @@ -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