blob: 0446ff8b23b38426cde7eafe0b4ea02b46be0b4b [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { deferred } from '@maka/core/test-only/async-primitives';
import type { SessionEvent } from '@maka/core/events';
import {
CTX_REFRESH_DEBOUNCE_MS,
createCtxRefresher,
isCtxRefreshRelevantEvent,
scheduleCtxRefreshTimeout,
} from '../tui-context-refresh.js';
function toolStartEvent(): SessionEvent {
return {
type: 'tool_start',
id: 'event-tool-start',
turnId: 'turn-1',
ts: 1,
toolUseId: 'tool-1',
toolName: 'Bash',
args: { command: 'npm test' },
};
}
function textDeltaEvent(): SessionEvent {
return {
type: 'text_delta',
id: 'event-text',
turnId: 'turn-1',
ts: 1,
messageId: 'message-1',
text: 'hello',
};
}
interface ScheduledRun {
callback: () => void;
delayMs: number;
}
function createFakeScheduler() {
let pending: ScheduledRun | undefined;
const schedule = (callback: () => void, delayMs: number) => {
pending = { callback, delayMs };
return () => {
if (pending?.callback === callback) pending = undefined;
};
};
return {
schedule,
hasPending: () => pending !== undefined,
pendingDelayMs: () => pending?.delayMs,
flush: () => {
const run = pending;
pending = undefined;
run?.callback();
},
};
}
describe('isCtxRefreshRelevantEvent', () => {
test('matches the desktop inspector trace-relevant set', () => {
for (const type of [
'tool_start',
'tool_result',
'token_usage',
'provider_retry',
'error',
'complete',
'abort',
]) {
assert.equal(isCtxRefreshRelevantEvent({ type } as SessionEvent), true, type);
}
});
test('streaming deltas never schedule a query', () => {
for (const type of ['text_delta', 'thinking_delta', 'tool_output', 'queue_update']) {
assert.equal(isCtxRefreshRelevantEvent({ type } as SessionEvent), false, type);
}
});
});
describe('createCtxRefresher', () => {
test('a relevant event runs one query after the debounce delay', async () => {
const scheduler = createFakeScheduler();
const queryCalls: number[] = [];
const applied: string[] = [];
const refresher = createCtxRefresher({
query: () => {
queryCalls.push(1);
return Promise.resolve('snapshot');
},
apply: (result) => applied.push(result),
delayMs: CTX_REFRESH_DEBOUNCE_MS,
schedule: scheduler.schedule,
});
refresher.observe(toolStartEvent());
assert.equal(scheduler.pendingDelayMs(), CTX_REFRESH_DEBOUNCE_MS);
assert.equal(queryCalls.length, 0);
scheduler.flush();
await Promise.resolve();
assert.deepEqual(queryCalls, [1]);
assert.deepEqual(applied, ['snapshot']);
});
test('a burst of events coalesces into one query, at the last event', async () => {
const scheduler = createFakeScheduler();
let queries = 0;
const refresher = createCtxRefresher({
query: () => {
queries += 1;
return Promise.resolve('snapshot');
},
apply: () => {},
delayMs: 400,
schedule: scheduler.schedule,
});
refresher.observe(toolStartEvent());
refresher.observe(toolStartEvent());
refresher.observe(toolStartEvent());
assert.equal(scheduler.hasPending(), true);
scheduler.flush();
await Promise.resolve();
assert.equal(queries, 1);
});
test('only the latest issued query may apply', async () => {
const scheduler = createFakeScheduler();
const queries: Array<ReturnType<typeof deferred<string>>> = [];
const applied: string[] = [];
const refresher = createCtxRefresher({
query: () => {
const query = deferred<string>();
queries.push(query);
return query.promise;
},
apply: (result) => applied.push(result),
delayMs: 400,
schedule: scheduler.schedule,
});
refresher.observe(toolStartEvent());
scheduler.flush();
refresher.observe(toolStartEvent());
scheduler.flush();
assert.equal(queries.length, 2);
// The newer answer lands first; the older read then resolves late and
// must not overwrite it with its staler snapshot.
queries[1]!.resolve('newer');
await Promise.resolve();
assert.deepEqual(applied, ['newer']);
queries[0]!.resolve('older');
await Promise.resolve();
assert.deepEqual(applied, ['newer']);
});
test('a newer relevant event retires an in-flight query before the replacement runs', async () => {
const scheduler = createFakeScheduler();
const queries: Array<ReturnType<typeof deferred<string>>> = [];
const applied: string[] = [];
const refresher = createCtxRefresher({
query: () => {
const query = deferred<string>();
queries.push(query);
return query.promise;
},
apply: (result) => applied.push(result),
delayMs: 400,
schedule: scheduler.schedule,
});
refresher.observe(toolStartEvent());
scheduler.flush();
refresher.observe(toolStartEvent());
queries[0]!.resolve('stale');
await Promise.resolve();
assert.deepEqual(applied, []);
scheduler.flush();
queries[1]!.resolve('fresh');
await Promise.resolve();
assert.deepEqual(applied, ['fresh']);
});
test('a failed query leaves the last value standing and later events still refresh', async () => {
const scheduler = createFakeScheduler();
const queries: Array<ReturnType<typeof deferred<string>>> = [];
const applied: string[] = [];
const refresher = createCtxRefresher({
query: () => {
const query = deferred<string>();
queries.push(query);
return query.promise;
},
apply: (result) => applied.push(result),
delayMs: 400,
schedule: scheduler.schedule,
});
refresher.observe(toolStartEvent());
scheduler.flush();
queries[0]!.reject(new Error('host not ready'));
// The rejection is consumed, not raised.
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(applied, []);
refresher.observe(toolStartEvent());
scheduler.flush();
queries[1]!.resolve('recovered');
await Promise.resolve();
assert.deepEqual(applied, ['recovered']);
});
test('cancel drops a scheduled query and retires an in-flight one', async () => {
const scheduler = createFakeScheduler();
const inFlight = deferred<string>();
const applied: string[] = [];
const refresher = createCtxRefresher({
query: () => inFlight.promise,
apply: (result) => applied.push(result),
delayMs: 400,
schedule: scheduler.schedule,
});
// Scheduled but not yet run: cancel prevents the query entirely.
refresher.observe(toolStartEvent());
refresher.cancel();
assert.equal(scheduler.hasPending(), false);
// In-flight: a resolution after cancel belongs to a retired read.
refresher.observe(toolStartEvent());
scheduler.flush();
refresher.cancel();
inFlight.resolve('stale');
await Promise.resolve();
assert.deepEqual(applied, []);
});
test('irrelevant events never schedule a query', () => {
const scheduler = createFakeScheduler();
const refresher = createCtxRefresher({
query: () => Promise.resolve('snapshot'),
apply: () => {},
delayMs: 400,
schedule: scheduler.schedule,
});
refresher.observe(textDeltaEvent());
assert.equal(scheduler.hasPending(), false);
});
test('the default timeout scheduler fires and cancels for real', async () => {
const fired = deferred<void>();
const cancel = scheduleCtxRefreshTimeout(() => fired.resolve(), 0);
cancel();
// A cancelled zero-delay timer must not fire on the next tick.
await new Promise((resolve) => setTimeout(resolve, 5));
let firedFlag = false;
void fired.promise.then(() => {
firedFlag = true;
});
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(firedFlag, false);
const fired2 = deferred<void>();
scheduleCtxRefreshTimeout(() => fired2.resolve(), 0);
await fired2.promise;
});
});