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
7c63b54
feat(storage): own external conversation bindings
me2seeks Aug 9, 2026
1f1af9c
feat(runtime-host): own bot conversation lifecycle
me2seeks Aug 9, 2026
35557b9
fix(runtime-host): preserve bot claims across recovery
me2seeks Aug 9, 2026
2f8941c
feat(desktop): route bot conversations through runtime host
me2seeks Aug 9, 2026
471bb93
fix(desktop): observe bot turns before admission
me2seeks Aug 9, 2026
d5aad41
fix(desktop): recover historical bot turns
me2seeks Aug 9, 2026
1e4581f
fix(runtime): keep qq reply targets routable
me2seeks Aug 9, 2026
ee2df2f
fix(storage): bound conversation release receipts
me2seeks Aug 9, 2026
041b65c
test(runtime-host): use workspace targets for bot sessions
me2seeks Aug 10, 2026
1b000fd
test(runtime-host): decode bot workspace targets
me2seeks Aug 10, 2026
a9ec294
test(desktop): preserve bot delta reset coverage
me2seeks Aug 11, 2026
67472fe
fix(runtime-host): preserve bot continuity after cutover
me2seeks Aug 12, 2026
ac04e09
fix(runtime-host): enforce bot workspace authority
me2seeks Aug 13, 2026
c92c7c8
fix(desktop): close bot turn admission races
me2seeks Aug 13, 2026
4bd4f65
docs(runtime): clarify bot reply streamId identity
me2seeks Aug 17, 2026
9f82453
fix(runtime): drop WeChat iLink messages without a stable upstream id…
me2seeks Aug 18, 2026
fa0b630
fix(desktop): fail closed on unexpected message-submit dispositions
me2seeks Aug 18, 2026
e29beca
fix(runtime-host): retry archive-time external conversation purges
me2seeks Aug 18, 2026
64341ea
fix(bot): align conversation continuity with current host contracts
me2seeks Aug 18, 2026
54fd20b
fix(runtime-host): preserve bot continuity recovery
me2seeks Aug 18, 2026
b093bce
test(runtime-host): fail on unexpected reconciliation results
me2seeks Aug 18, 2026
a4eee1e
fix(runtime-host): harden bot continuity recovery
me2seeks Aug 19, 2026
00fe287
fix(bot): require stable platform message ids
me2seeks Aug 19, 2026
d0b387f
fix(storage): preserve reset retry receipts
me2seeks Aug 19, 2026
20045a8
fix(bot): prove retries before transient admission
me2seeks Aug 19, 2026
5da3cd6
test(storage): migrate conversation schema after rebase
me2seeks Aug 19, 2026
06164c0
fix(bot): reject unstable channel identities
me2seeks Aug 19, 2026
780520a
fix(runtime-host): drop the retired surface field from the bot recove…
me2seeks Aug 23, 2026
8a2beea
fix(desktop): reconcile bot adapter seams after the rebase
me2seeks Aug 23, 2026
6e545df
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