Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ff70ef4
feat(storage): own external conversation bindings
me2seeks Aug 9, 2026
f69f1a2
feat(runtime-host): own bot conversation lifecycle
me2seeks Aug 9, 2026
435e26b
fix(runtime-host): preserve bot claims across recovery
me2seeks Aug 9, 2026
e173f85
feat(desktop): route bot conversations through runtime host
me2seeks Aug 9, 2026
9e546c9
fix(desktop): observe bot turns before admission
me2seeks Aug 9, 2026
8874eb1
fix(desktop): recover historical bot turns
me2seeks Aug 9, 2026
1c6c301
fix(runtime): keep qq reply targets routable
me2seeks Aug 9, 2026
e182fac
fix(storage): bound conversation release receipts
me2seeks Aug 9, 2026
60c9504
test(runtime-host): use workspace targets for bot sessions
me2seeks Aug 10, 2026
544bb00
test(runtime-host): decode bot workspace targets
me2seeks Aug 10, 2026
7b63aee
test(desktop): preserve bot delta reset coverage
me2seeks Aug 11, 2026
d20291b
fix(runtime-host): preserve bot continuity after cutover
me2seeks Aug 12, 2026
35c80fd
fix(runtime-host): enforce bot workspace authority
me2seeks Aug 13, 2026
fdf3a39
fix(desktop): close bot turn admission races
me2seeks Aug 13, 2026
2d097b1
docs(runtime): clarify bot reply streamId identity
me2seeks Aug 17, 2026
1810c27
fix(runtime): drop WeChat iLink messages without a stable upstream id…
me2seeks Aug 18, 2026
40c992c
fix(desktop): fail closed on unexpected message-submit dispositions
me2seeks Aug 18, 2026
2c7dcc0
fix(runtime-host): retry archive-time external conversation purges
me2seeks Aug 18, 2026
978a070
fix(bot): align conversation continuity with current host contracts
me2seeks Aug 18, 2026
f5e7ee7
fix(runtime-host): preserve bot continuity recovery
me2seeks Aug 18, 2026
c6c9241
test(runtime-host): fail on unexpected reconciliation results
me2seeks Aug 18, 2026
6084320
fix(runtime-host): harden bot continuity recovery
me2seeks Aug 19, 2026
e37849c
fix(bot): require stable platform message ids
me2seeks Aug 19, 2026
61ea892
fix(storage): preserve reset retry receipts
me2seeks Aug 19, 2026
66ed965
fix(bot): prove retries before transient admission
me2seeks Aug 19, 2026
f890e47
test(storage): migrate conversation schema after rebase
me2seeks Aug 19, 2026
ea221f6
fix(bot): reject unstable channel identities
me2seeks Aug 19, 2026
6ac1ced
fix(runtime-host): drop the retired surface field from the bot recove…
me2seeks Aug 23, 2026
f849234
fix(desktop): reconcile bot adapter seams after the rebase
me2seeks Aug 23, 2026
1d8eb81
fix(storage): sequence external-conversation bindings as schema migra…
me2seeks Aug 23, 2026
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
18 changes: 9 additions & 9 deletions apps/desktop/src/main/__tests__/bot-incoming-project-cwd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import type { BotIncomingMessage, BotRegistry } from '@maka/runtime/bots';
import { createBotIncomingMainService } from '../bot-incoming-main.js';
import { createTestBotSessionAdapter } from './bot-session-adapter-fixture.js';

describe('bot incoming new-session cwd', () => {
it('leaves the cwd to the shared desktop session resolver', async () => {
Expand All @@ -35,32 +36,31 @@ describe('bot incoming new-session cwd', () => {
return true;
},
} as unknown as BotRegistry,
sessions: {
async createSession(input) {
sessions: createTestBotSessionAdapter({
async resolveSession(input) {
createInput = input;
throw new Error('__short_circuit_after_create__');
},
async prepareSession() {
throw new Error('prepareSession must not be reached');
return { kind: 'permission_refused' };
},
async runTurn() {
throw new Error('runTurn must not be reached');
},
},
}),
});

await service.handleBotIncomingMessage({
platform: 'telegram',
userId: 'u',
userName: 'U',
chatId: 'c1',
conversationId: 'c1',
sourceEventId: 'source-1',
replyTarget: { chatId: 'c1', replyToMessageId: 'source-1' },
isGroup: false,
text: 'hello',
sourceMessageId: '',
receivedAt: Date.now(),
} as unknown as BotIncomingMessage);

assert.deepEqual(createInput, {
conversationId: 'telegram:c1',
name: 'Telegram 任务',
labels: ['bot', 'telegram'],
});
Expand Down
208 changes: 149 additions & 59 deletions apps/desktop/src/main/__tests__/bot-incoming-session-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,72 +18,162 @@
*/

import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { BotIncomingMessage, BotRegistry } from '@maka/runtime/bots';
import test from 'node:test';
import type { BotIncomingMessage } from '@maka/runtime/bots';
import { createBotIncomingMainService } from '../bot-incoming-main.js';
import { BotSessionUnavailableError } from '../bot-session-adapter.js';
import { createTestBotSessionAdapter } from './bot-session-adapter-fixture.js';

async function waitFor(predicate: () => boolean): Promise<void> {
const deadline = Date.now() + 1_000;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error('Timed out waiting for bot lifecycle test');
await new Promise<void>((resolve) => setImmediate(resolve));
}
}
test('routes one external conversation through Host resolution and stable message admission', async () => {
const resolutions: unknown[] = [];
const turns: unknown[] = [];
const sent: string[] = [];
const sessions = createTestBotSessionAdapter({
async resolveSession(input) {
resolutions.push(input);
return { kind: 'ready', sessionId: 'session-1' };
},
async runTurn(input) {
if (input.admissionMode === 'replay_only') return { kind: 'admission_required' };
turns.push(input);
return { kind: 'completed', text: 'reply' };
},
});
const service = createBotIncomingMainService({
sessions,
botRegistry: registry(sent),
});

describe('bot session lifecycle bindings', () => {
test('rebinds a conversation after its archived session rejects a send', async () => {
const created: string[] = [];
const sent: string[] = [];
const replies: string[] = [];
let ensureCalls = 0;
const sessions = {
async createSession() {
const id = `bot-session-${created.length + 1}`;
created.push(id);
return id;
},
async prepareSession(sessionId: string) {
ensureCalls += 1;
if (sessionId === 'bot-session-1' && ensureCalls === 1) {
throw new BotSessionUnavailableError('archived');
}
return 'ready' as const;
},
async runTurn({ sessionId }: { sessionId: string }) {
sent.push(sessionId);
return { kind: 'completed' as const, text: `reply from ${sessionId}` };
},
};
await service.handleBotIncomingMessage(message({ sourceEventId: 'source-1', text: 'first' }));
await service.handleBotIncomingMessage(message({ sourceEventId: 'source-2', text: 'second' }));

const service = createBotIncomingMainService({
botRegistry: {
async sendMessage(_platform: string, _chatId: string, text: string) {
replies.push(text);
return 'message-id';
},
async sendTypingIndicator() {
return true;
assert.deepEqual(
resolutions.map((entry) => (entry as { conversationId: string }).conversationId),
['telegram:chat-1', 'telegram:chat-1'],
);
assert.equal(turns.length, 2);
assert.equal((turns[0] as { messageId: string }).messageId.length, 68);
assert.notEqual(
(turns[0] as { messageId: string }).messageId,
(turns[1] as { messageId: string }).messageId,
);
assert.deepEqual(sent, ['reply', 'reply']);
await service.close();
});

test('routes every source delivery to Host idempotency with the same stable message id', async () => {
const firstIds: string[] = [];
const event = message({ sourceEventId: 'stable-source' });
const create = (ids: string[]) =>
createBotIncomingMainService({
sessions: createTestBotSessionAdapter({
async runTurn(input) {
ids.push(input.messageId);
return { kind: 'completed', text: 'reply' };
},
} as unknown as BotRegistry,
sessions,
}),
botRegistry: registry([]),
});

const base = {
platform: 'telegram',
userId: 'user',
userName: 'User',
chatId: 'chat',
isGroup: false,
receivedAt: Date.now(),
};
await service.handleBotIncomingMessage({ ...base, text: 'first', sourceMessageId: 'source-1' } as BotIncomingMessage);
await waitFor(() => replies.length === 1);
await service.handleBotIncomingMessage({ ...base, text: 'second', sourceMessageId: 'source-2', receivedAt: Date.now() + 1 } as BotIncomingMessage);
await waitFor(() => replies.length === 2);
const first = create(firstIds);
await first.handleBotIncomingMessage(event);
await first.handleBotIncomingMessage(event);
assert.equal(firstIds.length, 2);
assert.equal(firstIds[0], firstIds[1]);
await first.close();

const successorIds: string[] = [];
const successor = create(successorIds);
await successor.handleBotIncomingMessage(event);
assert.deepEqual(successorIds, [firstIds[0]]);
await successor.close();
});

test('service recreation replays more than the transient burst before admitting new work', async () => {
const admissions: Array<{ messageId: string; admissionMode: string }> = [];
const sent: string[] = [];
let replayProbeCount = 0;
const service = createBotIncomingMainService({
sessions: createTestBotSessionAdapter({
async runTurn(input) {
admissions.push({
messageId: input.messageId,
admissionMode: input.admissionMode ?? 'allow',
});
if (input.admissionMode === 'replay_only') {
const index = replayProbeCount++;
return index < 9
? { kind: 'completed', text: `reply-${index}` }
: { kind: 'admission_required' };
}
return { kind: 'completed', text: 'new-reply' };
},
}),
botRegistry: registry(sent),
});

for (let index = 0; index < 9; index++) {
await service.handleBotIncomingMessage(
message({ sourceEventId: `retry-${index}`, text: `retry-${index}` }),
);
}
await service.handleBotIncomingMessage(
message({ sourceEventId: 'new-source', text: 'new', conversationId: 'new-chat' }),
);

assert.equal(admissions.filter((entry) => entry.admissionMode === 'replay_only').length, 10);
assert.equal(admissions.filter((entry) => entry.admissionMode === 'allow').length, 1);
assert.deepEqual(sent, [
...Array.from({ length: 9 }, (_, index) => `reply-${index}`),
'new-reply',
]);
await service.close();
});

assert.deepEqual(created, ['bot-session-1', 'bot-session-2']);
assert.deepEqual(sent, ['bot-session-1', 'bot-session-2']);
assert.deepEqual(replies, ['reply from bot-session-1', 'reply from bot-session-2']);
test('releases a direct-message binding through a source-correlated reset operation', async () => {
const releases: unknown[] = [];
const sent: string[] = [];
const service = createBotIncomingMainService({
sessions: createTestBotSessionAdapter({
async releaseConversation(input) {
releases.push(input);
return true;
},
}),
botRegistry: registry(sent),
});

await service.handleBotIncomingMessage(message({ text: 'reset', sourceEventId: 'reset-1' }));

assert.equal(releases.length, 1);
assert.equal((releases[0] as { conversationId: string }).conversationId, 'telegram:chat-1');
assert.match((releases[0] as { operationId: string }).operationId, /^bot_[a-f0-9]{64}$/);
assert.deepEqual(sent, ['任务已重置,下一条消息会开新任务。']);
await service.close();
});

function message(overrides: Partial<BotIncomingMessage> = {}): BotIncomingMessage {
return {
platform: 'telegram',
userId: 'user-1',
userName: 'Alice',
conversationId: 'chat-1',
sourceEventId: 'source-1',
replyTarget: { chatId: 'chat-1', replyToMessageId: 'source-1' },
isGroup: false,
text: 'hello',
receivedAt: 1,
...overrides,
};
}

function registry(sent: string[]) {
return {
async sendMessage(_platform: string, _chatId: string, text: string) {
sent.push(text);
return 'sent';
},
async sendTypingIndicator() {
return true;
},
} as never;
}
37 changes: 15 additions & 22 deletions apps/desktop/src/main/__tests__/bot-incoming-typing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { getEventListeners } from 'node:events';
import { test } from 'node:test';
import type { BotIncomingMessage, BotRegistry } from '@maka/runtime/bots';
import { createBotIncomingMainService } from '../bot-incoming-main.js';
import { createTestBotSessionAdapter } from './bot-session-adapter-fixture.js';

async function waitFor(predicate: () => boolean, message: string): Promise<void> {
for (let attempt = 0; attempt < 100; attempt += 1) {
Expand Down Expand Up @@ -60,28 +61,24 @@ test('the bot typing loop owns only its active abort listener', async (t) => {
throw new Error('typing unavailable');
},
} as unknown as BotRegistry,
sessions: {
async createSession() {
return 'bot-session';
},
async prepareSession() {
return 'ready';
},
async runTurn() {
sessions: createTestBotSessionAdapter({
async runTurn(input) {
if (input.admissionMode === 'replay_only') return { kind: 'admission_required' };
await turnReleased;
return { kind: 'completed', text: 'Bot reply' };
},
},
}),
});

const handling = service.handleBotIncomingMessage({
platform: 'telegram',
userId: 'user',
userName: 'User',
chatId: 'chat',
conversationId: 'chat',
sourceEventId: 'source',
replyTarget: { chatId: 'chat', replyToMessageId: 'source' },
isGroup: false,
text: 'hello',
sourceMessageId: 'source',
receivedAt: Date.now(),
} as BotIncomingMessage);

Expand Down Expand Up @@ -125,7 +122,7 @@ test('streams reply snapshots and persists the final reply through one channel s
options: { isGroup: boolean; streamId: string },
) {
assert.equal(options.isGroup, false);
assert.match(options.streamId, /^[0-9a-f-]{36}$/);
assert.match(options.streamId, /^bot_[0-9a-f]{64}$/);
return {
update(text: string) {
updates.push(text);
Expand All @@ -145,29 +142,25 @@ test('streams reply snapshots and persists the final reply through one channel s
return true;
},
} as unknown as BotRegistry,
sessions: {
async createSession() {
return 'bot-session';
},
async prepareSession() {
return 'ready';
},
sessions: createTestBotSessionAdapter({
async runTurn(input) {
if (input.admissionMode === 'replay_only') return { kind: 'admission_required' };
input.onReplySnapshot?.('Hello');
input.onReplySnapshot?.('Hello world');
return { kind: 'completed', text: 'Hello world' };
},
},
}),
});

await service.handleBotIncomingMessage({
platform: 'telegram',
userId: 'user',
userName: 'User',
chatId: 'chat',
conversationId: 'chat',
sourceEventId: 'source',
replyTarget: { chatId: 'chat', replyToMessageId: 'source' },
isGroup: false,
text: 'hello',
sourceMessageId: 'source',
receivedAt: Date.now(),
} as BotIncomingMessage);

Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/main/__tests__/bot-session-adapter-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { BotSessionAdapter } from '../bot-session-adapter.js';

export function createTestBotSessionAdapter(
overrides: Partial<BotSessionAdapter> = {},
): BotSessionAdapter {
return {
async resolveSession() {
return { kind: 'ready', sessionId: 'bot-session-1' };
},
async releaseConversation() {
return false;
},
async runTurn(input) {
if (input.admissionMode === 'replay_only') return { kind: 'admission_required' };
return { kind: 'completed', text: 'ok' };
},
...overrides,
};
}
Loading
Loading