Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,16 @@ function navigationHref(navigation) {
}

function completionNavigation(navigation) {
return { href: navigationHref(navigation) || '/' };
const href = navigationHref(navigation) || '/';
let sessionId = null;
try {
const url = new URL(href, self.location.origin);
const match = url.pathname.match(/^\/session\/([^/]+)$/);
sessionId = match ? decodeURIComponent(match[1]) : null;
} catch {
sessionId = null;
}
return { href, sessionId };
}
function isSameOriginClient(client) {
try {
Expand Down Expand Up @@ -179,8 +188,21 @@ self.addEventListener('notificationclick', event => {

const navigation = navigationHref(event.notification.data?.navigation);
if (navigation) {
const sessionId = event.notification.data?.navigation?.sessionId || null;
event.waitUntil(
focusClientOrOpen(navigation, client => client.navigate(navigation))
focusClientOrOpen(navigation, client => {
client.postMessage({
type: 'notification:navigate',
sessionId,
provider: null,
urlPath: navigation
});
try {
return Promise.resolve(client.navigate(navigation)).catch(() => undefined);
} catch {
return Promise.resolve();
}
})
);
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,10 @@ function asResolvedActivity(value: unknown): MonitorResolvedActivity | null {
return result as MonitorResolvedActivity;
}

function completionPayload(session: ExternalCliSession, target: CompletionTargetResolution['target']): TerminalCompletionDecision['payload'] {
function completionPayload(
session: ExternalCliSession,
appSessionId: string | null,
): TerminalCompletionDecision['payload'] {
const title = typeof session.tmuxName === 'string' && session.tmuxName.trim()
? session.tmuxName.trim()
: 'ChatMux';
Expand All @@ -180,7 +183,7 @@ function completionPayload(session: ExternalCliSession, target: CompletionTarget
title,
body: `${label}: Reply ready`,
navigation: {
href: `/session/${encodeURIComponent(target.alias)}`,
href: appSessionId ? `/session/${encodeURIComponent(appSessionId)}` : '/',
title,
},
};
Expand Down Expand Up @@ -400,7 +403,7 @@ export function createExternalTurnMonitor(deps: MonitorDeps) {
evidenceCursor: cursor,
eventCode: 'reply_ready',
targetAliasSnapshot: resolution.target.alias,
payload: completionPayload(session, resolution.target),
payload: completionPayload(session, resolution.appSessionId),
now: now(),
});
if (decision.status === 'baselined') emitDiagnostic({ code: 'baselined', ...diagnosticContext(session) });
Expand Down
18 changes: 17 additions & 1 deletion server/modules/notifications/tests/external-turn-monitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function harness() {
resolveTargets: ((detailed: any) => detailed.sessions.filter((item: any) => item.kind !== 'cursor').map((item: any) => ({
generationIdentityKey: completionExternalGenerationIdentityKey(completionExternalGenerationIdentityFromSession(item)!),
generationTargetId: item.generationTargetId ?? 17,
appSessionId: null, target: { alias: 'target', watched: item.watched ?? true }, mappingState: item.mappingState ?? 'inactive_match',
appSessionId: item.appSessionId ?? null, target: { alias: 'target', watched: item.watched ?? true }, mappingState: item.mappingState ?? 'inactive_match',
}))) as any,
observeGeneration: (_id, cursor, observation) => {
if (throwObserve) throw new Error('db');
Expand Down Expand Up @@ -158,6 +158,22 @@ test('external monitor silently persists a startup reply-ready baseline, then cr
assert.equal(h.wakes.length, 1);
});

test('external completion deep-links only with the mapped app session id', async () => {
const mapped = harness();
mapped.setSessions([session({ appSessionId: 'app-session-1' })]);
mapped.setAnswer({
...resolved('waiting_user', 'reply_ready'),
appSession: { session_id: 'app-session-1' },
});
await mapped.monitor.tick();
assert.equal(mapped.decisions[0]?.payload.navigation.href, '/session/app-session-1');

const unmapped = harness();
await unmapped.monitor.tick();
assert.equal(unmapped.decisions[0]?.payload.navigation.href, '/');
assert.notEqual(unmapped.decisions[0]?.payload.navigation.href, '/session/target');
});

test('terminal replay is delegated to the durable decision repository after an armed generation', async () => {
const h = harness();
h.setAnswer(resolved('running', 'none', 'run')); await h.monitor.tick();
Expand Down
99 changes: 69 additions & 30 deletions server/modules/providers/list/codex/codex-sessions.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ function extractCodexTextContent(content: unknown): string {
type CodexHistoryAccumulator = {
messages: AnyRecord[];
tokenUsage: AnyRecord | null;
retainedBytes: number;
malformed: boolean;
};

Expand All @@ -113,11 +112,11 @@ type CodexHistoryCacheEntry = {
inode: number | bigint;
offset: number;
tail: string;
modifiedAtMs: number;
boundary: Buffer;
messages: NormalizedMessage[];
tokenUsage: AnyRecord | null;
malformed: boolean;
retainedBytes: number;
normalizedBytes: number;
toolResults: Map<string, NormalizedMessage>;
toolUses: Map<string, NormalizedMessage[]>;
sortTimestamps: WeakMap<NormalizedMessage, number>;
Expand All @@ -129,7 +128,12 @@ type CodexHistoryNormalizer = (
) => NormalizedMessage[];

const CODEX_HISTORY_CACHE_MAX_ENTRIES = 4;
const CODEX_HISTORY_CACHE_MAX_RETAINED_BYTES = 8 * 1024 * 1024;
// Bound cached *normalized* history rather than the raw rollout size. Codex
// rollouts often contain large context records that never become UI messages;
// evicting based on raw bytes made those files get reparsed from byte zero on
// every 20-message page request.
const CODEX_HISTORY_CACHE_MAX_NORMALIZED_BYTES = 96 * 1024 * 1024;
const CODEX_HISTORY_BOUNDARY_BYTES = 4 * 1024;
const codexHistoryCache = new Map<string, CodexHistoryCacheEntry>();
const codexHistoryRefreshes = new Map<string, Promise<CodexHistoryCacheEntry>>();

Expand Down Expand Up @@ -297,11 +301,43 @@ function parseCodexHistoryLine(line: string, accumulator: CodexHistoryAccumulato
function touchCodexHistoryCache(sessionId: string, entry: CodexHistoryCacheEntry): void {
codexHistoryCache.delete(sessionId);
codexHistoryCache.set(sessionId, entry);
while (codexHistoryCache.size > CODEX_HISTORY_CACHE_MAX_ENTRIES) {
const cachedBytes = () => Array.from(codexHistoryCache.values())
.reduce((total, candidate) => total + candidate.normalizedBytes, 0);
while (
codexHistoryCache.size > CODEX_HISTORY_CACHE_MAX_ENTRIES
|| (codexHistoryCache.size > 1 && cachedBytes() > CODEX_HISTORY_CACHE_MAX_NORMALIZED_BYTES)
) {
codexHistoryCache.delete(codexHistoryCache.keys().next().value!);
}
}

function estimateCodexMessageBytes(message: NormalizedMessage): number {
let value: string;
try {
value = JSON.stringify({
content: message.content,
images: message.images,
toolInput: message.toolInput,
}) || '';
} catch {
value = String(message.content || '');
}
return Buffer.byteLength(value) + 256;
}

async function readCodexHistoryBoundary(filePath: string, offset: number): Promise<Buffer> {
if (offset <= 0) return Buffer.alloc(0);
const length = Math.min(offset, CODEX_HISTORY_BOUNDARY_BYTES);
const buffer = Buffer.allocUnsafe(length);
const handle = await fsSync.promises.open(filePath, 'r');
try {
const { bytesRead } = await handle.read(buffer, 0, length, offset - length);
return buffer.subarray(0, bytesRead);
} finally {
await handle.close();
}
}

function codexMessageTimestamp(
message: NormalizedMessage,
sortTimestamps?: WeakMap<NormalizedMessage, number>,
Expand All @@ -327,25 +363,38 @@ function appendNormalizedCodexHistory(
const rawTimestamp = new Date(raw.timestamp || 0).getTime();
const sortTimestamp = Number.isFinite(rawTimestamp) ? rawTimestamp : 0;
for (const message of normalize(raw, sessionId)) {
entry.normalizedBytes += estimateCodexMessageBytes(message);
entry.sortTimestamps.set(message, sortTimestamp);
if (sortTimestamp < lastTimestamp) needsSort = true;
lastTimestamp = Math.max(lastTimestamp, sortTimestamp);

if (message.kind === 'tool_result' && message.toolId) {
entry.toolResults.set(message.toolId, message);
for (const toolUse of entry.toolUses.get(message.toolId) ?? []) {
const matchingToolUses = entry.toolUses.get(message.toolId) ?? [];
for (const toolUse of matchingToolUses) {
toolUse.toolResult = { content: message.content, isError: message.isError };
}
if (matchingToolUses.length > 0) {
entry.toolUses.delete(message.toolId);
entry.toolResults.delete(message.toolId);
}
} else if (message.kind === 'tool_use' && message.toolId) {
const toolResult = entry.toolResults.get(message.toolId);
if (toolResult) {
message.toolResult = { content: toolResult.content, isError: toolResult.isError };
entry.toolResults.delete(message.toolId);
} else {
const toolUses = entry.toolUses.get(message.toolId) ?? [];
toolUses.push(message);
entry.toolUses.set(message.toolId, toolUses);
}
const toolUses = entry.toolUses.get(message.toolId) ?? [];
toolUses.push(message);
entry.toolUses.set(message.toolId, toolUses);
}

// Tool results are represented inside their tool-use card. Returning the
// standalone result as well doubled large outputs and also made limit /
// offset count a different list than the frontend received.
if (message.kind === 'tool_result') continue;

if (sortTimestamp < lastTimestamp) needsSort = true;
lastTimestamp = Math.max(lastTimestamp, sortTimestamp);
entry.messages.push(message);
}
}
Expand All @@ -369,10 +418,12 @@ async function refreshCodexHistoryCache(
&& entry.filePath === sessionFilePath
&& entry.device === metadata.dev
&& entry.inode === metadata.ino;
const appendOnly = entry != null
const boundaryMatches = entry != null
&& sameFile
&& metadata.size >= entry.offset
&& !(metadata.size === entry.offset && metadata.mtimeMs !== entry.modifiedAtMs);
&& (entry.offset === 0
|| (await readCodexHistoryBoundary(sessionFilePath, entry.offset)).equals(entry.boundary));
const appendOnly = entry != null && boundaryMatches;

if (!entry || !appendOnly) {
entry = {
Expand All @@ -381,10 +432,10 @@ async function refreshCodexHistoryCache(
inode: metadata.ino,
offset: 0,
tail: '',
modifiedAtMs: metadata.mtimeMs,
boundary: Buffer.alloc(0),
messages: [],
tokenUsage: null,
retainedBytes: 0,
normalizedBytes: 0,
malformed: false,
toolResults: new Map(),
toolUses: new Map(),
Expand All @@ -396,7 +447,6 @@ async function refreshCodexHistoryCache(
const appended: CodexHistoryAccumulator = {
messages: [],
tokenUsage: entry.tokenUsage,
retainedBytes: 0,
malformed: entry.malformed,
};
let tail = entry.tail;
Expand All @@ -409,7 +459,6 @@ async function refreshCodexHistoryCache(
const lines = `${tail}${chunk}`.split(/\r?\n/);
tail = lines.pop() ?? '';
for (const line of lines) {
appended.retainedBytes += Buffer.byteLength(line);
parseCodexHistoryLine(line, appended);
if (appended.messages.length > 0) {
appendNormalizedCodexHistory(entry, appended.messages, sessionId, normalize);
Expand All @@ -421,16 +470,11 @@ async function refreshCodexHistoryCache(
entry.tokenUsage = appended.tokenUsage;
entry.tail = tail;
entry.offset = metadata.size;
entry.retainedBytes += appended.retainedBytes;
entry.boundary = await readCodexHistoryBoundary(sessionFilePath, entry.offset);
entry.malformed = appended.malformed;
entry.modifiedAtMs = metadata.mtimeMs;
}

if (entry.retainedBytes + Buffer.byteLength(entry.tail) <= CODEX_HISTORY_CACHE_MAX_RETAINED_BYTES) {
touchCodexHistoryCache(sessionId, entry);
} else {
codexHistoryCache.delete(sessionId);
}
touchCodexHistoryCache(sessionId, entry);
return entry;
}

Expand Down Expand Up @@ -801,12 +845,7 @@ export class CodexSessionsProvider implements IProviderSessions {
const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage;
const sourceStatus = Array.isArray(result) ? 'available' : result.sourceStatus;

let total = 0;
for (const msg of normalized) {
if (msg.kind !== 'tool_result') {
total += 1;
}
}
const total = normalized.length;
const normalizedOffset = Math.max(0, offset);
const normalizedLimit = limit === null ? null : Math.max(0, limit);
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
Expand Down
12 changes: 12 additions & 0 deletions server/modules/providers/provider.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1452,6 +1452,7 @@ router.get(
const sessionId = parseSessionId(req.params.sessionId);
const limitRaw = readOptionalQueryString(req.query.limit);
const offsetRaw = readOptionalQueryString(req.query.offset);
const includeImages = parseOptionalBooleanQuery(req.query.includeImages, 'includeImages');

let limit: number | null = null;
if (limitRaw !== undefined) {
Expand Down Expand Up @@ -1480,11 +1481,22 @@ router.get(
const result = await sessionsService.fetchHistory(sessionId, {
limit,
offset,
includeImages,
});
res.json(createApiSuccessResponse(result));
}),
);

router.get(
'/sessions/:sessionId/tool-result',
asyncHandler(async (req: Request, res: Response) => {
const sessionId = parseSessionId(req.params.sessionId);
const toolId = readAskToolId(req.query.toolId);
const result = await sessionsService.fetchToolResult(sessionId, toolId);
res.json(createApiSuccessResponse(result));
}),
);

router.get('/search/sessions', asyncHandler(async (req: Request, res: Response) => {
const query = parseSessionSearchQuery(req.query.q);
const limit = parseSessionSearchLimit(req.query.limit);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,11 +302,13 @@ const parseCodexEvidence = (records: JsonRecord[]): ExternalSessionParsedActivit
const payload = asRecord(record.payload);
const payloadType = readString(payload?.type)?.toLowerCase();

if (type === 'turn_aborted') return evidence('waiting_user', 'none');
if (type === 'turn_failed' || type === 'error' || isErrorRecord(record) || isErrorRecord(payload ?? {})) {
return evidence('waiting_user', 'failed');
}
if (type === 'turn_complete') return evidence('waiting_user', 'reply_ready');
if (type === 'event_msg') {
if (payloadType === 'turn_aborted') return evidence('waiting_user', 'none');
if (payloadType === 'turn_failed' || payloadType === 'error') return evidence('waiting_user', 'failed');
if (payloadType === 'task_complete' || payloadType === 'turn_complete') return evidence('waiting_user', 'reply_ready');
if (containsAskingTool(payload)) return evidence('asking_user', 'none');
Expand Down
Loading