mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 10:52:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b00e1458a7 | ||
|
|
21bd5dbff8 | ||
|
|
6a3977de09 | ||
|
|
2fe3868d65 | ||
|
|
c942de1cbb |
No files matched your search
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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}'; `);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user