Compare commits

..
Author SHA1 Message Date
bincxz 6a138ba0b1 fix(ai): preserve terminal replies between OSC contexts 2026-10-15 14:51:04 +08:00
0ed481c397 feat(ai-chat): fork a conversation from a completed response (#3604)
* fix(#3600): automated AI fix

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix: address Codex review on PR #3604

* fix(ai): narrow chat forks to independent transcript copies

* test(ai): keep fork render check correctly typed

* fix(ai): remove forked output arguments and support keyboard access

* fix(ai): guard signed fork reasoning and scan eligibility once

* fix(ai): remove bare source output references from fork text

* fix(ai): sanitize fork attachment previews without changing data

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-10-15 14:38:53 +08:00
陈大猫 e85c1fea30 feat(terminal): add a switchable Unix path highlight rule (#3672)
* feat(terminal): highlight Unix paths with existing keyword rules

* fix(terminal): keep Unicode and quoted paths together

* fix(terminal): exclude relative path suffix highlights

* fix(terminal): preserve punctuation inside path text
2026-10-15 14:37:16 +08:00
21 changed files with 951 additions and 57 deletions

No files matched your search

+2
View File
@@ -535,6 +535,8 @@ export const enAiMessages: Messages = {
'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.',
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.forkFromHere': 'Fork from here',
'ai.chat.forkCreated': 'Branched into a new chat — the original stays in history.',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
'ai.chat.menuHosts': 'Hosts',
+2
View File
@@ -526,6 +526,8 @@ export const esAiMessages: Messages = {
'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.',
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
'ai.chat.noSessions': 'No hay sesiones anteriores',
'ai.chat.forkFromHere': 'Bifurcar desde aquí',
'ai.chat.forkCreated': 'Conversación bifurcada en un chat nuevo; el original permanece en el historial.',
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
'ai.chat.menuHosts': 'Hosts',
+2
View File
@@ -482,6 +482,8 @@ export const ruAiMessages: Messages = {
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.forkFromHere': 'Ветвление отсюда',
'ai.chat.forkCreated': 'Разговор перенесён в новый чат; оригинал остаётся в истории.',
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.menuHosts': 'Хосты',
+2
View File
@@ -535,6 +535,8 @@ export const zhCNAiMessages: Messages = {
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.forkFromHere': '从此处分叉',
'ai.chat.forkCreated': '已分叉到新对话,原对话保留在历史中。',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
'ai.chat.menuHosts': '主机',
+2
View File
@@ -525,6 +525,8 @@ export const zhTWAiMessages: Messages = {
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
'ai.chat.noSessions': '沒有歷史工作階段',
'ai.chat.forkFromHere': '從此處分叉',
'ai.chat.forkCreated': '已分叉到新對話,原對話保留在歷史中。',
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
'ai.chat.menuHosts': '主機',
+134
View File
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SOURCE_SESSION: AISession = {
id: 'chat-source',
title: 'Fork source',
agentId: 'catty',
scope: { type: 'terminal', targetId: 'terminal-a' },
messages: [
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
{
id: 'user-2',
role: 'user',
content: 'bad last prompt',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
],
},
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
],
createdAt: 1,
updatedAt: 4,
};
type HookCapture = {
ai: ReturnType<typeof useAIState> | null;
sessions: AISession[];
activeSessionIdMap: Record<string, string | null>;
};
function ForkHarness({ capture }: { capture: HookCapture }) {
capture.ai = useAIState();
const store = useAISessionsStore();
capture.sessions = store.sessions as AISession[];
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
return null;
}
async function setupAiState(sessions: AISession[]) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost',
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
setLatestAISessionsSnapshot(sessions);
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
await act(async () => {
root.render(React.createElement(ForkHarness, { capture }));
});
return { dom, container, root, capture };
}
test('fork retains the completed prefix, replay metadata, and source-owned outputs', async () => {
const runtime = getAgentRuntime();
const outputStore = runtime.getToolOutputStore('chat-source');
const handle = outputStore.store({ chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'saved output' });
runtime.getSessionStateStore().updateFromToolResult('chat-source', 'terminal_start',
{ sessionId: 'terminal-a', command: 'running-command' },
JSON.stringify({ jobId: 'source-job', status: 'running', nextOffset: 7 }), false);
const source = structuredClone(SOURCE_SESSION);
source.externalSessionId = 'original-provider-thread';
source.messages[1].content = `Preview handleId=${handle.id}`;
source.messages[1].providerContinuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] };
const { dom, container, root, capture } = await setupAiState([source]);
let branch: AISession | null = null;
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1', { type: 'terminal', targetId: 'reconnected' }); });
assert.ok(branch);
assert.notEqual(branch!.id, source.id);
assert.equal(branch!.externalSessionId, undefined);
assert.deepEqual(branch!.messages[1].providerContinuation, source.messages[1].providerContinuation);
assert.match(branch!.messages[1].content, /Preview/);
assert.doesNotMatch(branch!.messages[1].content, /handleId=/);
assert.deepEqual(branch!.scope, { type: 'terminal', targetId: 'reconnected', hostIds: undefined });
assert.equal(capture.activeSessionIdMap['terminal:reconnected'], branch!.id);
assert.deepEqual({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }, source);
assert.equal(outputStore.read({ handleId: handle.id }, branch!.id), null);
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'saved output');
assert.deepEqual(runtime.getSessionStateStore().get(branch!.id).activeJobs, {});
assert.ok(runtime.getSessionStateStore().get(source.id).activeJobs['source-job']);
runtime.getSessionStateStore().clear(source.id);
outputStore.prune(source.id);
await act(async () => root.unmount()); container.remove(); dom.window.close();
});
test('fork rejects an incomplete boundary without adding a chat', async () => {
const source = structuredClone(SOURCE_SESSION);
source.messages[1].executionStatus = 'failed';
const { dom, container, root, capture } = await setupAiState([source]);
let result: unknown;
await act(async () => { result = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1'); });
assert.equal(result, null);
assert.equal(capture.sessions.length, 1);
await act(async () => root.unmount()); container.remove(); dom.window.close();
});
for (const newerCount of [49, 50]) {
test(`fork preserves the original on reload with ${newerCount} newer chats`, async () => {
const newer = Array.from({ length: newerCount }, (_, index) => ({ ...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index }));
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
let branch: AISession | null = null;
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
assert.ok(branch);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
await act(async () => root.unmount()); container.remove(); dom.window.close();
const reloaded = await setupAiState(saved);
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
await act(async () => reloaded.root.unmount()); reloaded.container.remove(); reloaded.dom.window.close();
});
}
+54
View File
@@ -55,6 +55,7 @@ import {
import { convertFilesToUploads } from './useFileUpload';
import { removeProviderReferences } from './aiProviderCleanup';
import { publishAISessionsSnapshot } from './aiSessionsStore';
import { planSessionFork } from '../../domain/aiSessionFork';
import {
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
@@ -680,6 +681,57 @@ export function useAIState() {
};
}, [persistSessions]);
// "Fork from here": copy the conversation up to a completed assistant
// response into a new session and make it the scope's active chat. The
// original session stays untouched in history. Pure boundary validation and
// plan building live in domain/aiSessionFork; the fork has no original
// external session id or pending-turn state. Per-message replay metadata is
// preserved; output handles and running jobs remain owned by the source.
// Null is returned when the boundary is not forkable.
// `viewingScope` is the panel scope the fork was requested from; the branch
// is created there so the fork is active and visible in the very scope that
// is viewing it, even when the source session carries a stale scope (e.g. a
// terminal chat resumed in a reconnected terminal or a merged workspace).
const forkSessionFromMessage = useCallback(async (
sessionId: string,
messageId: string,
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
): Promise<AISession | null> => {
const source = sessionsRef.current.find(s => s.id === sessionId);
if (!source) return null;
const plan = planSessionFork(source, messageId);
if (!plan.ok) return null;
const now = Date.now();
const fork: AISession = {
id: `ai_${now}_${Math.random().toString(36).slice(2, 8)}`,
title: plan.title,
agentId: source.agentId,
scope: {
...source.scope,
// Host membership stays with the source (the messages were produced
// there); the scope the fork belongs to is the viewing scope.
...(viewingScope ? { type: viewingScope.type, targetId: viewingScope.targetId } : {}),
hostIds: source.scope.hostIds ? [...source.scope.hostIds] : undefined,
},
messages: plan.messages,
createdAt: now,
updatedAt: now,
};
if (plan.contextCompaction) {
fork.contextCompaction = plan.contextCompaction;
}
setSessionsRaw(prev => {
// Keep the source alongside its branch within the existing history limit.
const next = [fork, { ...source, updatedAt: now }, ...prev.filter(session => session.id !== source.id)];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${fork.scope.type}:${fork.scope.targetId ?? ''}`;
setActiveSessionId(scopeKey, fork.id);
return fork;
}, [persistSessions, setActiveSessionId]);
const createSession = useCallback((scope: AISessionScope, agentId?: string): AISession => {
const now = Date.now();
const session: AISession = {
@@ -1319,6 +1371,7 @@ export function useAIState() {
seedWorkspaceActiveSessionFromMembers,
handoffDissolvedWorkspaceScope,
retargetWorkspaceActiveChatForMemberLoss,
forkSessionFromMessage,
}), [
providers,
setProviders,
@@ -1380,5 +1433,6 @@ export function useAIState() {
seedWorkspaceActiveSessionFromMembers,
handoffDissolvedWorkspaceScope,
retargetWorkspaceActiveChatForMemberLoss,
forkSessionFromMessage,
]);
}
+5
View File
@@ -47,6 +47,8 @@ interface AIChatPanelContentProps {
activeSessionId: string | null;
handleSelectSession: (sessionId: string) => void;
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
/** Fork the conversation up to a completed assistant response into a new session. */
onForkFromMessage?: (messageId: string) => void;
messages: ChatMessage[];
isStreaming: boolean;
activeCompaction?: import('../application/state/useAgentCompactionUi').ActiveCompactionUi | null;
@@ -127,6 +129,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
activeSessionId,
handleSelectSession,
handleDeleteSession,
onForkFromMessage,
messages,
isStreaming,
activeCompaction = null,
@@ -313,6 +316,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
isStreaming={isStreaming}
activeSessionId={activeSessionId}
activeCompaction={activeCompaction}
contextCompaction={activeSession?.contextCompaction}
onForkFromMessage={onForkFromMessage}
notes={notes}
hosts={hosts}
onOpenVaultNote={onOpenVaultNote}
+33
View File
@@ -278,6 +278,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
updateLastMessage,
updateMessageById,
persistContextCompaction,
forkSessionFromMessage,
providers,
activeProviderId,
activeModelId,
@@ -1854,6 +1855,36 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
[setActiveSessionId, showScopeSessionView],
);
// "Fork from here": copy the viewed conversation up to a completed assistant
// response into a new session, switch to it, and confirm via toast. The
// original stays in history. Boundary validation is re-run by the state
// hook; a null result means the fork was refused (already hidden in the UI).
// This panel's scope is passed so the branch is created in the scope viewing
// it, even when the source session's own scope went stale (e.g. a terminal
// chat resumed after reconnecting under a new terminal id).
const handleForkFromMessage = useCallback(async (messageId: string) => {
const sourceSessionId = activeSessionRef.current?.id ?? activeSessionId;
if (!sourceSessionId) return;
// Reject a double-click while the new session is being selected so one
// interaction cannot create two forks.
if (!tryBeginSendForKey(`fork:${sourceSessionId}`)) return;
try {
const fork = await forkSessionFromMessage?.(sourceSessionId, messageId, {
type: scopeType,
targetId: scopeTargetId,
});
if (!fork) return;
applyHistorySessionSelection(fork.id, {
showSessionView: showScopeSessionView,
setActiveSessionId,
closeHistory: () => setShowHistory(false),
});
toast.success(t('ai.chat.forkCreated'));
} finally {
endSendForKey(`fork:${sourceSessionId}`);
}
}, [activeSessionId, forkSessionFromMessage, scopeTargetId, scopeType, setActiveSessionId, showScopeSessionView, t]);
const handleDeleteSession = useCallback(
async (e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
@@ -1940,6 +1971,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
activeSessionId={activeSessionId}
handleSelectSession={handleSelectSession}
handleDeleteSession={handleDeleteSession}
onForkFromMessage={handleForkFromMessage}
messages={messages}
isStreaming={isStreaming}
activeCompaction={
@@ -2030,6 +2062,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'updateLastMessage',
'updateMessageById',
'persistContextCompaction',
'forkSessionFromMessage',
'providers',
'activeProviderId',
'activeModelId',
+5
View File
@@ -58,6 +58,11 @@ export interface AIChatSidePanelProps {
sessionId: string,
compaction: import('../infrastructure/ai/types').AISessionContextCompaction,
) => void;
forkSessionFromMessage?: (
sessionId: string,
messageId: string,
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
) => Promise<AISession | null>;
// Provider config
providers: ProviderConfig[];
activeProviderId: string;
+17
View File
@@ -585,3 +585,20 @@ test("Codex approval render plan preserves every approval for the same item", ()
["approval-3"],
);
});
test('fork is offered only for safe completed replies and hidden while streaming', () => {
const messages: ChatMessage[] = [
{ id: 'u', role: 'user', content: 'hello', timestamp: 1 },
{ id: 'done', role: 'assistant', content: 'completed', timestamp: 2, executionStatus: 'completed' },
{ id: 'failed', role: 'assistant', content: 'failed', timestamp: 3, executionStatus: 'failed' },
];
const render = (isStreaming: boolean) => renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider>
<ChatMessageList messages={messages} isStreaming={isStreaming} onForkFromMessage={() => {}} />
</TooltipProvider>
</I18nProvider>,
);
assert.equal((render(false).match(/Fork from here/g) ?? []).length, 1);
assert.equal((render(true).match(/Fork from here/g) ?? []).length, 0);
});
+37 -2
View File
@@ -6,10 +6,11 @@
* No avatars. Thinking blocks are collapsible.
*/
import { AlertCircle, BookOpen, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
import { AlertCircle, BookOpen, FileText, GitFork, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatMessage, ToolCall as AgentToolCall } from '../../infrastructure/ai/types';
import type { AISessionContextCompaction, ChatMessage, ToolCall as AgentToolCall } from '../../infrastructure/ai/types';
import { getForkableMessageIds } from '../../domain/aiSessionFork';
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
import {
Conversation,
@@ -92,6 +93,10 @@ interface ChatMessageListProps {
/** Active chat session ID — used to filter standalone MCP approval blocks */
activeSessionId?: string | null;
activeCompaction?: ActiveCompactionUi | null;
/** Stored compaction summary for the viewed session — fork boundaries must not fall inside its coverage. */
contextCompaction?: AISessionContextCompaction | null;
/** Present when the panel supports forking a conversation from a completed assistant response. */
onForkFromMessage?: (messageId: string) => void;
notes?: VaultNote[];
hosts?: Host[];
snippets?: Snippet[];
@@ -160,6 +165,8 @@ export function buildCodexApprovalRenderPlan(
const MESSAGE_RENDER_BATCH = 50;
const MESSAGE_RENDER_STEP = 50;
const EMPTY_FORKABLE_SET: ReadonlySet<string> = new Set();
export function pruneResolvedApprovals(
previous: ReadonlyMap<string, boolean>,
messages: readonly ChatMessage[],
@@ -180,6 +187,8 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
isStreaming,
activeSessionId,
activeCompaction = null,
contextCompaction,
onForkFromMessage,
notes = [],
hosts = [],
snippets = [],
@@ -399,6 +408,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
[messages],
);
// "Fork from here" targets: completed assistant responses whose whole
// prefix is a safe fork boundary (fully resolved tool calls, stored
// compaction summary still accurate). Hidden entirely while a turn streams.
const forkableMessageIds = useMemo(() => {
if (!onForkFromMessage || isStreaming) return EMPTY_FORKABLE_SET;
return getForkableMessageIds({ messages, contextCompaction: contextCompaction ?? undefined });
}, [onForkFromMessage, isStreaming, messages, contextCompaction]);
// While a jump target is active, re-resolve the tail against the current list
// so streaming appends cannot slide the window past the selected message.
const effectiveTailCount = activeJumpMessageId
@@ -798,6 +815,22 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
</div>
</div>
)}
{/* Fork from here — copies the conversation up to this completed
response into a new session. Only on safe boundaries; hidden
while a turn streams (forkableMessageIds empty). */}
{!isUser && onForkFromMessage && forkableMessageIds.has(message.id) && (
<div className="flex -mt-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100">
<button
type="button"
onClick={() => onForkFromMessage(message.id)}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground/40 hover:text-foreground hover:bg-white/[0.05] transition-colors cursor-pointer"
>
<GitFork size={11} />
<span>{t('ai.chat.forkFromHere')}</span>
</button>
</div>
)}
</MessageContent>
</Message>
);
@@ -1042,6 +1075,8 @@ function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps
if (prev.onOpenVaultHost !== next.onOpenVaultHost) return false;
if (prev.onOpenVaultSnippet !== next.onOpenVaultSnippet) return false;
if (prev.onOpenVaultSection !== next.onOpenVaultSection) return false;
if (prev.onForkFromMessage !== next.onForkFromMessage) return false;
if (prev.contextCompaction !== next.contextCompaction) return false;
if (prev.messages.length !== next.messages.length) return false;
if (prev.messages === next.messages) return true;
@@ -592,6 +592,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
updateLastMessage={aiConfig.updateLastMessage}
updateMessageById={aiConfig.updateMessageById}
persistContextCompaction={aiConfig.persistContextCompaction}
forkSessionFromMessage={aiConfig.forkSessionFromMessage}
providers={aiConfig.providers}
activeProviderId={aiConfig.activeProviderId}
activeModelId={aiConfig.activeModelId}
+318
View File
@@ -0,0 +1,318 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildForkTitle,
canForkFromMessage,
getForkableMessageIds,
planSessionFork,
stripMessageContinuationState,
} from './aiSessionFork.ts';
import type { ForkMessage } from './aiSessionFork.ts';
let nextId = 0;
const msg = (overrides: Partial<ForkMessage>): ForkMessage => ({
id: `m${nextId++}`,
role: 'assistant',
content: '',
timestamp: 0,
...overrides,
} as ForkMessage);
const session = (messages: ForkMessage[], contextCompaction?: { summary: string; compactedMessageCount: number }) => ({
title: 'Debug deploy',
messages,
contextCompaction,
});
test('planSessionFork keeps the original untouched and plans a copy through the boundary', () => {
const user = msg({ role: 'user', content: 'run tests' });
const assistant = msg({ content: 'done, all green' });
const source = session([user, assistant]);
const plan = planSessionFork(source, assistant.id);
assert.equal(plan.ok, true);
if (!plan.ok) return;
assert.equal(plan.boundaryIndex, 1);
assert.deepEqual(plan.messages, [user, assistant]);
assert.equal(plan.title, 'Debug deploy (fork)');
});
test('planSessionFork rejects non-assistant and missing boundaries', () => {
const source = session([
msg({ role: 'user', content: 'hi' }),
]);
assert.deepEqual(planSessionFork(source, source.messages[0].id), { ok: false, reason: 'not-assistant' });
assert.deepEqual(planSessionFork(source, 'missing'), { ok: false, reason: 'message-not-found' });
});
test('planSessionFork rejects failed, cancelled, running, and streaming boundaries', () => {
for (const executionStatus of ['failed', 'cancelled', 'running', 'pending']) {
const source = session([msg({ content: 'x', executionStatus })]);
assert.deepEqual(planSessionFork(source, source.messages[0].id), {
ok: false,
reason: 'not-completed',
});
}
const streamingSource = session([msg({ statusText: 'Waiting for response…' })]);
assert.deepEqual(planSessionFork(streamingSource, streamingSource.messages[0].id), {
ok: false,
reason: 'not-completed',
});
const failedSource = session([msg({ errorInfo: { type: 'provider', message: 'boom', retryable: true } })]);
assert.deepEqual(planSessionFork(failedSource, failedSource.messages[0].id), {
ok: false,
reason: 'not-completed',
});
const pendingSource = session([msg({ pendingApproval: {} })]);
assert.deepEqual(planSessionFork(pendingSource, pendingSource.messages[0].id), {
ok: false,
reason: 'not-completed',
});
});
test('planSessionFork rejects boundaries with dangling tool calls', () => {
const source = session([
msg({ role: 'user', content: 'hi' }),
msg({ content: '', toolCalls: [{ id: 'call-1' }] }),
msg({ content: '', toolCalls: [{ id: 'call-2' }] }),
msg({
role: 'tool',
content: 'ok',
toolResults: [{ toolCallId: 'call-1', content: 'out' }],
}),
msg({ content: 'final answer' }),
]);
// call-2 never got a result inside the retained prefix
assert.deepEqual(planSessionFork(source, source.messages[4].id), {
ok: false,
reason: 'dangling-tool-call',
});
// forking at the tool-issuing assistant turns is not possible anyway
assert.deepEqual(planSessionFork(source, source.messages[2].id), {
ok: false,
reason: 'dangling-tool-call',
});
});
test('planSessionFork forks at the first turn boundary after results resolve', () => {
const source = session([
msg({ role: 'user', content: 'hi' }),
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
msg({ role: 'tool', content: 'ok', toolResults: [{ toolCallId: 'call-1', content: 'out' }] }),
msg({ role: 'assistant', content: 'all done' }),
]);
assert.deepEqual(planSessionFork(source, source.messages[1].id), {
ok: false,
reason: 'dangling-tool-call',
});
const plan = planSessionFork(source, source.messages[3].id);
assert.equal(plan.ok, true);
});
test('planSessionFork rejects boundaries with dangling tool results', () => {
const source = session([
msg({ role: 'assistant', content: 'x' }),
msg({ role: 'tool', content: 'ok', toolResults: [{ toolCallId: 'orphan', content: 'out' }] }),
msg({ role: 'assistant', content: 'answer' }),
]);
assert.deepEqual(planSessionFork(source, source.messages[2].id), {
ok: false,
reason: 'dangling-tool-result',
});
});
test('planSessionFork lets the compaction summary ride along while it stays accurate', () => {
const source = session(
[msg({ role: 'user', content: 'hi' }), msg({ content: 'done' })],
{ summary: 'earlier talk', compactedMessageCount: 1 },
);
const plan = planSessionFork(source, source.messages[1].id);
assert.equal(plan.ok, true);
if (plan.ok) {
assert.deepEqual(plan.contextCompaction, { summary: 'earlier talk', compactedMessageCount: 1 });
}
});
test('planSessionFork refuses when the compaction summary would cover discarded messages', () => {
const early = msg({ content: 'early turn' });
const source = session(
[early, msg({ content: 'later turn' })],
{ summary: 'covers both turns', compactedMessageCount: 2 },
);
assert.deepEqual(planSessionFork(source, early.id), {
ok: false,
reason: 'compaction-covers-discarded',
});
});
test('planSessionFork preserves provider replay metadata but strips turn state', () => {
const continuation = { reasoningParts: [{ text: 'chain of thought' }] };
const user = msg({ role: 'user', content: 'hi', providerContinuation: continuation, statusText: 'streaming…' });
const assistant = msg({ content: 'answer', providerContinuation: continuation });
const source = session([user, assistant]);
const plan = planSessionFork(source, assistant.id);
assert.equal(plan.ok, true);
if (plan.ok) {
for (const message of plan.messages) {
assert.equal(message.providerContinuation, continuation);
assert.equal(message.statusText, undefined);
}
}
// untouched source keeps its original message objects and turn state
assert.equal(source.messages[0].providerContinuation, continuation);
assert.equal(source.messages[0].statusText, 'streaming…');
});
test('planSessionFork pairs repeated tool-call ids by occurrence', () => {
const source = session([
msg({ role: 'user', content: 'hi' }),
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
msg({ role: 'tool', content: 'first', toolResults: [{ toolCallId: 'call-1', content: 'out 1' }] }),
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
msg({ role: 'assistant', content: 'answer' }),
]);
// The provider reused call-1; the second occurrence never got a result.
assert.deepEqual(planSessionFork(source, source.messages[4].id), {
ok: false,
reason: 'dangling-tool-call',
});
const resolved = session([
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
msg({ role: 'tool', content: 'first', toolResults: [{ toolCallId: 'call-1', content: 'out 1' }] }),
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
msg({ role: 'tool', content: 'second', toolResults: [{ toolCallId: 'call-1', content: 'out 2' }] }),
msg({ role: 'assistant', content: 'answer' }),
]);
assert.equal(planSessionFork(resolved, resolved.messages[4].id).ok, true);
// One result cannot satisfy two pending calls with the same id.
const underResolved = session([
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }, { id: 'call-1' }] }),
msg({ role: 'tool', content: 'single', toolResults: [{ toolCallId: 'call-1', content: 'out' }] }),
msg({ role: 'assistant', content: 'answer' }),
]);
assert.deepEqual(planSessionFork(underResolved, underResolved.messages[2].id), {
ok: false,
reason: 'dangling-tool-call',
});
});
test('stripMessageContinuationState preserves values and clears transient state', () => {
const plain = msg({ content: 'x' });
assert.deepEqual(stripMessageContinuationState(plain), plain);
const withStatusText = msg({ content: 'x', statusText: 'streaming…' });
assert.notEqual(stripMessageContinuationState(withStatusText), withStatusText);
const withPendingApproval = msg({ content: 'x', pendingApproval: {} });
assert.notEqual(stripMessageContinuationState(withPendingApproval), withPendingApproval);
const continuationOnly = msg({ content: 'x', providerContinuation: {} });
assert.deepEqual(stripMessageContinuationState(continuationOnly), continuationOnly);
});
test('buildForkTitle appends a fork marker without double-suffixing', () => {
assert.equal(buildForkTitle('Debug deploy'), 'Debug deploy (fork)');
assert.equal(buildForkTitle('Debug deploy (fork)'), 'Debug deploy (fork)');
assert.equal(buildForkTitle(''), 'New Chat (fork)');
});
test('canForkFromMessage mirrors planSessionFork success', () => {
const source = session([msg({ role: 'user', content: 'hi' }), msg({ content: 'done' })]);
assert.equal(canForkFromMessage(source, source.messages[1].id), true);
assert.equal(canForkFromMessage(source, source.messages[0].id), false);
assert.equal(canForkFromMessage(source, 'nope'), false);
});
test('fork keeps output previews but removes source-only saved-output references', () => {
const assistant = msg({ content: 'preview handleId=tool-output-abc', toolResults: [{ toolCallId: 'call', content: '{"handleId":"tool-output-xyz","preview":"retained"}' }] });
const source = session([msg({ toolCalls: [{ id: 'call' }] }), assistant], { summary: 'archive handleId=tool-output-summary', compactedMessageCount: 1 });
const plan = planSessionFork(source, assistant.id);
assert.ok(plan.ok);
assert.doesNotMatch(JSON.stringify(plan), /tool-output-/);
assert.match(plan.messages[1].content, /preview/);
assert.match(plan.messages[1].toolResults![0].content!, /retained/);
assert.equal(JSON.parse(plan.messages[1].toolResults![0].content!).handleId, null);
assert.match(source.messages[1].content, /tool-output-abc/);
});
test('fork removes source output handles only from tool_output_read arguments', () => {
const read = msg({ toolCalls: [{ id: 'read', name: 'tool_output_read', arguments: { handleId: 'tool-output-source', offset: 12 } }] });
const done = msg({ toolResults: [{ toolCallId: 'read', content: 'preview' }], content: 'done' });
const source = session([read, done]);
const plan = planSessionFork(source, done.id);
assert.ok(plan.ok);
assert.equal(plan.messages[0].toolCalls![0].arguments!.handleId, undefined);
assert.equal(plan.messages[0].toolCalls![0].arguments!.offset, 12);
assert.equal(read.toolCalls![0].arguments!.handleId, 'tool-output-source');
const other = msg({ toolCalls: [{ id: 'other', name: 'custom_tool', arguments: { handleId: 'tool-output-not-owned-here' } }] });
assert.deepEqual(stripMessageContinuationState(other).toolCalls, other.toolCalls);
});
test('fork refuses source output references in provider reasoning without changing signed metadata', () => {
const continuation = { reasoningParts: [{ text: 'Read handleId=tool-output-source for details', providerOptions: { anthropic: { signature: 'valid-signature' } } }] };
const source = session([msg({ content: 'done', providerContinuation: continuation })]);
assert.deepEqual(planSessionFork(source, source.messages[0].id), { ok: false, reason: 'source-output-reasoning' });
assert.equal(canForkFromMessage(source, source.messages[0].id), false);
assert.equal(source.messages[0].providerContinuation, continuation);
assert.equal(continuation.reasoningParts[0].text, 'Read handleId=tool-output-source for details');
});
test('fork eligibility does not clone or scan retained message bodies', () => {
const assistant = msg({ content: 'completed' });
Object.defineProperty(assistant, 'content', { get() { throw new Error('eligibility should not build a transcript'); } });
assert.equal(canForkFromMessage(session([assistant]), assistant.id), true);
});
test('one-pass fork eligibility matches clicked-boundary validation', () => {
const messages = [
msg({ role: 'user', content: 'start' }),
msg({ content: 'first completed reply' }),
msg({ toolCalls: [{ id: 'reused' }, { id: 'reused' }] }),
msg({ role: 'tool', toolResults: [{ toolCallId: 'reused', content: 'one' }] }),
msg({ content: 'still unresolved' }),
msg({ role: 'tool', toolResults: [{ toolCallId: 'reused', content: 'two' }] }),
msg({ content: 'complete exchange' }),
msg({ executionStatus: 'failed' }),
msg({ content: 'unsafe reasoning', providerContinuation: { reasoningParts: [{ text: 'Read tool-output-source' }] } }),
msg({ content: 'after unsafe reasoning' }),
];
for (const compactedMessageCount of [0, 3, messages.length]) {
const source = session(messages, { summary: 'summary', compactedMessageCount });
assert.deepEqual([...getForkableMessageIds(source)], messages.filter(message => planSessionFork(source, message.id).ok).map(message => message.id));
}
const orphan = session([msg({ role: 'tool', toolResults: [{ toolCallId: 'orphan' }] }), msg({ content: 'reply' })]);
assert.equal(getForkableMessageIds(orphan).size, 0);
});
test('fork removes bare output identifiers in transcript previews while preserving JSON', () => {
const assistant = msg({ content: 'Read tool-output-source with tool_output_read', thinking: 'Output tool-output-source is saved', toolResults: [{ toolCallId: 'call', content: '{"handleId":"tool-output-source","preview":"Read tool-output-source"}' }] });
const plan = planSessionFork(session([msg({ toolCalls: [{ id: 'call' }] }), assistant]), assistant.id);
assert.ok(plan.ok);
assert.doesNotMatch(JSON.stringify(plan.messages), /tool-output-source/);
const parsed = JSON.parse(plan.messages[1].toolResults![0].content!);
assert.equal(parsed.handleId, null);
assert.match(parsed.preview, /Read/);
assert.match(assistant.content, /tool-output-source/);
});
test('fork sanitizes attachment previews without changing attachment data or identity', () => {
const attachment = { base64Data: 'SEVMTE8=', mediaType: 'text/plain', filename: 'terminal-selection.log', terminalSelection: true, lineCount: 3, filePath: '/original/selection.log', previewText: 'Read tool-output-selection with tool_output_read' };
const image = { base64Data: 'UE5H', mediaType: 'image/png', filename: 'original.png' };
const user = msg({ role: 'user', content: 'Inspect', attachments: [attachment, image], images: [attachment] });
const assistant = msg({ content: 'done' });
const plan = planSessionFork(session([user, assistant]), assistant.id);
assert.ok(plan.ok);
assert.doesNotMatch(plan.messages[0].attachments![0].previewText!, /tool-output-selection/);
assert.deepEqual({ ...plan.messages[0].attachments![0], previewText: attachment.previewText }, attachment);
assert.equal(plan.messages[0].attachments![1], image);
assert.doesNotMatch(plan.messages[0].images![0].previewText!, /tool-output-selection/);
assert.equal(user.attachments![0], attachment);
assert.equal(attachment.previewText, 'Read tool-output-selection with tool_output_read');
});
test('fork still rejects an unresolved retained call even when compaction covers it', () => {
const unresolved = msg({ toolCalls: [{ id: 'pending' }], executionStatus: 'cancelled' });
const done = msg({ content: 'later completed answer' });
const source = session([unresolved, done], { summary: 'the earlier call was interrupted', compactedMessageCount: 1 });
assert.deepEqual(planSessionFork(source, done.id), { ok: false, reason: 'dangling-tool-call' });
assert.equal(getForkableMessageIds(source).has(done.id), false);
});
+276
View File
@@ -0,0 +1,276 @@
import type { ChatMessageAttachment } from '../infrastructure/ai/types';
import type { ProviderContinuation } from '../infrastructure/ai/providerContinuation';
/**
* Pure planning logic for "Fork from here": copy an AI chat session up to a
* completed assistant response into a new session, leaving the original
* conversation untouched in history.
*
* Refusals (per the feature contract):
* - only completed assistant-response turn boundaries fork — streaming, failed,
* cancelled, or in-progress turns are rejected,
* - every tool call in the retained prefix must have a paired result and
* vice versa (no dangling calls or results on the branch),
* - a stored compaction summary that would cover messages discarded by the
* branch is refused (the summary must stay accurate for the fork),
* - incomplete-turn state (status text, pending approvals) is stripped while
* the per-message provider replay metadata is preserved, so the fresh
* provider thread replays the retained history.
*/
export type ForkRefusalReason =
| 'message-not-found'
| 'not-assistant'
| 'not-completed'
| 'dangling-tool-call'
| 'dangling-tool-result'
| 'compaction-covers-discarded'
| 'source-output-reasoning';
/** A message type that carries the fork-relevant subset (e.g. ChatMessage). */
export type ForkMessageLike = {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
thinking?: string;
providerContinuation?: ProviderContinuation;
attachments?: ReadonlyArray<ChatMessageAttachment>;
images?: ReadonlyArray<ChatMessageAttachment>;
toolCalls?: ReadonlyArray<{ id: string; name?: string; arguments?: Record<string, unknown> }>;
toolResults?: ReadonlyArray<{ toolCallId: string; content?: string; isError?: boolean }>;
errorInfo?: unknown;
statusText?: string;
pendingApproval?: unknown;
executionStatus?: string;
};
/** Structural subset of ChatMessage the fork planner needs. */
export type ForkMessage = ForkMessageLike & { timestamp?: number };
export type ForkPlan<M extends ForkMessageLike = ForkMessage> =
| {
ok: true;
boundaryIndex: number;
title: string;
messages: M[];
contextCompaction?: ForkContextCompaction;
}
| { ok: false; reason: ForkRefusalReason };
export type ForkContextCompaction = {
summary: string;
compactedMessageCount: number;
};
const INCOMPLETE_EXECUTION_STATUSES = new Set([
'pending',
'running',
'failed',
'cancelled',
]);
function isCompletedResponse(message: ForkMessageLike): boolean {
return message.role === 'assistant'
&& message.errorInfo == null
&& message.statusText == null
&& message.pendingApproval == null
&& !INCOMPLETE_EXECUTION_STATUSES.has(message.executionStatus ?? '');
}
function hasSourceOutputReasoning(message: ForkMessageLike): boolean {
return (message.providerContinuation?.reasoningParts ?? []).some(
part => /\btool-output-[A-Za-z0-9-]+/.test(part.text),
);
}
/** Eligibility only: never copy/sanitize transcript content while rendering. */
export function getForkableMessageIds(source: {
messages: readonly ForkMessageLike[];
contextCompaction?: ForkContextCompaction;
}): Set<string> {
const ids = new Set<string>();
const pending = new Map<string, number>();
let pendingCount = 0;
let orphanResult = false;
let sourceOutputReasoning = false;
for (const [index, message] of source.messages.entries()) {
sourceOutputReasoning ||= hasSourceOutputReasoning(message);
for (const call of message.toolCalls ?? []) {
if (!call.id) continue;
pending.set(call.id, (pending.get(call.id) ?? 0) + 1);
pendingCount += 1;
}
for (const result of message.toolResults ?? []) {
if (!result.toolCallId) continue;
const count = pending.get(result.toolCallId) ?? 0;
if (count === 0) orphanResult = true;
else {
pending.set(result.toolCallId, count - 1);
pendingCount -= 1;
}
}
if (isCompletedResponse(message) && pendingCount === 0
&& !orphanResult && !sourceOutputReasoning
&& (source.contextCompaction?.compactedMessageCount ?? 0) <= index + 1) {
ids.add(message.id);
}
}
return ids;
}
/**
* Validate that every tool call in the retained prefix is paired with a
* result and vice versa. Providers may reuse a tool-call id across turns, so
* each result is consumed by the nearest preceding unresolved call with the
* same id (same pairing rule as buildHistoricalToolReplayMaps): a result with
* no pending call before it is dangling, and so is a call still pending after
* the walk.
*/
function validateToolCallResultPairing(
messages: readonly ForkMessageLike[],
): 'dangling-tool-call' | 'dangling-tool-result' | null {
const pendingCallCounts = new Map<string, number>();
for (const message of messages) {
for (const toolCall of message.toolCalls ?? []) {
if (!toolCall?.id) continue;
pendingCallCounts.set(toolCall.id, (pendingCallCounts.get(toolCall.id) ?? 0) + 1);
}
for (const toolResult of message.toolResults ?? []) {
const id = toolResult?.toolCallId;
if (!id) continue;
const pendingCount = pendingCallCounts.get(id) ?? 0;
if (pendingCount === 0) return 'dangling-tool-result';
pendingCallCounts.set(id, pendingCount - 1);
}
}
for (const pendingCount of pendingCallCounts.values()) {
if (pendingCount > 0) return 'dangling-tool-call';
}
return null;
}
/**
* Strip incomplete-turn state so the branch starts clean; per-message provider
* continuation metadata (Responses reasoning items, OpenAI Chat assistant
* fields, tool-call wire options) is preserved because the fork's fresh
* provider thread replays the retained history through it and safely discards
* exchanges whose metadata no longer matches the active provider.
*/
/** Keep the preview but remove unusable source-session output references. */
function stripSourceOutputHandles(value: string): string {
return value
.replace(/\bhandleId=tool-output-[A-Za-z0-9-]+/g, '[saved output remains in the original chat]')
.replace(/"handleId"\s*:\s*"tool-output-[A-Za-z0-9-]+"/g, '"handleId":null,"note":"Saved output remains in the original chat"')
.replace(/\btool-output-[A-Za-z0-9-]+/g, '[saved output remains in the original chat]');
}
function stripAttachmentPreview<A extends { previewText?: string }>(attachment: A): A {
return attachment.previewText
? { ...attachment, previewText: stripSourceOutputHandles(attachment.previewText) }
: attachment;
}
export function stripMessageContinuationState<M extends ForkMessageLike>(message: M): M {
const next = {
...message,
content: stripSourceOutputHandles(message.content),
...(message.attachments ? { attachments: message.attachments.map(stripAttachmentPreview) } : {}),
...(message.images ? { images: message.images.map(stripAttachmentPreview) } : {}),
...(message.thinking ? { thinking: stripSourceOutputHandles(message.thinking) } : {}),
...(message.toolCalls ? {
toolCalls: message.toolCalls.map(call => {
if (call.name !== 'tool_output_read'
|| typeof call.arguments?.handleId !== 'string'
|| !call.arguments.handleId.startsWith('tool-output-')) return call;
const args = { ...call.arguments };
delete args.handleId;
return { ...call, arguments: args };
}),
} : {}),
...(message.toolResults ? {
toolResults: message.toolResults.map(result => ({
...result,
...(result.content ? { content: stripSourceOutputHandles(result.content) } : {}),
})),
} : {}),
};
delete next.statusText;
delete next.pendingApproval;
return next;
}
export function buildForkTitle(title: string): string {
const base = (title ?? '').trim() || 'New Chat';
return base.endsWith('(fork)') ? base : `${base} (fork)`;
}
/** Structural subset of AISession the fork planner needs. */
export type ForkSourceSession<M extends ForkMessageLike = ForkMessage> = {
title: string;
messages: readonly M[];
contextCompaction?: ForkContextCompaction;
};
/**
* Plan a fork at `messageId`. Returns a refusal reason when the boundary is
* not a safe completed turn, or the retained message list (trimmed of
* incomplete-turn state, keeping provider replay metadata) plus the
* carried-over compaction (when still accurate).
*/
export function planSessionFork<M extends ForkMessageLike>(
source: ForkSourceSession<M>,
messageId: string,
): ForkPlan<M> {
const boundaryIndex = source.messages.findIndex(
(message) => message.id === messageId,
);
if (boundaryIndex === -1) return { ok: false, reason: 'message-not-found' };
const boundary = source.messages[boundaryIndex];
if (boundary.role !== 'assistant') return { ok: false, reason: 'not-assistant' };
if (!isCompletedResponse(boundary)) return { ok: false, reason: 'not-completed' };
const retained = source.messages.slice(0, boundaryIndex + 1);
// Provider reasoning can be signed: replacing text or dropping only that
// block can invalidate its tool exchange. Refuse this cut without changing
// the source metadata rather than leaking an unusable source-only handle.
if (retained.some(hasSourceOutputReasoning)) {
return { ok: false, reason: 'source-output-reasoning' };
}
const pairingRefusal = validateToolCallResultPairing(retained);
if (pairingRefusal) return { ok: false, reason: pairingRefusal };
// A stored compaction summary covers the earliest `compactedMessageCount`
// persisted messages. The summary must never cover messages discarded by
// the branch, so refuse when its coverage would exceed the retained prefix.
const compaction = source.contextCompaction;
let nextCompaction: ForkContextCompaction | undefined;
if (compaction) {
if (compaction.compactedMessageCount > retained.length) {
return { ok: false, reason: 'compaction-covers-discarded' };
}
nextCompaction = {
summary: stripSourceOutputHandles(compaction.summary),
compactedMessageCount: compaction.compactedMessageCount,
};
}
return {
ok: true,
boundaryIndex,
title: buildForkTitle(source.title),
messages: retained.map(stripMessageContinuationState),
...(nextCompaction ? { contextCompaction: nextCompaction } : {}),
} as ForkPlan<M>;
}
export function canForkFromMessage(
source: {
messages: readonly ForkMessageLike[];
contextCompaction?: ForkContextCompaction;
},
messageId: string,
): boolean {
return getForkableMessageIds(source).has(messageId);
}
+45
View File
@@ -149,3 +149,48 @@ test("normalize keeps custom (non-built-in) rules verbatim", () => {
assert.deepEqual(rule.patterns, customRule.patterns);
assert.equal(rule.label, customRule.label);
});
test("Unix path highlighting covers terminal paths without matching relative prose or URL slashes", () => {
const rule = getRule(DEFAULT_KEYWORD_HIGHLIGHT_RULES, "unix-path");
for (const path of ["/sbin/iptables", "/var/log/syslog", "/usr/local/bin/g++", "~/projects/netcatty"]) {
const matches = [...`'${path}'`.matchAll(new RegExp(rule.patterns[0], "gi"))];
assert.deepEqual(matches.map((match) => match[0]), [path]);
}
for (const text of ["RELATED,ESTABLISHED", "TCP/IP", "https://example.com/log", " / ", "./foo", "../foo", "../../foo"]) {
assert.equal(matchesAny(rule.patterns, text), false, text);
}
});
test("Unix path rule upgrades old settings while preserving disabled highlights and edits", () => {
const legacy = DEFAULT_KEYWORD_HIGHLIGHT_RULES.filter((rule) => rule.id !== "unix-path");
const upgraded = normalizeTerminalSettings({ keywordHighlightEnabled: false, keywordHighlightRules: legacy });
assert.equal(upgraded.keywordHighlightEnabled, false);
assert.equal(getRule(upgraded.keywordHighlightRules, "unix-path").enabled, true);
const edited = { ...getRule(upgraded.keywordHighlightRules, "unix-path"), color: "#123456", enabled: false, customized: true, patterns: ["/chosen/path"] };
const restored = normalizeTerminalSettings({ keywordHighlightRules: [...legacy, edited] });
assert.deepEqual(getRule(restored.keywordHighlightRules, "unix-path"), edited);
});
test("Unix path highlighting keeps Unicode paths and quoted spaces together", () => {
const rule = getRule(DEFAULT_KEYWORD_HIGHLIGHT_RULES, "unix-path");
for (const [text, path] of [
["/home/用户/file", "/home/用户/file"],
['cat "/Users/me/My Projects/app"', "/Users/me/My Projects/app"],
["cat '/home/用户/My Projects/app'", "/home/用户/My Projects/app"],
]) {
const matches = [...text.matchAll(new RegExp(rule.patterns[0], "gi"))];
assert.deepEqual(matches.map((match) => match[0]), [path]);
}
});
test("Unix path highlighting keeps punctuation up to the text boundary", () => {
const rule = getRule(DEFAULT_KEYWORD_HIGHLIGHT_RULES, "unix-path");
for (const path of [
"/run/user/1000/gvfs/smb-share:server=nas,share=files/report.txt",
"/tmp/build=release/output",
]) {
const matches = [...path.matchAll(new RegExp(rule.patterns[0], "gi"))];
assert.deepEqual(matches.map((match) => match[0]), [path]);
}
});
+1
View File
@@ -288,6 +288,7 @@ export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
{ id: 'info', label: 'Info', patterns: ['\\[info\\]', '\\[notice\\]', '\\[note\\]', '\\bnotice\\b', '\\bnote\\b'], color: '#3B82F6', enabled: true },
{ id: 'debug', label: 'Debug', patterns: ['\\[debug\\]', '\\[trace\\]', '\\[verbose\\]', '\\bdebug\\b', '\\btrace\\b', '\\bverbose\\b'], color: '#A78BFA', enabled: true },
{ id: 'ip-mac', label: 'URL, IP & MAC', patterns: [URL_HIGHLIGHT_PATTERN, IPV4_HIGHLIGHT_PATTERN, IPV6_HIGHLIGHT_PATTERN, MAC_ADDRESS_HIGHLIGHT_PATTERN], color: '#EC4899', enabled: true },
{ id: 'unix-path', label: 'Unix paths', patterns: ["(?<=\")(?:/|~/)[^\"\\r\\n]+(?=\")|(?<=')(?:/|~/)[^'\\r\\n]+(?=')|(?<![\\w.:/\"'\\u0080-\\uFFFF])(?:/|~/)[^\\s\"']+"], color: '#A78BFA', enabled: true },
];
const cloneKeywordHighlightRule = (rule: KeywordHighlightRule): KeywordHighlightRule => ({
+1 -1
View File
@@ -13,7 +13,7 @@ const path = require("node:path");
// ── ANSI / URL regexes ──
const ANSI_ESCAPE_REGEX = /\u001B\[[0-?]*[ -/]*[@-~]/g;
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*?(?:\u0007|\u001B\\)/g;
const URL_CANDIDATE_REGEX = /https?:\/\/[^\s]+/g;
const WINDOWS_RUNNABLE_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
const MAX_PROMPT_TRACK_TAIL = 4096;
+14
View File
@@ -2,6 +2,7 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const {
stripAnsi,
addCodexExecutableEnvForSdk,
buildWindowsShellCommandLine,
extractTrailingIdlePrompt,
@@ -28,6 +29,19 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
test("OSC stripping preserves text between independently terminated sequences", () => {
for (const firstEnd of ["\x07", "\x1b\\"]) {
for (const secondEnd of ["\x07", "\x1b\\"]) {
const text = `\x1b]3008;start${firstEnd}\nprobe-reply\n\x1b]3008;end${secondEnd}prompt`;
assert.equal(stripAnsi(text), "\nprobe-reply\nprompt");
}
}
assert.equal(
stripAnsi("before\x1b[31mcolored\x1b[0m\x1b]0;title\x07after"),
"beforecoloredafter",
);
});
test("formatSyntheticEcho normalizes multi-line commands to CRLF so xterm doesn't staircase", () => {
assert.equal(
formatSyntheticEcho("set -e\ncd /tmp\necho done"),
@@ -560,7 +560,6 @@ async function runOpenCodeTurn({
let opencode = null;
let sessionId = resumeSessionId || null;
let hasContent = false;
let backgroundWarningCount = 0;
let failed = false;
let abortSent = false;
let removeAbortListener = null;
@@ -620,13 +619,6 @@ async function runOpenCodeTurn({
if (abortController?.signal?.aborted) break;
const eventSessionId = getOpenCodeSessionIdFromEvent(event);
if (eventSessionId && eventSessionId !== sessionId) continue;
if (!eventSessionId && getOpenCodeEventPayload(event)?.type === "session.error") {
const message = extractOpenCodeErrorMessage(getOpenCodeEventPayload(event).properties?.error)
|| "OpenCode reported a background error";
if (emitter.warning) emitter.warning(`opencode-background-error:${++backgroundWarningCount}`, message);
else emitter.status(message);
continue;
}
const result = translateOpenCodeEvent(event, emitter, state);
if (result.content) hasContent = true;
if (result.error) {
@@ -500,52 +500,6 @@ test("runOpenCodeTurn continues after a tool error part and reaches session.idle
assert.equal(events.some((event) => event.k === "error"), false);
});
for (const scoped of [false, true]) {
test(`runOpenCodeTurn ${scoped ? "fails on the current session error" : "warns about plugin errors without stopping the session"} (#3173)`, async () => {
const { events, emitter } = collector();
emitter.warning = (id, message) => events.push({ k: "warning", id, message });
const message = scoped ? "bad key" : "Failed to install plugin: invalid package name";
const stream = {
async *[Symbol.asyncIterator]() {
yield {
type: "session.error",
properties: { ...(scoped ? { sessionID: "sess-1" } : {}), error: { data: { message } } },
};
if (!scoped) {
yield {
type: "session.error",
properties: { error: { data: { message: "Failed to install another plugin" } } },
};
}
yield {
type: "message.part.updated",
properties: { part: { type: "text", sessionID: "sess-1", id: "p1", text: "reply" }, delta: "reply" },
};
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
},
};
await runOpenCodeTurn({
prompt: "continue",
resumeSessionId: "sess-1",
emitter,
openCodeFactory: async () => ({
client: {
global: { event: async () => ({ stream }) },
session: { promptAsync: async () => ({ data: true }) },
},
server: { close() {} },
}),
});
assert.equal(events.some((event) => event.k === "error" && event.m === message), scoped);
assert.equal(events.some((event) => event.k === "warning" && event.message === message), !scoped);
assert.equal(events.some((event) => event.k === "text" && event.t === "reply"), !scoped);
assert.equal(events.some((event) => event.k === "done"), !scoped);
const warnings = events.filter((event) => event.k === "warning");
assert.equal(warnings.length, scoped ? 0 : 2);
assert.equal(new Set(warnings.map((event) => event.id)).size, warnings.length);
});
}
test("mapOpenCodeModels flattens providers", () => {
assert.deepEqual(mapOpenCodeModels({
providers: [