Compare commits

..
Author SHA1 Message Date
bincxz 8222f42116 test(ai): keep image bridge fixture type safe 2026-10-15 15:09:47 +08:00
bincxz 1a18c90ad6 fix(ai): encode chat image attachments as data URLs 2026-10-15 15:09:07 +08:00
9 changed files with 3 additions and 267 deletions

No files matched your search

+1 -9
View File
@@ -757,10 +757,7 @@ 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,
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
): Promise<{
const undoLastTurnInSession = useCallback(async (sessionId: string): Promise<{
sessionId: string;
restored: UndoLastTurnRestoredDraft;
} | null> => {
@@ -771,11 +768,6 @@ 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)];
@@ -8,7 +8,6 @@ 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';
@@ -193,39 +192,3 @@ 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();
});
}
+1 -6
View File
@@ -852,10 +852,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
undoInFlightRef.current = true;
setIsUndoingLastTurn(true);
try {
const result = await undoLastTurnInSession(sessionId, {
type: scopeType,
targetId: scopeTargetId,
});
const result = await undoLastTurnInSession(sessionId);
if (!result) return;
ensureScopeDraft(currentAgentId);
@@ -902,8 +899,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
undoLastTurnInSession,
updateScopeDraft,
currentAgentId,
scopeType,
scopeTargetId,
]);
const providersRef = useRef(providers);
-1
View File
@@ -40,7 +40,6 @@ 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;
-19
View File
@@ -967,25 +967,6 @@ 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
@@ -560,7 +560,6 @@ async function runOpenCodeTurn({
let opencode = null;
let sessionId = resumeSessionId || null;
let hasContent = false;
let backgroundWarningCount = 0;
let failed = false;
let abortSent = false;
let removeAbortListener = null;
@@ -620,13 +619,6 @@ async function runOpenCodeTurn({
if (abortController?.signal?.aborted) break;
const eventSessionId = getOpenCodeSessionIdFromEvent(event);
if (eventSessionId && eventSessionId !== sessionId) continue;
if (!eventSessionId && getOpenCodeEventPayload(event)?.type === "session.error") {
const message = extractOpenCodeErrorMessage(getOpenCodeEventPayload(event).properties?.error)
|| "OpenCode reported a background error";
if (emitter.warning) emitter.warning(`opencode-background-error:${++backgroundWarningCount}`, message);
else emitter.status(message);
continue;
}
const result = translateOpenCodeEvent(event, emitter, state);
if (result.content) hasContent = true;
if (result.error) {
@@ -500,52 +500,6 @@ test("runOpenCodeTurn continues after a tool error part and reaches session.idle
assert.equal(events.some((event) => event.k === "error"), false);
});
for (const scoped of [false, true]) {
test(`runOpenCodeTurn ${scoped ? "fails on the current session error" : "warns about plugin errors without stopping the session"} (#3173)`, async () => {
const { events, emitter } = collector();
emitter.warning = (id, message) => events.push({ k: "warning", id, message });
const message = scoped ? "bad key" : "Failed to install plugin: invalid package name";
const stream = {
async *[Symbol.asyncIterator]() {
yield {
type: "session.error",
properties: { ...(scoped ? { sessionID: "sess-1" } : {}), error: { data: { message } } },
};
if (!scoped) {
yield {
type: "session.error",
properties: { error: { data: { message: "Failed to install another plugin" } } },
};
}
yield {
type: "message.part.updated",
properties: { part: { type: "text", sessionID: "sess-1", id: "p1", text: "reply" }, delta: "reply" },
};
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
},
};
await runOpenCodeTurn({
prompt: "continue",
resumeSessionId: "sess-1",
emitter,
openCodeFactory: async () => ({
client: {
global: { event: async () => ({ stream }) },
session: { promptAsync: async () => ({ data: true }) },
},
server: { close() {} },
}),
});
assert.equal(events.some((event) => event.k === "error" && event.m === message), scoped);
assert.equal(events.some((event) => event.k === "warning" && event.message === message), !scoped);
assert.equal(events.some((event) => event.k === "text" && event.t === "reply"), !scoped);
assert.equal(events.some((event) => event.k === "done"), !scoped);
const warnings = events.filter((event) => event.k === "warning");
assert.equal(warnings.length, scoped ? 0 : 2);
assert.equal(new Set(warnings.map((event) => event.id)).size, warnings.length);
});
}
test("mapOpenCodeModels flattens providers", () => {
assert.deepEqual(mapOpenCodeModels({
providers: [
@@ -1077,132 +1077,6 @@ 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,10 +234,6 @@ 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) {
@@ -685,10 +681,6 @@ const subscribeSyncedConnection = async (
connection.status = resolveBackendStatus(snapshot.status);
connection.error = snapshot.error;
}
if (connection.status === 'active') {
exhaustedReconnectRules.delete(ruleId);
connection.reconnectAttempts = 0;
}
return true;
};
@@ -828,10 +820,6 @@ 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);
}
}
@@ -905,9 +893,7 @@ 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...');