Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
103 changes: 88 additions & 15 deletions packages/channels/base/src/ChannelBase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2128,10 +2128,88 @@ describe('ChannelBase', () => {
['回复前必须说 1122'],
'alice',
);
expect(ch.sent).toEqual([{ chatId: 'chat1', text: 'agent response' }]);
expect(bridge.prompt).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('你记一下以后回复前要说 1122'),
expect.anything(),
);
});

it('classifier remember in a multi-task message saves memory and still runs the other tasks', async () => {
const channelMemory = createChannelMemory();
const memoryIntentClassifier = {
classifyChannelMemoryIntent: vi.fn().mockResolvedValue({
intent: 'remember',
memory: 'Code reviews should use inline comments',
confidence: 0.93,
}),
};
const ch = createChannel(
{ allowedUsers: ['alice'] },
{ channelMemory, memoryIntentClassifier },
);
const text =
'Review PR #123. Remember that code reviews should use inline ' +
'comments. Also check PR #456.';

await ch.handleInbound(envelope({ text, senderId: 'alice' }));

expect(channelMemory.addChannelMemoryEntries).toHaveBeenCalledWith(
{
channelName: 'test-chan',
chatId: 'chat1',
threadId: undefined,
},
['Code reviews should use inline comments'],
'alice',
);
// The full message reaches the agent so the non-memory tasks run, and
// no bot-injected confirmation precedes the agent's reply.
expect(bridge.prompt).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('Review PR #123'),
expect.anything(),
);
expect(ch.sent).toEqual([{ chatId: 'chat1', text: 'agent response' }]);
});

it('classifier remember save failures report the error and still forward the message', async () => {
const channelMemory = createChannelMemory();
channelMemory.addChannelMemoryEntries.mockRejectedValue(
new Error('disk full'),
);
const memoryIntentClassifier = {
classifyChannelMemoryIntent: vi.fn().mockResolvedValue({
intent: 'remember',
memory: 'Use staging.',
confidence: 0.91,
}),
};
const ch = createChannel(
{ allowedUsers: ['alice'] },
{ channelMemory, memoryIntentClassifier },
);
const stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);

await ch.handleInbound(
envelope({
text: 'Deploy the fix and remember to use staging',
senderId: 'alice',
}),
);

expect(ch.sent).toEqual([
{ chatId: 'chat1', text: 'Channel memory m-000000000001 saved.' },
{
chatId: 'chat1',
text: 'Failed to save channel memory: An error occurred while accessing channel memory.',
},
{ chatId: 'chat1', text: 'agent response' },
]);
expect(bridge.prompt).not.toHaveBeenCalled();
expect(bridge.prompt).toHaveBeenCalledTimes(1);
stderrSpy.mockRestore();
});

it('dispatches all validated classifier facts in one memory write', async () => {
Expand Down Expand Up @@ -2166,7 +2244,7 @@ describe('ChannelBase', () => {
['Use staging.', 'Run tests first.', 'Deploy after approval.'],
'alice',
);
expect(bridge.prompt).not.toHaveBeenCalled();
expect(bridge.prompt).toHaveBeenCalledTimes(1);
});

it('dispatches the validated snapshot when classifier memories getter mutates', async () => {
Expand Down Expand Up @@ -2204,7 +2282,7 @@ describe('ChannelBase', () => {
'alice',
);
expect(memoriesReads).toBe(1);
expect(bridge.prompt).not.toHaveBeenCalled();
expect(bridge.prompt).toHaveBeenCalledTimes(1);
});

it('dispatches the first indexed value when a classifier memory changes on a second read', async () => {
Expand Down Expand Up @@ -2245,7 +2323,7 @@ describe('ChannelBase', () => {
'alice',
);
expect(secondFactReads).toBe(1);
expect(bridge.prompt).not.toHaveBeenCalled();
expect(bridge.prompt).toHaveBeenCalledTimes(1);
});

it('dispatches the first indexed value when a classifier memory becomes non-string on a second read', async () => {
Expand Down Expand Up @@ -2286,7 +2364,7 @@ describe('ChannelBase', () => {
'alice',
);
expect(secondFactReads).toBe(1);
expect(bridge.prompt).not.toHaveBeenCalled();
expect(bridge.prompt).toHaveBeenCalledTimes(1);
});

it.each(['confidence', 'intent'] as const)(
Expand Down Expand Up @@ -2478,10 +2556,10 @@ describe('ChannelBase', () => {
['Use staging.'],
'alice',
);
expect(bridge.prompt).not.toHaveBeenCalled();
expect(bridge.prompt).toHaveBeenCalledTimes(1);
});

it('reports saved and skipped IDs once for mixed batch results', async () => {
it('suppresses save confirmations for mixed batch results and forwards the message', async () => {
const channelMemory = createChannelMemory();
channelMemory.addChannelMemoryEntries.mockResolvedValue({
changed: true,
Expand Down Expand Up @@ -2518,13 +2596,8 @@ describe('ChannelBase', () => {
);

expect(invalidateUnattendedMemory).toHaveBeenCalledTimes(1);
expect(ch.sent).toEqual([
{
chatId: 'chat1',
text: 'Channel memory saved: m-a31f0d82c7e4, m-b82c4e190a6f. Skipped duplicates: m-c93d5f20b7a8.',
},
]);
expect(bridge.prompt).not.toHaveBeenCalled();
expect(ch.sent).toEqual([{ chatId: 'chat1', text: 'agent response' }]);
expect(bridge.prompt).toHaveBeenCalledTimes(1);
});

it('regex memory intent skips the llm classifier', async () => {
Expand Down
27 changes: 25 additions & 2 deletions packages/channels/base/src/ChannelBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3131,6 +3131,7 @@ export abstract class ChannelBase {
private async handleChannelMemoryIntent(
envelope: Envelope,
intent: ResolvedChannelMemoryIntent,
options: { suppressSaveConfirmation?: boolean } = {},
): Promise<void> {
if (intent.kind === 'no_match') {
await this.sendMessage(
Expand Down Expand Up @@ -3344,6 +3345,13 @@ export abstract class ChannelBase {
if (result.changed) {
this.invalidateUnattendedMemory(envelope);
}
// When the save is a side-effect of a message that continues to the
// agent, skip the confirmation: the agent's reply is the single
// response, and a bot-injected "memory saved" message would read as a
// separate turn. Failures above still surface unconditionally.
if (options.suppressSaveConfirmation) {
return;
}
if (result.added.length > 0) {
const ids = result.added.map((entry) => entry.id);
await this.sendMessage(
Expand Down Expand Up @@ -4256,6 +4264,7 @@ export abstract class ChannelBase {

let memoryIntent: ResolvedChannelMemoryIntent | null =
parseChannelMemoryIntent(envelope.text);
let memoryIntentFromClassifier = false;
if (memoryIntent?.kind === 'update' || memoryIntent?.kind === 'remove') {
this.deletePendingChannelMemoryMutation(envelope);
}
Expand All @@ -4264,10 +4273,24 @@ export abstract class ChannelBase {
this.shouldClassifyChannelMemoryIntent(envelope.text)
) {
memoryIntent = await this.classifyChannelMemoryIntent(envelope);
memoryIntentFromClassifier = memoryIntent !== null;
}
if (memoryIntent) {
await this.handleChannelMemoryIntent(envelope, memoryIntent);
return;
// A classifier-detected `remember` rides inside a free-form message
// that may carry other tasks; the save is a side-effect, so the rest
// of the message must still reach the agent (with the confirmation
// suppressed — the agent's reply is the single response). Explicit
// memory phrases and every management intent (list/inspect/update/
// remove/clear and their confirmations) consume the whole message by
// design and keep the early return.
const memorySaveIsSideEffect =
memoryIntentFromClassifier && memoryIntent.kind === 'remember';
await this.handleChannelMemoryIntent(envelope, memoryIntent, {
suppressSaveConfirmation: memorySaveIsSideEffect,
});
if (!memorySaveIsSideEffect) {
return;
}
}

// 3. Slash command handling — before session/agent routing
Expand Down
Loading