mirror of
https://github.com/Orkas-AI/Orkas.git
synced 2026-11-07 14:21:02 -10:00
Compare commits
19
Commits
v2026.11.4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f8a980e1c | ||
|
|
378d2fba9a | ||
|
|
6b5cd5cd89 | ||
|
|
ca48c16d27 | ||
|
|
0f6a52e0bb | ||
|
|
5ca617db4b | ||
|
|
304a40a0ec | ||
|
|
9040159980 | ||
|
|
bac0063078 | ||
|
|
15d04b7788 | ||
|
|
310c162452 | ||
|
|
967c46b0d0 | ||
|
|
c7c0e21fad | ||
|
|
b691acd48b | ||
|
|
9ad2e19b91 | ||
|
|
311d64afeb | ||
|
|
008a6b820d | ||
|
|
678f06cfbb | ||
|
|
af19c79cf8 |
No files matched your search
@@ -88,9 +88,11 @@ export class ConversationHistoryWorker {
|
||||
}
|
||||
|
||||
let worker: ConversationHistoryWorker | undefined;
|
||||
let closingHistory: Promise<void> | undefined;
|
||||
let lifetime = 0;
|
||||
export function conversationHistoryLifetime(): number { return lifetime; }
|
||||
export function historyRequest<T>(command: HistoryCommand): Promise<T> {
|
||||
if (closingHistory) return Promise.reject(new Error('Conversation history worker stopped'));
|
||||
if (!worker?.available) worker = new ConversationHistoryWorker();
|
||||
return worker.request<T>(command);
|
||||
}
|
||||
@@ -100,6 +102,7 @@ let liveWorker: ConversationHistoryWorker | undefined;
|
||||
/** Keep an active display projection out of the persisted-history queue.
|
||||
* The command and returned representation remain identical. */
|
||||
export async function liveHistoryRequest<T>(command: HistoryCommand): Promise<T> {
|
||||
while (closingHistory) await closingHistory;
|
||||
if (!liveWorker?.available) {
|
||||
const generation = lifetime;
|
||||
if (liveWorker) await liveWorker.close();
|
||||
@@ -112,6 +115,7 @@ let turnGeneration = 0;
|
||||
/** A separate bounded lane keeps navigation scans out of interactive paging.
|
||||
* Same bootstrap, queue cap, timeout, redacted errors and idle-release policy. */
|
||||
export async function turnIndexRequest<T>(command: HistoryCommand): Promise<T> {
|
||||
while (closingHistory) await closingHistory;
|
||||
if (!turnWorker?.available) {
|
||||
// Do not overlap an interrupted writer with its replacement.
|
||||
const generation = turnGeneration;
|
||||
@@ -122,16 +126,20 @@ export async function turnIndexRequest<T>(command: HistoryCommand): Promise<T> {
|
||||
return turnWorker.request<T>(command);
|
||||
}
|
||||
|
||||
export async function closeConversationHistoryWorker(): Promise<void> {
|
||||
export function closeConversationHistoryWorker(): Promise<void> {
|
||||
lifetime++;
|
||||
turnGeneration++;
|
||||
if (closingHistory) return closingHistory;
|
||||
const current = worker;
|
||||
worker = undefined;
|
||||
const navigation = turnWorker;
|
||||
const live = liveWorker;
|
||||
turnGeneration++;
|
||||
// Keep the stopped writer reachable until termination so an incoming read
|
||||
// cannot create a replacement while the old index is still being published.
|
||||
await Promise.all([current?.close(), navigation?.close(), live?.close()]);
|
||||
if (liveWorker === live) liveWorker = undefined;
|
||||
if (turnWorker === navigation) turnWorker = undefined;
|
||||
const closing = Promise.all([current?.close(), navigation?.close(), live?.close()]).then(() => {
|
||||
if (worker === current) worker = undefined;
|
||||
if (liveWorker === live) liveWorker = undefined;
|
||||
if (turnWorker === navigation) turnWorker = undefined;
|
||||
}).finally(() => { if (closingHistory === closing) closingHistory = undefined; });
|
||||
closingHistory = closing;
|
||||
return closing;
|
||||
}
|
||||
@@ -1113,7 +1113,8 @@ interface CidState {
|
||||
skillImportPreviews?: Map<string, { sourcePath: string; fingerprint: string; expiresAt: number }>;
|
||||
// Read the existing public process trail only when a view is rebuilt. These
|
||||
// closures own no second event log and are released with their actor turn.
|
||||
liveDisplays?: Map<string, () => GroupMessage[]>;
|
||||
liveDisplays?: Map<string, { read: () => GroupMessage[]; afterMessageId?: string }>;
|
||||
latestDisplayUserMessageId?: string;
|
||||
shuttingDown?: boolean;
|
||||
shutdownArchives?: Map<string, Promise<GroupMessage | null>>;
|
||||
displaySequence?: number;
|
||||
@@ -1611,9 +1612,21 @@ function _emitTaskRunTerminalIfQuiescent(state: CidState, stateFile?: StateFile)
|
||||
}
|
||||
|
||||
function emit(state: CidState, ev: GroupEvent, processItems?: ProcessItem[]): void {
|
||||
if (ev.type === 'message' && ev.msg.from === USER_ID) {
|
||||
state.latestDisplayUserMessageId = ev.msg.id;
|
||||
}
|
||||
if (ev.type === 'process') {
|
||||
ev.display_seq = state.displaySequence = (state.displaySequence || 0) + 1;
|
||||
}
|
||||
// Preserve the user boundary crossed by visible output, including while the
|
||||
// renderer is elsewhere. Message identity avoids second-resolution clock
|
||||
// ties and never promotes old recovered content past a later user update.
|
||||
if (ev.type === 'artifact_created' || (ev.type === 'process'
|
||||
&& (ev.data.type === 'event' || ev.data.type === 'commentary-finalized'
|
||||
|| ((ev.data.type === 'delta' || ev.data.type === 'progress') && ev.data.text)))) {
|
||||
const display = ev.turn_id ? state.liveDisplays?.get(ev.turn_id) : null;
|
||||
if (display) display.afterMessageId = state.latestDisplayUserMessageId;
|
||||
}
|
||||
// Single chokepoint feeding the `after` admission gate: every task
|
||||
// terminal — turn settlement, queue drops, per-task cancel, waiting_input
|
||||
// resolution — already flows through a task_state emit, so recording
|
||||
@@ -1763,8 +1776,11 @@ export function liveDisplaySnapshot(uid: string, cid: string) {
|
||||
sequence: state?.displaySequence || 0,
|
||||
active_turns: activeTurns,
|
||||
turns: activeTurns.flatMap((turn) => {
|
||||
const read = state?.liveDisplays?.get(turn.turn_id);
|
||||
return read ? [{ ...turn, records: read() }] : [];
|
||||
const display = state?.liveDisplays?.get(turn.turn_id);
|
||||
return display ? [{ ...turn,
|
||||
...(display.afterMessageId ? { activity_after_message_id: display.afterMessageId } : {}),
|
||||
records: display.read(),
|
||||
}] : [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -5683,11 +5699,11 @@ async function runActorTurn(
|
||||
w.pendingSavedTasks = undefined;
|
||||
if (item.actor.kind !== 'worker') {
|
||||
state.liveDisplays ||= new Map();
|
||||
state.liveDisplays.set(item.turnId, () => [{
|
||||
state.liveDisplays.set(item.turnId, { read: () => [{
|
||||
id: '', from: item.actor.id, to: [USER_ID], ts: localIsoAt(turnStartedAt), text: '',
|
||||
turn_id: item.turnId, source_message_id: item.msgId,
|
||||
...(item.taskId ? { task_id: item.taskId } : {}),
|
||||
}]);
|
||||
}] });
|
||||
}
|
||||
try {
|
||||
return await (() => runActorTurnWithDisplay(state, w, item, turnStartedAt))();
|
||||
@@ -6290,7 +6306,7 @@ async function runActorTurnWithDisplay(
|
||||
const nativeDisplaySegments: GroupMessage[] = [];
|
||||
if (actor.kind !== 'worker') {
|
||||
state.liveDisplays ||= new Map();
|
||||
state.liveDisplays.set(item.turnId, () => [
|
||||
state.liveDisplays.set(item.turnId, { read: () => [
|
||||
...nativeDisplaySegments,
|
||||
{
|
||||
id: '', from: actor.id, to: [USER_ID],
|
||||
@@ -6303,7 +6319,7 @@ async function runActorTurnWithDisplay(
|
||||
...(turnArtifacts.length ? { artifacts: turnArtifacts.map(a => ({ ...a, agent_id: actor.id })) } : {}),
|
||||
...(item.taskId ? { task_id: item.taskId } : {}),
|
||||
},
|
||||
]);
|
||||
] });
|
||||
}
|
||||
let errText: string | null = null;
|
||||
// Shown beside errText to the user only; see TurnFinishedEvent.errDetail.
|
||||
@@ -10185,7 +10201,7 @@ async function archiveInterruptedTurn(state: CidState, turnId: string): Promise<
|
||||
state.shutdownArchives ||= new Map();
|
||||
const previous = state.shutdownArchives.get(turnId);
|
||||
if (previous) return previous;
|
||||
const tail = state.liveDisplays?.get(turnId)?.().find(row => !row.id);
|
||||
const tail = state.liveDisplays?.get(turnId)?.read().find(row => !row.id);
|
||||
if (!tail) return null;
|
||||
const snapshot = structuredClone(tail);
|
||||
const process = snapshot.process || [];
|
||||
|
||||
@@ -180,6 +180,30 @@ class VideoControlHttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** A structured terminal failure must not be confused with lost tracking. */
|
||||
class VideoTaskFailedError extends Error {
|
||||
/** `reason` is safe to show the Agent; it never carries provider prose. */
|
||||
constructor(message: string, readonly reason = '', readonly inputRejected = false) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const VIDEO_TASK_NEW_ATTEMPT = 'Calling generate_video again starts a new paid generation.';
|
||||
|
||||
// Provider prose can echo a private prompt, so a rejection is identified and
|
||||
// reported only by its structured code.
|
||||
const PROVIDER_ERROR_CODE = /^[A-Za-z][A-Za-z0-9_.-]{0,100}$/;
|
||||
// https://docs.volcengine.com/docs/82379/1299023
|
||||
const DOUBAO_INPUT_REJECTION_CODE =
|
||||
/^(?:Input(?:Text|Image|Video|Audio)(?:SensitiveContentDetected|RiskDetection)|SensitiveContentDetected|InvalidParameter|InvalidImageURL)(?:\.[A-Za-z]*)?$/;
|
||||
|
||||
function providerErrorCode(error: unknown): string {
|
||||
const code = (error as { code?: unknown } | null | undefined)?.code;
|
||||
return typeof code === 'string' && PROVIDER_ERROR_CODE.test(code) ? code : '';
|
||||
}
|
||||
|
||||
class VideoInputRejectedError extends VideoControlHttpError {}
|
||||
|
||||
const TRANSIENT_VIDEO_ERROR_RE =
|
||||
/系统繁忙|请稍?后(?:重|再)?试|\b(?:429|50[0-4])\b|timed?\s*out|timeout|service unavailable|temporarily unavailable|too many requests|ECONNRESET|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|fetch failed|socket hang up|\bnet::ERR_[A-Z0-9_]+/i;
|
||||
export function classifyVideoGenError(message: string): 'TIMEOUT' | 'PROVIDER_API_ERROR' | 'BAD_INPUT' {
|
||||
@@ -242,6 +266,7 @@ export async function generateVideo(input: GenerateVideoInput): Promise<Generate
|
||||
}
|
||||
|
||||
let lastProviderError = '';
|
||||
let lastInputRejected = false;
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
const candidate = candidates[i];
|
||||
const inputError = candidate.entry.provider === 'kling' ? klingVideoInputError(input) : null;
|
||||
@@ -309,8 +334,11 @@ export async function generateVideo(input: GenerateVideoInput): Promise<Generate
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
errorCode: input.signal?.aborted ? 'BAD_INPUT' : lastProviderError.includes('timed out') ? 'TIMEOUT' : 'PROVIDER_API_ERROR',
|
||||
message: 'Video task tracking stopped after a generation request was sent. The task or charge status may be unknown.',
|
||||
errorCode: input.signal?.aborted || (err instanceof VideoTaskFailedError && err.inputRejected)
|
||||
? 'BAD_INPUT' : lastProviderError.includes('timed out') ? 'TIMEOUT' : 'PROVIDER_API_ERROR',
|
||||
message: err instanceof VideoTaskFailedError
|
||||
? `${err.reason} ${VIDEO_TASK_NEW_ATTEMPT}`
|
||||
: 'Video task tracking stopped after a generation request was sent. The task or charge status may be unknown.',
|
||||
...(acceptedTaskId ? { taskId: acceptedTaskId } : {}),
|
||||
postDispatch: true,
|
||||
};
|
||||
@@ -322,6 +350,7 @@ export async function generateVideo(input: GenerateVideoInput): Promise<Generate
|
||||
message: sanitizeLogTextForUpload(lastProviderError),
|
||||
};
|
||||
}
|
||||
lastInputRejected = err instanceof VideoInputRejectedError;
|
||||
const suffix = i < candidates.length - 1 ? '; trying next provider' : '';
|
||||
log.warn(`video gen API call failed (${candidate.entry.provider}/${candidate.entry.model})${suffix}`, {
|
||||
error: logErrorSummary(err),
|
||||
@@ -382,7 +411,7 @@ export async function generateVideo(input: GenerateVideoInput): Promise<Generate
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
errorCode: lastProviderError.includes('timed out') ? 'TIMEOUT' : 'PROVIDER_API_ERROR',
|
||||
errorCode: lastInputRejected ? 'BAD_INPUT' : lastProviderError.includes('timed out') ? 'TIMEOUT' : 'PROVIDER_API_ERROR',
|
||||
message: sanitizeLogTextForUpload(lastProviderError || 'video generation failed'),
|
||||
};
|
||||
}
|
||||
@@ -672,7 +701,16 @@ async function fetchVideoControl<T>(
|
||||
|
||||
async function readNativeVideoResponse(response: Response, label: string): Promise<any> {
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new VideoControlHttpError(`${label} API ${response.status}`, response.status);
|
||||
if (!response.ok) {
|
||||
let body: any;
|
||||
try { body = JSON.parse(text); } catch { /* an unparsable body names no code */ }
|
||||
const code = providerErrorCode(body?.error);
|
||||
const message = `${label} API ${response.status}${code ? `: ${code}` : ''}`;
|
||||
if (label.startsWith('DouBao') && DOUBAO_INPUT_REJECTION_CODE.test(code)) {
|
||||
throw new VideoInputRejectedError(message, response.status);
|
||||
}
|
||||
throw new VideoControlHttpError(message, response.status);
|
||||
}
|
||||
try { return text ? JSON.parse(text) : {}; }
|
||||
catch { return {}; } // Invalid successful bodies remain missing receipts.
|
||||
}
|
||||
@@ -853,7 +891,14 @@ export async function callDoubaoVideo(req: AdapterVideoRequest): Promise<Adapter
|
||||
continue;
|
||||
}
|
||||
if (VIDEO_TASK_FAILED_STATUSES.includes(status)) {
|
||||
throw new Error(`DouBao video task ${taskId} ${status}: ${truncate(JSON.stringify((last as any)?.error || last), 500)}`);
|
||||
const failureCode = providerErrorCode((last as any)?.error);
|
||||
const reason = failureCode
|
||||
? `The provider failed this video task with code ${failureCode}.`
|
||||
: 'The provider reported that this video task failed.';
|
||||
throw new VideoTaskFailedError(
|
||||
`DouBao video task ${taskId} ${status}${failureCode ? `: ${failureCode}` : ''}`,
|
||||
reason, DOUBAO_INPUT_REJECTION_CODE.test(failureCode),
|
||||
);
|
||||
}
|
||||
if (['succeeded', 'success', 'completed', 'done'].includes(status)) {
|
||||
const videoUrl = extractVideoUrl(last);
|
||||
|
||||
@@ -1534,6 +1534,10 @@ if (!gotLock) {
|
||||
library.closeAllLibraryCorpora();
|
||||
},
|
||||
localTasks: [
|
||||
{ name: 'conversation-history', run: async () => {
|
||||
const history = await import('./features/conversation-history-client');
|
||||
await history.closeConversationHistoryWorker();
|
||||
} },
|
||||
{ name: 'open-lifecycle', run: async () => { await openLifecycleTracking?.flushQuit(); } },
|
||||
{ name: 'connectors', run: () => connectorsFeature.shutdown() },
|
||||
{ name: 'search', run: () => searchFeature.flushAll() },
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ const REDACT_KEYS = new Set([
|
||||
'cid', 'conversationid', 'conversation_id',
|
||||
'secret', 'clientsecret', 'client_secret',
|
||||
'password', 'passwd', 'pwd',
|
||||
'authorization',
|
||||
'authorization', 'proxyauthorization', 'proxy_authorization', 'proxy-authorization',
|
||||
'cookie', 'setcookie', 'set-cookie',
|
||||
// PII (defense-in-depth — no current call site logs these unmasked, but
|
||||
// the rule shields against future regressions).
|
||||
|
||||
@@ -77,7 +77,7 @@ import {
|
||||
visitRepositoryFiles,
|
||||
createRepositorySearchBudget,
|
||||
readIgnoreScope,
|
||||
isIgnoredByScopes,
|
||||
createDirectoryIgnoreMatcher,
|
||||
type IgnoreScope,
|
||||
} from './repository-search';
|
||||
|
||||
@@ -1430,6 +1430,7 @@ async function visitFiles(
|
||||
if (capped || opts.signal.aborted) return;
|
||||
const local = honourIgnores ? readIgnoreScope(dir) : null;
|
||||
const scopes = local ? [...inherited, local] : inherited;
|
||||
const isIgnored = createDirectoryIgnoreMatcher(dir, scopes);
|
||||
try {
|
||||
const entries = await fs.promises.opendir(dir);
|
||||
for await (const entry of entries) {
|
||||
@@ -1438,7 +1439,7 @@ async function visitFiles(
|
||||
if (opts.signal.aborted) break;
|
||||
if (entry.name === '.git' || (entry.isDirectory() && fallbackDirectoryExcluded(entry.name))) continue;
|
||||
const file = path.join(dir, entry.name);
|
||||
if (honourIgnores && isIgnoredByScopes(file, entry.isDirectory(), scopes)) continue;
|
||||
if (honourIgnores && isIgnored(entry.name, entry.isDirectory())) continue;
|
||||
if (entry.isDirectory()) await walk(file, scopes);
|
||||
else if (entry.isFile() && !fallbackFileExcluded(entry.name)) capped = await visit(file);
|
||||
}
|
||||
|
||||
@@ -422,11 +422,36 @@ export function isIgnoredByScopes(
|
||||
const rel = path.relative(scope.dir, absPath);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
|
||||
const posix = rel.split(path.sep).join('/');
|
||||
for (const rule of scope.rules) {
|
||||
if (rule.dirOnly && !isDir) continue;
|
||||
if (!rule.re.test(posix)) continue;
|
||||
ignored = !rule.negate;
|
||||
}
|
||||
ignored = applyIgnoreRules(ignored, posix, isDir, scope.rules);
|
||||
}
|
||||
return ignored;
|
||||
}
|
||||
|
||||
function applyIgnoreRules(ignored: boolean, relative: string, isDir: boolean, rules: readonly IgnoreRule[]): boolean {
|
||||
for (const rule of rules) {
|
||||
if (rule.dirOnly && !isDir) continue;
|
||||
if (rule.re.test(relative)) ignored = !rule.negate;
|
||||
}
|
||||
return ignored;
|
||||
}
|
||||
|
||||
/** Prepare inherited ignore scopes once for direct opendir entries in this directory. */
|
||||
export function createDirectoryIgnoreMatcher(dir: string, scopes: readonly IgnoreScope[]): (name: string, isDir: boolean) => boolean {
|
||||
const prepared = scopes.flatMap(scope => {
|
||||
const relative = path.relative(scope.dir, dir);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) return [];
|
||||
const prefix = relative ? relative.split(path.sep).join('/') + '/' : '';
|
||||
return [{ prefix, rules: scope.rules, fileRules: scope.rules.filter(rule => !rule.dirOnly) }];
|
||||
});
|
||||
const fileScopes = prepared.filter(scope => scope.fileRules.length > 0);
|
||||
return (name, isDir) => {
|
||||
let ignored = false;
|
||||
for (const scope of isDir ? prepared : fileScopes) {
|
||||
const relative = scope.prefix + name;
|
||||
// Preserve the absolute-path helper's existing relative-path boundary.
|
||||
if (relative.startsWith('..')) continue;
|
||||
ignored = applyIgnoreRules(ignored, relative, isDir, isDir ? scope.rules : scope.fileRules);
|
||||
}
|
||||
return ignored;
|
||||
};
|
||||
}
|
||||
@@ -20,10 +20,10 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
const SENSITIVE_FIELD =
|
||||
'(?:api_?key|access_?token|refresh_?token|id_?token|session_?id|conversation_?id|cid|client_?secret|private_?key|password|passwd|pwd|secret|token|authorization|cookie|set-cookie|path|abs_?path|rel_?path|file_?path|file_?name|filename|working_?dir|cwd)';
|
||||
'(?:api_?key|access_?token|refresh_?token|id_?token|session_?id|conversation_?id|cid|client_?secret|private_?key|password|passwd|pwd|secret|token|(?:proxy[-_]?)?authorization|cookie|set-cookie|path|abs_?path|rel_?path|file_?path|file_?name|filename|working_?dir|cwd)';
|
||||
|
||||
const SENSITIVE_QUERY_FIELD =
|
||||
'(?:api_?key|access_?token|refresh_?token|id_?token|session_?id|conversation_?id|cid|client_?secret|private_?key|password|passwd|pwd|secret|token|authorization|cookie|set-cookie|code|state|signature|sign|q-ak|q-signature|x-cos-security-token|x-amz-signature|x-amz-security-token|x-amz-credential|ossaccesskeyid|security-token)';
|
||||
'(?:api_?key|access_?token|refresh_?token|id_?token|session_?id|conversation_?id|cid|client_?secret|private_?key|password|passwd|pwd|secret|token|(?:proxy[-_]?)?authorization|cookie|set-cookie|code|state|signature|sign|q-ak|q-signature|x-cos-security-token|x-amz-signature|x-amz-security-token|x-amz-credential|ossaccesskeyid|security-token)';
|
||||
|
||||
const JSON_SECRET_FIELD_RE = new RegExp(
|
||||
`(["'])(${SENSITIVE_FIELD})\\1(\\s*:\\s*)(["'])(.*?)\\4`,
|
||||
@@ -81,7 +81,14 @@ export function sanitizeLogTextForUpload(text: string): string {
|
||||
// and JSON-stringified "\"authorization\":\"Bearer X\"". Case-insensitive
|
||||
// because RFC 6750 declares the scheme name case-insensitive.
|
||||
.replace(/Bearer\s+[A-Za-z0-9._\-~+/=]+/gi, 'Bearer ***')
|
||||
.replace(/Basic\s+[A-Za-z0-9+/=]+/gi, 'Basic ***')
|
||||
// An explicit authentication field stays private even when its Basic
|
||||
// value is invalid or truncated. Preserve its surrounding diagnostics.
|
||||
.replace(/(\b(?:proxy[-_]?)?authorization["']?\s*[:=]\s*["']?Basic\s+)[^\s"',;)\]}]+/gi, '$1***')
|
||||
// Outside explicit fields, distinguish Basic credentials (base64 of
|
||||
// "user-id:password", RFC 7617) from prose by the decoded colon. Keep
|
||||
// ordinary text such as "falling back to basic retrieval" unchanged.
|
||||
.replace(/Basic\s+([A-Za-z0-9+/=]+)/gi, (m, token: string) =>
|
||||
(Buffer.from(token, 'base64').toString('latin1').includes(':') ? 'Basic ***' : m))
|
||||
// JWT — three base64url segments, first two starting "eyJ" (base64 of "{").
|
||||
.replace(/\beyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\b/g, '***JWT***')
|
||||
// JSON-ish secret fields, preserving the original quote style.
|
||||
|
||||
@@ -1446,18 +1446,19 @@ async function _ensureViewerFileExists(absPath, cid, projectId) {
|
||||
|
||||
async function openChatFileViewer(absPath, displayName, opts) {
|
||||
if (!absPath) return;
|
||||
if (window.OrkasPreviewWindows) {
|
||||
if (!(await _ensureViewerFileExists(absPath, opts?.cid, opts?.projectId))) return;
|
||||
if (_kindOf(absPath) === 'image') return window.OrkasPreviewWindows.image(_chatMediaLocalUrl(absPath), displayName || absPath.split(/[\\/]/).pop(), { ...opts, absPath });
|
||||
if (_kindOf(absPath) === 'video') return openChatVideoUrlViewer(_chatMediaLocalUrl(absPath), displayName || absPath.split(/[\\/]/).pop(), { ...opts, absPath });
|
||||
return window.OrkasPreviewWindows.open({ kind: 'file', path: absPath, title: displayName || absPath.split(/[\\/]/).pop(), options: window.OrkasPreviewWindows.options(opts) });
|
||||
}
|
||||
const cid = (opts && opts.cid) || null;
|
||||
const projectId = (opts && opts.projectId) || null;
|
||||
const name = displayName || (absPath.split(/[\\/]/).pop() || absPath);
|
||||
const exists = await _ensureViewerFileExists(absPath, cid, projectId);
|
||||
if (!exists) return;
|
||||
const kind = _kindOf(absPath);
|
||||
// Unsupported files need only the owner dialog, never a preview window.
|
||||
if (kind === 'unsupported') return _showUnsupportedDialog(absPath, cid, projectId, {});
|
||||
if (window.OrkasPreviewWindows) {
|
||||
if (kind === 'image') return window.OrkasPreviewWindows.image(_chatMediaLocalUrl(absPath), name, { ...opts, absPath });
|
||||
if (kind === 'video') return openChatVideoUrlViewer(_chatMediaLocalUrl(absPath), name, { ...opts, absPath });
|
||||
return window.OrkasPreviewWindows.open({ kind: 'file', path: absPath, title: name, options: window.OrkasPreviewWindows.options(opts) });
|
||||
}
|
||||
|
||||
if (kind === 'image') {
|
||||
// Delegate — the image lightbox already has zoom / pan / keyboard. We
|
||||
|
||||
@@ -7580,6 +7580,7 @@ function _restoreLiveDisplaySnapshot(cid, snapshot) {
|
||||
}
|
||||
_replayPersistedProcessItems(row, current.process);
|
||||
if (current.text) _streamingAppendFinalDelta(row, current.text);
|
||||
_restoreStreamingMessageActivityPosition(row, turn.activity_after_message_id);
|
||||
const details = row.querySelector?.('.stream-process');
|
||||
if (details && typeof wasOpen === 'boolean') details.open = wasOpen;
|
||||
const pending = pendingConvs.get(cid);
|
||||
@@ -12405,19 +12406,21 @@ function _appendBeforeSpacer(container, msg) {
|
||||
*
|
||||
* Bubbles without `data-ts` (legacy / history-load) are treated as
|
||||
* earlier-than-anything new — new bubbles always append after them. */
|
||||
function _insertByTimestamp(container, msg) {
|
||||
function _insertByTimestamp(container, msg, afterMessage = null) {
|
||||
// A background reorder must not make Chromium follow the appended user
|
||||
// bubble upward when the live reply crosses it. Preserve the viewport only
|
||||
// for an existing row; new messages retain the ordinary sticky-follow path.
|
||||
const previousTop = msg.parentElement === container ? container.scrollTop : null;
|
||||
const newTs = Number(msg.dataset.ts || 0);
|
||||
const children = container.querySelectorAll(':scope > .chat-message[data-ts]');
|
||||
let reference = null;
|
||||
for (const existing of children) {
|
||||
const existingTs = Number(existing.dataset.ts || 0);
|
||||
if (existingTs > newTs) {
|
||||
reference = existing;
|
||||
break;
|
||||
let reference = afterMessage ? _nextChatMessage(afterMessage) : null;
|
||||
if (!afterMessage) {
|
||||
const children = container.querySelectorAll(':scope > .chat-message[data-ts]');
|
||||
for (const existing of children) {
|
||||
const existingTs = Number(existing.dataset.ts || 0);
|
||||
if (existingTs > newTs) {
|
||||
reference = existing;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reference) container.insertBefore(msg, reference);
|
||||
@@ -12432,6 +12435,26 @@ function _insertByTimestamp(container, msg) {
|
||||
}
|
||||
}
|
||||
|
||||
// A recovered snapshot is existing output, not new activity. Restore only the
|
||||
// user boundary recorded when that output arrived; newer user messages stay
|
||||
// below it until the next live update, even when their timestamps are equal.
|
||||
function _restoreStreamingMessageActivityPosition(msg, afterMessageId) {
|
||||
if (!_isLivePlaceholderMessage(msg) || !msg.parentElement || !afterMessageId) return;
|
||||
const container = msg.parentElement;
|
||||
const user = container.querySelector(`.chat-message.user[data-msg-id="${CSS.escape(String(afterMessageId))}"]`);
|
||||
if (!user) return;
|
||||
let following = _nextChatMessage(msg);
|
||||
while (following && following !== user) following = _nextChatMessage(following);
|
||||
if (!following) return;
|
||||
const userTs = Number(user.dataset.ts || 0);
|
||||
if (!Number.isFinite(userTs) || userTs <= 0) return;
|
||||
const floor = Math.max(userTs, Number(msg.dataset.activitySortAfter || 0) || 0);
|
||||
msg.dataset.ts = String(floor);
|
||||
msg.dataset.activitySortAfter = String(floor);
|
||||
if (Number(msg.dataset.activitySortFloor || 0) <= floor) delete msg.dataset.activitySortFloor;
|
||||
_insertByTimestamp(container, msg, user);
|
||||
}
|
||||
|
||||
function _isTimestampPositionCorrect(container, msg) {
|
||||
if (!container || !msg || msg.parentElement !== container) return false;
|
||||
const msgTs = Number(msg.dataset.ts || 0);
|
||||
@@ -15247,10 +15270,11 @@ function _anchorPlaceholderToTurnStart(ph, startedAtMs) {
|
||||
const startMs = Number(startedAtMs);
|
||||
if (!ph || !Number.isFinite(startMs) || startMs <= 0) return;
|
||||
const activitySortAfter = Number(ph.dataset.activitySortAfter || 0);
|
||||
ph.dataset.ts = String(Math.max(
|
||||
startMs,
|
||||
Number.isFinite(activitySortAfter) ? activitySortAfter : 0,
|
||||
));
|
||||
// A crossed user boundary owns the row's position. Its persisted clock has
|
||||
// second precision and can precede this turn's millisecond start; replacing
|
||||
// it with the start would cross still-newer users on the next runtime poll.
|
||||
ph.dataset.ts = String(Number.isFinite(activitySortAfter) && activitySortAfter > 0
|
||||
? activitySortAfter : startMs);
|
||||
// Recovery creates the DOM now, but this header describes the running turn,
|
||||
// not when the user reopened the page. Keep it aligned with the row anchor.
|
||||
const timeEl = ph.querySelector?.('.chat-msg-time');
|
||||
|
||||
@@ -315,7 +315,7 @@ async function _loadVisibleChatSnippets() {
|
||||
const anchor = body.querySelector('.search-result.active');
|
||||
const rect = anchor?.getBoundingClientRect();
|
||||
const bounds = body.getBoundingClientRect();
|
||||
const visible = rect && rect.bottom > bounds.top && rect.top < bounds.bottom;
|
||||
const visible = top > 0 && rect && rect.bottom > bounds.top && rect.top < bounds.bottom;
|
||||
_renderSearchResults(query);
|
||||
body.scrollTop = top;
|
||||
const retained = visible && body.querySelector('.search-result.active');
|
||||
@@ -362,7 +362,9 @@ async function _runProgressiveSearch(query, seq, startedAt, projectId, pending)
|
||||
const anchor = body?.querySelector('.search-result.active');
|
||||
const rect = anchor?.getBoundingClientRect();
|
||||
const bounds = anchor ? body.getBoundingClientRect() : null;
|
||||
const visible = rect && rect.bottom > bounds.top && rect.top < bounds.bottom;
|
||||
// At the start of the list, late categories must not pull the viewport
|
||||
// down to the row that happened to be selected by the first page.
|
||||
const visible = top > 0 && rect && rect.bottom > bounds.top && rect.top < bounds.bottom;
|
||||
_renderSearchResults(query);
|
||||
if (body) {
|
||||
body.scrollTop = top;
|
||||
|
||||
@@ -1530,15 +1530,16 @@ function _markdownVisualizationHtml(body) {
|
||||
const spec = JSON.parse(body);
|
||||
if (!spec || Array.isArray(spec) || typeof spec !== 'object'
|
||||
|| !Object.hasOwn(spec, 'path')
|
||||
|| Object.keys(spec).some(key => key !== 'path' && key !== 'mode')
|
||||
|| Object.keys(spec).some(key => key !== 'path' && key !== 'mode' && key !== 'title')
|
||||
|| (Object.hasOwn(spec, 'mode') && spec.mode !== 'wide')
|
||||
|| (Object.hasOwn(spec, 'title') && typeof spec.title !== 'string')
|
||||
|| typeof spec.path !== 'string'
|
||||
|| !/^(?:\/(?!\/)|[A-Za-z]:[\\/])/.test(spec.path)
|
||||
|| /[\u0000-\u001f\u007f]/.test(spec.path)
|
||||
|| !_HTML_EXT_RE.test(spec.path)) return '';
|
||||
// Both default and wide references fit the existing chat preview surface.
|
||||
// Hydration and main's normal HTML resolver retain the sandbox boundary.
|
||||
return _markdownHtmlEmbedHtml(_normalizeLocalMediaSrc(spec.path), '');
|
||||
return _markdownHtmlEmbedHtml(_normalizeLocalMediaSrc(spec.path), spec.title || '');
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import path from 'node:path';
|
||||
|
||||
import { expect, test } from './fixtures/orkas';
|
||||
|
||||
declare const currentCid: string;
|
||||
|
||||
const accountTest = test;
|
||||
|
||||
async function sendNewChat(
|
||||
@@ -662,7 +664,7 @@ test.describe('real chat pipeline with a local model', () => {
|
||||
expect(modelOrkas.modelRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('steers a text-reference composer update and reorders the live reply on its next update', async ({ modelOrkas }) => {
|
||||
test('steers a text-reference composer update and preserves reply order across task switches', async ({ modelOrkas }, testInfo) => {
|
||||
modelOrkas.setModelMode('controlled-slow');
|
||||
const page = await sendNewChat(modelOrkas, 'E2E active turn accepts a send-now update.');
|
||||
await expect.poll(
|
||||
@@ -710,6 +712,30 @@ test.describe('real chat pipeline with a local model', () => {
|
||||
.toHaveText('Hello from the local E2E model.');
|
||||
await expect(page.locator('#chat-send-btn')).toHaveClass(/\bstreaming\b/);
|
||||
|
||||
const conversationId = await page.locator('#conversation-list .conv-item.active').getAttribute('data-cid');
|
||||
const roles = () => page.locator('#chat-history > .chat-message').evaluateAll(nodes =>
|
||||
nodes.map(node => node.classList.contains('user') ? 'user' : 'assistant'));
|
||||
await expect.poll(roles).toEqual(['user', 'user', 'assistant']);
|
||||
// Hold the model at the same fragment: neither a new delta nor terminal
|
||||
// persistence may repair an incorrectly restored position for this check.
|
||||
for (const [language, width] of [['en', 1280], ['zh', 760]] as const) {
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
await page.evaluate(lang => (window as any).setLang(lang), language);
|
||||
await page.locator('#new-chat-btn').click();
|
||||
if (language === 'zh') await page.reload();
|
||||
await page.locator(`#conversation-list .conv-item[data-cid="${conversationId}"]`).click();
|
||||
await expect(page.locator('#chat-history .chat-message.assistant [data-role="final"]'))
|
||||
.toHaveText('Hello from the local E2E model.');
|
||||
expect(await roles()).toEqual(['user', 'user', 'assistant']);
|
||||
await page.evaluate(async () => {
|
||||
const runtime = window as any;
|
||||
await runtime.loadConversationHistory(currentCid, { preserveScroll: true, refreshDisplay: true });
|
||||
});
|
||||
expect(await roles()).toEqual(['user', 'user', 'assistant']);
|
||||
await page.screenshot({ path: testInfo.outputPath(`send-now-reopened-${language}.png`) });
|
||||
}
|
||||
expect(modelOrkas.modelRequests).toHaveLength(1);
|
||||
|
||||
// The terminal boundary confirms the visible draft. Keep the next steered
|
||||
// round active so chronology is checked during the live turn.
|
||||
modelOrkas.setModelMode('very-slow');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { appendFileSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { expect, test } from './fixtures/orkas';
|
||||
|
||||
@@ -219,3 +220,43 @@ test('native local image previews decode common raster formats and aliases', asy
|
||||
await orkas.closePreview(preview);
|
||||
}
|
||||
});
|
||||
|
||||
for (const language of ['en', 'zh'] as const) {
|
||||
test.describe(`unsupported bubble files (${language})`, () => {
|
||||
test('shows only the owner dialog and restores bubble focus when dismissed', async ({ orkas }, info) => {
|
||||
const page = orkas.page!;
|
||||
await page.evaluate(language => (window as any).setLang(language), language);
|
||||
const name = `${'release-package-'.repeat(8)}2.5.0.zip`;
|
||||
const file = orkas.createWorkspaceFile(name, 'unsupported preview fixture');
|
||||
const created: any = await orkas.invoke('conversations.create', { title: 'Unsupported file preview' });
|
||||
const cid = created.conversation.conversation_id;
|
||||
appendFileSync(path.join(orkas.workspaceRoot, 'account-e2e', 'cloud', 'chats', `${cid}.jsonl`), JSON.stringify({
|
||||
id: 'unsupported-file', from: 'commander', to: ['user'], ts: new Date().toISOString(),
|
||||
text: `[${name}](${pathToFileURL(file).href})`,
|
||||
}) + '\n');
|
||||
await page.evaluate(async cid => {
|
||||
await (window as any).loadConversations();
|
||||
await (window as any).setView('conversation', cid);
|
||||
}, cid);
|
||||
const chip = page.locator('#chat-history [data-chat-md-file-open="1"]');
|
||||
await expect(chip).toBeVisible();
|
||||
const countWindows = () => orkas.electronApp!.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length);
|
||||
const windows = await countWindows();
|
||||
for (const [width, height] of [[1280, 800], [960, 640]]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await chip.click();
|
||||
const dialog = page.locator('.ui-dialog-overlay.open');
|
||||
await expect(dialog).toContainText(language === 'zh' ? '无法在应用内预览 .zip 文件' : 'Cannot preview .zip files in app.');
|
||||
await expect(dialog.getByRole('button', { name: language === 'zh' ? '打开文件夹' : 'Open Folder', exact: true })).toBeFocused();
|
||||
expect(await countWindows()).toBe(windows);
|
||||
await expect(page.locator('.chat-file-viewer.is-open')).toHaveCount(0);
|
||||
await page.screenshot({ path: info.outputPath(`unsupported-${language}-${width}.png`) });
|
||||
if (width === 1280) await dialog.getByRole('button', { name: language === 'zh' ? '关闭' : 'Close', exact: true }).click();
|
||||
else await page.keyboard.press('Escape');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(chip).toBeFocused();
|
||||
expect(await countWindows()).toBe(windows);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -601,6 +601,73 @@ test('shows cold chat matches within the input latency budget while snippet read
|
||||
}
|
||||
});
|
||||
|
||||
for (const [language, width, height] of [['zh', 1280, 800], ['en', 900, 640]] as const) {
|
||||
test(`keeps the start of search results visible when earlier categories arrive later (${language})`, async ({}, info) => {
|
||||
const app = new OrkasTestApp(info, { configuredModel: false, seedLanguage: language });
|
||||
try {
|
||||
await app.launch();
|
||||
await installEmptyLibrarySearch(app, pcRoot);
|
||||
const ids: string[] = [];
|
||||
for (let index = 0; index < 10; index++) {
|
||||
const { task } = await app.invoke<any>('autoTasks.create', {
|
||||
title: `Scrollmarker automation ${index}`, content: 'Scrollmarker ' + 'long summary '.repeat(30),
|
||||
enabled: false, schedule: { type: 'daily', hour: 9, minute: 0 },
|
||||
});
|
||||
ids.push(task.id);
|
||||
}
|
||||
await app.electronApp!.evaluate(async (_, root) => {
|
||||
const req = (process as any).mainModule.require.bind((process as any).mainModule);
|
||||
const uid = req(`${root}/src/main/features/users.ts`).getActiveUserId();
|
||||
const now = new Date().toISOString();
|
||||
await req(`${root}/src/main/features/connectors/registry.ts`).upsert(uid, {
|
||||
id: 'custom-scrollmarker', origin: 'custom', display_name: 'Scrollmarker connector',
|
||||
transport: { kind: 'streamable-http', url: 'https://example.test/mcp' },
|
||||
enabled_subtools: null, tools_cache: [], tools_cached_at: 0,
|
||||
status: { kind: 'disconnected' }, created_at: now, updated_at: now,
|
||||
});
|
||||
}, pcRoot);
|
||||
const page = app.page!;
|
||||
await page.setViewportSize({ width, height });
|
||||
// Hold only the automation files, leaving the real connector search,
|
||||
// stream/preload/IPC and renderer free to publish the first category.
|
||||
await app.electronApp!.evaluate((_, input) => {
|
||||
const req = (process as any).mainModule.require.bind((process as any).mainModule);
|
||||
const uid = req(`${input.root}/src/main/features/users.ts`).getActiveUserId();
|
||||
const layout = req(`${input.root}/src/main/util/project-layout.ts`);
|
||||
const files = new Set(input.ids.map(id => layout.findAutoTaskLocation(uid, id).configFile));
|
||||
const fsp = req('node:fs/promises'), originalRead = fsp.readFile;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
fsp.readFile = async function(target: any, ...args: any[]) {
|
||||
if (files.has(String(target))) await gate;
|
||||
return originalRead.call(this, target, ...args);
|
||||
};
|
||||
req('node:module').syncBuiltinESMExports();
|
||||
(globalThis as any).__releaseScrollmarker = () => {
|
||||
release(); fsp.readFile = originalRead; req('node:module').syncBuiltinESMExports();
|
||||
};
|
||||
}, { root: pcRoot, ids });
|
||||
await page.locator('#sidebar-search-btn').click();
|
||||
await page.locator('#search-input').fill('Scrollmarker');
|
||||
await page.locator('#search-input').press('Enter');
|
||||
const results = page.locator('#search-body .search-result');
|
||||
await expect(results).toHaveCount(1);
|
||||
await expect(results).toContainText('Scrollmarker connector');
|
||||
expect(await page.locator('#search-body').evaluate(body => body.scrollTop)).toBe(0);
|
||||
await app.electronApp!.evaluate(() => (globalThis as any).__releaseScrollmarker());
|
||||
await expect(results).toHaveCount(11);
|
||||
await expect(page.locator('#search-body .search-loading-spinner')).toHaveCount(0);
|
||||
expect(await page.locator('#search-body').evaluate(body => body.scrollHeight > body.clientHeight)).toBe(true);
|
||||
expect(await page.locator('#search-body').evaluate(body => body.scrollTop)).toBe(0);
|
||||
await expect(results.first()).toBeInViewport();
|
||||
await expect(results.first()).toContainText('Scrollmarker automation 0');
|
||||
await expect(page.locator('#search-input')).toBeFocused();
|
||||
await page.screenshot({ path: info.outputPath(`search-top-${language}-${width}.png`) });
|
||||
await page.locator('#search-input').press('Escape');
|
||||
} finally { await app.dispose(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('paints local hits before held semantic retrieval and preserves selection when body results arrive', async ({}, info) => {
|
||||
const app = new OrkasTestApp(info, { configuredModel: false, seedLanguage: 'en' });
|
||||
try {
|
||||
|
||||
@@ -19,7 +19,8 @@ button { font: inherit; padding: 8px 12px; } output { margin-left: 12px; }
|
||||
</style><h1>时间选择器 / Time picker</h1>
|
||||
<button onclick="document.querySelector('output').textContent = ++window.count">Add / 添加</button><output>0</output>
|
||||
<script>window.count = 0;</script>`);
|
||||
const marker = `\uE200visualize\uE202${JSON.stringify({ path: file, mode: 'wide' })}\uE201`;
|
||||
const title = '时间选择器(中文)';
|
||||
const marker = `\uE200visualize\uE202${JSON.stringify({ path: file, mode: 'wide', title })}\uE201`;
|
||||
app.setModelTextReplies([`UI 已完成。\n\n${marker}\n\n检查结果:预览可以交互。`]);
|
||||
await page.locator('#new-chat-btn').click();
|
||||
await page.locator('#new-chat-input').fill('展示时间选择器的交互预览。');
|
||||
@@ -30,6 +31,7 @@ button { font: inherit; padding: 8px 12px; } output { margin-left: 12px; }
|
||||
await expect(final.locator(frameSelector)).toHaveCount(1);
|
||||
await expect(final).not.toContainText('\uE200visualize');
|
||||
await expect(final.locator(frameSelector)).toHaveAttribute('sandbox', 'allow-scripts');
|
||||
await expect(final.locator(frameSelector)).toHaveAttribute('title', new RegExp(`${title}$`));
|
||||
let preview = final.locator(frameSelector).contentFrame();
|
||||
await preview.getByRole('button', { name: 'Add / 添加' }).press('Enter');
|
||||
await expect(preview.locator('output')).toHaveText('1');
|
||||
@@ -62,6 +64,7 @@ button { font: inherit; padding: 8px 12px; } output { margin-left: 12px; }
|
||||
final = page.locator('#chat-history .chat-message.assistant').filter({ hasText: 'UI 已完成。' }).last();
|
||||
await expect(final.locator(frameSelector)).toHaveCount(1);
|
||||
await expect(final).not.toContainText('\uE200visualize');
|
||||
await expect(final.locator(frameSelector)).toHaveAttribute('title', new RegExp(`${title}$`));
|
||||
preview = final.locator(frameSelector).contentFrame();
|
||||
await preview.getByRole('button', { name: 'Add / 添加' }).press('Enter');
|
||||
await expect(preview.locator('output')).toHaveText('1');
|
||||
|
||||
@@ -14,6 +14,7 @@ beforeEach(() => {
|
||||
});
|
||||
afterEach(async () => {
|
||||
await (await import('../../../src/main/features/conversation-history-client')).closeConversationHistoryWorker();
|
||||
vi.restoreAllMocks();
|
||||
if (previous === undefined) delete process.env.ORKAS_WORKSPACE_ROOT;
|
||||
else process.env.ORKAS_WORKSPACE_ROOT = previous;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
@@ -22,6 +23,69 @@ const row = (id: string, output = '') => ({ id, from: 'commander', to: ['user'],
|
||||
text: `完整消息 ${id} \\(x^2\\)`, process: [{ type: 'event', event: { stream: 'tool',
|
||||
data: { name: 'bash', phase: 'end', output } } }] });
|
||||
|
||||
|
||||
const gate = () => {
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>(resolve => { release = resolve; });
|
||||
return { pending, release };
|
||||
};
|
||||
function historySource(name: string, output = '') {
|
||||
const file = path.join(root, `${name}.jsonl`);
|
||||
fs.writeFileSync(file, JSON.stringify(row(name, output)) + '\n');
|
||||
return file;
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ lane: 'live', warm: false }, { lane: 'navigation', warm: false },
|
||||
{ lane: 'live', warm: true }, { lane: 'navigation', warm: true },
|
||||
] as const)('waits for all history cleanup before $lane work resumes (warm=$warm)', async ({ lane, warm }) => {
|
||||
const client = await import('../../../src/main/features/conversation-history-client');
|
||||
const cache = await import('../../../src/main/features/conversation_history_cache');
|
||||
const realRequest = client.ConversationHistoryWorker.prototype.request;
|
||||
const realClose = client.ConversationHistoryWorker.prototype.close;
|
||||
const owners = new Set<any>(), released = gate();
|
||||
const posted: string[] = [];
|
||||
let closingStarted = false, closed = false, prematureDispatch = false;
|
||||
vi.spyOn(client.ConversationHistoryWorker.prototype, 'request').mockImplementation(function(command) {
|
||||
if (closingStarted && !closed) prematureDispatch = true;
|
||||
owners.add(this); posted.push(command.kind); return realRequest.call(this, command);
|
||||
});
|
||||
const sourceFile = historySource('late-shutdown', 'Preserved complete output '.repeat(1000));
|
||||
const original = fs.readFileSync(sourceFile);
|
||||
await cache.readConversationHistoryPage('user', sourceFile, 1);
|
||||
const request = () => lane === 'live'
|
||||
? cache.projectLiveConversationHistoryRecords('user', sourceFile, [row('late-live')])
|
||||
: client.turnIndexRequest({ kind: 'turns', userId: 'user', cid: 'late-shutdown', sourceFile });
|
||||
if (warm) await request();
|
||||
const admitted = [...posted];
|
||||
const oldOwners = [...owners];
|
||||
vi.spyOn(client.ConversationHistoryWorker.prototype, 'close').mockImplementation(async function() {
|
||||
await realClose.call(this); await released.pending;
|
||||
});
|
||||
closingStarted = true;
|
||||
const closing = client.closeConversationHistoryWorker();
|
||||
void closing.then(() => { closed = true; });
|
||||
const late = request();
|
||||
const outcome = Promise.allSettled([late]);
|
||||
try {
|
||||
await vi.waitFor(() => expect(oldOwners.every(owner => owner.worker.threadId === -1)).toBe(true));
|
||||
const beforeRelease = [...posted];
|
||||
released.release(); await closing;
|
||||
const results = await outcome;
|
||||
expect(beforeRelease).toEqual(admitted);
|
||||
expect(prematureDispatch).toBe(false);
|
||||
expect(results[0].status).toBe('fulfilled');
|
||||
expect(oldOwners.every(owner => owner.worker.threadId === -1)).toBe(true);
|
||||
expect(fs.readFileSync(sourceFile)).toEqual(original);
|
||||
if (lane === 'live') {
|
||||
expect((await cache.projectLiveConversationHistoryRecords('user', sourceFile, [row('fresh-live')]))[0].id).toBe('fresh-live');
|
||||
} else {
|
||||
const fresh = await client.turnIndexRequest<any>({ kind: 'turns', userId: 'user', cid: 'late-shutdown', sourceFile });
|
||||
expect(fresh.total).toBe(0);
|
||||
}
|
||||
} finally { released.release(); await Promise.allSettled([closing, late]); }
|
||||
});
|
||||
|
||||
it('keeps every live process item inline and isolates its queue from persisted-history reads', async () => {
|
||||
const client = await import('../../../src/main/features/conversation-history-client');
|
||||
const real = client.ConversationHistoryWorker.prototype.request;
|
||||
@@ -259,3 +323,29 @@ it.each([1000, 30000, 100000])('PF-02 preserves %i nested events across cold/war
|
||||
coldMs, warmMs, queuedSmallMs: smallWaits, expandedEvents: complete.length,
|
||||
scope: 'actual shared history worker; warm OS cache; queue wait includes heavy cold read; no UI/p95 claim' }));
|
||||
}, 30000);
|
||||
|
||||
it('shares pending cleanup, rejects new persisted reads, and reopens unchanged history after release', async () => {
|
||||
const client = await import('../../../src/main/features/conversation-history-client');
|
||||
const cache = await import('../../../src/main/features/conversation_history_cache');
|
||||
const sourceFile = historySource('repeat-close');
|
||||
const original = fs.readFileSync(sourceFile);
|
||||
await cache.readConversationHistoryPage('user', sourceFile, 1);
|
||||
const released = gate();
|
||||
const realClose = client.ConversationHistoryWorker.prototype.close;
|
||||
vi.spyOn(client.ConversationHistoryWorker.prototype, 'close').mockImplementation(async function() {
|
||||
await realClose.call(this);
|
||||
await released.pending;
|
||||
});
|
||||
const closing = client.closeConversationHistoryWorker();
|
||||
const repeated = client.closeConversationHistoryWorker();
|
||||
const late = client.historyRequest({ kind: 'page', userId: 'user', sourceFile, limit: 1 });
|
||||
try {
|
||||
expect(repeated).toBe(closing);
|
||||
await expect(late).rejects.toThrow('Conversation history worker stopped');
|
||||
} finally {
|
||||
released.release();
|
||||
await Promise.allSettled([closing, repeated, late]);
|
||||
}
|
||||
expect((await cache.readConversationHistoryPage('user', sourceFile, 1)).records[0].id).toBe('repeat-close');
|
||||
expect(fs.readFileSync(sourceFile)).toEqual(original);
|
||||
});
|
||||
@@ -846,6 +846,9 @@ describe('group_chat bus › enqueue routing + persistence', () => {
|
||||
recovered = await recovered;
|
||||
expect(recovered.turns).toHaveLength(1);
|
||||
const records = recovered.turns[0].records;
|
||||
expect(recovered.turns[0].activity_after_message_id).toBe(
|
||||
live.find(event => event.type === 'message' && event.msg.from === 'user').msg.id,
|
||||
);
|
||||
expect(records.at(-1).text).toBe('Partial reply\n');
|
||||
expect(records.flatMap((r: any) => r.process || []).filter((p: any) => p.event?.data?.type === 'tool-event')).toHaveLength(350);
|
||||
if (cli === 'claude') expect(records[0].text).toBe('Inspect the code.\n');
|
||||
|
||||
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
|
||||
}>,
|
||||
registerGeneratedMediaUrl: vi.fn(),
|
||||
downloadBinaryWithProxyPolicy: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/main/features/auth', () => ({
|
||||
@@ -30,7 +31,7 @@ vi.mock('../../../src/main/util/proxy-dispatcher', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/main/logger', () => ({
|
||||
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
||||
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: mocks.warn, error: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { generateVideo, validateDownloadedVideo } from '../../../src/main/features/video_gen';
|
||||
@@ -59,6 +60,7 @@ beforeEach(() => {
|
||||
createdAt: 1,
|
||||
}];
|
||||
mocks.registerGeneratedMediaUrl.mockReset();
|
||||
mocks.warn.mockReset();
|
||||
mocks.downloadBinaryWithProxyPolicy.mockReset();
|
||||
mocks.downloadBinaryWithProxyPolicy.mockImplementation(async (_url, options) => {
|
||||
const body = mp4Fixture();
|
||||
@@ -436,3 +438,93 @@ describe('public Orkas API poll timeout budget', () => {
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('video_gen public provider rejection reason', () => {
|
||||
const output = path.join(os.tmpdir(), 'uncreated-video-rejection.mp4');
|
||||
it('reports a user-key Doubao input rejection by its code and never its prose', async () => {
|
||||
mocks.profiles = [{ id: 'primary', provider: 'doubao', model: 'doubao-seedance-2-0-260128', apiKey: 'fixture-key', label: 'Fixture', createdAt: 1 }];
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
|
||||
error: { code: 'InputImageSensitiveContentDetected.PrivacyInformation', message: 'private-generation-prompt' },
|
||||
}), { status: 400 })));
|
||||
const { generateVideo } = await import('../../../src/main/features/video_gen');
|
||||
const result = await generateVideo({ prompt: 'A quiet landscape', outputAbsPath: output });
|
||||
expect(result).toMatchObject({ ok: false, errorCode: 'BAD_INPUT' });
|
||||
expect(JSON.stringify(result)).toContain('InputImageSensitiveContentDetected.PrivacyInformation');
|
||||
expect(JSON.stringify(result)).not.toContain('private-generation-prompt');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ error: { code: 'AuthenticationError', message: 'private-generation-prompt' } }, 401, 'AuthenticationError'],
|
||||
[{ error: { code: 'private-generation-prompt and more words' } }, 400, null],
|
||||
[{ error: { code: 1613 } }, 400, null],
|
||||
['private-generation-prompt', 400, null],
|
||||
])('does not treat a user-key response as an input rejection: %j', async (body, status, code) => {
|
||||
mocks.profiles = [{ id: 'primary', provider: 'doubao', model: 'doubao-seedance-2-0-260128', apiKey: 'fixture-key', label: 'Fixture', createdAt: 1 }];
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(typeof body === 'string' ? body : JSON.stringify(body), { status })));
|
||||
const { generateVideo } = await import('../../../src/main/features/video_gen');
|
||||
const result = await generateVideo({ prompt: 'A quiet landscape', outputAbsPath: output });
|
||||
expect(result).toMatchObject({ ok: false, errorCode: 'PROVIDER_API_ERROR' });
|
||||
expect(JSON.stringify(result)).not.toContain('private-generation-prompt');
|
||||
if (code) expect(JSON.stringify(result)).toContain(code);
|
||||
});
|
||||
|
||||
it('reports why a user-key Doubao task failed by its code', async () => {
|
||||
mocks.profiles = [{ id: 'primary', provider: 'doubao', model: 'doubao-seedance-2-0-260128', apiKey: 'fixture-key', label: 'Fixture', createdAt: 1 }];
|
||||
vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => (init?.method === 'POST'
|
||||
? new Response(JSON.stringify({ id: 'byok-task' }))
|
||||
: new Response(JSON.stringify({ status: 'failed', error: { code: 'OutputVideoSensitiveContentDetected', message: 'private-generation-prompt' } })))));
|
||||
const { generateVideo } = await import('../../../src/main/features/video_gen');
|
||||
const result = await generateVideo({ prompt: 'A quiet landscape', outputAbsPath: output, pollIntervalMs: 1 });
|
||||
expect(result).toMatchObject({ ok: false, errorCode: 'PROVIDER_API_ERROR', postDispatch: true, taskId: 'byok-task' });
|
||||
expect(JSON.stringify(result)).toContain('OutputVideoSensitiveContentDetected');
|
||||
expect(JSON.stringify(result)).not.toContain('private-generation-prompt');
|
||||
});
|
||||
});
|
||||
|
||||
// A terminal task and a rejected create have different replay and billing facts.
|
||||
describe('public video rejection recovery boundaries', () => {
|
||||
const primary = { id: 'primary', provider: 'doubao', model: 'doubao-seedance-2-0-260128', apiKey: 'fixture-key', label: 'Fixture', createdAt: 1 };
|
||||
it.each([
|
||||
['InputImageSensitiveContentDetected.PrivacyInformation', 'BAD_INPUT'],
|
||||
['UpstreamFailure', 'PROVIDER_API_ERROR'],
|
||||
['InvalidParameter and private prose', 'PROVIDER_API_ERROR'],
|
||||
[null, 'PROVIDER_API_ERROR'],
|
||||
])('preserves terminal failure and no replay for code %s', async (code, errorCode) => {
|
||||
mocks.profiles = [primary, { ...primary, id: 'fallback' }];
|
||||
const fetch = vi.fn(async (_url: unknown, init?: RequestInit) => new Response(JSON.stringify(init?.method === 'POST'
|
||||
? { id: 'terminal-task' }
|
||||
: { status: 'failed', error: { code, message: 'private-provider-prose' } })));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const result = await generateVideo({ prompt: 'A quiet landscape', outputAbsPath: path.join(tmpDir, 'failed.mp4'), pollIntervalMs: 1 });
|
||||
expect(result).toMatchObject({ ok: false, errorCode, postDispatch: true, taskId: 'terminal-task' });
|
||||
expect(fetch.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1);
|
||||
expect(mocks.downloadBinaryWithProxyPolicy).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.join(tmpDir, 'failed.mp4'))).toBe(false);
|
||||
expect(JSON.stringify([result, mocks.warn.mock.calls])).not.toContain('private-provider-prose');
|
||||
expect(JSON.stringify(result)).not.toMatch(/nothing was charged|no credits were charged/i);
|
||||
expect((result as {message: string}).message).toContain('video task');
|
||||
});
|
||||
it('uses the configured fallback only after an explicitly rejected creation', async () => {
|
||||
mocks.profiles = [primary, { ...primary, id: 'fallback' }];
|
||||
let creations = 0;
|
||||
const fetch = vi.fn(async (_url: unknown, init?: RequestInit) => {
|
||||
if (init?.method === 'POST') return ++creations === 1
|
||||
? new Response(JSON.stringify({error: {code: 'InvalidParameter', message: 'private-provider-prose'}}), {status: 400})
|
||||
: new Response(JSON.stringify({id: 'fallback-task'}));
|
||||
return new Response(JSON.stringify({status: 'succeeded', content: {video_url: 'https://cdn.example.test/video.mp4'}}));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const output = path.join(tmpDir, 'fallback.mp4');
|
||||
expect(await generateVideo({prompt: 'A quiet landscape', outputAbsPath: output, pollIntervalMs: 1}))
|
||||
.toMatchObject({ok: true, taskId: 'fallback-task'});
|
||||
expect(creations).toBe(2);
|
||||
expect(fs.readFileSync(output)).toEqual(mp4Fixture());
|
||||
expect(JSON.stringify(mocks.warn.mock.calls)).not.toContain('private-provider-prose');
|
||||
});
|
||||
it.each(['InvalidParameterLookalike', 'Prefix.InvalidImageURL', 'InputImageSensitiveContentDetected.PrivacyInformation.extra'])('keeps an unrecognized structured code a provider error: %s', async code => {
|
||||
mocks.profiles = [primary];
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({error: {code}}), {status: 400})));
|
||||
expect(await generateVideo({prompt: 'A quiet landscape', outputAbsPath: path.join(tmpDir, 'unknown.mp4')}))
|
||||
.toMatchObject({ok: false, errorCode: 'PROVIDER_API_ERROR'});
|
||||
});
|
||||
});
|
||||
@@ -30,15 +30,17 @@ it('main and renderer records bypass direct file/console sinks and snapshot once
|
||||
initLogger();
|
||||
const logger = createLogger('business');
|
||||
const getter = vi.fn(() => { throw new Error('diagnostic accessor'); });
|
||||
const data = { result: 'success', token: 'private-canary', get details() { return getter(); } };
|
||||
const data = { result: 'success', token: 'private-canary',
|
||||
'Proxy-Authorization': 'Basic incomplete', get details() { return getter(); } };
|
||||
logger.debug('disabled', data);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
logger.info('completed', data);
|
||||
data.result = 'later';
|
||||
expect(send.mock.calls[0][0]).toMatchObject({ scope: 'business', data: ['completed', { result: 'success', token: '***REDACTED***', details: '[accessor]' }] });
|
||||
const error = Object.assign(new Error('failed'), { code: 'ECONNRESET', token: 'private-canary' });
|
||||
expect(send.mock.calls[0][0]).toMatchObject({ scope: 'business', data: ['completed', { result: 'success', token: '***REDACTED***',
|
||||
'Proxy-Authorization': '***REDACTED***', details: '[accessor]' }] });
|
||||
const error = Object.assign(new Error('failed Authorization: Basic incomplete'), { code: 'ECONNRESET', token: 'private-canary' });
|
||||
logger.error('request failed', error);
|
||||
expect(structuredClone(send.mock.calls[1][0]).data[1]).toMatchObject({ message: 'failed', code: 'ECONNRESET', token: '***REDACTED***' });
|
||||
expect(structuredClone(send.mock.calls[1][0]).data[1]).toMatchObject({ message: 'failed Authorization: Basic ***', code: 'ECONNRESET', token: '***REDACTED***' });
|
||||
logFromRenderer({ level: 'warn', module: 'ui', message: 'renderer result' });
|
||||
expect(send.mock.calls[2][0].scope).toBe('renderer/ui');
|
||||
canAccept.mockReturnValue(false);
|
||||
|
||||
@@ -150,6 +150,27 @@ describe('logger › redact', () => {
|
||||
// The self-reference is replaced, not traversed forever.
|
||||
expect(out.self).toBe('[circular]');
|
||||
});
|
||||
|
||||
it('keeps incomplete Basic headers private in Error messages and stacks', async () => {
|
||||
const { redact } = await import('../../src/main/logger');
|
||||
const value = Buffer.from('synthetic-only').toString('base64');
|
||||
const error = new Error(`request rejected Proxy-Authorization: Basic ${value}`);
|
||||
error.stack = `Error: ${error.message}\n at request`;
|
||||
const out = redact(error) as Error;
|
||||
expect(out.message).toBe('request rejected Proxy-Authorization: Basic ***');
|
||||
expect(out.stack).toBe('Error: request rejected Proxy-Authorization: Basic ***\n at request');
|
||||
expect(error.message).toContain(value);
|
||||
expect(redact('falling back to basic retrieval')).toBe('falling back to basic retrieval');
|
||||
expect(redact({ authorization: `Basic ${value}` })).toEqual({ authorization: '***REDACTED***' });
|
||||
});
|
||||
|
||||
it('masks proxy authorization fields before serializing structured log records', async () => {
|
||||
const { redact } = await import('../../src/main/logger');
|
||||
for (const field of ['Proxy-Authorization', 'proxy_authorization', 'proxyAuthorization']) {
|
||||
expect(redact({ [field]: 'Basic incomplete', status: 401 }))
|
||||
.toEqual({ [field]: '***REDACTED***', status: 401 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('logger › sweepLogs', () => {
|
||||
|
||||
@@ -2948,6 +2948,51 @@ describe('file-tools › read_files metadata_only', () => {
|
||||
});
|
||||
|
||||
describe('file-tools › search_files', () => {
|
||||
it.each(['search_files', 'grep_files'])('keeps file and directory ignore scopes distinct across rescans (%s)', async (name) => {
|
||||
const { tools, wsDir } = await buildTools();
|
||||
const files = ['excluded.txt/hidden.txt', 'visible/excluded.txt', 'visible/deep/needle.txt',
|
||||
'pkg/keep.txt', 'pkg/drop.txt', 'pkg/child/drop.txt', 'pkg/child/keep.txt', 'peer/other.txt'];
|
||||
for (const relative of files) {
|
||||
const file = path.join(wsDir, relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, 'needle\n');
|
||||
}
|
||||
fs.writeFileSync(path.join(wsDir, '.gitignore'), 'excluded.txt/\n');
|
||||
fs.writeFileSync(path.join(wsDir, 'pkg', '.gitignore'), '*.txt\n!keep.txt\n');
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = tmpDir;
|
||||
try {
|
||||
for (const phase of [0, 1]) {
|
||||
if (phase === 1) fs.writeFileSync(path.join(wsDir, '.gitignore'), 'visible/\n');
|
||||
const visible = phase === 0
|
||||
? ['visible/excluded.txt', 'visible/deep/needle.txt', 'pkg/keep.txt', 'pkg/child/keep.txt', 'peer/other.txt']
|
||||
: ['excluded.txt/hidden.txt', 'pkg/keep.txt', 'pkg/child/keep.txt', 'peer/other.txt'];
|
||||
for (const includeIgnored of [false, true]) {
|
||||
const result = await run(getTool(tools, name), {
|
||||
root: wsDir, query: '*.txt', pattern: 'needle', include_glob: ['*.txt'], include_ignored: includeIgnored,
|
||||
});
|
||||
expect(result.isError, result.content).toBeFalsy();
|
||||
expect(result.content).toContain('backend=walk');
|
||||
expect(result.content).toContain('complete=true');
|
||||
const expected = (includeIgnored ? files : visible).map(relative => path.join(wsDir, relative)).sort();
|
||||
if (name === 'search_files') {
|
||||
const listing = searchResults(result.content);
|
||||
expect(listing.files.map(file => path.join(listing.roots[file.root], file.path)).sort()).toEqual(expected);
|
||||
} else {
|
||||
expect(result.content).toContain(`${expected.length} match(es)`);
|
||||
for (const relative of files) {
|
||||
const absolute = path.join(wsDir, relative);
|
||||
expect(result.content.includes(`file: ${JSON.stringify(absolute)}\n`), relative).toBe(expected.includes(absolute));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (originalPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['search_files', 'grep_files'])('honours trailing double-star ignores through the walk fallback (%s)', async (name) => {
|
||||
const { tools, wsDir } = await buildTools();
|
||||
const files = ['logs/today.txt', 'logs/old/jan.txt', 'logs/keep.txt', 'src/logs/a.txt',
|
||||
|
||||
@@ -9,7 +9,8 @@ import { PassThrough } from 'node:stream';
|
||||
|
||||
import {
|
||||
grepRepository,
|
||||
isIgnoredByScopes,
|
||||
isIgnoredByScopes as isIgnoredByAbsolutePath,
|
||||
createDirectoryIgnoreMatcher,
|
||||
listRepositoryFiles,
|
||||
parseIgnoreRules,
|
||||
visitRepositoryFiles,
|
||||
@@ -145,7 +146,10 @@ describe('repository search interruption', () => {
|
||||
// implementation these cases pin. A miss here means `search_files` starts
|
||||
// surfacing build output, logs and local config that the project ignored —
|
||||
// the model's context fills with noise nobody asked for.
|
||||
describe('repository-search › ignore rules', () => {
|
||||
describe.each(['absolute', 'directory'] as const)('repository-search › ignore rules (%s)', backend => {
|
||||
const isIgnoredByScopes: typeof isIgnoredByAbsolutePath = backend === 'absolute'
|
||||
? isIgnoredByAbsolutePath
|
||||
: (file, isDir, scopes) => createDirectoryIgnoreMatcher(path.dirname(file), scopes)(path.basename(file), isDir);
|
||||
function scope(dir: string, content: string) {
|
||||
return [{ dir, rules: parseIgnoreRules(content) }];
|
||||
}
|
||||
@@ -213,6 +217,15 @@ describe('repository-search › ignore rules', () => {
|
||||
expect(isIgnoredByScopes(at('other', 'dist'), true, nested)).toBe(false);
|
||||
});
|
||||
|
||||
it('applies nested scope overrides in declaration order without leaking to sibling directories', () => {
|
||||
const scopes = [...scope(ROOT, '*.log\n'), ...scope(at('pkg'), '!keep.log\nprivate/\n')];
|
||||
expect(isIgnoredByScopes(at('pkg', 'keep.log'), false, scopes)).toBe(false);
|
||||
expect(isIgnoredByScopes(at('pkg', 'drop.log'), false, scopes)).toBe(true);
|
||||
expect(isIgnoredByScopes(at('peer', 'keep.log'), false, scopes)).toBe(true);
|
||||
expect(isIgnoredByScopes(at('pkg', 'private'), true, scopes)).toBe(true);
|
||||
expect(isIgnoredByScopes(at('pkg', 'private'), false, scopes)).toBe(false);
|
||||
});
|
||||
|
||||
it('skips comments and blank lines instead of treating them as patterns', () => {
|
||||
const rules = parseIgnoreRules('# comment\n\n \nreal\n');
|
||||
expect(rules).toHaveLength(1);
|
||||
|
||||
@@ -98,6 +98,14 @@ describe('log-redact', () => {
|
||||
expect(serialized).not.toContain('not-a-directory');
|
||||
});
|
||||
|
||||
it('keeps malformed Basic authentication private in failed-request diagnostics', () => {
|
||||
const value = Buffer.from('synthetic-only').toString('base64');
|
||||
const ref = logErrorRef(new Error(`request rejected Authorization: Basic ${value}`));
|
||||
expect(ref.message).toBe('request rejected Authorization: Basic ***');
|
||||
expect(JSON.stringify(ref)).not.toContain(value);
|
||||
expect(sanitizeLogText('falling back to basic retrieval')).toBe('falling back to basic retrieval');
|
||||
});
|
||||
|
||||
it('preserves safe URLs adjacent to cloud paths while redacting secrets', () => {
|
||||
const text = sanitizeLogText(
|
||||
'failed cloud/agents/abc123/skills/private/SKILL.md via https://example.com/sync/sts?token=secret ghp_abcdefghijklmnopqrstuvwxyz',
|
||||
|
||||
@@ -20,6 +20,64 @@ describe('sanitizeLogTextForUpload › set A (must be masked)', () => {
|
||||
expect(out).toBe('{"authorization":"***"}');
|
||||
});
|
||||
|
||||
it('masks Basic credentials from a raw Authorization header', () => {
|
||||
// base64("alice:s3cret")
|
||||
const out = sanitizeLogTextForUpload('Authorization: Basic YWxpY2U6czNjcmV0');
|
||||
expect(out).toBe('Authorization: Basic ***');
|
||||
});
|
||||
|
||||
it('masks Basic credentials with a lowercase scheme name (RFC 7617)', () => {
|
||||
const out = sanitizeLogTextForUpload('proxy header basic dXNlcjpwYXNz rejected');
|
||||
expect(out).toBe('proxy header Basic *** rejected');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['colonless', Buffer.from('synthetic-only').toString('base64')],
|
||||
['truncated', Buffer.from('long-user-name:synthetic-only').toString('base64').slice(0, 8)],
|
||||
['malformed', 'not-base64_credential'],
|
||||
])('masks %s Basic values in explicit authentication headers', (_label, credential) => {
|
||||
// Authentication failures can echo invalid or incomplete credentials.
|
||||
// Their header context is sufficient to establish that they are private.
|
||||
for (const prefix of ['Authorization: Basic ', 'pRoXy-AuThOrIzAtIoN:\t bAsIc\t', 'authorization:\nBasic ']) {
|
||||
expect(sanitizeLogTextForUpload(`${prefix}${credential} rejected`)).toBe(`${prefix}*** rejected`);
|
||||
}
|
||||
});
|
||||
|
||||
it('masks repeated authentication headers without swallowing diagnostic delimiters', () => {
|
||||
const input = '(Authorization: Basic bad), Proxy-Authorization: Basic incomplete; status=401';
|
||||
expect(sanitizeLogTextForUpload(input)).toBe('(Authorization: Basic ***), Proxy-Authorization: Basic ***; status=401');
|
||||
});
|
||||
|
||||
it('masks malformed credentials in structured text representations of authentication fields', () => {
|
||||
for (const field of ['authorization', 'Proxy-Authorization', 'proxy_authorization', 'proxyAuthorization']) {
|
||||
const input = JSON.stringify({ [field]: 'Basic incomplete', status: 401 });
|
||||
expect(sanitizeLogTextForUpload(input)).toBe(JSON.stringify({ [field]: '***', status: 401 }));
|
||||
expect(sanitizeLogTextForUpload(`${field}=Basic incomplete retry=true`)).not.toContain('incomplete');
|
||||
expect(sanitizeLogTextForUpload(`https://example.test/?${field}=Basic%20incomplete&retry=true`))
|
||||
.toBe(`https://example.test/?${field}=***&retry=true`);
|
||||
}
|
||||
expect(sanitizeLogTextForUpload("'Proxy-Authorization': 'Basic incomplete'"))
|
||||
.toBe("'Proxy-Authorization': '***'");
|
||||
});
|
||||
|
||||
it('masks standard credentials with empty fields, Unicode and omitted padding', () => {
|
||||
for (const user of ['', 'a', 'ab', 'alice', '用户', 'ü']) {
|
||||
for (const password of ['', 'x', 'xy', 'xyz', 'long:password', '密码']) {
|
||||
const token = Buffer.from(`${user}:${password}`).toString('base64');
|
||||
for (const value of [token, token.replace(/=+$/, '')]) {
|
||||
expect(sanitizeLogTextForUpload(`bAsIc ${value}`)).toBe('Basic ***');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps redaction stable when an error crosses multiple sanitizing consumers', () => {
|
||||
const input = 'Authorization: Basic incomplete; falling back to basic retrieval';
|
||||
const once = sanitizeLogTextForUpload(input);
|
||||
expect(once).toBe('Authorization: Basic ***; falling back to basic retrieval');
|
||||
expect(sanitizeLogTextForUpload(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('is case-insensitive on the Bearer scheme name (RFC 6750)', () => {
|
||||
const out = sanitizeLogTextForUpload('Cookie: bearer x.y.z');
|
||||
expect(out).toBe('Cookie: Bearer ***');
|
||||
@@ -114,6 +172,13 @@ describe('sanitizeLogTextForUpload › set A (must be masked)', () => {
|
||||
});
|
||||
|
||||
describe('sanitizeLogTextForUpload › set B (must be preserved)', () => {
|
||||
it('leaves the word "basic" in prose untouched', () => {
|
||||
// Basic credentials are base64 of "user:password"; a plain word after
|
||||
// "basic" is not one and must stay readable in the log.
|
||||
expect(sanitizeLogTextForUpload('falling back to basic retrieval')).toBe('falling back to basic retrieval');
|
||||
expect(sanitizeLogTextForUpload('Basic auth failed for proxy')).toBe('Basic auth failed for proxy');
|
||||
});
|
||||
|
||||
it('leaves "BearerWord" (no whitespace after Bearer) untouched', () => {
|
||||
// word-boundary follower required — "Bearer" without trailing whitespace
|
||||
// is just a word in prose, not an auth header.
|
||||
|
||||
@@ -43,6 +43,7 @@ describe('application shutdown durability and latency', () => {
|
||||
expect(compositions).toHaveLength(1);
|
||||
const archived = gate();
|
||||
const browserSaved = gate();
|
||||
const historyExited = gate();
|
||||
const lifecycleSaved = gate();
|
||||
const appDataDrained = gate();
|
||||
const exited = gate();
|
||||
@@ -56,6 +57,7 @@ describe('application shutdown durability and latency', () => {
|
||||
'./features/kb_vector': { closeAllKb: mark('kb-database') },
|
||||
'./features/kb_embed': { closeEmbedder: mark('embed-worker') },
|
||||
'./util/sharp-runtime': { closeImageRuntime: mark('image-worker') },
|
||||
'./features/conversation-history-client': { closeConversationHistoryWorker: () => { events.push('history-workers'); return historyExited.promise; } },
|
||||
'./util/office-extract': { closeOfficeExtraction: mark('office-extract') },
|
||||
'./features/office/office_engine': { closeAllOfficeResidents: mark('office') },
|
||||
'./features/web_assist_session': { flushWebAssistSessions: () => { events.push('browser-save'); return browserSaved.promise; } },
|
||||
@@ -91,6 +93,7 @@ describe('application shutdown durability and latency', () => {
|
||||
expect(events).toEqual(mode === 'quit' ? ['capture', 'auto-scheduler', 'driver-scheduler', 'archive', 'background-cli', 'process-sessions'] : ['auto-scheduler', 'driver-scheduler', 'archive', 'background-cli', 'process-sessions']);
|
||||
archived.release();
|
||||
await vi.waitFor(() => expect(events).toContain('browser-save'));
|
||||
expect(events).toContain('history-workers');
|
||||
expect(events.indexOf('library-owners')).toBeLessThan(events.indexOf('kb-database'));
|
||||
expect(events.indexOf('library-owners')).toBeLessThan(events.indexOf('embed-worker'));
|
||||
expect(events.indexOf('boot-work')).toBeLessThan(events.indexOf('library-owners'));
|
||||
@@ -106,6 +109,9 @@ describe('application shutdown durability and latency', () => {
|
||||
expect(events).not.toContain('terminate');
|
||||
expect(events.indexOf('app-close')).toBeLessThan(events.indexOf('app-databases'));
|
||||
appDataDrained.release();
|
||||
await Promise.resolve();
|
||||
expect(shutdown.complete).toBe(false);
|
||||
historyExited.release();
|
||||
if (mode === 'quit') {
|
||||
await vi.waitFor(() => expect(events).toContain('terminate'));
|
||||
expect(shutdown.complete).toBe(false);
|
||||
@@ -114,7 +120,7 @@ describe('application shutdown durability and latency', () => {
|
||||
exited.release();
|
||||
}
|
||||
await pending;
|
||||
const owners = ['auto-scheduler', 'driver-scheduler', 'archive', 'background-cli', 'process-sessions', 'boot-work', 'library-owners', 'connectors', 'search-workers', 'kb-database', 'embed-worker', 'image-worker', 'office-extract', 'office', 'browser-save', 'open-lifecycle', 'app-close', 'app-databases'];
|
||||
const owners = ['auto-scheduler', 'driver-scheduler', 'archive', 'background-cli', 'process-sessions', 'boot-work', 'library-owners', 'connectors', 'search-workers', 'kb-database', 'embed-worker', 'image-worker', 'history-workers', 'office-extract', 'office', 'browser-save', 'open-lifecycle', 'app-close', 'app-databases'];
|
||||
expect([...events].sort()).toEqual([...owners, ...(mode === 'quit' ? ['capture', 'terminate', 'dispose'] : [])].sort());
|
||||
expect(shutdown.complete).toBe(true);
|
||||
});
|
||||
@@ -140,6 +146,7 @@ describe('application shutdown durability and latency', () => {
|
||||
'./features/kb_vector': { closeAllKb: vi.fn() },
|
||||
'./features/kb_embed': { closeEmbedder: vi.fn() },
|
||||
'./util/sharp-runtime': { closeImageRuntime: vi.fn() },
|
||||
'./features/conversation-history-client': { closeConversationHistoryWorker: vi.fn() },
|
||||
'./util/office-extract': { closeOfficeExtraction: vi.fn() },
|
||||
'./features/office/office_engine': { closeAllOfficeResidents: vi.fn() },
|
||||
'./features/web_assist_session': { flushWebAssistSessions: vi.fn() },
|
||||
|
||||
@@ -92,6 +92,50 @@ describe('chat-file-viewer › _kindOf', () => {
|
||||
});
|
||||
|
||||
describe('file preview availability feedback', () => {
|
||||
it.each([
|
||||
['archive.zip', 'Download report.pdf', false],
|
||||
['ARCHIVE.ZIP', 'Archive', true],
|
||||
['README', 'Read me', false],
|
||||
])('shows the fallback in the owner without opening a preview for %s', async (name, label, confirm) => {
|
||||
const path = `/fixture/${name}`;
|
||||
const open = vi.fn();
|
||||
const image = vi.fn();
|
||||
const invoke = vi.fn(async () => ({ ok: true, exists: true, isFile: true }));
|
||||
const uiConfirm = vi.fn(async () => confirm);
|
||||
const context = vm.createContext({
|
||||
window: { orkas: { invoke }, OrkasPreviewWindows: { open, image, options: (value: unknown) => value } },
|
||||
uiConfirm,
|
||||
});
|
||||
vm.runInContext(readFileSync(require.resolve('../../src/renderer/modules/chat-file-viewer.js'), 'utf8'), context);
|
||||
await context.openChatFileViewer(path, label, { cid: 'task', projectId: 'project' });
|
||||
expect(uiConfirm).toHaveBeenCalledExactlyOnceWith({
|
||||
message: `Cannot preview ${name === 'README' ? '(no extension)' : '.zip'} files in app. Open the containing folder?`,
|
||||
okLabel: 'Open Folder', cancelLabel: 'Close',
|
||||
});
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(image).not.toHaveBeenCalled();
|
||||
expect(invoke.mock.calls).toEqual([
|
||||
['workspace.statPath', { path, cid: 'task', projectId: 'project' }],
|
||||
...(confirm ? [['workspace.revealPath', { path, cid: 'task', projectId: 'project' }]] : []),
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a missing unsupported file without opening a preview or offering to reveal it', async () => {
|
||||
const open = vi.fn();
|
||||
const uiConfirm = vi.fn();
|
||||
const uiToast = vi.fn();
|
||||
const context = vm.createContext({
|
||||
window: { orkas: { invoke: vi.fn(async () => ({ ok: true, exists: false })) },
|
||||
OrkasPreviewWindows: { open, options: (value: unknown) => value } },
|
||||
uiConfirm, uiToast,
|
||||
});
|
||||
vm.runInContext(readFileSync(require.resolve('../../src/renderer/modules/chat-file-viewer.js'), 'utf8'), context);
|
||||
await context.openChatFileViewer('/fixture/missing.zip', 'missing.zip');
|
||||
expect(uiToast).toHaveBeenCalledExactlyOnceWith('The file no longer exists.', { variant: 'warning' });
|
||||
expect(uiConfirm).not.toHaveBeenCalled();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['stat', 'reveal', 'reveal-response'])('keeps private file details out of %s failure diagnostics', async (failure) => {
|
||||
const privatePath = '/private/account/todo/brief.png';
|
||||
const privateError = `EACCES: ${privatePath}`;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../src/renderer/modules/conversation.js'), 'utf8');
|
||||
|
||||
@@ -39,6 +40,9 @@ class FakeElement {
|
||||
dataset: Record<string, string> = {};
|
||||
classList: FakeClassList;
|
||||
parentElement: FakeHistory | null = null;
|
||||
style = {};
|
||||
querySelector() { return null; }
|
||||
matches(selector: string) { return selector === '.chat-message[data-ts]' && this.dataset.ts != null; }
|
||||
|
||||
constructor(public label: string, className: string, dataset: Record<string, string> = {}) {
|
||||
this.classList = new FakeClassList(className);
|
||||
@@ -50,10 +54,26 @@ class FakeElement {
|
||||
const idx = this.parentElement.children.indexOf(this);
|
||||
return idx > 0 ? this.parentElement.children[idx - 1] : null;
|
||||
}
|
||||
|
||||
get nextElementSibling(): FakeElement | null {
|
||||
if (!this.parentElement) return null;
|
||||
const idx = this.parentElement.children.indexOf(this);
|
||||
return this.parentElement.children[idx + 1] || null;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeHistory {
|
||||
children: FakeElement[] = [];
|
||||
scrollTop = 123;
|
||||
style = { removeProperty() {} };
|
||||
|
||||
querySelector(selector: string) {
|
||||
const id = selector.match(/data-msg-id="([^"]+)"/)?.[1];
|
||||
return id ? this.children.find(el => el.dataset.msgId === id) || null : null;
|
||||
}
|
||||
|
||||
querySelectorAll() { return this.children.filter(el => el.dataset.ts != null); }
|
||||
appendChild(el: FakeElement) { this.insertBefore(el, null); }
|
||||
|
||||
append(label: string, className: string, dataset: Record<string, string> = {}) {
|
||||
const el = new FakeElement(label, className, dataset);
|
||||
@@ -75,6 +95,39 @@ class FakeHistory {
|
||||
}
|
||||
}
|
||||
|
||||
function loadSnapshotOrdering(history: FakeHistory, row: FakeElement) {
|
||||
const parsed = ts.createSourceFile('conversation.js', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
||||
const names = new Set(['_restoreLiveDisplaySnapshot', '_restoreStreamingMessageActivityPosition',
|
||||
'_insertByTimestamp', '_appendBeforeSpacer', '_nextChatMessage', '_isChatMessageEl',
|
||||
'_hasChatMessageClass', '_isLivePlaceholderMessage', '_anchorPlaceholderToTurnStart',
|
||||
'_isTimestampPositionCorrect']);
|
||||
const functions = parsed.statements.filter(node => ts.isFunctionDeclaration(node)
|
||||
&& names.has(node.name?.text || '')).map(node => node.getText(parsed)).join('\n');
|
||||
const context: any = {
|
||||
currentCid: 'c1', CSS: { escape: (s: string) => s },
|
||||
formatTime: (s: string) => s,
|
||||
_turnHasEndedRow: () => false, _unpersistedTurnRows: () => [row],
|
||||
_cancelPendingStreamRaf() {}, _phKey: (...parts: string[]) => parts.join('|'),
|
||||
_groupPlaceholders: new Map(), _absorbedTurnSegments: new Map(),
|
||||
_isFoldableSegmentActor: () => true, _rowSegmentIndex: () => 0,
|
||||
_stampRenderKey(el: FakeElement, _cid: string, key: string) { el.dataset.renderKey = key; },
|
||||
_segmentRenderKey: (turn: string, seg: number) => `s:${turn}:${seg}`,
|
||||
_replayPersistedProcessItems() {}, _streamingAppendFinalDelta() {},
|
||||
_markProgrammaticStickyScroll() {}, pendingConvs: new Map(),
|
||||
_liveDisplayWatermarks: new Map(), _offViewLiveDisplayDirty: new Set(),
|
||||
document: { getElementById: () => history },
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(functions, context);
|
||||
return {
|
||||
restore: (afterMessageId?: string) => context._restoreLiveDisplaySnapshot('c1', {
|
||||
sequence: 4, turns: [{ actor: 'codex', turn_id: 'turn', activity_after_message_id: afterMessageId,
|
||||
records: [{ seg: 0, text: 'Recovered output', process: [] }] }],
|
||||
}),
|
||||
reanchor: (startedAt: number) => context._anchorPlaceholderToTurnStart(row, startedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function loadMoveHelper(): (container: FakeHistory, userEl: FakeElement) => boolean {
|
||||
const fns = [
|
||||
'_isChatMessageEl',
|
||||
@@ -88,6 +141,39 @@ function loadMoveHelper(): (container: FakeHistory, userEl: FakeElement) => bool
|
||||
}
|
||||
|
||||
describe('conversation message ordering', () => {
|
||||
it.each([1000, 2300])('restores output after the updates it followed while retaining a newer same-second user below it (start=%s)', startedAt => {
|
||||
const history = new FakeHistory();
|
||||
history.append('original', 'chat-message user', { ts: '900', msgId: 'original' });
|
||||
const row = history.append('reply', 'chat-message assistant', {
|
||||
ts: String(startedAt), placeholder: '1', renderKey: 's:turn:0',
|
||||
});
|
||||
history.append('update-1', 'chat-message user', { ts: '2000', msgId: 'update-1' });
|
||||
history.append('update-2', 'chat-message user', { ts: '2000', msgId: 'update-2' });
|
||||
history.append('not-yet-followed', 'chat-message user', { ts: '2000', msgId: 'newer' });
|
||||
const { restore, reanchor } = loadSnapshotOrdering(history, row);
|
||||
restore('update-2');
|
||||
expect(history.labels()).toEqual(['original', 'update-1', 'update-2', 'reply', 'not-yet-followed']);
|
||||
reanchor(startedAt);
|
||||
expect(history.labels()).toEqual(['original', 'update-1', 'update-2', 'reply', 'not-yet-followed']);
|
||||
expect(history.scrollTop).toBe(123);
|
||||
// Repeated foreground recovery does not change order or promote the
|
||||
// recovered old content across a user message received after that content.
|
||||
restore('update-2');
|
||||
expect(history.labels()).toEqual(['original', 'update-1', 'update-2', 'reply', 'not-yet-followed']);
|
||||
expect(row.dataset.activitySortAfter).toBe('2000');
|
||||
});
|
||||
|
||||
it.each([undefined, 'not-in-page', 'original'])('keeps an earlier reply in place without a newer recovered user boundary: %s', afterId => {
|
||||
const history = new FakeHistory();
|
||||
history.append('original', 'chat-message user', { ts: '900', msgId: 'original' });
|
||||
const row = history.append('reply', 'chat-message assistant', {
|
||||
ts: '1000', placeholder: '1', renderKey: 's:turn:0',
|
||||
});
|
||||
history.append('newer', 'chat-message user', { ts: '1000', msgId: 'newer' });
|
||||
loadSnapshotOrdering(history, row).restore(afterId);
|
||||
expect(history.labels()).toEqual(['original', 'reply', 'newer']);
|
||||
});
|
||||
|
||||
it('places a late-rendered user message before an orphan live placeholder', () => {
|
||||
const move = loadMoveHelper();
|
||||
const history = new FakeHistory();
|
||||
|
||||
@@ -630,6 +630,38 @@ it('hydrates only visible chat rows and requests newly visible rows after scroll
|
||||
} finally { vi.useRealTimers(); vi.restoreAllMocks(); }
|
||||
});
|
||||
|
||||
it.each([0, 40])('keeps the reading position at scrollTop=%i when summaries above the selection grow', async initialTop => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { body, input, context, streams } = progressiveHarness(domHarness());
|
||||
vi.spyOn(MiniElement.prototype, 'getBoundingClientRect').mockImplementation(function(this: MiniElement) {
|
||||
if (this.id === 'search-body') return { top: 0, bottom: 200, left: 0, right: 0, width: 0, height: 200 };
|
||||
const index = Number(this.dataset.idx);
|
||||
const growth = index > 0 && body.querySelector('.search-result-snippet') ? 120 : 0;
|
||||
const top = index * 80 + growth - body.scrollTop;
|
||||
return { top, bottom: top + 80, left: 0, right: 0, width: 0, height: 80 };
|
||||
});
|
||||
input.value = 'needle';
|
||||
const rows = [0, 1].map(index => ({ kind: 'chat', cid: `c${index}`, msg_index: 0, conv_title: `Result ${index}` }));
|
||||
const search = context._runSearchNow(input.value);
|
||||
streams[0].finish({ results: rows, pending_kinds: [], failed_kinds: [] });
|
||||
await search;
|
||||
vm.runInContext('_searchActiveIdx = 1', context);
|
||||
context._renderSearchResults(input.value);
|
||||
body.scrollTop = initialTop;
|
||||
const before = body.querySelector('.search-result.active').getBoundingClientRect().top;
|
||||
const summaries = context._loadVisibleChatSnippets();
|
||||
streams[1].finish({
|
||||
results: rows.map((row, index) => ({ ...row, snippet_loaded: true, snippet: index === 0 ? 'needle summary' : '' })),
|
||||
pending_kinds: [], failed_kinds: [],
|
||||
});
|
||||
await summaries;
|
||||
expect(body.querySelector('.search-result-snippet').textContent).toBe('needle summary');
|
||||
if (initialTop === 0) expect(body.scrollTop).toBe(0);
|
||||
else expect(body.querySelector('.search-result.active').getBoundingClientRect().top).toBe(before);
|
||||
} finally { vi.useRealTimers(); vi.restoreAllMocks(); }
|
||||
});
|
||||
|
||||
it('paints completed modules while chats are held and retains selection when chat and Library results arrive', async () => {
|
||||
const { body, context, streams } = progressiveHarness();
|
||||
|
||||
|
||||
@@ -24,8 +24,26 @@ describe('visualization content references in replies', () => {
|
||||
expect(html).not.toContain('<iframe');
|
||||
});
|
||||
|
||||
it.each([undefined, 'wide'])('renders the optional visualization title with mode %s', mode => {
|
||||
// The visualization protocol permits title; this is the shape of the
|
||||
// reported feedback-dialog reply that previously stayed as raw source.
|
||||
const marker = visualize({ path: '/workspace/message-feedback.html', title: '消息反馈弹窗(中文)', ...(mode ? { mode } : {}) });
|
||||
const html = renderMarkdown(marker);
|
||||
expect(html).toContain('data-chat-md-html-embed="1"');
|
||||
expect(html).toContain('aria-label="Preview: 消息反馈弹窗(中文)"');
|
||||
expect(html).not.toContain('\uE200');
|
||||
});
|
||||
|
||||
it('treats the optional title as escaped text rather than markup', () => {
|
||||
const html = renderMarkdown(visualize({ path: '/workspace/preview.html', title: '\"><img src=x onerror="alert(1)"> & preview' }));
|
||||
expect(html).toContain('data-chat-md-html-embed="1"');
|
||||
expect(html).toContain('"><img src=x onerror="alert(1)"> & preview');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('<script');
|
||||
});
|
||||
|
||||
it('keeps reference examples literal inside code and prose', () => {
|
||||
const marker = visualize({ path: '/workspace/preview.html' });
|
||||
const marker = visualize({ path: '/workspace/preview.html', title: 'Preview' });
|
||||
for (const source of [`\`${marker}\``, `\`\`\`text\n${marker}\n\`\`\``, `Example: ${marker}`, `> ${marker}`]) {
|
||||
const html = renderMarkdown(source);
|
||||
expect(html).toContain('\uE200visualize\uE202');
|
||||
@@ -43,6 +61,7 @@ describe('visualization content references in replies', () => {
|
||||
{ path: '/workspace/preview\ud800.html' },
|
||||
{ path: `/workspace/${'a'.repeat(16_384)}.html` },
|
||||
{ path: '/workspace/preview.html', mode: 'unknown' },
|
||||
...[null, 42, [], {}].map(title => ({ path: '/workspace/preview.html', title })),
|
||||
{ path: '/workspace/preview.html', script: '<script>alert(1)</script>' },
|
||||
].map(spec => [spec]))('does not activate an invalid or lookalike reference (case %#)', spec => {
|
||||
const html = renderMarkdown(`Before\n${visualize(spec)}\nAfter`);
|
||||
@@ -61,7 +80,7 @@ describe('visualization content references in replies', () => {
|
||||
});
|
||||
|
||||
it('activates only a complete streamed reference and keeps cached rendering equivalent', () => {
|
||||
const marker = visualize({ path: '/workspace/preview.html' });
|
||||
const marker = visualize({ path: '/workspace/preview.html', title: '消息反馈弹窗(中文)' });
|
||||
const cache: any = {};
|
||||
for (let end = 1; end <= marker.length; end++) {
|
||||
const source = `Before\n\n${marker.slice(0, end)}`;
|
||||
|
||||
Reference in new issue
Block a user