feat: add registry-driven Kimi provider
This commit is contained in:
@@ -17,6 +17,10 @@ const codexSession = {
|
||||
project: '-Users-tomiya-Code-quiet-zero',
|
||||
message_count: 2052,
|
||||
};
|
||||
const sourceCatalog = [
|
||||
{ id: 'claude', name: 'Claude Code', color: '#d97757' },
|
||||
{ id: 'codex', name: 'Codex', color: '#10a37f' },
|
||||
];
|
||||
|
||||
test('single-source activity groups omit provider provenance', () => {
|
||||
const split = { normal: [codexSession], noise: [{ ...codexSession, message_count: 12 }] };
|
||||
@@ -42,6 +46,7 @@ test('mixed activity groups expose provider before project and count', () => {
|
||||
activitySessionMetaParts(claudeSession, {
|
||||
mixedSources: true,
|
||||
projectLabel: 'quiet-zero',
|
||||
sourceCatalog,
|
||||
}),
|
||||
[
|
||||
{ kind: 'source', text: 'Claude Code' },
|
||||
|
||||
@@ -292,8 +292,8 @@ test('indexer service passes changed JSONL paths to the build worker', async ()
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].reason, 'watch');
|
||||
assert.deepEqual(calls[0].changedPaths, [
|
||||
'project-a/session-1.jsonl',
|
||||
'project-a/session-2.json',
|
||||
join(projectsDir, 'project-a/session-1.jsonl'),
|
||||
join(projectsDir, 'project-a/session-2.json'),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -342,6 +342,6 @@ test('indexer service watches Claude projects and Codex sessions for app-side in
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(calls[0].changedPaths, [
|
||||
'2026/06/15/rollout-2026-06-15T00-00-00-codex.jsonl',
|
||||
join(codexSessionsDir, '2026/06/15/rollout-2026-06-15T00-00-00-codex.jsonl'),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { buildIndex } from '../app/src/main/indexer.ts';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
class TestDatabase {
|
||||
constructor(dbPath) {
|
||||
this.db = new DatabaseSync(dbPath);
|
||||
}
|
||||
|
||||
pragma(statement) { this.db.exec(`PRAGMA ${statement}`); }
|
||||
exec(sql) { return this.db.exec(sql); }
|
||||
prepare(sql) { return this.db.prepare(sql); }
|
||||
close() { return this.db.close(); }
|
||||
}
|
||||
|
||||
function writeSession(kimiDir) {
|
||||
const sessionDir = join(kimiDir, 'sessions', 'workspace-1', 'session-index-1');
|
||||
const mainDir = join(sessionDir, 'agents', 'main');
|
||||
mkdirSync(mainDir, { recursive: true });
|
||||
writeFileSync(join(sessionDir, 'state.json'), JSON.stringify({
|
||||
title: 'Indexed Kimi session',
|
||||
workDir: '/tmp/indexed-kimi',
|
||||
createdAt: '2026-07-20T10:00:00.000Z',
|
||||
updatedAt: '2026-07-20T10:01:00.000Z',
|
||||
agents: { main: { type: 'main' } },
|
||||
}));
|
||||
const wirePath = join(mainDir, 'wire.jsonl');
|
||||
const records = [
|
||||
{ type: 'metadata', protocol_version: '1.5', created_at: 1753005600000 },
|
||||
{ type: 'context.append_message', time: 1753005601000, message: { role: 'user', content: [{ type: 'text', text: 'kimi index needle' }], toolCalls: [], origin: { kind: 'user' } } },
|
||||
];
|
||||
writeFileSync(wirePath, records.map((record) => JSON.stringify(record)).join('\n') + '\n');
|
||||
return { sessionDir, wirePath, records };
|
||||
}
|
||||
|
||||
test('app build indexes Kimi sessions through the provider registry without changing schema', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-kimi-index-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const codexDir = join(home, '.codex');
|
||||
const kimiDir = join(home, '.kimi-code');
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
writeSession(kimiDir);
|
||||
|
||||
const first = buildIndex({
|
||||
claudeDir,
|
||||
codexDir,
|
||||
providerRoots: { kimi: kimiDir },
|
||||
dbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
});
|
||||
assert.deepEqual(first.affectedSessionIds, ['kimi:session-index-1']);
|
||||
|
||||
const db = new TestDatabase(dbPath);
|
||||
assert.deepEqual(
|
||||
db.prepare('SELECT id,title,source,message_count FROM sessions').all().map((row) => ({ ...row })),
|
||||
[{ id: 'kimi:session-index-1', title: 'Indexed Kimi session', source: 'kimi', message_count: 1 }],
|
||||
);
|
||||
assert.equal(db.prepare('SELECT text FROM messages').get().text, 'kimi index needle');
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path = ?').get(
|
||||
join(kimiDir, 'sessions', 'workspace-1', 'session-index-1'),
|
||||
).c, 1);
|
||||
const schema = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='messages'").get().sql;
|
||||
assert.doesNotMatch(schema, /kimi/i);
|
||||
db.close();
|
||||
|
||||
const second = buildIndex({
|
||||
claudeDir,
|
||||
codexDir,
|
||||
providerRoots: { kimi: kimiDir },
|
||||
dbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
});
|
||||
assert.deepEqual(second.affectedSessionIds, []);
|
||||
});
|
||||
|
||||
test('Kimi undo and clear replace the indexed session instead of leaving stale rows', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-kimi-replay-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const codexDir = join(home, '.codex');
|
||||
const kimiDir = join(home, '.kimi-code');
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
const { wirePath, records } = writeSession(kimiDir);
|
||||
const assistant = {
|
||||
type: 'context.append_loop_event',
|
||||
time: 1753005602000,
|
||||
event: { type: 'content.part', uuid: 'answer-1', stepUuid: 'step-1', part: { type: 'text', text: 'answer removed by undo' } },
|
||||
};
|
||||
writeFileSync(wirePath, [...records, assistant].map(record => JSON.stringify(record)).join('\n') + '\n');
|
||||
|
||||
const options = {
|
||||
claudeDir,
|
||||
codexDir,
|
||||
providerRoots: { kimi: kimiDir },
|
||||
dbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
};
|
||||
buildIndex(options);
|
||||
let db = new TestDatabase(dbPath);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM messages').get().c, 2);
|
||||
db.close();
|
||||
|
||||
// Kimi undo shrinks the durable wire transcript.
|
||||
writeFileSync(wirePath, records.map(record => JSON.stringify(record)).join('\n') + '\n');
|
||||
buildIndex(options);
|
||||
db = new TestDatabase(dbPath);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM messages').get().c, 1);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM messages WHERE text LIKE '%removed by undo%'").get().c, 0);
|
||||
db.close();
|
||||
|
||||
// Clear retains the session container but removes all projected messages.
|
||||
writeFileSync(wirePath, JSON.stringify(records[0]) + '\n');
|
||||
buildIndex(options);
|
||||
db = new TestDatabase(dbPath);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM messages').get().c, 0);
|
||||
assert.equal(db.prepare('SELECT message_count FROM sessions WHERE id=?').get('kimi:session-index-1').message_count, 0);
|
||||
db.close();
|
||||
});
|
||||
@@ -197,13 +197,14 @@ test('dev mode does not open DevTools unless explicitly requested', async () =>
|
||||
assert.equal(devtoolsWindows[0].devToolsOpened, true);
|
||||
});
|
||||
|
||||
test('main process watches Codex sessions directory instead of Codex root', async () => {
|
||||
test('main process watches every root declared by the built-in provider registry', async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const home = join(tmpdir(), `obelisk-main-watch-dirs-${Date.now()}`);
|
||||
const claudeDir = join(home, '.claude');
|
||||
const codexDir = join(home, '.codex');
|
||||
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
|
||||
mkdirSync(join(codexDir, 'sessions'), { recursive: true });
|
||||
mkdirSync(join(home, '.kimi-code', 'sessions'), { recursive: true });
|
||||
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
||||
writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), '');
|
||||
process.env.HOME = home;
|
||||
@@ -258,6 +259,9 @@ test('main process watches Codex sessions directory instead of Codex root', asyn
|
||||
assert.deepEqual(serviceOptions[0].watchDirs, [
|
||||
join(claudeDir, 'projects'),
|
||||
join(codexDir, 'sessions'),
|
||||
join(codexDir, 'session_index.jsonl'),
|
||||
join(home, '.kimi-code', 'sessions'),
|
||||
join(home, '.kimi-code', 'session_index.jsonl'),
|
||||
]);
|
||||
assert.equal(serviceOptions[0].watchDirs.includes(codexDir), false);
|
||||
} finally {
|
||||
@@ -414,10 +418,7 @@ test('session IPC hides Codex rows by default and supports explicit source opt-i
|
||||
|
||||
await ipcHandlers.get('settings:get')();
|
||||
assert.ok(
|
||||
queries.some(q => /COUNT\(\*\) as c FROM sessions WHERE COALESCE\(source, 'claude'\) = 'claude'/.test(q.sql)),
|
||||
);
|
||||
assert.ok(
|
||||
queries.some(q => /MAX\(started_at\) as t FROM sessions WHERE COALESCE\(source, 'claude'\) = 'claude'/.test(q.sql)),
|
||||
queries.some(q => /GROUP BY COALESCE\(source, 'claude'\)/.test(q.sql)),
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { buildIndex } from '../app/src/main/indexer.ts';
|
||||
import { createProviderRegistry } from '../packages/core/src/providers/registry.ts';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
class TestDatabase {
|
||||
constructor(dbPath) { this.db = new DatabaseSync(dbPath); }
|
||||
pragma(statement) { this.db.exec(`PRAGMA ${statement}`); }
|
||||
exec(sql) { return this.db.exec(sql); }
|
||||
prepare(sql) { return this.db.prepare(sql); }
|
||||
close() { return this.db.close(); }
|
||||
}
|
||||
|
||||
test('app indexer persists every provider through one registry-driven loop', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-provider-indexer-'));
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
const registry = createProviderRegistry([{
|
||||
name: 'alpha',
|
||||
descriptor: { id: 'alpha', name: 'Alpha', vendor: 'Test', defaultRoot: '/alpha', color: '#123456' },
|
||||
watchRoots: () => [],
|
||||
discover(ctx) {
|
||||
return ctx.lastCursor('alpha:unit') === '10:1'
|
||||
? []
|
||||
: [{ key: 'alpha:unit', sessionId: 'alpha:session', project: '-tmp-alpha' }];
|
||||
},
|
||||
*parse(unit) {
|
||||
yield {
|
||||
kind: 'session', id: unit.sessionId, title: 'Alpha session', project: unit.project,
|
||||
started_at: '2026-07-20T10:00:00.000Z', ended_at: '2026-07-20T10:01:00.000Z',
|
||||
git_branch: null, version: null, message_count: 1, countMode: 'total',
|
||||
jsonl_path: unit.key, source: 'alpha',
|
||||
};
|
||||
yield {
|
||||
kind: 'message', uuid: 'alpha:message', session_id: unit.sessionId, type: 'user',
|
||||
parent_uuid: null, timestamp: '2026-07-20T10:00:00.000Z', role: 'user',
|
||||
text: 'registry tracer bullet', content_type: 'text', is_meta: 0, model: null,
|
||||
is_sidechain: 0, agent_id: null, input_tokens: null, output_tokens: null,
|
||||
cwd: '/tmp/alpha', skill: null, source: 'alpha',
|
||||
};
|
||||
return '10:1';
|
||||
},
|
||||
raw: () => null,
|
||||
}]);
|
||||
|
||||
const first = buildIndex({
|
||||
providerRegistry: registry,
|
||||
providerRoots: { alpha: '/alpha' },
|
||||
claudeDir: join(home, 'empty-claude'),
|
||||
codexDir: join(home, 'empty-codex'),
|
||||
dbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
});
|
||||
assert.deepEqual(first.affectedSessionIds, ['alpha:session']);
|
||||
assert.equal(first.files, 1);
|
||||
|
||||
const db = new TestDatabase(dbPath);
|
||||
assert.deepEqual(
|
||||
{ ...db.prepare('SELECT id,source,message_count FROM sessions').get() },
|
||||
{ id: 'alpha:session', source: 'alpha', message_count: 1 },
|
||||
);
|
||||
db.close();
|
||||
|
||||
const second = buildIndex({
|
||||
providerRegistry: registry,
|
||||
providerRoots: { alpha: '/alpha' },
|
||||
dbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
});
|
||||
assert.deepEqual(second.affectedSessionIds, []);
|
||||
assert.equal(second.files, 0);
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { createKimiProvider } from '../packages/core/src/providers/kimi.ts';
|
||||
|
||||
function drain(gen) {
|
||||
const values = [];
|
||||
let step = gen.next();
|
||||
while (!step.done) {
|
||||
values.push(step.value);
|
||||
step = gen.next();
|
||||
}
|
||||
return { values, ret: step.value };
|
||||
}
|
||||
|
||||
function writeKimiFixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'obelisk-kimi-'));
|
||||
const sessionDir = join(root, 'sessions', 'workspace-1', 'session-native-1');
|
||||
const mainDir = join(sessionDir, 'agents', 'main');
|
||||
const childDir = join(sessionDir, 'agents', 'agent-7');
|
||||
mkdirSync(mainDir, { recursive: true });
|
||||
mkdirSync(childDir, { recursive: true });
|
||||
writeFileSync(join(sessionDir, 'state.json'), JSON.stringify({
|
||||
title: 'Kimi fixture',
|
||||
createdAt: '2026-07-20T10:00:00.000Z',
|
||||
updatedAt: '2026-07-20T10:01:00.000Z',
|
||||
workDir: '/tmp/kimi-project',
|
||||
agents: {
|
||||
main: { type: 'main' },
|
||||
'agent-7': { type: 'sub', parentAgentId: 'main', labels: { profile: 'explore' } },
|
||||
},
|
||||
}));
|
||||
|
||||
const mainRecords = [
|
||||
{ type: 'metadata', protocol_version: '1.5', created_at: 1753005600000 },
|
||||
{ type: 'config.update', time: 1753005600100, modelAlias: 'kimi-k2' },
|
||||
{ type: 'context.append_message', time: 1753005601000, message: { role: 'user', content: [{ type: 'text', text: 'inspect the project' }], toolCalls: [], origin: { kind: 'user' } } },
|
||||
{ type: 'context.append_loop_event', time: 1753005602000, event: { type: 'step.begin', uuid: 'step-1', turnId: '0' } },
|
||||
{ type: 'context.append_loop_event', time: 1753005602100, event: { type: 'content.part', uuid: 'thinking-1', stepUuid: 'step-1', part: { type: 'thinking', thinking: 'I should inspect it' } } },
|
||||
{ type: 'context.append_loop_event', time: 1753005602200, event: { type: 'tool.call', uuid: 'tool-event-1', stepUuid: 'step-1', toolCallId: 'call-1', name: 'Read', args: { file_path: '/tmp/kimi-project/a.ts' } } },
|
||||
{ type: 'context.append_loop_event', time: 1753005602300, event: { type: 'tool.result', parentUuid: 'tool-result-1', toolCallId: 'call-1', result: { output: 'agent_id: agent-7\nfile body', isError: false } } },
|
||||
{ type: 'context.append_loop_event', time: 1753005602500, event: { type: 'content.part', uuid: 'text-1', stepUuid: 'step-1', part: { type: 'text', text: 'done' } } },
|
||||
{ type: 'context.append_loop_event', time: 1753005603000, event: { type: 'step.end', uuid: 'step-1', usage: { inputOther: 7, inputCacheRead: 3, inputCacheCreation: 2, output: 3 } } },
|
||||
{ type: 'context.apply_compaction', time: 1753005604000, summary: 'Earlier work summary', compactedCount: 2 },
|
||||
];
|
||||
writeFileSync(join(mainDir, 'wire.jsonl'), mainRecords.map((record) => JSON.stringify(record)).join('\n') + '\n');
|
||||
|
||||
const childRecords = [
|
||||
{ type: 'metadata', protocol_version: '1.5', created_at: 1753005600000 },
|
||||
{ type: 'context.append_message', time: 1753005602400, message: { role: 'user', content: [{ type: 'text', text: 'child prompt' }], toolCalls: [], origin: { kind: 'system_trigger', name: 'subagent' } } },
|
||||
];
|
||||
writeFileSync(join(childDir, 'wire.jsonl'), childRecords.map((record) => JSON.stringify(record)).join('\n') + '\n');
|
||||
return { root, sessionDir };
|
||||
}
|
||||
|
||||
test('kimi provider discovers a changed session directory and returns a stable cursor', () => {
|
||||
const { root, sessionDir } = writeKimiFixture();
|
||||
const provider = createKimiProvider({ rootDir: root });
|
||||
const units = provider.discover({ lastCursor: () => null });
|
||||
|
||||
assert.equal(units.length, 1);
|
||||
assert.equal(units[0].key, sessionDir);
|
||||
assert.equal(units[0].sessionId, 'kimi:session-native-1');
|
||||
assert.match(units[0].meta.currentCursor, /^\d+(?:\.\d+)?:\d+$/);
|
||||
|
||||
const unchanged = provider.discover({ lastCursor: () => units[0].meta.currentCursor });
|
||||
assert.deepEqual(unchanged, []);
|
||||
});
|
||||
|
||||
test('kimi provider folds main and subagent wire logs into the existing record language', () => {
|
||||
const { root } = writeKimiFixture();
|
||||
const provider = createKimiProvider({ rootDir: root });
|
||||
const unit = provider.discover({ lastCursor: () => null })[0];
|
||||
const { values, ret } = drain(provider.parse(unit, null));
|
||||
const byKind = (kind) => values.filter((record) => record.kind === kind);
|
||||
|
||||
const goldenRecords = values.map((record) => record.kind === 'session'
|
||||
? { ...record, jsonl_path: '<fixture-wire>' }
|
||||
: record);
|
||||
assert.equal(
|
||||
createHash('sha256').update(JSON.stringify(goldenRecords)).digest('hex'),
|
||||
'ce3c70798bbc50e438605d86eafb28482630ee38dc2baa41a695975c84646822',
|
||||
'complete yielded record sequence changed',
|
||||
);
|
||||
|
||||
assert.deepEqual(values[0], { kind: 'delete-session', sessionId: 'kimi:session-native-1' });
|
||||
assert.equal(ret, unit.meta.currentCursor);
|
||||
|
||||
const session = byKind('session')[0];
|
||||
assert.deepEqual(
|
||||
(({ id, title, project, source, countMode }) => ({ id, title, project, source, countMode }))(session),
|
||||
{
|
||||
id: 'kimi:session-native-1',
|
||||
title: 'Kimi fixture',
|
||||
project: '-tmp-kimi-project',
|
||||
source: 'kimi',
|
||||
countMode: 'total',
|
||||
},
|
||||
);
|
||||
|
||||
const messages = byKind('message');
|
||||
assert.deepEqual(messages.map((message) => [message.role, message.content_type, message.text]), [
|
||||
['user', 'text', 'inspect the project'],
|
||||
['assistant', 'thinking', 'I should inspect it'],
|
||||
['assistant', 'tool_use', null],
|
||||
['assistant', 'text', 'done'],
|
||||
['user', 'text', 'child prompt'],
|
||||
]);
|
||||
assert.equal(messages.at(-1).agent_id, 'kimi:session-native-1:agent-7');
|
||||
assert.equal(messages.at(-1).is_sidechain, 1);
|
||||
assert.equal(messages.find((message) => message.text === 'done').input_tokens, 12);
|
||||
assert.equal(messages.find((message) => message.text === 'done').output_tokens, 3);
|
||||
|
||||
assert.deepEqual(byKind('tool_call').map((record) => [record.id, record.name, record.file_path]), [
|
||||
['kimi:session-native-1:main:call-1', 'Read', '/tmp/kimi-project/a.ts'],
|
||||
]);
|
||||
assert.deepEqual(byKind('tool_result').map((record) => [record.tool_use_id, record.is_error]), [
|
||||
['kimi:session-native-1:main:call-1', 0],
|
||||
]);
|
||||
assert.deepEqual(byKind('summary').map((record) => record.content), ['Earlier work summary']);
|
||||
assert.deepEqual(byKind('subagent').map((record) => [record.agent_id, record.parent_tool_use_id, record.agent_type]), [
|
||||
['kimi:session-native-1:agent-7', 'kimi:session-native-1:main:call-1', 'explore'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('kimi provider ignores a torn final wire line until it is completed', () => {
|
||||
const { root, sessionDir } = writeKimiFixture();
|
||||
const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl');
|
||||
writeFileSync(wirePath, readFileSync(wirePath, 'utf8') + '{"type":"context.append_message"');
|
||||
const provider = createKimiProvider({ rootDir: root });
|
||||
const unit = provider.discover({ lastCursor: () => null })[0];
|
||||
|
||||
const { values, ret } = drain(provider.parse(unit, null));
|
||||
|
||||
assert.equal(values.filter(record => record.kind === 'message').length, 5);
|
||||
assert.equal(ret, unit.meta.currentCursor);
|
||||
});
|
||||
|
||||
test('kimi provider replays clear and undo markers with Kimi transcript semantics', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'obelisk-kimi-undo-'));
|
||||
const sessionDir = join(root, 'sessions', 'workspace-1', 'session-undo-1');
|
||||
const mainDir = join(sessionDir, 'agents', 'main');
|
||||
mkdirSync(mainDir, { recursive: true });
|
||||
writeFileSync(join(sessionDir, 'state.json'), JSON.stringify({
|
||||
workDir: '/tmp/kimi-undo',
|
||||
agents: { main: { type: 'main' } },
|
||||
}));
|
||||
const records = [
|
||||
{ type: 'metadata', protocol_version: '1.5', created_at: 1753005600000 },
|
||||
{ type: 'context.append_message', time: 1, message: { role: 'user', content: 'before clear', toolCalls: [], origin: { kind: 'user' } } },
|
||||
{ type: 'context.append_loop_event', time: 2, event: { type: 'content.part', uuid: 'before-answer', stepUuid: 's1', part: { type: 'text', text: 'kept answer' } } },
|
||||
{ type: 'context.clear', time: 3 },
|
||||
{ type: 'context.append_message', time: 4, message: { role: 'user', content: 'undone prompt', toolCalls: [], origin: { kind: 'user' } } },
|
||||
{ type: 'context.append_message', time: 5, message: { role: 'user', content: 'persistent injection', toolCalls: [], origin: { kind: 'injection' } } },
|
||||
{ type: 'context.append_message', time: 6, message: { role: 'user', content: 'ephemeral system trigger', toolCalls: [], origin: { kind: 'system_trigger' } } },
|
||||
{ type: 'context.append_loop_event', time: 7, event: { type: 'content.part', uuid: 'undone-answer', stepUuid: 's2', part: { type: 'text', text: 'undone answer' } } },
|
||||
{ type: 'context.undo', time: 8, count: 1 },
|
||||
];
|
||||
writeFileSync(join(mainDir, 'wire.jsonl'), records.map(record => JSON.stringify(record)).join('\n') + '\n');
|
||||
const provider = createKimiProvider({ rootDir: root });
|
||||
const unit = provider.discover({ lastCursor: () => null })[0];
|
||||
|
||||
const { values } = drain(provider.parse(unit, null));
|
||||
assert.deepEqual(
|
||||
values.filter(record => record.kind === 'message').map(record => record.text),
|
||||
['before clear', 'kept answer', 'persistent injection'],
|
||||
);
|
||||
assert.equal(values.find(record => record.kind === 'session').message_count, 3);
|
||||
});
|
||||
|
||||
test('kimi provider scopes changed-path discovery to one session and bypasses an unchanged cursor', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'obelisk-kimi-changed-path-'));
|
||||
const firstDir = join(root, 'sessions', 'workspace-1', 'session-1');
|
||||
const secondDir = join(root, 'sessions', 'workspace-1', 'session-2');
|
||||
for (const sessionDir of [firstDir, secondDir]) {
|
||||
mkdirSync(join(sessionDir, 'agents', 'main'), { recursive: true });
|
||||
writeFileSync(join(sessionDir, 'state.json'), JSON.stringify({ workDir: '/tmp/project' }));
|
||||
writeFileSync(join(sessionDir, 'agents', 'main', 'wire.jsonl'), '{"type":"metadata"}\n');
|
||||
}
|
||||
const provider = createKimiProvider({ rootDir: root });
|
||||
const initial = provider.discover({ lastCursor: () => null });
|
||||
const cursorByKey = new Map(initial.map(unit => [unit.key, unit.meta.currentCursor]));
|
||||
|
||||
const units = provider.discover({
|
||||
lastCursor: key => cursorByKey.get(key) ?? null,
|
||||
changedPaths: [join(firstDir, 'state.json')],
|
||||
});
|
||||
|
||||
assert.deepEqual(units.map(unit => unit.key), [firstDir]);
|
||||
});
|
||||
|
||||
test('kimi provider maps protocol-1.0 embedded tool calls and results', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'obelisk-kimi-legacy-tools-'));
|
||||
const sessionDir = join(root, 'sessions', 'workspace-1', 'session-tools-1');
|
||||
const mainDir = join(sessionDir, 'agents', 'main');
|
||||
mkdirSync(mainDir, { recursive: true });
|
||||
writeFileSync(join(sessionDir, 'state.json'), JSON.stringify({ workDir: '/tmp/tools' }));
|
||||
const records = [
|
||||
{ type: 'metadata', protocol_version: '1.0', created_at: 1 },
|
||||
{ type: 'context.append_message', time: 2, message: {
|
||||
role: 'assistant', content: [],
|
||||
toolCalls: [{ type: 'function', id: 'legacy-call', function: { name: 'Read', arguments: '{"file_path":"/tmp/tools/a.ts"}' } }],
|
||||
} },
|
||||
{ type: 'context.append_message', time: 3, message: {
|
||||
role: 'tool', content: [{ type: 'text', text: 'legacy result' }], toolCalls: [], toolCallId: 'legacy-call',
|
||||
} },
|
||||
];
|
||||
writeFileSync(join(mainDir, 'wire.jsonl'), records.map(record => JSON.stringify(record)).join('\n') + '\n');
|
||||
const provider = createKimiProvider({ rootDir: root });
|
||||
const unit = provider.discover({ lastCursor: () => null })[0];
|
||||
|
||||
const { values } = drain(provider.parse(unit, null));
|
||||
assert.deepEqual(values.filter(record => record.kind === 'tool_call').map(record => ({
|
||||
id: record.id, name: record.name, file_path: record.file_path,
|
||||
})), [{
|
||||
id: 'kimi:session-tools-1:main:legacy-call', name: 'Read', file_path: '/tmp/tools/a.ts',
|
||||
}]);
|
||||
assert.deepEqual(values.filter(record => record.kind === 'tool_result').map(record => ({
|
||||
tool_use_id: record.tool_use_id, content: record.content,
|
||||
})), [{
|
||||
tool_use_id: 'kimi:session-tools-1:main:legacy-call', content: 'legacy result',
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
test('passive-pull runtime indexes Kimi sessions from the default home', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-kimi-runtime-'));
|
||||
const sessionDir = join(home, '.kimi-code', 'sessions', 'workspace-1', 'session-runtime-1');
|
||||
const mainDir = join(sessionDir, 'agents', 'main');
|
||||
mkdirSync(mainDir, { recursive: true });
|
||||
writeFileSync(join(sessionDir, 'state.json'), JSON.stringify({
|
||||
title: 'Runtime Kimi session',
|
||||
workDir: '/tmp/runtime-kimi',
|
||||
createdAt: '2026-07-20T10:00:00.000Z',
|
||||
updatedAt: '2026-07-20T10:01:00.000Z',
|
||||
agents: { main: { type: 'main' } },
|
||||
}));
|
||||
writeFileSync(join(mainDir, 'wire.jsonl'), [
|
||||
JSON.stringify({ type: 'metadata', protocol_version: '1.5', created_at: 1753005600000 }),
|
||||
JSON.stringify({ type: 'context.append_message', time: 1753005601000, message: { role: 'user', content: [{ type: 'text', text: 'runtime kimi needle' }], toolCalls: [], origin: { kind: 'user' } } }),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const coreUrl = pathToFileURL(join(process.cwd(), 'packages/core/src/core.ts')).href;
|
||||
const script = `
|
||||
import { executeQuery } from ${JSON.stringify(coreUrl)};
|
||||
const result = await executeQuery("return sessions({ source: 'kimi', limit: 5 });");
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
`;
|
||||
const run = spawnSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', script], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, HOME: home, USERPROFILE: home },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
const sessions = JSON.parse(run.stdout);
|
||||
assert.deepEqual(sessions.map(({ id, title, source }) => ({ id, title, source })), [{
|
||||
id: 'kimi:session-runtime-1',
|
||||
title: 'Runtime Kimi session',
|
||||
source: 'kimi',
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createProviderRegistry } from '../packages/core/src/providers/registry.ts';
|
||||
import { createBuiltinProviderRegistry } from '../packages/core/src/providers/builtins.ts';
|
||||
|
||||
function fakeProvider(id, root) {
|
||||
return {
|
||||
name: id,
|
||||
descriptor: {
|
||||
id,
|
||||
name: `${id} display`,
|
||||
vendor: `${id} vendor`,
|
||||
defaultRoot: root,
|
||||
color: '#123456',
|
||||
},
|
||||
watchRoots(configuredRoot) {
|
||||
return [`${configuredRoot}/sessions`, `${configuredRoot}/session-index`];
|
||||
},
|
||||
discover() {
|
||||
return [];
|
||||
},
|
||||
*parse() {
|
||||
yield* [];
|
||||
return null;
|
||||
},
|
||||
raw(input) {
|
||||
return { text: `${id}:${input.messageUuid}` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('provider registry drives source catalog, watch roots, and raw lookup', () => {
|
||||
const registry = createProviderRegistry([
|
||||
fakeProvider('alpha', '/default/alpha'),
|
||||
fakeProvider('beta', '/default/beta'),
|
||||
]);
|
||||
|
||||
assert.deepEqual(registry.catalog(), [
|
||||
{ id: 'alpha', name: 'alpha display', vendor: 'alpha vendor', defaultRoot: '/default/alpha', color: '#123456' },
|
||||
{ id: 'beta', name: 'beta display', vendor: 'beta vendor', defaultRoot: '/default/beta', color: '#123456' },
|
||||
]);
|
||||
assert.deepEqual(registry.watchRoots({ alpha: '/custom/alpha' }), [
|
||||
'/custom/alpha/sessions',
|
||||
'/custom/alpha/session-index',
|
||||
'/default/beta/sessions',
|
||||
'/default/beta/session-index',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
registry.raw({ source: 'beta', messageUuid: 'message-1', session: null, agentId: null }),
|
||||
{ text: 'beta:message-1' },
|
||||
);
|
||||
assert.equal(
|
||||
registry.raw({ source: 'missing', messageUuid: 'message-1', session: null, agentId: null }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('built-in provider registry exposes every source without caller-side branching', () => {
|
||||
const registry = createBuiltinProviderRegistry({
|
||||
claude: '/sources/claude',
|
||||
codex: '/sources/codex',
|
||||
kimi: '/sources/kimi',
|
||||
});
|
||||
|
||||
assert.deepEqual(registry.catalog().map(({ id, name }) => ({ id, name })), [
|
||||
{ id: 'claude', name: 'Claude Code' },
|
||||
{ id: 'codex', name: 'Codex' },
|
||||
{ id: 'kimi', name: 'Kimi Code' },
|
||||
]);
|
||||
assert.deepEqual(registry.watchRoots(), [
|
||||
'/sources/claude/projects',
|
||||
'/sources/codex/sessions',
|
||||
'/sources/codex/session_index.jsonl',
|
||||
'/sources/kimi/sessions',
|
||||
'/sources/kimi/session_index.jsonl',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
test('provider adapters do not change the frozen SQLite schema', () => {
|
||||
const schema = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url));
|
||||
assert.equal(
|
||||
createHash('sha256').update(schema).digest('hex'),
|
||||
'3e0615ed2db0d7338561df4d51c4240395714c191aa69567ffcdb70efec49826',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createProviderRegistry } from '../packages/core/src/providers/registry.ts';
|
||||
import {
|
||||
buildSourceCatalog,
|
||||
resolveProviderRoots,
|
||||
setPersistedSetting,
|
||||
} from '../app/src/main/provider-settings.ts';
|
||||
|
||||
function provider(id, defaultRoot, color) {
|
||||
return {
|
||||
name: id,
|
||||
descriptor: { id, name: `${id} name`, vendor: `${id} vendor`, defaultRoot, color },
|
||||
watchRoots: () => [],
|
||||
discover: () => [],
|
||||
*parse() { yield* []; return null; },
|
||||
raw: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
test('provider roots and source settings are derived from the registry without source branches', () => {
|
||||
const registry = createProviderRegistry([
|
||||
provider('alpha', '/default/alpha', '#112233'),
|
||||
provider('beta', '/default/beta', '#445566'),
|
||||
]);
|
||||
const persisted = {
|
||||
alphaDir: '/legacy/alpha',
|
||||
providerRoots: { beta: '/custom/beta' },
|
||||
};
|
||||
|
||||
assert.deepEqual(resolveProviderRoots(registry, persisted), {
|
||||
alpha: '/legacy/alpha',
|
||||
beta: '/custom/beta',
|
||||
});
|
||||
|
||||
const rootsChanged = setPersistedSetting(persisted, 'providerRoots.alpha', '/custom/alpha');
|
||||
assert.equal(rootsChanged, true);
|
||||
assert.deepEqual(persisted.providerRoots, {
|
||||
alpha: '/custom/alpha',
|
||||
beta: '/custom/beta',
|
||||
});
|
||||
|
||||
const sources = buildSourceCatalog({
|
||||
registry,
|
||||
roots: resolveProviderRoots(registry, persisted),
|
||||
stats: new Map([
|
||||
['alpha', { sessionCount: 2, lastIndexed: '2026-07-20T10:00:00.000Z' }],
|
||||
['beta', { sessionCount: 0, lastIndexed: '' }],
|
||||
]),
|
||||
pathExists: path => path === '/custom/alpha' || path === '/custom/beta',
|
||||
});
|
||||
|
||||
assert.deepEqual(sources, [
|
||||
{
|
||||
id: 'alpha', name: 'alpha name', vendor: 'alpha vendor', color: '#112233',
|
||||
path: '/custom/alpha', settingKey: 'providerRoots.alpha', exists: true,
|
||||
sessionCount: 2, lastIndexed: '2026-07-20T10:00:00.000Z',
|
||||
status: 'ok', statusText: 'Connected',
|
||||
},
|
||||
{
|
||||
id: 'beta', name: 'beta name', vendor: 'beta vendor', color: '#445566',
|
||||
path: '/custom/beta', settingKey: 'providerRoots.beta', exists: true,
|
||||
sessionCount: 0, lastIndexed: '', status: 'warn', statusText: 'No sessions found',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('removing a generic provider root restores its descriptor default', () => {
|
||||
const registry = createProviderRegistry([provider('gamma', '/default/gamma', '#778899')]);
|
||||
const persisted = { providerRoots: { gamma: '/custom/gamma' } };
|
||||
|
||||
assert.equal(setPersistedSetting(persisted, 'providerRoots.gamma', null), true);
|
||||
assert.deepEqual(resolveProviderRoots(registry, persisted), { gamma: '/default/gamma' });
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { createQueryApi } from '../packages/core/src/query.ts';
|
||||
import { createProviderRegistry } from '../packages/core/src/providers/registry.ts';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const SCHEMA = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
|
||||
|
||||
test('raw query delegates source semantics to the registered provider', () => {
|
||||
const calls = [];
|
||||
const registry = createProviderRegistry([{
|
||||
name: 'alpha',
|
||||
descriptor: { id: 'alpha', name: 'Alpha', vendor: 'Test', defaultRoot: '/alpha', color: '#123456' },
|
||||
watchRoots: () => [],
|
||||
discover: () => [],
|
||||
*parse() { yield* []; return null; },
|
||||
raw(input) {
|
||||
calls.push(input);
|
||||
return { text: '0123456789', totalLength: 10 };
|
||||
},
|
||||
}]);
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(SCHEMA);
|
||||
db.prepare('INSERT INTO sessions (id,jsonl_path,source) VALUES (?,?,?)')
|
||||
.run('alpha:session', '/alpha/session.data', 'alpha');
|
||||
db.prepare('INSERT INTO messages (uuid,session_id,agent_id,source) VALUES (?,?,?,?)')
|
||||
.run('alpha:message', 'alpha:session', 'alpha:agent', 'alpha');
|
||||
db.prepare('INSERT INTO subagents (agent_id,session_id,description) VALUES (?,?,?)')
|
||||
.run('alpha:agent', 'alpha:session', 'agent metadata');
|
||||
|
||||
const result = createQueryApi(db, { providerRegistry: registry }).raw('alpha:message', {
|
||||
offset: 2,
|
||||
limit: 4,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
text: '2345', totalLength: 10, offset: 2, limit: 4, hasMore: true,
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].source, 'alpha');
|
||||
assert.equal(calls[0].session.id, 'alpha:session');
|
||||
assert.equal(calls[0].subagent.description, 'agent metadata');
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { sourceColor, sourceLabel } from '../app/src/renderer/src/source-catalog.mjs';
|
||||
|
||||
const catalog = [
|
||||
{ id: 'alpha', name: 'Alpha Agent', color: '#112233' },
|
||||
{ id: 'beta', name: 'Beta Code', color: '#445566' },
|
||||
];
|
||||
|
||||
test('renderer source presentation comes from the runtime provider catalog', () => {
|
||||
assert.equal(sourceLabel('beta', catalog), 'Beta Code');
|
||||
assert.equal(sourceColor('alpha', catalog), '#112233');
|
||||
assert.equal(sourceLabel('future-provider', catalog), 'Future Provider');
|
||||
assert.equal(sourceColor('future-provider', catalog), '#8b8b93');
|
||||
});
|
||||
Reference in New Issue
Block a user