Compare commits

..
6 changed files with 71 additions and 72 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;
+56 -17
View File
@@ -967,24 +967,63 @@ 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" });
// Issue #3485: interactive bash 4.4+ expands PS0 after reading a command and
// before executing it, so a user-defined PS0 hook (commonly a timestamp from
// ~/.bashrc; root usually has none) prints extra output inside the hidden
// wrapper's echo window. The wrapper must silence it for its own reads and
// restore the original value afterwards.
test("posix wrapper saves PS0 before wrapping and restores it after execution", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("echo PS0_HOOK_TEST", "posix", marker);
assert.match(wrapped, /\[ -n "\$\{PS0-\}" \]&&\{ __NCMCP_PS0=\$\{PS0-\}; PS0=; \}; printf/);
assert.match(
wrapped,
/\[ -n "\$\{__NCMCP_PS0-\}" \]&&\{ PS0=\$\{__NCMCP_PS0-\}; unset __NCMCP_PS0; \}; \(exit \$__NCMCP_rc\); \}/,
);
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`));
});
}
const result = spawnSync(
"bash",
["--norc", "-c", 'PS0="[hook] "\n' + wrapped + 'printf "RESTORED_PS0=%s\\n" "$PS0"'],
{ encoding: "utf8" },
);
assert.equal(result.error, undefined);
assert.equal(result.status, 0);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
assert.match(result.stdout, /PS0_HOOK_TEST/);
// The user's PS0 value is back once the wrapper group finishes, so their
// own subsequent commands still run the hook.
assert.match(result.stdout, /RESTORED_PS0=\[hook\] /);
});
test("posix wrapper leaves PS0 alone when the user has none set", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("echo NO_PS0_TEST", "posix", marker);
const result = spawnSync(
"bash",
["--norc", "-c", `unset PS0\n${wrapped}printf "PS0_SET=[%s]\\n" "\${PS0-}"`],
{ encoding: "utf8" },
);
assert.equal(result.error, undefined);
assert.equal(result.status, 0);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
// Nothing was saved and nothing is restored: PS0 stays unset afterwards.
assert.match(result.stdout, /NO_PS0_TEST/);
const ps0Line = result.stdout.split("\n").find((line) => line.startsWith("PS0_SET="));
assert.equal(ps0Line, "PS0_SET=[]");
});
test("posix wrapper silencing still works under sh (dash) where PS0 does not exist", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("echo DASH_TEST", "posix", marker);
const result = spawnSync("sh", ["-c", `${wrapped}printf 'SH_ALIVE\\n'`], { encoding: "utf8" });
assert.equal(result.error, undefined);
assert.equal(result.status, 0);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
assert.match(result.stdout, /SH_ALIVE/);
});
// Issue #3265: with plain HISTCONTROL the wrapper line is recorded in bash
// history; arrow-up recall then redraws the marker text, the preload __NCMCP_
+13 -2
View File
@@ -274,16 +274,27 @@ function buildPosixWrapperBody(command, marker, startFormat, { omitBashHistoryCl
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
}
const historyCleanup = omitBashHistoryCleanup ? [] : [buildBashHistoryCleanup(marker)];
// Issue #3485: interactive bash 4.4+ expands PS0 after reading a command
// and before executing it. A user-set PS0 hook (commonly a pre-exec
// timestamp/hook defined in ~/.bashrc — root usually has none) prints extra
// output inside the hidden wrapper's echo window, garbling the agent's
// command echo while manual typing renders natively. Save PS0 only while it
// is currently set (so an interrupted run cannot overwrite the saved value
// with empty), silence it for every wrapper read, and restore it after the
// wrapper group finishes. zsh/dash/fish have no PS0, so both guards are
// no-ops there.
const ps0Save = '[ -n "${PS0-}" ]&&{ __NCMCP_PS0=${PS0-}; PS0=; }';
const ps0Restore = '[ -n "${__NCMCP_PS0-}" ]&&{ PS0=${__NCMCP_PS0-}; unset __NCMCP_PS0; }';
// OpenWrt builds BusyBox with a 512-byte interactive line editor. Keep
// every physical line below that limit, even for short user commands.
// Emit a short standalone input marker before the shell reads any PS2
// continuation. A leading newline separates it from concurrent PTY echo.
return `${marker}=0; printf '\\n%s\\n' '${marker}_I'\n` + [
return `${marker}=0; ${ps0Save}; printf '\\n%s\\n' '${marker}_I'\n` + [
` : '${marker}'; ${cmdAssign}`,
`{ printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT`,
`printf '%s\\n' '${marker}_E:'"$__NCMCP_rc"`,
...historyCleanup,
`(exit $__NCMCP_rc); }`,
`${ps0Restore}; (exit $__NCMCP_rc); }`,
].join(`; \\\n: '${marker}'; `);
}