Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
822dbb3
feat(storage): own external conversation bindings
me2seeks Aug 9, 2026
ac61589
feat(runtime-host): own bot conversation lifecycle
me2seeks Aug 9, 2026
6159caf
fix(runtime-host): preserve bot claims across recovery
me2seeks Aug 9, 2026
1e34258
feat(desktop): route bot conversations through runtime host
me2seeks Aug 9, 2026
b558282
fix(desktop): observe bot turns before admission
me2seeks Aug 9, 2026
7e3e12d
fix(desktop): recover historical bot turns
me2seeks Aug 9, 2026
01a3eae
fix(runtime): keep qq reply targets routable
me2seeks Aug 9, 2026
6f9aa5a
fix(storage): bound conversation release receipts
me2seeks Aug 9, 2026
8503636
test(runtime-host): use workspace targets for bot sessions
me2seeks Aug 10, 2026
3e07bec
test(runtime-host): decode bot workspace targets
me2seeks Aug 10, 2026
1f0e752
test(desktop): preserve bot delta reset coverage
me2seeks Aug 11, 2026
f5e1fae
fix(runtime-host): preserve bot continuity after cutover
me2seeks Aug 12, 2026
1efe021
fix(runtime-host): enforce bot workspace authority
me2seeks Aug 13, 2026
b97fae4
fix(desktop): close bot turn admission races
me2seeks Aug 13, 2026
523808e
docs(runtime): clarify bot reply streamId identity
me2seeks Aug 17, 2026
fec01aa
fix(runtime): drop WeChat iLink messages without a stable upstream id…
me2seeks Aug 18, 2026
28b6c2b
fix(desktop): fail closed on unexpected message-submit dispositions
me2seeks Aug 18, 2026
1c98073
fix(runtime-host): retry archive-time external conversation purges
me2seeks Aug 18, 2026
58944c2
fix(bot): align conversation continuity with current host contracts
me2seeks Aug 18, 2026
fc278b1
fix(runtime-host): preserve bot continuity recovery
me2seeks Aug 18, 2026
d386baf
test(runtime-host): fail on unexpected reconciliation results
me2seeks Aug 18, 2026
fa5a8c3
fix(runtime-host): harden bot continuity recovery
me2seeks Aug 19, 2026
c9e8873
fix(bot): require stable platform message ids
me2seeks Aug 19, 2026
06d54df
fix(storage): preserve reset retry receipts
me2seeks Aug 19, 2026
cb2fecb
fix(bot): prove retries before transient admission
me2seeks Aug 19, 2026
098574c
test(storage): migrate conversation schema after rebase
me2seeks Aug 19, 2026
0cc638e
fix(bot): reject unstable channel identities
me2seeks Aug 19, 2026
1c91e35
fix(runtime-host): drop the retired surface field from the bot recove…
me2seeks Aug 23, 2026
70e88ff
fix(desktop): reconcile bot adapter seams after the rebase
me2seeks Aug 23, 2026
2bd2660
fix(runtime-host): bump epoch 48→49 for bot conversation continuity
me2seeks Aug 25, 2026
f76bce1
fix(runtime-host): align external-conversation tests with main
me2seeks Aug 25, 2026
31778f3
chore: add ASF headers to external-conversation files
me2seeks Aug 25, 2026
76e683b
fix(storage): close migration 29 template literal after rebase
me2seeks Aug 25, 2026
cdbad20
fix(storage): declare external-conversation-authority as SQLite-backed
me2seeks Aug 25, 2026
ccecbc9
style: format execution-composition to CI biome
me2seeks Aug 25, 2026
cf8b045
fix(runtime-host): expect migration 31 after workhub renumber
me2seeks Aug 25, 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
38 changes: 38 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,38 @@
/*
* 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 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