Compare commits

...
19 Commits
Author SHA1 Message Date
Orkas 7f8a980e1c Merge pull request #187: Avoid repeated ignore-path work during fallback search
Prepare inherited ignore paths and file-only rules once per directory during fallback searches. Preserve nested overrides, directory semantics, rescanning, cancellation and result limits. Cover filename and content searches without ripgrep.
2026-11-07 09:19:25 +08:00
CloudTianTian 378d2fba9a Avoid repeated ignore-path work during fallback search
Prepare inherited ignore paths and file-only rules once per directory during fallback searches. Preserve nested overrides, directory semantics, rescanning, cancellation and result limits. Cover filename and content searches without ripgrep.
2026-11-07 09:12:59 +08:00
Orkas 6b5cd5cd89 Merge pull request #186: Render visualization references with optional titles
Accept optional text titles in visualization references, escape preview labels, and retain strict shape, path and sandbox validation. Cover streaming, invalid titles and interactive previews after reopening.
2026-11-07 09:12:53 +08:00
BlueSkyID666 ca48c16d27 Render visualization references with optional titles
Accept optional text titles in visualization references, escape preview labels, and retain strict shape, path and sandbox validation. Cover streaming, invalid titles and interactive previews after reopening.
2026-11-07 09:06:40 +08:00
Orkas 0f6a52e0bb Merge pull request #185 from Orkas-AI/fix/pr178-basic-auth-diagnostics
fix: keep explicit Basic authentication fields private
2026-11-06 10:55:42 +08:00
Orkas 5ca617db4b Merge pull request #178 from kevin9327/fix/log-basic-word
Keep the word "basic" in log text that is not a Basic credential
2026-11-06 10:54:51 +08:00
wyt 304a40a0ec fix: preserve Basic authentication privacy in diagnostics
Preserve the PR's decoded-colon check for ambiguous prose, while masking explicit authentication fields even when credentials are invalid or truncated. Include proxy authorization in structured redaction and cover the logger delivery boundary.

Verified 72 related cases under Electron and the exact PR tree main typecheck. New privacy regressions fail before the repair.
2026-11-06 10:22:55 +08:00
Orkas 9040159980 Merge pull request #183: Preserve reply order when restoring active conversations
Retain the last visible user-message boundary for active reply snapshots and restore replies after that boundary. Preserve newer messages with equal timestamps, viewport position and runtime polling order. Add renderer, bus and native recovery regressions.
2026-11-06 09:28:09 +08:00
CloudTianTian bac0063078 Preserve reply order when restoring active conversations
Retain the last visible user-message boundary for active reply snapshots and restore replies after that boundary. Preserve newer messages with equal timestamps, viewport position and runtime polling order. Add renderer, bus and native recovery regressions.
2026-11-06 09:21:22 +08:00
Orkas 15d04b7788 Merge pull request #182: Wait for history cleanup before reopening worker lanes
Share the existing worker cleanup barrier across live projections and turn navigation. Reject new persisted-history admission during cleanup, preserve wait-and-succeed behavior for view recovery, and keep source history intact. Cover cold and warm lanes, repeated close, rejection and explicit recovery. Include the existing history-owner cleanup in desktop quit and relaunch, while retaining target lifecycle hooks.
2026-11-06 09:21:09 +08:00
BlueSkyID666 310c162452 Wait for history cleanup before reopening worker lanes
Share the existing worker cleanup barrier across live projections and turn navigation. Reject new persisted-history admission during cleanup, preserve wait-and-succeed behavior for view recovery, and keep source history intact. Cover cold and warm lanes, repeated close, rejection and explicit recovery. Include the existing history-owner cleanup in desktop quit and relaunch, while retaining target lifecycle hooks.
2026-11-06 09:14:21 +08:00
BlueSkyID666 967c46b0d0 Wait for history cleanup before reopening worker lanes
Share the existing worker cleanup barrier across live projections and turn navigation. Reject new persisted-history admission during cleanup, preserve wait-and-succeed behavior for view recovery, and keep source history intact. Cover cold and warm lanes, repeated close, rejection and explicit recovery.
2026-11-06 09:11:26 +08:00
Orkas c7c0e21fad Merge pull request #181: Preserve structured video provider rejection reasons
Preserve bounded structured provider codes for rejected creates and failed tasks without exposing provider prose. Retain configured fallback after rejected creates, preserve accepted-task no-replay behavior, and avoid unverified billing claims for user-owned keys. Add regression coverage for privacy, task ownership and recovery.
2026-11-05 09:30:07 +08:00
CloudTianTian b691acd48b Preserve structured video provider rejection reasons
Preserve bounded structured provider codes for rejected creates and failed tasks without exposing provider prose. Retain configured fallback after rejected creates, preserve accepted-task no-replay behavior, and avoid unverified billing claims for user-owned keys. Add regression coverage for privacy, task ownership and recovery.
2026-11-05 09:22:32 +08:00
Orkas 9ad2e19b91 Merge pull request #180: Show unsupported file guidance in the owner window
Validate file availability and kind before dispatching a native preview. Keep unsupported-file guidance, folder reveal, dismissal and focus in the owner window, with unit and Electron coverage.
2026-11-05 09:22:27 +08:00
BlueSkyID666 311d64afeb Show unsupported file guidance in the owner window
Validate file availability and kind before dispatching a native preview. Keep unsupported-file guidance, folder reveal, dismissal and focus in the owner window, with unit and Electron coverage.
2026-11-05 09:15:11 +08:00
Orkas 008a6b820d Merge pull request #179: Keep initial search results at the top
Keep the result list at its start when delayed categories or chat summaries arrive. Preserve anchoring after the user scrolls. Add deterministic and native renderer regressions.
2026-11-05 09:15:05 +08:00
CloudTianTian 678f06cfbb Keep initial search results at the top
Keep the result list at its start when delayed categories or chat summaries arrive. Preserve anchoring after the user scrolls. Add deterministic and native renderer regressions.
2026-11-05 09:07:40 +08:00
kevin af19c79cf8 Keep the word "basic" in log text that is not a Basic credential
sanitizeLogTextForUpload masked any "basic <word>" as "Basic ***", so
ordinary log text such as "falling back to basic retrieval" lost the
word after "basic". Basic credentials are base64 of "user-id:password"
(RFC 7617), so only mask a token that decodes to something with a colon.
2026-11-05 08:12:56 +09:00
30 changed files with 863 additions and 65 deletions

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;
}
+24 -8
View File
@@ -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 || [];
+50 -5
View File
@@ -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);
+4
View File
@@ -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
View File
@@ -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).
+3 -2
View File
@@ -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);
}
+30 -5
View 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;
};
}
+10 -3
View File
@@ -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.
+7 -6
View File
@@ -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
+36 -12
View File
@@ -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');
+4 -2
View File
@@ -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;
+3 -2
View File
@@ -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 '';
}
+27 -1
View File
@@ -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');
+42 -1
View File
@@ -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);
}
});
});
}
+67
View File
@@ -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 {
+4 -1
View File
@@ -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');
+93 -1
View File
@@ -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'});
});
});
+6 -4
View File
@@ -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);
+21
View File
@@ -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);
+8
View File
@@ -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',
+65
View File
@@ -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.
+8 -1
View File
@@ -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() },
+44
View File
@@ -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}`;
+86
View File
@@ -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();
+21 -2
View File
@@ -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('&quot;&gt;&lt;img src=x onerror=&quot;alert(1)&quot;&gt; &amp; 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)}`;