refactor(agent): replace OMP with Claude Code kernel
This commit is contained in:
1 parent
0fb2226f8c
commit
9a1f182397
232 files changed
+2561
-25564
No files matched your search
@@ -13,9 +13,7 @@ interface PersistenceTestState {
|
||||
promptCalls: number
|
||||
abortCalls: number
|
||||
cancelOutputEmitted: boolean
|
||||
useOmp: boolean
|
||||
fallbackHistory: ChatMessage[] | null
|
||||
fallbackWireHistory?: Array<{ role: string; content: string }>
|
||||
resumeAts: Array<string | undefined>
|
||||
}
|
||||
|
||||
const TEST_STATE_KEY = '__YCODE_CHAT_PERSISTENCE_TEST__'
|
||||
@@ -73,8 +71,7 @@ async function main(): Promise<void> {
|
||||
promptCalls: 0,
|
||||
abortCalls: 0,
|
||||
cancelOutputEmitted: false,
|
||||
useOmp: true,
|
||||
fallbackHistory: null
|
||||
resumeAts: []
|
||||
}
|
||||
await mkdir(state.userData, { recursive: true })
|
||||
|
||||
@@ -134,74 +131,74 @@ async function main(): Promise<void> {
|
||||
export class WebContentsView {}
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/omp$/ }, () => ({ path: 'omp', namespace: 'mock-omp' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-omp' }, () => ({
|
||||
pluginBuild.onResolve({ filter: /^\.\/agent\/claude\/claude-kernel$/ }, () => ({ path: 'claude-kernel', namespace: 'mock-claude-kernel' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-claude-kernel' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
import { EventEmitter } from 'node:events';
|
||||
const state = globalThis.${TEST_STATE_KEY};
|
||||
const toolId = 'persisted-tool';
|
||||
const fullOutput = '${'long-output-'.repeat(60)}PERSISTED-OUTPUT-SENTINEL';
|
||||
const emit = (kernel, frame) => kernel.emit('event', frame);
|
||||
export class OmpKernel extends EventEmitter {
|
||||
constructor() { super(); }
|
||||
setThinkingLevel() { return Promise.resolve(); }
|
||||
setHostTools() { return Promise.resolve({ type: 'response', success: true, data: { toolNames: [] } }); }
|
||||
send() { return Promise.resolve({ type: 'response', success: true, data: { messages: [] } }); }
|
||||
prompt(message) {
|
||||
const kernels = new Map();
|
||||
class MockClaudeKernel {
|
||||
constructor(options) { this.options = options; this.busy = false; this.active = null; }
|
||||
async ensureStarted() {}
|
||||
async setPolicy() {}
|
||||
async runTurn(request) {
|
||||
state.promptCalls++;
|
||||
if (message.split('\\n', 1)[0] === 'persist-cancel') {
|
||||
setImmediate(() => {
|
||||
emit(this, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'cancel-before' } });
|
||||
emit(this, { type: 'tool_execution_start', toolCall: { id: toolId, name: 'shell', args: { command: 'long-running' } } });
|
||||
emit(this, { type: 'tool_execution_update', toolCall: { id: toolId, partialResult: 'cancelled tool output retained' } });
|
||||
state.cancelOutputEmitted = true;
|
||||
});
|
||||
} else {
|
||||
setImmediate(() => {
|
||||
emit(this, { type: 'message_end', message: { role: 'assistant', usage: { input: 1200, output: 10, totalTokens: 1210 } } });
|
||||
emit(this, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'before' } });
|
||||
emit(this, { type: 'tool_execution_start', toolCall: { id: toolId, name: 'read_file', args: { path: 'large.txt' } } });
|
||||
emit(this, { type: 'tool_execution_update', toolCall: { id: toolId, partialResult: fullOutput } });
|
||||
emit(this, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'after' } });
|
||||
emit(this, { type: 'tool_execution_end', toolCall: { id: toolId, name: 'read_file', result: { content: [{ type: 'text', text: fullOutput }], isError: false }, success: true } });
|
||||
emit(this, { type: 'message_end', message: { role: 'assistant', usage: { contextTokens: 12800, input: 12000, cacheRead: 800, output: 200, totalTokens: 13000 } } });
|
||||
emit(this, { type: 'agent_end', yielded: true });
|
||||
this.busy = true;
|
||||
const cancelled = request.prompt.startsWith('persist-cancel');
|
||||
if (cancelled) {
|
||||
request.emit({ runId: request.runId, type: 'text', delta: 'cancel-before' });
|
||||
request.emit({ runId: request.runId, type: 'tool_call', call: {
|
||||
id: toolId, name: 'Read', args: { path: 'large.txt' }, argsRaw: '{"path":"large.txt"}',
|
||||
status: 'running', output: 'cancelled tool output retained'
|
||||
} });
|
||||
state.cancelOutputEmitted = true;
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => {
|
||||
state.abortCalls++;
|
||||
this.busy = false;
|
||||
reject(Object.assign(new Error('回合已取消'), { name: 'AbortError' }));
|
||||
};
|
||||
if (request.signal.aborted) abort();
|
||||
else request.signal.addEventListener('abort', abort, { once: true });
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
const tool = {
|
||||
id: toolId, name: 'Read', args: { path: 'large.txt' }, argsRaw: '{"path":"large.txt"}',
|
||||
status: 'ok', output: fullOutput, summary: fullOutput.slice(0, 600), result: { content: fullOutput }
|
||||
};
|
||||
request.emit({ runId: request.runId, type: 'text', delta: 'before' });
|
||||
request.emit({ runId: request.runId, type: 'tool_call', call: { ...tool, status: 'running', output: undefined, summary: undefined, result: undefined } });
|
||||
request.emit({ runId: request.runId, type: 'tool_result', call: tool });
|
||||
request.emit({ runId: request.runId, type: 'text', delta: 'after' });
|
||||
request.emit({ runId: request.runId, type: 'usage', usage: { inputTokens: 12800, outputTokens: 200, totalTokens: 13000, cachedTokens: 800 } });
|
||||
this.busy = false;
|
||||
return { content: 'beforeafter', tools: [tool], status: 'done', entryId: 'mock-claude-entry' };
|
||||
}
|
||||
abort() {
|
||||
state.abortCalls++;
|
||||
setImmediate(() => emit(this, { type: 'agent_end', yielded: true }));
|
||||
return Promise.resolve({ type: 'response', success: true });
|
||||
}
|
||||
dispose() {}
|
||||
async rewindFiles() {}
|
||||
async close() { this.busy = false; }
|
||||
}
|
||||
export const detectOmpPath = () => state.useOmp ? 'mock-omp' : null;
|
||||
export const getOmpInfo = () => ({ available: true, exePath: 'mock-omp', profile: 'ycode', model: 'ycode/test' });
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/agent\/loop$/ }, () => ({ path: 'agent-loop', namespace: 'mock-loop' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-loop' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
const state = globalThis.${TEST_STATE_KEY};
|
||||
export async function runTurn(options) {
|
||||
state.fallbackHistory = options.history;
|
||||
state.fallbackWireHistory = options.wireHistory;
|
||||
const content = 'fallback response';
|
||||
options.emit({ runId: options.runId, type: 'text', delta: content });
|
||||
options.emit({ runId: options.runId, type: 'done', content, tools: [] });
|
||||
return { content, tools: [] };
|
||||
export function getClaudeKernel(options) {
|
||||
state.resumeAts.push(options.resumeAt);
|
||||
let kernel = kernels.get(options.sessionId);
|
||||
if (!kernel) { kernel = new MockClaudeKernel(options); kernels.set(options.sessionId, kernel); }
|
||||
return kernel;
|
||||
}
|
||||
export const cachedClaudeKernel = (sessionId) => kernels.get(sessionId);
|
||||
export const releaseClaudeKernel = async (sessionId, force) => {
|
||||
if (!force) return;
|
||||
const kernel = kernels.get(sessionId);
|
||||
if (!kernel) return;
|
||||
kernels.delete(sessionId);
|
||||
await kernel.close();
|
||||
};
|
||||
export const setSessionVisible = () => {};
|
||||
export const removeSessionView = () => {};
|
||||
export const disposeClaudeKernels = async () => { for (const kernel of kernels.values()) await kernel.close(); kernels.clear(); };
|
||||
export const claudeRuntimeInfo = () => ({ available: false, exePath: null, sessionDir: state.userData });
|
||||
`
|
||||
}))
|
||||
pluginBuild.onResolve({ filter: /^\.\/gateway$/ }, () => ({ path: 'gateway', namespace: 'mock-gateway' }))
|
||||
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-gateway' }, () => ({
|
||||
loader: 'js',
|
||||
contents: 'export class Gateway { constructor() {} }'
|
||||
}))
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -235,6 +232,7 @@ async function main(): Promise<void> {
|
||||
]
|
||||
assert.deepEqual(normalAssistant.parts, expectedParts, 'session:messages should retain text/tool/text order')
|
||||
assert.equal(normalAssistant.status, 'done')
|
||||
assert.equal(normalAssistant.claudeEntryId, 'mock-claude-entry')
|
||||
assert.equal(normalAssistant.tools?.length, 1)
|
||||
const normalTool = normalAssistant.tools?.[0]
|
||||
assert.ok(normalTool, 'completed tool should be present')
|
||||
@@ -243,7 +241,8 @@ async function main(): Promise<void> {
|
||||
assert.ok((normalTool.summary?.length ?? Infinity) < (normalTool.output?.length ?? 0),
|
||||
'the summary may be truncated while full output remains intact')
|
||||
|
||||
const diskNormalAssistant = assistant(await readDiskMessages(state.userData, normalSession.id))
|
||||
const diskNormalAssistant = await waitForDiskAssistant(state.userData, normalSession.id,
|
||||
(message) => message.status === 'done' && message.claudeEntryId === 'mock-claude-entry')
|
||||
assert.deepEqual(normalAssistant.contextUsage, {
|
||||
usage: { inputTokens: 12800, outputTokens: 200, totalTokens: 13000, cachedTokens: 800 }, contextWindow: 64000,
|
||||
modelId:'usage-snapshot-model',requestId:normalAssistant.requestId,source:'upstream',sampledAt:normalAssistant.contextUsage?.sampledAt
|
||||
@@ -252,45 +251,32 @@ async function main(): Promise<void> {
|
||||
assert.deepEqual(diskNormalAssistant.contextUsage, normalAssistant.contextUsage,
|
||||
'the checkpoint whitelist must persist the upstream sample and frozen denominator together')
|
||||
assert.deepEqual(diskNormalAssistant.parts, expectedParts, 'disk JSON should retain text/tool/text order')
|
||||
assert.equal(diskNormalAssistant.claudeEntryId, 'mock-claude-entry',
|
||||
'disk JSON should retain the Claude fork point')
|
||||
assert.ok((diskNormalAssistant.tools?.[0].output?.length ?? 0) > 600,
|
||||
'disk JSON should retain output beyond the summary limit')
|
||||
assert.equal(diskNormalAssistant.tools?.[0].output, normalTool.output,
|
||||
'disk JSON should retain the complete tool output')
|
||||
assert.ok(diskNormalAssistant.tools?.[0].output?.endsWith('PERSISTED-OUTPUT-SENTINEL'))
|
||||
|
||||
const fallbackSession = (await invoke('session:create', 'fallback history')) as { id: string }
|
||||
const sessions = ipc.sessions as {
|
||||
appendMessage(sessionId: string, message: ChatMessage): Promise<void>
|
||||
}
|
||||
await sessions.appendMessage(fallbackSession.id, {
|
||||
id: 'fallback-user',
|
||||
role: 'user',
|
||||
content: 'old request',
|
||||
quotes: [{ id: 'saved-table', sourceId: 'table-source', kind: 'table', markdown: '| name | value |\n| --- | --- |\n| A | 42 |', preview: 'A 42' }],
|
||||
createdAt: Date.now(),
|
||||
status: 'done'
|
||||
await invoke('chat:send', { sessionId: normalSession.id, text: 'persist-second' })
|
||||
const secondMessages = await waitForMessages(invoke, normalSession.id,
|
||||
(messages) => messages.filter((message) => message.role === 'assistant').length === 2 &&
|
||||
assistant(messages).status === 'done')
|
||||
const secondUser = secondMessages.filter((message) => message.role === 'user').at(-1)
|
||||
assert.ok(secondUser)
|
||||
await invoke('chat:send', {
|
||||
sessionId: normalSession.id,
|
||||
text: 'persist-edited',
|
||||
fromMessageId: secondUser.id,
|
||||
action: 'edit',
|
||||
rollbackArtifacts: false
|
||||
})
|
||||
await sessions.appendMessage(fallbackSession.id, {
|
||||
id: 'fallback-assistant',
|
||||
role: 'assistant',
|
||||
content: 'old answer',
|
||||
parts: [{ type: 'text', text: 'old answer' }],
|
||||
createdAt: Date.now(),
|
||||
status: 'done'
|
||||
})
|
||||
state.useOmp = false
|
||||
await invoke('chat:send', { sessionId: fallbackSession.id, text: 'fallback-history' })
|
||||
const fallbackMessages = await waitForMessages(
|
||||
invoke,
|
||||
fallbackSession.id,
|
||||
(messages) => assistant(messages).content === 'fallback response' && assistant(messages).status === 'done'
|
||||
)
|
||||
assert.equal(assistant(fallbackMessages).content, 'fallback response')
|
||||
assert.deepEqual(state.fallbackHistory?.map((message) => message.content), ['old request', 'old answer'],
|
||||
'the fallback loop should receive previously persisted user and assistant messages')
|
||||
assert.equal(state.fallbackWireHistory?.[0].content, 'old request\n\n| name | value |\n| --- | --- |\n| A | 42 |',
|
||||
'the next fallback request must include the exact previously quoted table snapshot')
|
||||
state.useOmp = true
|
||||
await waitForMessages(invoke, normalSession.id,
|
||||
(messages) => messages.filter((message) => message.role === 'assistant').length === 2 &&
|
||||
assistant(messages).status === 'done')
|
||||
assert.equal(state.resumeAts.at(-1), normalAssistant.claudeEntryId,
|
||||
'editing a prior turn should fork at the last kept Claude chain entry')
|
||||
|
||||
const cancelSession = (await invoke('session:create', 'cancelled persistence')) as { id: string }
|
||||
const cancelledSend = (await invoke('chat:send', {
|
||||
@@ -298,7 +284,7 @@ async function main(): Promise<void> {
|
||||
text: 'persist-cancel'
|
||||
})) as { runId: string }
|
||||
for (let attempt = 0; attempt < 100 && !state.cancelOutputEmitted; attempt++) await wait(10)
|
||||
assert.equal(state.cancelOutputEmitted, true, 'fake OMP should emit a running tool and partial output')
|
||||
assert.equal(state.cancelOutputEmitted, true, 'the mock Claude kernel should emit a running tool and partial output')
|
||||
const streamingCheckpoint = await waitForDiskAssistant(state.userData, cancelSession.id, (message) =>
|
||||
message.status === 'streaming' &&
|
||||
message.content === 'cancel-before' &&
|
||||
@@ -331,7 +317,7 @@ async function main(): Promise<void> {
|
||||
assert.equal(diskCancelledAssistant.tools?.[0].status, 'cancelled', 'disk JSON should persist terminal tool status')
|
||||
assert.equal(diskCancelledAssistant.tools?.[0].output, 'cancelled tool output retained',
|
||||
'disk JSON should preserve output from a running tool cancelled mid-turn')
|
||||
assert.equal(state.promptCalls, 2)
|
||||
assert.equal(state.promptCalls, 4)
|
||||
assert.equal(state.abortCalls, 1)
|
||||
|
||||
console.log('chat-persistence-regression: all checks passed')
|
||||
|
||||
Reference in new issue
Block a user