fix: harden and de-slop the merged contribution batch

Follow-ups promised on merge, plus review findings on the batch itself:

- chat: task-tool output now respects the 512KiB render cap; quick-open
  icon is visible at rest on coarse pointers and reachable by keyboard
  (row keydown no longer swallows inner-button Enter/Space); composer
  inline-code decoration drops the metric-shifting padding; a btw fork
  send carries only the boundary instruction, never the promotion notice
- sync: cascade revert/unrevert aborts busy descendants, busy state is
  read from every child store at the moment of use; rule 9 documents
  redo clearing all descendant revert markers
- electron: renderer recovery keeps memory-eviction (a valid
  render-process-gone reason) and both windows share one
  attachRendererRecovery helper
- vscode: process registry is a thin re-export of the web module
  (provider-env-aliases precedent) with ordered register/unregister
  writes and an awaited close
- server/cli: managed-process registry takes injectable deps (fixes the
  unreaped-orphans ReferenceError), corrupt settings errors name the
  file, getWorktrees test restores console.warn
- tests: module-mock harnesses removed (AgentsSidebar, SettingsView
  mobile focus — behaviors stay live but uncovered, accepted trade),
  QuestionMarkdown asserts rendered DOM
- i18n: German gains the debug-panel request keys, Japanese/German drop
  removed worktree keys, Ukrainian unit spacing fixed
- changelog: Copilot AI Credits entries (main + VS Code)
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 02:08:09 +03:00
parent a79aff45c1
commit b8465ae133
33 changed files with 673 additions and 1205 deletions
@@ -36,7 +36,7 @@ import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session
import { BtwPanel } from './btw/BtwPanel';
import { useBtwPanelState } from './btw/useBtwPanelState';
import { wasPromotedBtwSession } from '@/lib/sessionBtwMetadata';
import { BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
import { buildBtwSyntheticTexts, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import type { ToolPopupContent } from './message/types';
@@ -1134,12 +1134,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
composerText: !queuedOnly && inputSnapshot.hasContent ? inputSnapshot.message : null,
composerAttachments: attachedFiles,
inlineComments: drafts,
// btw mode: the boundary rides with every send, not just the
// first one, so the inherited transcript stays reference material
// for the whole side conversation.
syntheticTexts: [
...(isBtwActive ? [BTW_BOUNDARY_INSTRUCTION] : []),
...(isPromotedBtwSession ? [BTW_PROMOTION_NOTICE] : []),
...buildBtwSyntheticTexts({ isBtwActive, isPromotedBtwSession }),
...(syntheticParts?.map((part) => part.text) ?? []),
],
linkedIssue: linkedIssue
@@ -1,25 +1,35 @@
import { describe, expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { SimpleMarkdownRenderer } from './MarkdownRenderer';
import { QuestionMarkdown } from './QuestionMarkdown';
// The markdown renderer is lazy, so a synchronous server render always emits the
// Suspense fallback QuestionMarkdown supplies. That fallback is the surface that
// has to keep the exact question text and the question typography classes.
describe('QuestionMarkdown', () => {
test('delegates exact content to the tool markdown renderer', () => {
test('renders the question content verbatim', () => {
const content = 'Choose **one** from `mode`: [details](https://example.com)';
const element = QuestionMarkdown({ content, size: 'meta' });
expect(element.type).toBe(SimpleMarkdownRenderer);
expect(element.props.content).toBe(content);
expect(element.props.variant).toBe('tool');
expect(element.props.fallbackContent.props.children).toBe(content);
expect(element.props.fallbackContent.props.className).toContain('whitespace-pre-wrap');
const html = renderToStaticMarkup(<QuestionMarkdown content={content} size="meta" />);
expect(html).toBe(
`<div class="question-markdown typography-meta whitespace-pre-wrap">${content}</div>`,
);
});
test('preserves question typography size and caller classes', () => {
const meta = QuestionMarkdown({ content: 'Meta', size: 'meta', className: 'font-medium text-foreground' });
const micro = QuestionMarkdown({ content: 'Micro', size: 'micro', className: 'text-muted-foreground' });
test('applies meta typography and caller classes', () => {
const html = renderToStaticMarkup(
<QuestionMarkdown content="Meta" size="meta" className="font-medium text-foreground" />,
);
expect(meta.props.className).toBe('question-markdown typography-meta font-medium text-foreground');
expect(micro.props.className).toBe('question-markdown typography-micro text-muted-foreground');
expect(html).toContain('class="question-markdown typography-meta font-medium text-foreground whitespace-pre-wrap"');
});
test('applies micro typography and caller classes', () => {
const html = renderToStaticMarkup(
<QuestionMarkdown content="Micro" size="micro" className="text-muted-foreground" />,
);
expect(html).toContain('class="question-markdown typography-micro text-muted-foreground whitespace-pre-wrap"');
});
});
@@ -103,7 +103,7 @@ const STYLE_CLASS: Record<AnyStyle, string> = {
mentionAgent: 'text-[var(--status-success)]',
mentionCommand: 'text-[var(--primary)]',
mentionSnippet: 'text-[var(--status-warning)]',
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)] px-[0.3125rem] py-0.5',
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)]',
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
// A `~path` is written for the reader's benefit, not to attach anything —
// it takes the same colour as a file mention, since it names the same kind
@@ -46,9 +46,9 @@ import {
buildTaskSummaryEntriesFromSession,
normalizeTaskSummaryEntries,
parseTaskMetadataBlock,
prepareTaskToolOutput,
readTaskSessionIdFromOutput,
readTaskSessionIdFromRecord,
stripTaskMetadataFromOutput,
type TaskToolSummaryEntry,
} from './taskToolModel';
import { areRenderRelevantPartsEqual } from '../renderCompare';
@@ -1004,9 +1004,7 @@ const TaskToolSummary: React.FC<{
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
const runtime = React.useContext(RuntimeAPIContext);
const trimmedOutput = typeof output === 'string'
? stripTaskMetadataFromOutput(output)
: '';
const trimmedOutput = prepareTaskToolOutput(output);
const hasOutput = trimmedOutput.length > 0;
const [isOutputExpanded, setIsOutputExpanded] = React.useState(false);
@@ -2037,6 +2035,9 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
};
const handleMainKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
// Nested buttons (quick-open, copy) handle their own Enter/Space; the row
// must not swallow the key and toggle instead.
if (event.target !== event.currentTarget) return;
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
@@ -2092,13 +2093,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
openQuickTarget();
};
const handleQuickOpenKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
openQuickTarget();
};
const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
const shouldRenderTaskSummary = useDeferredExpandedContent(isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || !!taskSessionId));
@@ -2192,7 +2186,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
</div>
</div>
<div className="flex items-center gap-1 min-w-0 flex-1">
<div className={cn('flex items-center min-w-0 flex-1', quickOpenTarget ? 'gap-1' : 'gap-2')}>
<MinDurationShineText
active={Boolean(isActive && !isError)}
minDurationMs={300}
@@ -2206,10 +2200,11 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
<button
type="button"
onClick={handleQuickOpen}
onKeyDown={handleQuickOpenKeyDown}
className={cn(
'flex-shrink-0 inline-flex h-4 w-4 items-center justify-center rounded transition-opacity hover:bg-[var(--surface-hover)]',
'opacity-0 group-hover/tool:opacity-60 hover:opacity-100 focus-visible:opacity-100',
// Coarse pointers never hover, so the icon has to rest visible
// there or it stays invisible while remaining tappable.
'opacity-0 group-hover/tool:opacity-60 hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-60',
)}
style={{ color: 'var(--tools-icon)' }}
title={t('chat.toolPart.openFile')}
@@ -4,9 +4,11 @@ import type { Message, Part } from '@opencode-ai/sdk/v2';
import {
buildTaskSummaryEntriesFromSession,
parseTaskMetadataBlock,
prepareTaskToolOutput,
readTaskSessionIdFromRecord,
readTaskSessionIdFromOutput,
} from './taskToolModel';
import { TOOL_OUTPUT_MAX_CHARS } from '../toolRenderers';
describe('taskToolModel', () => {
test('reads the current OpenCode running-state identity contract', () => {
@@ -39,4 +41,19 @@ describe('taskToolModel', () => {
state: { status: 'completed', title: undefined, input: { filePath: 'a.ts' } },
}]);
});
test('strips task metadata and caps oversized task output before markdown rendering', () => {
const oversized = 'x'.repeat(TOOL_OUTPUT_MAX_CHARS + 5_000);
const output = `${oversized}\n<task_metadata>{"sessionID":"child-1"}</task_metadata>`;
const prepared = prepareTaskToolOutput(output);
expect(prepared.length).toBeLessThan(oversized.length);
expect(prepared).toContain('output truncated');
expect(prepared).not.toContain('task_metadata');
});
test('leaves normal task output untouched', () => {
expect(prepareTaskToolOutput('done\n<task_metadata>{"sessionID":"child-1"}</task_metadata>')).toBe('done');
expect(prepareTaskToolOutput(undefined)).toBe('');
});
});
@@ -1,5 +1,6 @@
import type { MessageRecord } from '@/lib/messageCompletion';
import { capToolOutputText } from '../toolRenderers';
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
export type TaskToolSummaryEntry = {
@@ -131,3 +132,12 @@ export const buildTaskSummaryEntriesFromSession = (messages: MessageRecord[]): T
export const stripTaskMetadataFromOutput = (output: string): string => {
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
};
// The task tool renders its output through the markdown parser instead of the
// shared tool-output path, so it needs the same size guard as
// `getToolOutputText` (issue #2265): an unbounded single string reaching the
// parser can exhaust V8's Zone allocator and crash the renderer.
export const prepareTaskToolOutput = (output: string | undefined): string => {
if (!output) return '';
return capToolOutputText(stripTaskMetadataFromOutput(output));
};
@@ -2,8 +2,18 @@ import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { buildChildrenIndex, computeSubtreeCost, formatCost } from './subagentCost';
function makeSession(id: string, cost: number, parentID?: string): Session {
return { id, cost, parentID } as unknown as Session;
function makeSession(id: string, cost: number | undefined, parentID?: string): Session {
return {
id,
slug: id,
projectID: 'project',
directory: '/project',
title: id,
version: '1',
time: { created: 0, updated: 0 },
cost,
parentID,
};
}
describe('buildChildrenIndex', () => {
@@ -57,7 +67,7 @@ describe('computeSubtreeCost', () => {
test('treats zero and undefined cost as zero, not a break', () => {
const root = makeSession('root', 0);
const child = { id: 'child', parentID: 'root' } as unknown as Session;
const child = makeSession('child', undefined, 'root');
const sessions = [root, child];
const sessionsById = new Map(sessions.map((s) => [s.id, s]));
const childrenByParent = buildChildrenIndex(sessions);
@@ -17,7 +17,7 @@ export const formatCost = (cost: number): string => `$${trimZeros(cost.toFixed(4
export function buildChildrenIndex(sessions: Session[]): Map<string, Session[]> {
const index = new Map<string, Session[]>();
for (const session of sessions) {
const parentID = (session as unknown as { parentID?: string }).parentID;
const parentID = session.parentID;
if (!parentID) continue;
const existing = index.get(parentID);
if (existing) {
@@ -30,8 +30,7 @@ export function buildChildrenIndex(sessions: Session[]): Map<string, Session[]>
}
function sessionCost(session: Session | undefined): number {
const cost = (session as unknown as { cost?: number } | undefined)?.cost;
return typeof cost === 'number' ? cost : 0;
return session?.cost ?? 0;
}
/**
@@ -3,7 +3,17 @@ import type { Session } from '@opencode-ai/sdk/v2';
import { computeRollup } from './useSubagentCostRollup';
function makeSession(id: string, cost: number, parentID?: string): Session {
return { id, cost, parentID } as unknown as Session;
return {
id,
slug: id,
projectID: 'project',
directory: '/project',
title: id,
version: '1',
time: { created: 0, updated: 0 },
cost,
parentID,
};
}
const sessions: Session[] = [
@@ -1,228 +0,0 @@
import React from 'react';
import { describe, expect, mock, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
type ClickEvent = { stopPropagation: () => void };
type ClickHandler = (event: ClickEvent) => void;
type ChildrenProps = { children?: React.ReactNode };
type ClickableProps = ChildrenProps & { onClick?: ClickHandler };
type TriggerProps = ChildrenProps & { render?: React.ReactNode };
interface AgentDraftSnapshot {
name: string;
scope: string;
description?: string;
model?: string | null;
variant?: string;
temperature?: number;
top_p?: number;
prompt?: string;
mode?: string;
permission?: Record<string, string>;
disable?: boolean;
}
interface AgentRecord {
name: string;
description: string;
model: { providerID: string; modelID: string };
variant: string;
temperature: number;
topP: number;
prompt: string;
mode: string;
permission: Array<{ permission: string; pattern: string; action: 'allow' | 'ask' | 'deny' }>;
scope: string;
disable: boolean;
}
interface AgentStoreState {
selectedAgentName: string | null;
agents: AgentRecord[];
setAgentDraft: (draft: AgentDraftSnapshot) => void;
setSelectedAgent: (name: string) => void;
createAgent: () => Promise<{ ok: boolean }>;
deleteAgent: () => Promise<{ ok: boolean }>;
loadAgents: () => Promise<void>;
}
const sourceAgent: AgentRecord = {
name: 'writer',
description: 'Writes concise documentation',
model: { providerID: 'openai', modelID: 'gpt-4.1' },
variant: 'fast',
temperature: 0.4,
topP: 0.8,
prompt: 'Write clear documentation.',
mode: 'subagent',
permission: [{ permission: 'bash', pattern: '*', action: 'ask' }],
scope: 'project',
disable: true,
};
let recordedDraft: AgentDraftSnapshot | null = null;
let selectedAgentName: string | null = null;
let duplicateMenuClick: ClickHandler | null = null;
let mobileDevice = true;
const agentStore: AgentStoreState = {
selectedAgentName: null,
agents: [sourceAgent],
setAgentDraft: (draft) => {
recordedDraft = draft;
},
setSelectedAgent: (name) => {
selectedAgentName = name;
},
createAgent: async () => ({ ok: true }),
deleteAgent: async () => ({ ok: true }),
loadAgents: async () => {},
};
function useAgentsStore<Selected>(selector: (state: AgentStoreState) => Selected): Selected {
return selector(agentStore);
}
function useShallow<Selector>(selector: Selector): Selector {
return selector;
}
mock.module('@/components/ui/button', () => ({
Button: ({ children, onClick }: ClickableProps) => <button onClick={onClick}>{children}</button>,
}));
mock.module('@/components/ui/input', () => ({
Input: () => <input />,
}));
mock.module('@/components/ui', () => ({
toast: { error: () => {}, success: () => {}, warning: () => {} },
}));
mock.module('@/lib/device', () => ({
isMobileDeviceViaCSS: () => mobileDevice,
}));
mock.module('@/components/ui/dialog', () => ({
Dialog: ({ children }: ChildrenProps) => <>{children}</>,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogDescription: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogFooter: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
}));
mock.module('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: ChildrenProps) => <>{children}</>,
DropdownMenuContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DropdownMenuItem: ({ children, onClick }: ClickableProps) => {
if (React.Children.toArray(children).includes('Duplicate')) {
duplicateMenuClick = onClick ?? null;
}
return <button onClick={onClick}>{children}</button>;
},
DropdownMenuTrigger: ({ children }: ChildrenProps) => <>{children}</>,
}));
mock.module('@/components/ui/context-menu', () => ({
ContextMenu: ({ children }: ChildrenProps) => <>{children}</>,
ContextMenuContent: ({ children }: ChildrenProps) => <div>{children}</div>,
ContextMenuItem: ({ children }: ChildrenProps) => <div>{children}</div>,
ContextMenuTrigger: ({ children, render }: TriggerProps) => <>{render}{children}</>,
}));
mock.module('@/hooks/useSettingsDirectory', () => ({
useSettingsDirectory: () => '/workspace',
}));
mock.module('@/stores/useAgentsStore', () => ({
useAgentsStore,
selectAgentsForDirectory: (state: AgentStoreState) => state.agents,
isAgentBuiltIn: () => false,
isAgentHidden: () => false,
}));
mock.module('zustand/react/shallow', () => ({ useShallow }));
mock.module('@/lib/utils', () => ({
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
}));
mock.module('@/components/ui/ScrollableOverlay', () => ({
ScrollableOverlay: ({ children }: ChildrenProps) => <div>{children}</div>,
}));
mock.module('@/components/sections/shared/SettingsProjectSelector', () => ({
SettingsProjectSelector: () => null,
}));
mock.module('@/components/sections/shared/SidebarGroup', () => ({
SidebarGroup: ({ children }: ChildrenProps) => <>{children}</>,
}));
mock.module('@/components/icon/Icon', () => ({
Icon: () => null,
}));
mock.module('@/lib/i18n', () => ({
useI18n: () => ({
t: (key: string) => (key === 'settings.common.actions.duplicate' ? 'Duplicate' : key),
}),
}));
mock.module('@/components/sections/shared/SettingsSection', () => ({
SETTINGS_PANEL_TITLE_CLASS: '',
}));
const { AgentsSidebar } = await import('./AgentsSidebar');
function getDuplicateMenuClick(): ClickHandler {
if (!duplicateMenuClick) {
throw new Error('Expected the duplicate action to be rendered');
}
return duplicateMenuClick;
}
describe('AgentsSidebar duplicate action', () => {
test('notifies the mobile split-view parent once after preparing a prefilled agent draft', () => {
recordedDraft = null;
selectedAgentName = null;
duplicateMenuClick = null;
mobileDevice = true;
let mobileTransitionCount = 0;
renderToStaticMarkup(
<AgentsSidebar onItemSelect={() => { mobileTransitionCount += 1; }} />,
);
getDuplicateMenuClick()({ stopPropagation: () => {} });
expect(recordedDraft).toEqual({
name: 'writer-copy',
scope: 'project',
description: 'Writes concise documentation',
model: 'openai/gpt-4.1',
variant: 'fast',
temperature: 0.4,
top_p: 0.8,
prompt: 'Write clear documentation.',
mode: 'subagent',
permission: { bash: 'ask' },
disable: true,
});
expect(selectedAgentName).toBe('writer-copy');
expect(mobileTransitionCount).toBe(1);
});
test('does not require a mobile transition callback on desktop', () => {
recordedDraft = null;
selectedAgentName = null;
duplicateMenuClick = null;
mobileDevice = false;
renderToStaticMarkup(<AgentsSidebar />);
getDuplicateMenuClick()({ stopPropagation: () => {} });
expect(selectedAgentName).toBe('writer-copy');
});
});
@@ -172,7 +172,7 @@ const LineChart: React.FC<{
);
};
export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
const { t } = useI18n();
const [activeTab, setActiveTab] = React.useState<DebugTab>('memory');
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle');
@@ -1,438 +0,0 @@
import React, { act } from 'react';
import { describe, expect, mock, test } from 'bun:test';
import { createRoot, type Root } from 'react-dom/client';
type ChildrenProps = { children?: React.ReactNode };
type AgentsSidebarProps = { onItemSelect?: () => void };
type SettingsPageLayoutProps = {
children: React.ReactNode;
title?: React.ReactNode;
showSaveStatus?: boolean;
};
interface FakeNode {
nodeType: number;
nodeName: string;
tagName: string;
namespaceURI: string;
ownerDocument: FakeDocument;
parentNode: FakeNode | null;
childNodes: FakeNode[];
style: { setProperty: () => void; getPropertyValue: () => string };
classList: FakeClassList;
attributes: Map<string, string>;
textContent: string;
nodeValue: string | null;
focusOptions?: FocusOptions;
appendChild: (child: FakeNode) => FakeNode;
insertBefore: (child: FakeNode, before: FakeNode | null) => FakeNode;
removeChild: (child: FakeNode) => FakeNode;
setAttribute: (name: string, value: string) => void;
removeAttribute: (name: string) => void;
getAttribute: (name: string) => string | null;
hasAttribute: (name: string) => boolean;
addEventListener: () => void;
removeEventListener: () => void;
contains: (child: FakeNode | null) => boolean;
querySelector: (selector: string) => FakeNode | null;
focus: (options?: FocusOptions) => void;
}
interface FakeDocument {
nodeType: number;
nodeName: string;
defaultView: FakeWindow | null;
body: FakeNode | null;
documentElement: FakeNode | null;
activeElement: FakeNode | null;
createElement: (tag: string) => FakeNode & Element;
createElementNS: (_namespace: string, tag: string) => FakeNode & Element;
createTextNode: (text: string) => FakeNode & Element;
addEventListener: () => void;
removeEventListener: () => void;
}
interface FakeWindow {
document: FakeDocument;
navigator: { userAgent: string; platform: string; maxTouchPoints: number };
history: { state: null; back: () => void; pushState: () => void };
location: { href: string };
requestAnimationFrame: (callback: FrameRequestCallback) => number;
cancelAnimationFrame: (frame: number) => void;
addEventListener: () => void;
removeEventListener: () => void;
HTMLIFrameElement: typeof FakeElement;
HTMLFrameSetElement: typeof FakeElement;
HTMLInputElement: typeof FakeElement;
HTMLTextAreaElement: typeof FakeElement;
HTMLSelectElement: typeof FakeElement;
HTMLOptionElement: typeof FakeElement;
HTMLAnchorElement: typeof FakeElement;
}
type GlobalStubValue = FakeDocument | FakeWindow | FakeWindow['navigator'] | FakeWindow['location'] | typeof FakeElement | boolean;
class FakeElement {}
class FakeClassList {
private readonly classes = new Set<string>();
add(...classes: string[]) {
classes.forEach((className) => this.classes.add(className));
}
remove(...classes: string[]) {
classes.forEach((className) => this.classes.delete(className));
}
contains(className: string) {
return this.classes.has(className);
}
}
function makeNode(tag: string, ownerDocument: FakeDocument, nodeType = 1): FakeNode & Element {
const attributes = new Map<string, string>();
const properties: FakeNode = {
nodeType,
nodeName: nodeType === 3 ? '#text' : tag.toUpperCase(),
tagName: nodeType === 3 ? '#text' : tag.toUpperCase(),
namespaceURI: 'http://www.w3.org/1999/xhtml',
ownerDocument,
parentNode: null,
childNodes: [],
style: {
setProperty: () => {},
getPropertyValue: () => '',
},
classList: new FakeClassList(),
attributes,
textContent: '',
nodeValue: null,
appendChild(child) {
this.childNodes.push(child);
child.parentNode = this;
return child;
},
insertBefore(child, before) {
const index = before ? this.childNodes.indexOf(before) : -1;
if (index === -1) {
this.childNodes.push(child);
} else {
this.childNodes.splice(index, 0, child);
}
child.parentNode = this;
return child;
},
removeChild(child) {
const index = this.childNodes.indexOf(child);
if (index !== -1) {
this.childNodes.splice(index, 1);
}
child.parentNode = null;
return child;
},
setAttribute(name, value) {
attributes.set(name, value);
},
removeAttribute(name) {
attributes.delete(name);
},
getAttribute(name) {
return attributes.get(name) ?? null;
},
hasAttribute(name) {
return attributes.has(name);
},
addEventListener: () => {},
removeEventListener: () => {},
contains(child) {
if (child === this) {
return true;
}
return this.childNodes.some((nodeChild) => nodeChild.contains(child));
},
querySelector(selector) {
if (selector !== '[data-settings-page-heading]') {
return null;
}
if (this.hasAttribute('data-settings-page-heading')) {
return this;
}
for (const child of this.childNodes) {
const match = child.querySelector(selector);
if (match) {
return match;
}
}
return null;
},
focus(options) {
this.focusOptions = options;
this.ownerDocument.activeElement = this;
},
};
const node: FakeNode & Element = Object.assign(Object.create(FakeElement.prototype), properties);
return node;
}
function installDomStub() {
const descriptors = new Map<string, PropertyDescriptor | undefined>();
const setGlobal = (name: string, value: GlobalStubValue) => {
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
};
const frames = new Map<number, FrameRequestCallback>();
let nextFrame = 1;
const documentStub: FakeDocument = {
nodeType: 9,
nodeName: '#document',
defaultView: null,
body: null,
documentElement: null,
activeElement: null,
createElement: (tag) => makeNode(tag, documentStub),
createElementNS: (_namespace, tag) => makeNode(tag, documentStub),
createTextNode: (text) => {
const node = makeNode('#text', documentStub, 3);
node.nodeValue = text;
node.textContent = text;
return node;
},
addEventListener: () => {},
removeEventListener: () => {},
};
const windowStub: FakeWindow = {
document: documentStub,
navigator: { userAgent: 'test', platform: 'test', maxTouchPoints: 0 },
history: { state: null, back: () => {}, pushState: () => {} },
location: { href: 'http://localhost/' },
requestAnimationFrame: (callback) => {
const frame = nextFrame;
nextFrame += 1;
frames.set(frame, callback);
return frame;
},
cancelAnimationFrame: (frame) => {
frames.delete(frame);
},
addEventListener: () => {},
removeEventListener: () => {},
HTMLIFrameElement: FakeElement,
HTMLFrameSetElement: FakeElement,
HTMLInputElement: FakeElement,
HTMLTextAreaElement: FakeElement,
HTMLSelectElement: FakeElement,
HTMLOptionElement: FakeElement,
HTMLAnchorElement: FakeElement,
};
documentStub.defaultView = windowStub;
documentStub.body = makeNode('body', documentStub);
documentStub.documentElement = makeNode('html', documentStub);
documentStub.activeElement = documentStub.body;
setGlobal('document', documentStub);
setGlobal('window', windowStub);
setGlobal('navigator', windowStub.navigator);
setGlobal('location', windowStub.location);
setGlobal('Element', FakeElement);
setGlobal('HTMLElement', FakeElement);
setGlobal('HTMLIFrameElement', FakeElement);
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
return {
container: documentStub.createElement('div'),
document: documentStub,
frameCount: () => frames.size,
flushFrames: () => {
const callbacks = Array.from(frames.values());
frames.clear();
callbacks.forEach((callback) => callback(Date.now()));
},
restore: () => {
for (const [name, descriptor] of descriptors) {
if (descriptor) {
Object.defineProperty(globalThis, name, descriptor);
} else {
Reflect.deleteProperty(globalThis, name);
}
}
},
};
}
const Empty = () => null;
const uiStore = {
settingsPage: 'agents',
isSettingsDialogOpen: true,
setSettingsPage: () => {},
};
type UiStoreValue = (typeof uiStore)[keyof typeof uiStore];
const agentsMeta = { slug: 'agents', title: 'Agents', group: 'opencode', kind: 'split' };
let sidebarOnItemSelect: (() => void) | undefined;
let SettingsPageLayout: React.ComponentType<SettingsPageLayoutProps> | null = null;
mock.module('@/lib/utils', () => ({
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
getModifierLabel: () => 'Ctrl',
}));
mock.module('@/stores/useUIStore', () => ({
useUIStore: (selector: (state: typeof uiStore) => UiStoreValue) => selector(uiStore),
}));
mock.module('@/hooks/useSettingsDirectory', () => ({ useSettingsDirectory: () => '/workspace' }));
mock.module('@/stores/useProjectsStore', () => ({
useProjectsStore: (selector: (state: { activeProjectId: null }) => null) => selector({ activeProjectId: null }),
}));
mock.module('@/stores/useAgentsStore', () => ({
refreshAfterOpenCodeRestart: async () => {},
useAgentsStore: { getState: () => ({ loadAgents: async () => {} }) },
}));
mock.module('@/stores/useCommandsStore', () => ({ useCommandsStore: { getState: () => ({ loadCommands: async () => {} }) } }));
mock.module('@/stores/useMcpConfigStore', () => ({ useMcpConfigStore: { getState: () => ({ loadMcpConfigs: async () => {} }) } }));
mock.module('@/stores/useSnippetsStore', () => ({ useSnippetsStore: { getState: () => ({ loadSnippets: async () => {} }) } }));
mock.module('@/stores/useSkillsStore', () => ({ useSkillsStore: { getState: () => ({ loadSkills: async () => {} }) } }));
mock.module('@/stores/useSkillsCatalogStore', () => ({ useSkillsCatalogStore: { getState: () => ({ loadCatalog: async () => {} }) } }));
mock.module('@/stores/useConfigStore', () => ({ useConfigStore: { getState: () => ({ providers: [], setSelectedProvider: () => {} }) } }));
mock.module('@/stores/usePendingOpenCodeRestartStore', () => ({
selectPendingOpenCodeRestartCount: () => 0,
usePendingOpenCodeRestartStore: () => 0,
}));
mock.module('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: ChildrenProps) => <>{children}</>,
TooltipTrigger: ({ children }: ChildrenProps) => <>{children}</>,
}));
mock.module('@/components/ui/ErrorBoundary', () => ({ ErrorBoundary: ({ children }: ChildrenProps) => <>{children}</> }));
mock.module('@/components/ui/ScrollableOverlay', () => ({ ScrollableOverlay: ({ children }: ChildrenProps) => <div>{children}</div> }));
mock.module('@/components/sections/shared/SettingsSection', () => ({
SETTINGS_DESCRIPTION_CLASS: '',
SETTINGS_PAGE_TITLE_CLASS: '',
SETTINGS_SECTION_TITLE_CLASS: '',
}));
mock.module('@/lib/persistence', () => ({
getSettingsSaveState: () => 'idle',
subscribeToSettingsSaveState: () => () => {},
}));
mock.module('@/components/icon/Icon', () => ({ Icon: Empty }));
mock.module('@/components/icons/McpIcon', () => ({ McpIcon: Empty }));
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
mock.module('@/lib/device', () => ({
useDeviceInfo: () => ({ isMobile: false }),
}));
mock.module('@/lib/desktop', () => ({
getDesktopHomeDirectory: async () => null,
isDesktopLocalOriginActive: () => false,
isDesktopShell: () => false,
isVSCodeRuntime: () => false,
isWebRuntime: () => true,
}));
mock.module('@/lib/platform', () => ({ isWindowsArm64: () => false }));
mock.module('@/lib/settings/metadata', () => ({
SETTINGS_PAGE_METADATA: [agentsMeta],
getSettingsNavIcon: () => 'settings-3',
getSettingsPageMeta: (slug: string) => slug === 'agents' ? agentsMeta : null,
resolveSettingsSlug: (slug: string) => slug === 'agents' ? 'agents' : 'home',
}));
mock.module('@/lib/settings/search', () => ({ buildSettingsSearchResults: () => [] }));
mock.module('@/components/views/OpenCodeReloadFooterAction', () => ({ OpenCodeReloadFooterAction: Empty }));
mock.module('@/components/sections/agents/AgentsSidebar', () => ({
AgentsSidebar: ({ onItemSelect }: AgentsSidebarProps) => {
sidebarOnItemSelect = onItemSelect;
return <button type="button" onClick={onItemSelect}>Duplicate</button>;
},
}));
mock.module('@/components/sections/agents/AgentsPage', () => ({
AgentsPage: () => {
const Layout = SettingsPageLayout;
if (!Layout) {
throw new Error('SettingsPageLayout must load before SettingsView');
}
return <Layout title="New agent" showSaveStatus={false}><div /></Layout>;
},
}));
for (const [module, exports] of [
['@/components/sections/behavior/BehaviorPage', ['BehaviorPage']],
['@/components/sections/commands/CommandsSidebar', ['CommandsSidebar']],
['@/components/sections/commands/CommandsPage', ['CommandsPage']],
['@/components/sections/mcp/McpSidebar', ['McpSidebar']],
['@/components/sections/mcp/McpPage', ['McpPage']],
['@/components/sections/plugins', ['PluginsSidebar', 'PluginsPage']],
['@/components/sections/skills/SkillsSidebar', ['SkillsSidebar']],
['@/components/sections/skills/SkillsPage', ['SkillsPage']],
['@/components/sections/projects/ProjectsSidebar', ['ProjectsSidebar']],
['@/components/sections/projects/ProjectsPage', ['ProjectsPage']],
['@/components/sections/remote-instances/RemoteInstancesPage', ['RemoteInstancesPage']],
['@/components/sections/providers/ProvidersSidebar', ['ProvidersSidebar']],
['@/components/sections/providers/ProvidersPage', ['ProvidersPage']],
['@/components/sections/usage/UsageSidebar', ['UsageSidebar']],
['@/components/sections/usage/UsagePage', ['UsagePage']],
['@/components/sections/magic-prompts/MagicPromptsSidebar', ['MagicPromptsSidebar']],
['@/components/sections/magic-prompts/MagicPromptsPage', ['MagicPromptsPage']],
['@/components/sections/snippets/SnippetsSidebar', ['SnippetsSidebar']],
['@/components/sections/snippets/SnippetsPage', ['SnippetsPage']],
['@/components/sections/git-identities/GitPage', ['GitPage']],
['@/components/sections/integrations/IntegrationsPage', ['IntegrationsPage']],
['@/components/sections/openchamber/OpenChamberPage', ['OpenChamberPage']],
['@/components/sections/openchamber/AboutSettings', ['AboutSettings']],
] as const) {
mock.module(module, () => Object.fromEntries(exports.map((name) => [name, Empty])));
}
SettingsPageLayout = (await import('../sections/shared/SettingsPageLayout')).SettingsPageLayout;
const { SettingsView } = await import('./SettingsView');
describe('SettingsView mobile split-page focus', () => {
test('focuses the rendered editor heading after a mobile sidebar selection', async () => {
const dom = installDomStub();
const root: Root = createRoot(dom.container);
sidebarOnItemSelect = undefined;
try {
await act(async () => {
root.render(<SettingsView forceMobile initialMobileStage="page-sidebar" />);
});
expect(sidebarOnItemSelect).toBeDefined();
await act(async () => {
sidebarOnItemSelect?.();
});
const heading = dom.container.querySelector('[data-settings-page-heading]');
expect(heading).not.toBeNull();
expect(heading?.getAttribute('tabindex')).toBe('-1');
expect(dom.document.activeElement).toBe(dom.document.body);
expect(dom.frameCount()).toBe(1);
await act(async () => {
dom.flushFrames();
});
expect(dom.document.activeElement).toBe(heading);
expect(heading?.focusOptions).toEqual({ preventScroll: true });
} finally {
await act(async () => {
root.unmount();
});
dom.restore();
}
});
test('does not pass the mobile selection callback to desktop split pages', async () => {
const dom = installDomStub();
const root: Root = createRoot(dom.container);
sidebarOnItemSelect = undefined;
try {
await act(async () => {
root.render(<SettingsView forceMobile={false} />);
});
expect(sidebarOnItemSelect).toBe(undefined);
expect(dom.frameCount()).toBe(0);
} finally {
await act(async () => {
root.unmount();
});
dom.restore();
}
});
});
+22 -1
View File
@@ -58,7 +58,7 @@ mock.module('@/sync/sync-refs', () => ({
}),
}));
const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, findLastCompletedAssistantMessageID, BTW_BOUNDARY_INSTRUCTION } =
const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, findLastCompletedAssistantMessageID, BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, buildBtwSyntheticTexts } =
await import('@/lib/btw');
const { useBtwStore } = await import('@/stores/useBtwStore');
@@ -304,3 +304,24 @@ describe('promoteBtwSession', () => {
expect(currentSessionSwitches).toEqual([]);
});
});
describe('buildBtwSyntheticTexts', () => {
test('a send routed to an active fork carries only the boundary instruction', () => {
// Regression: a promoted parent that opens a new btw fork used to send the
// promotion notice into the fork alongside the boundary instruction, telling
// the fork both that btw constraints apply and that they no longer apply.
expect(buildBtwSyntheticTexts({ isBtwActive: true, isPromotedBtwSession: true }))
.toEqual([BTW_BOUNDARY_INSTRUCTION]);
expect(buildBtwSyntheticTexts({ isBtwActive: true, isPromotedBtwSession: false }))
.toEqual([BTW_BOUNDARY_INSTRUCTION]);
});
test('a promoted session with no active fork carries the promotion notice', () => {
expect(buildBtwSyntheticTexts({ isBtwActive: false, isPromotedBtwSession: true }))
.toEqual([BTW_PROMOTION_NOTICE]);
});
test('an ordinary session carries neither', () => {
expect(buildBtwSyntheticTexts({ isBtwActive: false, isPromotedBtwSession: false })).toEqual([]);
});
});
+17
View File
@@ -74,6 +74,23 @@ export const BTW_PROMOTION_NOTICE =
+ 'The btw constraints in the history above no longer apply: this is now the main thread, and the '
+ 'usual tool, sub-agent and workspace permissions are in force.';
/**
* The btw framing texts a composer send carries.
*
* The boundary instruction rides with every send routed to an active btw fork,
* so the inherited transcript stays reference material for the whole side
* conversation. The promotion notice is the opposite case: it tells a promoted
* session that the btw constraints in its own history are lifted. A send routed
* to a fresh fork is never that session, so the two never travel together.
*/
export const buildBtwSyntheticTexts = (state: {
isBtwActive: boolean;
isPromotedBtwSession: boolean;
}): string[] => {
if (state.isBtwActive) return [BTW_BOUNDARY_INSTRUCTION];
return state.isPromotedBtwSession ? [BTW_PROMOTION_NOTICE] : [];
};
/** The boundary as an `additionalParts` entry for `sendMessage`. */
const btwBoundaryParts = (): Array<{ text: string; synthetic: true }> =>
[{ text: BTW_BOUNDARY_INSTRUCTION, synthetic: true }];
+11 -2
View File
@@ -1779,8 +1779,6 @@ export const dict = {
'session.newWorktree.noMatchingBranches': 'Keine übereinstimmenden Branches',
'session.newWorktree.localBranches': 'Lokale Branches',
'session.newWorktree.remoteBranches': 'Remote-Branches',
'session.newWorktree.otherLocalBranches': 'Andere lokale Branches',
'session.newWorktree.otherRemoteBranches': 'Andere Remote-Branches',
'session.newWorktree.branchName': 'Branch-Name',
'session.newWorktree.branchNamePlaceholder': 'feature/mein-geil-feature',
'session.newWorktree.actions.change': 'Ändern',
@@ -2811,6 +2809,7 @@ export const dict = {
'memoryDebugPanel.title': 'Debug Panel',
'memoryDebugPanel.tabs.memory': 'Speicher',
'memoryDebugPanel.tabs.streaming': 'Streaming',
'memoryDebugPanel.tabs.requests': 'Anfragen',
'memoryDebugPanel.section.sessionsInMemory': 'Sitzungen im Speicher',
'memoryDebugPanel.section.uiStreamingMetrics': 'UI-Streaming-Metriken',
'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code Bridge Metriken',
@@ -2848,6 +2847,16 @@ export const dict = {
'memoryDebugPanel.streaming.copy.copied': 'Streaming-Debug-JSON kopiert',
'memoryDebugPanel.streaming.copy.failed': 'Fehler beim Kopieren der JSON-Datei',
'memoryDebugPanel.streaming.copy.hint': 'Kopieren exportiert sowohl UI- als auch VS Code-Streaming-Metriken als JSON',
'memoryDebugPanel.requests.inFlight': 'Laufend',
'memoryDebugPanel.requests.peak': 'Spitze',
'memoryDebugPanel.requests.duration': 'Dauer',
'memoryDebugPanel.requests.totalRequests': 'Gesamtanfragen',
'memoryDebugPanel.requests.tracking': 'Aufzeichnung',
'memoryDebugPanel.requests.now': 'jetzt',
'memoryDebugPanel.requests.noSamples': 'Noch keine Anfragen aufgezeichnet. Lassen Sie dieses Panel geöffnet, um Fetch-Aktivität zu erfassen.',
'memoryDebugPanel.requests.chartLabel': 'Laufende Fetch-Anfragen im Zeitverlauf, Spitze {peak}',
'memoryDebugPanel.requests.windowHint': 'letzte {seconds}s',
'memoryDebugPanel.requests.percentileChartLabel': 'Perzentile des Alters laufender Anfragen (p50, p90, p99, max) im Zeitverlauf',
'memoryDebugPanel.common.idle': 'inaktiv',
'memoryDebugPanel.common.live': 'live',
'memoryDebugPanel.common.notAvailable': 'n/a',
-2
View File
@@ -1954,8 +1954,6 @@ export const dict: Record<I18nKey, string> = {
'session.newWorktree.noMatchingBranches': '一致するブランチがありません',
'session.newWorktree.localBranches': 'ローカルブランチ',
'session.newWorktree.remoteBranches': 'リモートブランチ',
'session.newWorktree.otherLocalBranches': 'その他のローカルブランチ',
'session.newWorktree.otherRemoteBranches': 'その他のリモートブランチ',
'session.newWorktree.branchName': 'ブランチ名',
'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature',
'session.newWorktree.actions.change': '変更',
+1 -1
View File
@@ -3022,7 +3022,7 @@ export const dict: Record<I18nKey, string> = {
"memoryDebugPanel.requests.now": "зараз",
"memoryDebugPanel.requests.noSamples": "Запитів ще немає. Тримайте цю панель відкритою, щоб фіксувати активність fetch.",
"memoryDebugPanel.requests.chartLabel": "Запити fetch у виконанні з часом, пік {peak}",
"memoryDebugPanel.requests.windowHint": "останні {seconds}s",
"memoryDebugPanel.requests.windowHint": "останні {seconds} с",
"memoryDebugPanel.requests.percentileChartLabel": "Перцентилі віку запитів у виконанні (p50, p90, p99, max) з часом",
"memoryDebugPanel.common.idle": "очікування",
"memoryDebugPanel.common.live": "live",
+1 -1
View File
@@ -268,7 +268,7 @@ Rules:
6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation.
8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
9. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative.
9. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo.
Examples of global-store updates performed in `session-actions.ts`:
@@ -16,6 +16,7 @@ let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { statu
const sessionMessageRecords = new Map<string, Array<{ info: Message; parts: Part[] }>>()
const failingRevertSessionIds = new Set<string>()
const failingUnrevertSessionIds = new Set<string>()
let afterUnrevertCall: ((sessionId: string) => void) | null = null
let sessionDeleteError: unknown | null = null
let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null
let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null
@@ -73,6 +74,7 @@ const mockSdk = {
}),
unrevert: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "session.unrevert", params })
afterUnrevertCall?.(String(params.sessionID))
if (failingUnrevertSessionIds.has(String(params.sessionID))) {
return Promise.resolve({ error: { message: "rejected" }, response: { status: 500 } })
}
@@ -1435,6 +1437,44 @@ describe("revertToMessage passes session directory", () => {
"root",
])
})
test("aborts a busy descendant before reverting it", async () => {
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 } },
{ id: "busy-child", parentID: "root", directory: "/tree", time: { created: 2 } },
{ id: "idle-child", parentID: "root", directory: "/tree", time: { created: 3 } },
] as Session[]
const store = createStore({}, {
session: sessions,
message: { root: [rootMessage] },
session_status: { "busy-child": { type: "busy" }, "idle-child": { type: "idle" } },
})
for (const id of ["busy-child", "idle-child"]) {
sessionMessageRecords.set(id, [{
info: { id: `${id}-target`, sessionID: id, role: "user", time: { created: 20 } } as Message,
parts: [],
}])
}
const { setActionRefs, revertToMessage } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await revertToMessage("root", "root-cutoff")
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
.toEqual(["busy-child"])
const busyAbortIndex = replyCalls.findIndex((call) => call.method === "session.abort")
const busyRevertIndex = replyCalls.findIndex(
(call) => call.method === "session.revert" && call.params.sessionID === "busy-child",
)
expect(busyAbortIndex).toBeLessThan(busyRevertIndex)
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => call.params.sessionID)).toEqual([
"busy-child",
"idle-child",
"root",
])
})
})
describe("unrevertSession descendant cascade", () => {
@@ -1442,6 +1482,7 @@ describe("unrevertSession descendant cascade", () => {
replyCalls.length = 0
sessionMessagesResult = { data: [] }
failingUnrevertSessionIds.clear()
afterUnrevertCall = null
})
test("unreverts only marked descendants before the parent", async () => {
@@ -1485,6 +1526,75 @@ describe("unrevertSession descendant cascade", () => {
"root",
])
})
test("aborts a busy descendant before unreverting it", async () => {
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
{ id: "busy-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "busy-target" } },
{ id: "idle-child", parentID: "root", directory: "/tree", time: { created: 3 }, revert: { messageID: "idle-target" } },
] as Session[]
const store = createStore({}, {
session: sessions,
session_status: { "busy-child": { type: "busy" }, "idle-child": { type: "idle" } },
})
const { setActionRefs, unrevertSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await unrevertSession("root")
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
.toEqual(["busy-child"])
const abortIndex = replyCalls.findIndex((call) => call.method === "session.abort")
const unrevertIndex = replyCalls.findIndex(
(call) => call.method === "session.unrevert" && call.params.sessionID === "busy-child",
)
expect(abortIndex).toBeLessThan(unrevertIndex)
})
test("treats a descendant as busy when any child store reports a non-idle status", async () => {
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
{ id: "busy-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "busy-target" } },
] as Session[]
// The session list is deduped onto /tree, but the live status arrived in the
// store for another directory.
const treeStore = createStore({}, { session: sessions })
const statusStore = createStore({}, { session_status: { "busy-child": { type: "busy" } } })
const { setActionRefs, unrevertSession } = await import("./session-actions")
setActionRefs(
mockSdk as unknown as OpencodeClient,
createChildStores([["/tree", treeStore], ["/other", statusStore]]),
() => "/tree",
)
await unrevertSession("root")
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
.toEqual(["busy-child"])
})
test("aborts a descendant that turns busy after the subtree snapshot", async () => {
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
{ id: "first-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "first-target" } },
{ id: "second-child", parentID: "root", directory: "/tree", time: { created: 3 }, revert: { messageID: "second-target" } },
] as Session[]
const store = createStore({}, { session: sessions, session_status: {} })
afterUnrevertCall = (sessionId) => {
if (sessionId !== "first-child") return
store.getState().patch({ session_status: { "second-child": { type: "busy" } } })
}
const { setActionRefs, unrevertSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await unrevertSession("root")
expect(replyCalls.filter((call) => call.method === "session.abort").map((call) => call.params.sessionID))
.toEqual(["second-child"])
})
})
describe("dismissPermission passes directory", () => {
+34 -1
View File
@@ -443,13 +443,40 @@ type DescendantSession = {
directory: string
}
/**
* A session's live status can live in a different child store than the one that
* wins the directory dedup, so any store reporting a non-idle status counts.
* Read at the moment of use: a descendant can start working after the subtree
* snapshot was taken.
*/
function isSessionBusyNow(sessionId: string): boolean {
const stores = _childStores
if (!stores) return false
for (const [, store] of stores.children) {
const status = store.getState().session_status?.[sessionId]
if (status && status.type !== "idle") return true
}
return false
}
async function abortDescendantIfBusy(sessionId: string, directory: string): Promise<void> {
if (!isSessionBusyNow(sessionId)) return
try {
await sdk().session.abort({ sessionID: sessionId, directory })
} catch {
// ignore abort errors
}
}
function getDescendantSessions(rootId: string): DescendantSession[] {
const stores = _childStores
if (!stores) return []
const sessionsById = new Map<string, DescendantSession>()
for (const [storeDirectory, store] of stores.children) {
for (const session of store.getState().session) {
const state = store.getState()
for (const session of state.session) {
const directory = session.directory || storeDirectory
const current = sessionsById.get(session.id)
if (!current || session.directory) sessionsById.set(session.id, { session, directory })
@@ -483,6 +510,9 @@ async function fetchSessionMessages(sessionId: string, directory?: string | null
async function cascadeRevertToDescendants(rootId: string, cutoff: number): Promise<void> {
for (const { session, directory } of getDescendantSessions(rootId)) {
try {
// A running descendant would keep writing messages past the revert
// boundary, so stop it first for the same reason the parent is aborted.
await abortDescendantIfBusy(session.id, directory)
const messages = await fetchSessionMessages(session.id, directory)
// Equal timestamps belong to the reverted side of the boundary. Keeping
// them would rely on unrelated message IDs to decide chronology.
@@ -500,6 +530,9 @@ async function cascadeUnrevertToDescendants(rootId: string): Promise<void> {
for (const { session, directory } of getDescendantSessions(rootId)) {
if (!session.revert) continue
try {
// Same reason as the revert cascade: a running descendant keeps writing
// messages that the unrevert would race against.
await abortDescendantIfBusy(session.id, directory)
const result = await sdk().session.unrevert({ sessionID: session.id, directory })
mirrorSessionIntoLiveStores(assertSdkData(result, "session.unrevert"), directory)
} catch (error) {