Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e77df6f063 | ||
|
|
158f204baa | ||
|
|
1c34384651 | ||
|
|
3e7d871a33 | ||
|
|
758966a79c | ||
|
|
f0458dd7cc | ||
|
|
52797e9edd | ||
|
|
cc001c9a4b | ||
|
|
b81b27f817 | ||
|
|
3a2bf6498d | ||
|
|
53a4bf29b0 | ||
|
|
0ed481c397 | ||
|
|
e85c1fea30 |
No files matched your search
@@ -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',
|
||||
|
||||
@@ -1149,6 +1149,9 @@ Highlight the focused split pane:
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.connectSelected': 'Connect ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
|
||||
'vault.hosts.moveSelected': 'Move to group ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
|
||||
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1107,6 +1107,9 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.deleteMultiple.success': 'Se eliminaron {count} hosts',
|
||||
'vault.hosts.connectSelected': 'Conectar ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Conectando {count} hosts',
|
||||
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
|
||||
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
|
||||
'vault.hosts.empty.title': 'Configura tus hosts',
|
||||
|
||||
@@ -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': 'Хосты',
|
||||
|
||||
@@ -128,6 +128,9 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': 'Удалено хостов: {count}',
|
||||
'vault.hosts.connectSelected': 'Подключить ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Подключение хостов: {count}',
|
||||
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
|
||||
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
|
||||
'vault.hosts.empty.title': 'Настройте свои хосты',
|
||||
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
|
||||
@@ -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': '主机',
|
||||
|
||||
@@ -766,6 +766,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.connectSelected': '连接 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
|
||||
'vault.hosts.moveSelected': '移动到分组 ({count})',
|
||||
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
|
||||
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
|
||||
@@ -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': '主機',
|
||||
|
||||
@@ -768,6 +768,9 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': '已刪除 {count} 個主機',
|
||||
'vault.hosts.connectSelected': '連線 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在連線 {count} 個主機',
|
||||
'vault.hosts.moveSelected': '移動到群組 ({count})',
|
||||
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
|
||||
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
|
||||
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from "node:test";
|
||||
import type { Host } from "../../domain/models";
|
||||
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
|
||||
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
|
||||
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
|
||||
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
|
||||
import {
|
||||
createHostTerminalSession,
|
||||
createSerialTerminalSession,
|
||||
@@ -171,3 +171,16 @@ test("host session factories snapshot plugin connection configuration", () => {
|
||||
assert.deepEqual(workspace.pluginConnection, pluginConnection);
|
||||
assert.notEqual(workspace.pluginConnection, pluginConnection);
|
||||
});
|
||||
|
||||
test("local sessions preserve intentionally absent display metadata when the saved shell changes", () => {
|
||||
const original = host({ protocol: "local", hostname: "localhost", localShell: "/bin/bash" });
|
||||
const source = createHostTerminalSession("original-bash", original);
|
||||
const edited = { ...original, localShell: "/bin/zsh", localShellName: "Zsh", localShellIcon: "zsh" };
|
||||
const copied = resolveTerminalSessionHost({ session: source, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: "macos" });
|
||||
assert.equal(copied.localShell, "/bin/bash");
|
||||
assert.equal(copied.localShellName, "");
|
||||
assert.equal(copied.localShellIcon, "");
|
||||
const named = createHostTerminalSession("named-zsh", edited);
|
||||
assert.equal(named.localShellName, "Zsh");
|
||||
assert.equal(named.localShellIcon, "zsh");
|
||||
});
|
||||
@@ -153,8 +153,8 @@ export const createHostTerminalSession = (
|
||||
// Preserve field presence: an edited host must not contribute its args
|
||||
// to a session that was launched with an explicit shell and no args.
|
||||
localShellArgs: host.localShellArgs ?? EMPTY_LOCAL_SHELL_ARGS,
|
||||
localShellName: host.localShellName,
|
||||
localShellIcon: host.localShellIcon,
|
||||
localShellName: host.localShellName ?? "",
|
||||
localShellIcon: host.localShellIcon ?? "",
|
||||
// Effective directory from resolveHostDefaultLocalShell, including a
|
||||
// Settings directory the saved host inherits. Copy Tab overlays this
|
||||
// snapshot, so a later settings or vault edit cannot move the relaunch.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 = {
|
||||
@@ -705,7 +757,10 @@ export function useAIState() {
|
||||
// Non-destructive "undo last turn": branch the session at the boundary
|
||||
// before the latest user message and keep the original in history intact.
|
||||
// Returns the new branch id plus the undone user message for the composer.
|
||||
const undoLastTurnInSession = useCallback(async (sessionId: string): Promise<{
|
||||
const undoLastTurnInSession = useCallback(async (
|
||||
sessionId: string,
|
||||
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
|
||||
): Promise<{
|
||||
sessionId: string;
|
||||
restored: UndoLastTurnRestoredDraft;
|
||||
} | null> => {
|
||||
@@ -716,6 +771,11 @@ export function useAIState() {
|
||||
if (!result) return null;
|
||||
|
||||
const branched = result.session;
|
||||
// Like Fork, Undo belongs to the panel viewing the source, which may have
|
||||
// resumed a chat from a closed terminal or inherited it after a merge.
|
||||
if (viewingScope) {
|
||||
branched.scope = { ...branched.scope, ...viewingScope };
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
// Keep both recently used chats within the existing history limit.
|
||||
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
|
||||
@@ -1319,6 +1379,7 @@ export function useAIState() {
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
forkSessionFromMessage,
|
||||
}), [
|
||||
providers,
|
||||
setProviders,
|
||||
@@ -1380,5 +1441,6 @@ export function useAIState() {
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
forkSessionFromMessage,
|
||||
]);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { AISession, UploadedFile } from '../../infrastructure/ai/types.ts';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
|
||||
import { useAIState } from './useAIState.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
|
||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
|
||||
const SCOPE_KEY = 'terminal:terminal-a';
|
||||
@@ -192,3 +193,39 @@ for (const newerCount of [49, 50]) {
|
||||
reloaded.container.remove(); reloaded.dom.window.close();
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewingScope of [
|
||||
{ type: 'terminal' as const, targetId: 'terminal-b' },
|
||||
{ type: 'workspace' as const, targetId: 'workspace-b' },
|
||||
]) {
|
||||
test(`undo stays visible when a ${viewingScope.type} views a chat from a closed terminal`, async () => {
|
||||
const source = {
|
||||
...structuredClone(SOURCE_SESSION),
|
||||
scope: { type: 'terminal' as const, targetId: 'terminal-closed', hostIds: ['host-a'] },
|
||||
};
|
||||
const { dom, container, root, capture } = await setupAiState([source]);
|
||||
const sourceBefore = structuredClone(source);
|
||||
let branchId: string | undefined;
|
||||
await act(async () => {
|
||||
capture.ai!.setActiveSessionId('terminal:terminal-b', source.id);
|
||||
branchId = (await capture.ai!.undoLastTurnInSession(source.id, viewingScope))?.sessionId;
|
||||
});
|
||||
assert.ok(branchId);
|
||||
const branch = capture.sessions.find(item => item.id === branchId)!;
|
||||
assert.deepEqual(branch.messages, source.messages.slice(0, 2));
|
||||
assert.deepEqual(branch.scope, { ...source.scope, ...viewingScope });
|
||||
assert.equal(capture.activeSessionIdMap[`${viewingScope.type}:${viewingScope.targetId}`], branch.id);
|
||||
// The real history predicate must still include the selected branch after
|
||||
// the source terminal no longer belongs to the current workspace.
|
||||
assert.equal(getSessionScopeMatchRank(
|
||||
branch, viewingScope.type, viewingScope.targetId, ['host-a'],
|
||||
new Set([branch.id]), new Set(['terminal-b']), new Set([source.id]),
|
||||
), 3);
|
||||
const original = capture.sessions.find(item => item.id === source.id)!;
|
||||
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
|
||||
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
|
||||
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
|
||||
await act(async () => root.unmount());
|
||||
container.remove(); dom.window.close();
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -278,6 +278,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
forkSessionFromMessage,
|
||||
providers,
|
||||
activeProviderId,
|
||||
activeModelId,
|
||||
@@ -851,7 +852,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
undoInFlightRef.current = true;
|
||||
setIsUndoingLastTurn(true);
|
||||
try {
|
||||
const result = await undoLastTurnInSession(sessionId);
|
||||
const result = await undoLastTurnInSession(sessionId, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
});
|
||||
if (!result) return;
|
||||
|
||||
ensureScopeDraft(currentAgentId);
|
||||
@@ -898,6 +902,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
undoLastTurnInSession,
|
||||
updateScopeDraft,
|
||||
currentAgentId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
]);
|
||||
|
||||
const providersRef = useRef(providers);
|
||||
@@ -1854,6 +1860,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 +1976,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
activeSessionId={activeSessionId}
|
||||
handleSelectSession={handleSelectSession}
|
||||
handleDeleteSession={handleDeleteSession}
|
||||
onForkFromMessage={handleForkFromMessage}
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
activeCompaction={
|
||||
@@ -2030,6 +2067,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'updateLastMessage',
|
||||
'updateMessageById',
|
||||
'persistContextCompaction',
|
||||
'forkSessionFromMessage',
|
||||
'providers',
|
||||
'activeProviderId',
|
||||
'activeModelId',
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface AIChatSidePanelProps {
|
||||
/** Non-destructively branch before the last user turn; returns the branch id + restored composer content. */
|
||||
undoLastTurnInSession?: (
|
||||
sessionId: string,
|
||||
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
|
||||
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
@@ -58,6 +59,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;
|
||||
|
||||
@@ -1358,6 +1358,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
handleUnmanageGroup,
|
||||
managedGroupPaths,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
setDragOverDropTarget,
|
||||
setGroupDragOverDropTarget,
|
||||
} = useVaultGroupDragHandlers({
|
||||
@@ -1602,6 +1603,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
managedSources,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
Network,
|
||||
newFolderName,
|
||||
newHostGroupPath,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -86,7 +86,7 @@ const SessionTabIcon: React.FC<{
|
||||
// Local protocol → shell-specific icon if available, else OS-specific icon
|
||||
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
|
||||
// Use shell icon from discovery when available
|
||||
const iconId = shellIcon || host?.localShellIcon;
|
||||
const iconId = shellIcon ?? host?.localShellIcon;
|
||||
if (iconId) {
|
||||
return (
|
||||
<img
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Combobox } from "../ui/combobox";
|
||||
import React, { useCallback } from "react";
|
||||
import { deleteVaultKey } from "../../application/defaultKeyPassphrases";
|
||||
import { VaultGroupDeletionConfirmationChangedError } from "../../application/state/useVaultGroupDeletion";
|
||||
@@ -243,6 +244,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
managedSources,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
Network,
|
||||
newFolderName,
|
||||
newHostGroupPath,
|
||||
@@ -445,6 +447,8 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
persistWidthStorageKey: STORAGE_KEY_VAULT_HOST_PANEL_WIDTH,
|
||||
resizeAriaLabel: t("vault.panel.resizeWidth"),
|
||||
};
|
||||
const [isMoveHostsOpen, setIsMoveHostsOpen] = React.useState(false);
|
||||
const [moveTargetGroup, setMoveTargetGroup] = React.useState("");
|
||||
const [isSidebarResizing, setIsSidebarResizing] = React.useState(false);
|
||||
const keyListRef = React.useRef(keys);
|
||||
keyListRef.current = keys;
|
||||
@@ -1130,6 +1134,19 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
count: selectedHostIds.size,
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={() => {
|
||||
setMoveTargetGroup("");
|
||||
setIsMoveHostsOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderTree size={12} className="mr-1" />
|
||||
{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@@ -1794,6 +1811,43 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isMoveHostsOpen} onOpenChange={setIsMoveHostsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}</DialogTitle>
|
||||
<DialogDescription>{t("vault.hosts.moveSelected.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: "", label: t("vault.hosts.allHosts") },
|
||||
...allGroupPaths
|
||||
.filter((path: string) => path !== "General"
|
||||
|| customGroups.includes("General")
|
||||
|| allGroupPaths.some((group: string) => group.startsWith("General/")))
|
||||
.map((path: string) => ({ value: path, label: path })),
|
||||
]}
|
||||
value={moveTargetGroup}
|
||||
onValueChange={setMoveTargetGroup}
|
||||
ariaLabel={t("hostDetails.group.placeholder")}
|
||||
placeholder={t("vault.hosts.allHosts")}
|
||||
clearable={false}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setIsMoveHostsOpen(false)}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={() => {
|
||||
moveHostsToGroup(selectedHostIds, moveTargetGroup || null);
|
||||
setIsMoveHostsOpen(false);
|
||||
clearHostSelection();
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ImportVaultDialog
|
||||
open={isImportOpen}
|
||||
onOpenChange={setIsImportOpen}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import type { Host, ManagedSource } from '../../types';
|
||||
import { useVaultGroupDragHandlers } from './useVaultGroupDragHandlers';
|
||||
|
||||
const host = (id: string, extra: Partial<Host> = {}): Host => ({
|
||||
id, label: `Host ${id}`, hostname: `${id}.example`, username: 'user', port: 22,
|
||||
tags: ['keep'], group: 'Old', ...extra,
|
||||
});
|
||||
|
||||
test('bulk group move publishes all selected hosts together and preserves unselected hosts', async () => {
|
||||
const original = [host('a'), host('b', { protocol: 'telnet' }), host('c')];
|
||||
const writes: Host[][] = [];
|
||||
await withActions(original, [], actions => {
|
||||
act(() => actions.moveHostsToGroup(new Set(['a', 'b']), 'New/Nested'));
|
||||
assert.equal(writes.length, 1);
|
||||
assert.deepEqual(writes[0].map(h => h.group), ['New/Nested', 'New/Nested', 'Old']);
|
||||
assert.equal(writes[0][2], original[2]);
|
||||
assert.deepEqual(writes[0][0].tags, ['keep']);
|
||||
assert.equal(writes[0][0].hostname, original[0].hostname);
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('mixed protocols entering and leaving managed groups retain single-host move rules', async () => {
|
||||
const original = [host('ssh'), host('telnet', { protocol: 'telnet' }), host('local', { protocol: 'local' })];
|
||||
const sources = [{ id: 'parent', groupName: 'Managed' }, { id: 'child', groupName: 'Managed/Nested' }] as ManagedSource[];
|
||||
const writes: Host[][] = [];
|
||||
await withActions(original, sources, actions => {
|
||||
act(() => actions.moveHostsToGroup(new Set(original.map(h => h.id)), 'Managed/Nested'));
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0][0].managedSourceId, 'child');
|
||||
assert.equal(writes[0][0].label, 'Hostssh');
|
||||
assert.equal(writes[0][1].managedSourceId, undefined);
|
||||
assert.equal(writes[0][1].label, 'Host telnet');
|
||||
assert.equal(writes[0][2].managedSourceId, undefined);
|
||||
assert.equal(writes[0][2].label, 'Host local');
|
||||
}, writes);
|
||||
await withActions(writes[0], sources, actions => {
|
||||
act(() => actions.moveHostsToGroup(new Set(original.map(h => h.id)), null));
|
||||
assert.deepEqual(writes[1].map(h => h.group), ['', '', '']);
|
||||
assert.ok(writes[1].every(h => h.managedSourceId === undefined));
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('same-group, missing and empty selections do not publish changes', async () => {
|
||||
const writes: Host[][] = [];
|
||||
await withActions([host('a')], [], actions => {
|
||||
act(() => actions.moveHostsToGroup(new Set(['a']), 'Old'));
|
||||
act(() => actions.moveHostsToGroup(new Set(['missing']), 'New'));
|
||||
act(() => actions.moveHostsToGroup(new Set(), 'New'));
|
||||
assert.equal(writes.length, 0);
|
||||
act(() => actions.moveHostToGroup('a', 'New'));
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0][0].group, 'New');
|
||||
}, writes);
|
||||
});
|
||||
|
||||
async function withActions(
|
||||
hosts: Host[], managedSources: ManagedSource[],
|
||||
run: (actions: ReturnType<typeof useVaultGroupDragHandlers>) => void,
|
||||
writes: Host[][],
|
||||
) {
|
||||
const previousWindow = globalThis.window;
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = { setTimeout: () => 1, clearTimeout: () => {} } as unknown as Window & typeof globalThis;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let actions: ReturnType<typeof useVaultGroupDragHandlers>;
|
||||
function Probe() {
|
||||
actions = useVaultGroupDragHandlers({ hosts, managedSources,
|
||||
onUpdateHosts: next => writes.push(next), onUpdateManagedSources: () => {}, t: key => key });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(<Probe />); });
|
||||
run(actions!);
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
globalThis.window = previousWindow;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
}
|
||||
}
|
||||
@@ -66,53 +66,40 @@ export function useVaultGroupDragHandlers({
|
||||
setDragOverDropTarget(path ? { kind: "group", path } : null);
|
||||
}, []);
|
||||
|
||||
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
|
||||
const moveHostsToGroup = useCallback((hostIds: ReadonlySet<string>, groupPath: string | null) => {
|
||||
const targetGroup = groupPath || "";
|
||||
const hostToMove = hosts.find((h) => h.id === hostId);
|
||||
if (!hostToMove || (hostToMove.group || "") === targetGroup) {
|
||||
setDragOverDropTarget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the most specific (deepest) managed source that matches the target group
|
||||
const targetManagedSource = managedSources
|
||||
.filter(s => targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/"))
|
||||
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
|
||||
|
||||
const movedHost = {
|
||||
...hostToMove,
|
||||
group: targetGroup,
|
||||
};
|
||||
const updatedHost = (() => {
|
||||
const h = movedHost;
|
||||
|
||||
// Only SSH hosts can be managed (SSH config only supports SSH)
|
||||
const canBeManaged = !h.protocol || h.protocol === "ssh";
|
||||
|
||||
// Sanitize label if moving to a managed group (SSH config requires no spaces in Host alias)
|
||||
let label = h.label;
|
||||
if (targetManagedSource && canBeManaged && label) {
|
||||
label = label.replace(/\s/g, '');
|
||||
}
|
||||
|
||||
return {
|
||||
...h,
|
||||
label,
|
||||
group: targetGroup,
|
||||
managedSourceId: (targetManagedSource && canBeManaged) ? targetManagedSource.id : undefined,
|
||||
};
|
||||
})();
|
||||
onUpdateHosts(hosts.map((host) => (host.id === hostId ? updatedHost : host)));
|
||||
let movedCount = 0;
|
||||
const updatedHosts = hosts.map(host => {
|
||||
if (!hostIds.has(host.id) || (host.group || "") === targetGroup) return host;
|
||||
movedCount += 1;
|
||||
const canBeManaged = !host.protocol || host.protocol === "ssh";
|
||||
return {
|
||||
...host,
|
||||
group: targetGroup,
|
||||
label: targetManagedSource && canBeManaged
|
||||
? host.label.replace(/\s/g, '') : host.label,
|
||||
managedSourceId: targetManagedSource && canBeManaged ? targetManagedSource.id : undefined,
|
||||
};
|
||||
});
|
||||
setDragOverDropTarget(null);
|
||||
if (movedCount === 0) return;
|
||||
// Publish the entire selection once; repeated single-host updates would
|
||||
// each start from the same render snapshot and overwrite earlier moves.
|
||||
onUpdateHosts(updatedHosts);
|
||||
pulseDropTarget(groupPath ? { kind: "group", path: groupPath } : { kind: "root" });
|
||||
toast.success(
|
||||
t("vault.hosts.moveToGroup.success", {
|
||||
host: hostToMove.label,
|
||||
group: groupPath || t("vault.hosts.allHosts"),
|
||||
}),
|
||||
);
|
||||
toast.success(t("vault.hosts.moveMultiple.success", {
|
||||
count: movedCount,
|
||||
group: groupPath || t("vault.hosts.allHosts"),
|
||||
}));
|
||||
}, [hosts, managedSources, onUpdateHosts, pulseDropTarget, t]);
|
||||
|
||||
|
||||
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
|
||||
moveHostsToGroup(new Set([hostId]), groupPath);
|
||||
}, [moveHostsToGroup]);
|
||||
|
||||
const getDropTargetClasses = (target: DropTarget) =>
|
||||
cn(
|
||||
isSameDropTarget(dragOverDropTarget, target) &&
|
||||
@@ -151,6 +138,7 @@ export function useVaultGroupDragHandlers({
|
||||
handleUnmanageGroup,
|
||||
managedGroupPaths,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
setDragOverDropTarget,
|
||||
setGroupDragOverDropTarget,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -875,3 +875,21 @@ test("restore retains an explicit backend-default local shell snapshot", () => {
|
||||
assert.deepEqual(restored.sessions[0].localShellArgs, []);
|
||||
assert.equal(restored.sessions[0].localStartDir, "~");
|
||||
});
|
||||
|
||||
test("restore retains absent local display snapshots while legacy fields remain absent", () => {
|
||||
const payload = buildSessionRestorePayload({
|
||||
sessions: [
|
||||
{ ...session("empty-display"), protocol: "local", localShellName: "", localShellIcon: "" },
|
||||
{ ...session("named-display"), protocol: "local", localShellName: "Bash", localShellIcon: "bash" },
|
||||
{ ...session("legacy-display"), protocol: "local" },
|
||||
],
|
||||
workspaces: [], tabOrder: [], activeTabId: "empty-display", now: 123,
|
||||
});
|
||||
const restored = sanitizeSessionRestorePayload(JSON.parse(JSON.stringify(payload)));
|
||||
assert.equal(restored.sessions[0].localShellName, "");
|
||||
assert.equal(restored.sessions[0].localShellIcon, "");
|
||||
assert.equal(restored.sessions[1].localShellName, "Bash");
|
||||
assert.equal(restored.sessions[1].localShellIcon, "bash");
|
||||
assert.equal(restored.sessions[2].localShellName, undefined);
|
||||
assert.equal(restored.sessions[2].localShellIcon, undefined);
|
||||
});
|
||||
@@ -154,8 +154,8 @@ const restoreSession = (session: TerminalSession): RestoredTerminalSession => {
|
||||
...(serialConfig ? { serialConfig } : {}),
|
||||
...(session.localShell !== undefined ? { localShell: session.localShell } : {}),
|
||||
...(session.localShellArgs ? { localShellArgs: [...session.localShellArgs] } : {}),
|
||||
...(session.localShellName ? { localShellName: session.localShellName } : {}),
|
||||
...(session.localShellIcon ? { localShellIcon: session.localShellIcon } : {}),
|
||||
...(session.localShellName !== undefined ? { localShellName: session.localShellName } : {}),
|
||||
...(session.localShellIcon !== undefined ? { localShellIcon: session.localShellIcon } : {}),
|
||||
...(session.localStartDir ? { localStartDir: session.localStartDir } : {}),
|
||||
...(session.fontSize !== undefined ? { fontSize: session.fontSize } : {}),
|
||||
...(session.fontSizeOverride !== undefined ? { fontSizeOverride: session.fontSizeOverride } : {}),
|
||||
@@ -197,8 +197,8 @@ const restoreSessionFromUnknown = (value: unknown): RestoredTerminalSession | nu
|
||||
...(serialConfig ? { serialConfig } : {}),
|
||||
...(readStringAllowEmpty(value, "localShell") !== undefined ? { localShell: readStringAllowEmpty(value, "localShell") } : {}),
|
||||
...(Array.isArray(value.localShellArgs) ? { localShellArgs: value.localShellArgs.filter((arg): arg is string => typeof arg === "string") } : {}),
|
||||
...(readString(value, "localShellName") ? { localShellName: readString(value, "localShellName") } : {}),
|
||||
...(readString(value, "localShellIcon") ? { localShellIcon: readString(value, "localShellIcon") } : {}),
|
||||
...(readStringAllowEmpty(value, "localShellName") !== undefined ? { localShellName: readStringAllowEmpty(value, "localShellName") } : {}),
|
||||
...(readStringAllowEmpty(value, "localShellIcon") !== undefined ? { localShellIcon: readStringAllowEmpty(value, "localShellIcon") } : {}),
|
||||
...(readString(value, "localStartDir") ? { localStartDir: readString(value, "localStartDir") } : {}),
|
||||
...(readNumber(value, "fontSize") !== undefined ? { fontSize: readNumber(value, "fontSize") } : {}),
|
||||
...(readBoolean(value, "fontSizeOverride") !== undefined ? { fontSizeOverride: readBoolean(value, "fontSizeOverride") } : {}),
|
||||
|
||||
@@ -967,6 +967,25 @@ test("posix wrapper keeps cd contained in the subshell (documented trade-off)",
|
||||
}
|
||||
});
|
||||
|
||||
// A cancelled paced delivery may execute only the first physical line.
|
||||
// Neither partial delivery nor a complete command may change user shell hooks.
|
||||
for (const [partial, hasPS0] of [[true, true], [false, true], [true, false], [false, false]]) {
|
||||
test(`posix ${partial ? "partial" : "complete"} wrapper preserves ${hasPS0 ? "set" : "unset"} PS0 and existing shell state`, () => {
|
||||
const marker = "__NCMCP_TEST__";
|
||||
const wrapped = buildWrappedCommand("echo HOOK_STATE_TEST", "posix", marker);
|
||||
const script = partial ? wrapped.slice(0, wrapped.indexOf("\n") + 1) : wrapped;
|
||||
const result = spawnSync("bash", ["--norc", "-c",
|
||||
(hasPS0 ? 'PS0="[hook] "; ' : 'unset PS0; ') + '__NCMCP_PS0=user_value\n' + script
|
||||
+ 'printf "HOOK_STATE=<%s> SCRATCH=<%s>\\n" "$PS0" "$__NCMCP_PS0"',
|
||||
], { encoding: "utf8" });
|
||||
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes(`HOOK_STATE=<${hasPS0 ? "[hook] " : ""}> SCRATCH=<user_value>`), result.stdout);
|
||||
if (!partial) assert.match(result.stdout, new RegExp(`${marker}_E:0`));
|
||||
});
|
||||
}
|
||||
|
||||
// Issue #3265: with plain HISTCONTROL the wrapper line is recorded in bash
|
||||
// history; arrow-up recall then redraws the marker text, the preload __NCMCP_
|
||||
// filter suppresses the redraw fragments containing it, and readline's row
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +560,7 @@ 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;
|
||||
@@ -619,6 +620,13 @@ 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,6 +500,52 @@ 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: [
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { generateText, streamText } from "ai";
|
||||
import { buildCattySdkMessages, createContinuationContext } from "./harness/turnDrivers/cattyMessageBuilder";
|
||||
import { ToolOutputStore } from "./harness/toolOutputStore";
|
||||
|
||||
import { createModelFromConfig, resolveProviderEndpoint } from "./sdk/providers";
|
||||
import type { ProviderConfig } from "./types";
|
||||
@@ -280,3 +283,43 @@ test("SDK requests forward custom headers and scope OpenCode sessions", async (t
|
||||
}
|
||||
assert.equal(requests.length, 16);
|
||||
});
|
||||
|
||||
test("OpenAI Chat sends inline image attachments as data URLs through the bridge", async (t) => {
|
||||
const browserGlobal = globalThis as typeof globalThis & { window?: unknown };
|
||||
const originalWindow = browserGlobal.window;
|
||||
t.after(() => { browserGlobal.window = originalWindow; });
|
||||
const requests: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||
Reflect.set(globalThis, "window", { netcatty: {
|
||||
aiFetch: async (url: string, _method: string, _headers: Record<string, string>, body: string) => {
|
||||
requests.push({ url, body: JSON.parse(body) });
|
||||
return { ok: false, status: 400, data: '{"error":{"message":"captured request"}}' };
|
||||
},
|
||||
aiChatStream: async (_requestId: string, url: string, _headers: Record<string, string>, body: string) => {
|
||||
requests.push({ url, body: JSON.parse(body) });
|
||||
return { ok: false, error: "captured request" };
|
||||
},
|
||||
onAiStreamData: () => () => {},
|
||||
onAiStreamEnd: () => () => {},
|
||||
onAiStreamError: () => () => {},
|
||||
} });
|
||||
|
||||
for (const [mediaType, base64Data] of [["image/png", "iVBORw0KGgo="], ["image/jpeg", "/9j/4A=="]]) {
|
||||
const messages = buildCattySdkMessages({
|
||||
allMessages: [], includeCurrentUserMessage: true, trimmed: "Read this image",
|
||||
attachments: [{ mediaType, base64Data }],
|
||||
continuationContext: createContinuationContext("p", "openai", "m", false),
|
||||
chatSessionId: "images", toolOutputStore: new ToolOutputStore(), fieldsByMessage: new Map(),
|
||||
});
|
||||
const model = createModelFromConfig(makeConfig({ openaiApi: "chat", apiKey: "test-key", baseURL: "https://example.test/v1" }));
|
||||
await assert.rejects(generateText({ model, messages, maxRetries: 0 }), /captured request/);
|
||||
await assert.rejects(async () => await streamText({ model, messages, maxRetries: 0, onError: () => {} }).text);
|
||||
for (const request of requests.slice(-2)) {
|
||||
assert.equal(request.url, "https://example.test/v1/chat/completions");
|
||||
assert.deepEqual(request.body.messages, [{ role: "user", content: [
|
||||
{ type: "text", text: "Read this image" },
|
||||
{ type: "image_url", image_url: { url: `data:${mediaType};base64,${base64Data}` } },
|
||||
] }]);
|
||||
}
|
||||
}
|
||||
assert.equal(requests.length, 4);
|
||||
});
|
||||
@@ -1077,6 +1077,132 @@ for (const rejects of [false, true]) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const rejects of [false, true]) {
|
||||
test(`final failure notification and ${rejects ? "rejection" : "reply"} cannot restart an exhausted cycle`, async (t) => {
|
||||
let statusListener: ((status: PortForwardingRule["status"], error?: string) => void) | undefined;
|
||||
let forceFinalAttempt = true;
|
||||
const reconnectRule = rule({ id: `final-double-failure-${rejects}`, autoStart: true });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => {
|
||||
if (forceFinalAttempt) getActiveConnection(reconnectRule.id)!.reconnectAttempts = 5;
|
||||
statusListener?.("error", "SSH connection failed");
|
||||
if (rejects) throw new Error("SSH connection failed");
|
||||
return { success: false, error: "SSH connection failed" };
|
||||
},
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
|
||||
statusListener = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
} },
|
||||
});
|
||||
let reconnects = 0;
|
||||
setReconnectCallback(async () => { reconnects += 1; return { success: true }; });
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(reconnects, 0);
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
|
||||
|
||||
// An explicit restart must still get a new retry cycle if it fails.
|
||||
forceFinalAttempt = false;
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
assert.equal(getActiveConnection(reconnectRule.id)?.reconnectAttempts, 1);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(reconnects, 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const adoption of ["sync", "reconcile"] as const) {
|
||||
test(`${adoption} starts a fresh retry cycle when adopting an active backend tunnel`, async (t) => {
|
||||
const reconnectRule = rule({ id: `exhausted-adoption-${adoption}`, autoStart: true });
|
||||
const listeners = new Map<string, (status: PortForwardingRule["status"]) => void>();
|
||||
const backendTunnel = { ruleId: reconnectRule.id, tunnelId: "fresh-backend-tunnel", status: "active" };
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => ({ success: true }),
|
||||
listPortForwards: async () => [backendTunnel],
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (id: string, listener: (status: PortForwardingRule["status"]) => void) => {
|
||||
listeners.set(id, listener);
|
||||
return () => undefined;
|
||||
},
|
||||
} },
|
||||
});
|
||||
let reconnects = 0;
|
||||
setReconnectCallback(async () => { reconnects += 1; return { success: true }; }, () => true);
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
const oldConnection = getActiveConnection(reconnectRule.id)!;
|
||||
oldConnection.reconnectAttempts = 5;
|
||||
const oldListener = listeners.get(oldConnection.tunnelId)!;
|
||||
oldListener("error");
|
||||
oldListener("inactive");
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
|
||||
|
||||
if (adoption === "sync") await syncWithBackend({ shouldReconnect: () => true });
|
||||
else await reconcileWithBackend();
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), false);
|
||||
const replacement = getActiveConnection(reconnectRule.id)!;
|
||||
assert.equal(replacement.tunnelId, backendTunnel.tunnelId);
|
||||
oldListener("error");
|
||||
assert.equal(getActiveConnection(reconnectRule.id), replacement);
|
||||
listeners.get(backendTunnel.tunnelId)!("inactive");
|
||||
assert.equal(getActiveConnection(reconnectRule.id)?.reconnectAttempts, 1);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(reconnects, 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const adoption of ["sync", "reconcile"] as const) {
|
||||
for (const rejects of [false, true]) {
|
||||
test(`${adoption} preserves exhaustion if backend adoption ${rejects ? "rejects" : "finds a stopped tunnel"}`, async (t) => {
|
||||
const reconnectRule = rule({ id: `failed-adoption-${adoption}-${rejects}`, autoStart: true });
|
||||
let statusListener: ((status: PortForwardingRule["status"]) => void) | undefined;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => ({ success: true }),
|
||||
listPortForwards: async () => [{ ruleId: reconnectRule.id, tunnelId: "stopped-before-subscribe", status: "active" }],
|
||||
subscribePortForward: async () => {
|
||||
if (rejects) throw new Error("backend unavailable during adoption");
|
||||
return { tunnelId: "stopped-before-subscribe", status: "inactive" };
|
||||
},
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
|
||||
statusListener = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
} },
|
||||
});
|
||||
setReconnectCallback(async () => ({ success: true }), () => true);
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
getActiveConnection(reconnectRule.id)!.reconnectAttempts = 5;
|
||||
statusListener!("error");
|
||||
statusListener!("inactive");
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
|
||||
if (adoption === "sync") await syncWithBackend({ shouldReconnect: () => true });
|
||||
else await reconcileWithBackend();
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("inactive close events preserve an already scheduled reconnect", async (t) => {
|
||||
let statusListener: ((status: PortForwardingRule["status"], error?: string | null) => void) | undefined;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
|
||||
@@ -234,6 +234,10 @@ const scheduleReconnectIfNeeded = (
|
||||
return true;
|
||||
}
|
||||
|
||||
// One failed dial can report both a status event and a rejected start.
|
||||
// Once exhausted, neither notification may begin another retry cycle.
|
||||
if (exhaustedReconnectRules.has(ruleId)) return false;
|
||||
|
||||
const currentConn = activeConnections.get(ruleId);
|
||||
if (currentConn?.reconnectSuppressed) return false;
|
||||
if (currentConn?.reconnectTimerCallback) {
|
||||
@@ -681,6 +685,10 @@ const subscribeSyncedConnection = async (
|
||||
connection.status = resolveBackendStatus(snapshot.status);
|
||||
connection.error = snapshot.error;
|
||||
}
|
||||
if (connection.status === 'active') {
|
||||
exhaustedReconnectRules.delete(ruleId);
|
||||
connection.reconnectAttempts = 0;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -820,6 +828,10 @@ export const reconcileWithBackend = async (): Promise<{
|
||||
if (existing.status !== backendStatus || existing.error !== tunnel.error) {
|
||||
existing.status = backendStatus;
|
||||
existing.error = tunnel.error;
|
||||
if (backendStatus === 'active') {
|
||||
exhaustedReconnectRules.delete(ruleId);
|
||||
existing.reconnectAttempts = 0;
|
||||
}
|
||||
result.appeared.push(ruleId);
|
||||
}
|
||||
}
|
||||
@@ -893,7 +905,9 @@ export const startPortForward = async (
|
||||
|
||||
// Clear any existing reconnect timer
|
||||
clearReconnectTimer(rule.id);
|
||||
|
||||
// Explicit starts (including network recovery) authorize a fresh cycle.
|
||||
exhaustedReconnectRules.delete(rule.id);
|
||||
|
||||
if (!bridge?.startPortForward) {
|
||||
// Fallback for browser/dev mode - simulate the connection
|
||||
logger.warn('[PortForwardingService] Backend not available, simulating connection...');
|
||||
|
||||
Generated
+36
-6
@@ -16,7 +16,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.0",
|
||||
"@ai-sdk/google": "^4.0.0",
|
||||
"@ai-sdk/openai": "^4.0.0",
|
||||
"@ai-sdk/openai": "4.0.8",
|
||||
"@aws-sdk/client-s3": "^3.956.0",
|
||||
"@eslint-community/regexpp": "4.12.2",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
@@ -197,13 +197,43 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.0.tgz",
|
||||
"integrity": "sha512-XcI/bJEG+Ymx8ZwQMY9GE9waHdEcSzT6wn2o+YW80hOQPf8IAGQvdNWKaD0mxLi6AmOM0L01y/p626w7rImblQ==",
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.8.tgz",
|
||||
"integrity": "sha512-yW2UuS6jlu2LRYCd+cSpVdu/umf+QjypihYAiGvKk5HnpqQf21ffNjFbke9xo9jhT+TBt2YR30Fs5Sy0md/dfA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "4.0.0",
|
||||
"@ai-sdk/provider-utils": "5.0.0"
|
||||
"@ai-sdk/provider": "4.0.2",
|
||||
"@ai-sdk/provider-utils": "5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.2.tgz",
|
||||
"integrity": "sha512-pfPoy9J1B1xV7cqJ8MYHOsDYrMv5tR3+EMNfI249OhkD2uRakvav3Fo7XpD2luuN/YNCBY7KfEQc7vEV7KEtyw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"json-schema": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider-utils": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.5.tgz",
|
||||
"integrity": "sha512-oI0t3dvCoqWNV1I8o1Rybi2DXDvHES5r/TrwtJW90tuFLVepgJlftPxrcjh8vaSvjqC2diTuA2vXyjKAyHJm4A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "4.0.2",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@workflow/serde": "4.1.0",
|
||||
"eventsource-parser": "^3.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.0",
|
||||
"@ai-sdk/google": "^4.0.0",
|
||||
"@ai-sdk/openai": "^4.0.0",
|
||||
"@ai-sdk/openai": "4.0.8",
|
||||
"@aws-sdk/client-s3": "^3.956.0",
|
||||
"@eslint-community/regexpp": "4.12.2",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
|
||||
Reference in new issue
Block a user