Skip to content
Open
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"cmdk": "^1.1.1",
"electron-squirrel-startup": "^1.0.1",
"fix-path": "^5.0.0",
"grammy": "^1.42.0",
"global-agent": "^3.0.0",
"lucide-react": "^0.511.0",
"nanoid": "^5.1.7",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 19 additions & 7 deletions src/electron/main/runtime/agent-message-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { inferAttachmentKind } from '@/shared/agents/message-content';

const WORKSPACE_GROUP_PREFIX = '/workspace/group/';

interface AttachmentCandidate extends Omit<Partial<AgentAttachment>, 'kind'> {
kind?: AgentAttachment['kind'] | 'document';
path?: string;
}

/** Converts to local attachment path. */
function toLocalAttachmentPath(
source: string,
Expand Down Expand Up @@ -81,24 +86,31 @@ export function normalizeAgentAttachments(
continue;
}

const candidate = attachment as Partial<AgentAttachment>;
const rawUrl = typeof candidate.url === 'string' ? candidate.url.trim() : '';
const candidate = attachment as AttachmentCandidate;
const rawSource =
typeof candidate.path === 'string' && candidate.path.trim()
? candidate.path.trim()
: typeof candidate.url === 'string'
? candidate.url.trim()
: '';
const sourceBackedUrl =
rawUrl && (rawUrl.startsWith('https://') || rawUrl.startsWith('file://'))
? rawUrl
: rawUrl
? createAgentAttachmentFromSource(rawUrl, options)?.url ?? ''
rawSource && (rawSource.startsWith('https://') || rawSource.startsWith('file://'))
? rawSource
: rawSource
? createAgentAttachmentFromSource(rawSource, options)?.url ?? ''
: '';

if (!sourceBackedUrl || seenUrls.has(sourceBackedUrl)) {
continue;
}

const normalizedKind = inferAttachmentKind({
const inferredKind = inferAttachmentKind({
...(typeof candidate.mimeType === 'string' ? { mimeType: candidate.mimeType } : {}),
...(typeof candidate.name === 'string' ? { name: candidate.name } : {}),
url: sourceBackedUrl,
});
const normalizedKind =
candidate.kind === 'document' ? 'file' : candidate.kind ?? inferredKind;
const normalizedName =
typeof candidate.name === 'string' && candidate.name.trim()
? candidate.name
Expand Down
96 changes: 93 additions & 3 deletions src/electron/main/runtime/agent-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
resolveProjectDuneDir,
} from '@/electron/main/dune-paths';
import type { DuneChannel } from './dune-channel';
import type { OutboundMessageAttachmentSource } from './dune-channel';
import { toAgentChatJid } from '@/shared/agents/agent-id';
import { createProjectMainAgentName } from '@/shared/agents/project-main-name';

Expand Down Expand Up @@ -290,7 +291,11 @@ function createTelegramChannelFactoryHarness(
connect?: () => Promise<void> | void;
disconnect?: () => Promise<void> | void;
isConnected?: () => boolean;
sendMessage?: (jid: string, text: string) => Promise<void> | void;
sendMessage?: (
jid: string,
text: string,
attachments?: OutboundMessageAttachmentSource[],
) => Promise<void> | void;
} = {},
) {
const connectedTokens = new Set<string>();
Expand All @@ -309,8 +314,12 @@ function createTelegramChannelFactoryHarness(
connectedTokens.delete(currentToken);
}
});
const sendMessage = vi.fn(async (jid: string, text: string) => {
await options.sendMessage?.(jid, text);
const sendMessage = vi.fn(async (
jid: string,
text: string,
attachments?: OutboundMessageAttachmentSource[],
) => {
await options.sendMessage?.(jid, text, attachments);
});

const resolveConfig = (
Expand Down Expand Up @@ -1675,6 +1684,87 @@ describe('AgentRuntime', () => {
expect(telegramHarness.sendMessage).toHaveBeenCalledTimes(1);
});

it('passes outbound assistant attachments through to Telegram and stores them on the assistant message', async () => {
const homeDir = createTempHome();
const harness = createAgentLiteModuleHarness();
const telegramHarness = createTelegramChannelFactoryHarness();
const telegramSecretsStore = createMemorySecretsStore();

tempDirs.push(homeDir);

const host = new AgentRuntime({
agentStore: createMemoryStore(),
createTelegramChannelFactory: telegramHarness.createTelegramChannelFactory,
homeDir,
loadAgentLiteModule: harness.loadAgentLiteModule,
resolveModelCredentials: async () => ({}),
resolveTelegramBotUsername: async () => 'agentlite_test_bot',
telegramSecretsStore,
});

await host.start();

const agentId = await host.service.createAgent({
channelId: 'dune-chat',
name: 'Release triage',
projectId: 'project-1',
});
const sessionId = await host.service.startTelegramSetupSession({
agentId,
token: 'telegram-bot-token',
});
const pairCode = host.getSnapshot().telegramSetupSessions
.find((session) => session.id === sessionId)?.pairCode ?? '';

telegramHarness.emitChatMetadata('tg:123', {
isGroup: true,
name: 'Product QA',
});
telegramHarness.emitIncomingMessage('tg:123', {
content: `/pair ${pairCode}`,
sender: 'alice',
senderName: 'Alice',
});
await flushMicrotasks();

await host.service.updateAgentChannel({
agentId,
channelId: 'telegram',
telegramSetupSessionId: sessionId,
});

const attachments = [
{
kind: 'video' as const,
name: 'demo.mp4',
url: 'file:///tmp/demo.mp4',
},
];

await harness.duneChannel('release-triage').sendMessage(
toAgentChatJid(agentId),
'Demo attached.',
attachments,
);

expect(telegramHarness.sendMessage).toHaveBeenLastCalledWith(
'tg:123',
'Demo attached.',
attachments,
);

const agent = host.getSnapshot().agents.find((item) => item.id === agentId);
const assistantMessage = agent?.messages.find((message) => message.role === 'assistant');

expect(assistantMessage?.attachments).toEqual([
{
kind: 'video',
name: 'demo.mp4',
url: 'file:///tmp/demo.mp4',
},
]);
});

it('appends token usage summaries to outbound Telegram responses and keeps per-session totals', async () => {
vi.useFakeTimers();

Expand Down
24 changes: 19 additions & 5 deletions src/electron/main/runtime/agent-runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ import {
} from '@/electron/main/dune-paths';
import { auto as autoAcpPeers } from '@boxlite-ai/agentlite/acp/peers';
import { DuneAgent, type DuneAcpOptions } from '../dune-agent';
import type { OutboundMessageAttachmentSource } from '../dune-channel';
import { withTelegramOutboundMedia } from '../telegram-media-driver';
import {
registerDuneActions,
type ActionHostServices,
Expand Down Expand Up @@ -370,7 +372,7 @@ export class AgentRuntime implements AgentRuntimeContract {
const { telegram } = await importTelegramModule(
'@boxlite-ai/agentlite/channels/telegram',
);
return telegram({ token });
return withTelegramOutboundMedia(telegram({ token }));
});
this.telegram = new TelegramBridge({
callbacks: {
Expand Down Expand Up @@ -1337,13 +1339,24 @@ export class AgentRuntime implements AgentRuntimeContract {
return appendAgentTokenUsageSummary(text, summary);
}

private handleOutboundMessage(chatJid: string, text: string) {
private handleOutboundMessage(
chatJid: string,
text: string,
attachmentSources: OutboundMessageAttachmentSource[] = [],
) {
const agentId = this.resolveAgentIdByChatJid(chatJid);

if (!agentId) {
return;
}

const record = this.records.get(agentId);
const attachments = record
? normalizeAgentAttachments(attachmentSources, {
groupFolder: record.groupFolder,
runtimeRoot: this.runtimeRoot,
})
: [];
const pending = this.messageStream.get(agentId);
const now = this.now();

Expand All @@ -1369,7 +1382,7 @@ export class AgentRuntime implements AgentRuntimeContract {
messages: [
...agent.messages,
{
attachments: [],
attachments,
content: text,
createdAt: now,
format: 'markdown',
Expand All @@ -1390,6 +1403,7 @@ export class AgentRuntime implements AgentRuntimeContract {
message.id === pending.messageId
? {
...message,
attachments: attachments.length > 0 ? attachments : message.attachments,
content:
text.startsWith(message.content)
? text
Expand Down Expand Up @@ -1783,8 +1797,8 @@ export class AgentRuntime implements AgentRuntimeContract {
onExternalInbound: (text, senderName, attachments) => {
this.handleExternalInboundMessage(agentId, text, senderName, attachments);
},
onOutboundMessage: (chatJid, text) => {
this.handleOutboundMessage(chatJid, text);
onOutboundMessage: (chatJid, text, attachments) => {
this.handleOutboundMessage(chatJid, text, attachments);
},
primaryChatJid: toAgentChatJid(agentId),
...(actionServicesForAgent && ownerProjectId ? {
Expand Down
11 changes: 8 additions & 3 deletions src/electron/main/runtime/dune-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
} from '@boxlite-ai/agentlite';

import { DuneChannel } from './dune-channel';
import type { OutboundMessageAttachmentSource } from './dune-channel';

/** ACP peer config understood by newer AgentLite runtimes. */
export interface DuneAcpPeerConfig {
Expand Down Expand Up @@ -42,7 +43,11 @@ export interface DuneAgentOptions {
}>;
name: string;
onExternalInbound?: (text: string, senderName: string, attachments?: string[]) => void;
onOutboundMessage: (chatJid: string, text: string) => void;
onOutboundMessage: (
chatJid: string,
text: string,
attachments?: OutboundMessageAttachmentSource[],
) => void;
primaryChatJid: string;
/**
* Called immediately after `agentLite.getOrCreateAgent(...)` returns, with
Expand Down Expand Up @@ -106,8 +111,8 @@ export class DuneAgent {
decorateOutboundMessage: options.decorateOutboundMessage,
externalChannelFactory: options.externalChannelFactory,
onExternalInbound: options.onExternalInbound,
onOutboundMessage: (jid, text) => {
options.onOutboundMessage(jid, text);
onOutboundMessage: (jid, text, attachments) => {
options.onOutboundMessage(jid, text, attachments);
},
primaryJid: options.primaryChatJid,
});
Expand Down
59 changes: 59 additions & 0 deletions src/electron/main/runtime/dune-channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,63 @@ describe('DuneChannel', () => {
'dune credentials ok\n\n📊 dune:agent:test',
);
});

it('forwards outbound attachments to the external channel and local callbacks', async () => {
const onOutboundMessage = vi.fn().mockResolvedValue(undefined);
const onChatMetadata = vi.fn();
const onMessage = vi.fn();
const externalSendMessage = vi.fn().mockResolvedValue(undefined);
const duneChannel = new DuneChannel({
boundExternalJid: 'tg:123',
config: {
onChatMetadata,
onMessage,
registeredGroups: () => ({
'dune:agent:test': {
added_at: new Date('2026-04-04T00:00:00.000Z').toISOString(),
folder: 'release-coordinator',
name: 'Release coordinator',
trigger: '@Dune',
},
}),
},
externalChannelFactory: () => ({
connect: vi.fn(() => Promise.resolve()),
disconnect: vi.fn(() => Promise.resolve()),
isConnected: vi.fn(() => true),
ownsJid: (jid: string) => jid.startsWith('tg:'),
sendMessage: externalSendMessage,
}),
onOutboundMessage,
primaryJid: 'dune:agent:test',
});
const attachments = [
{
kind: 'image' as const,
name: 'chart.png',
url: 'file:///tmp/chart.png',
},
];

await duneChannel.connect();
await duneChannel.sendMessage('dune:agent:test', 'chart attached', attachments);

expect(onMessage).toHaveBeenCalledWith(
'dune:agent:test',
expect.objectContaining({
attachments,
content: 'chart attached',
}),
);
expect(externalSendMessage).toHaveBeenCalledWith(
'tg:123',
'chart attached',
attachments,
);
expect(onOutboundMessage).toHaveBeenCalledWith(
'dune:agent:test',
'chart attached',
attachments,
);
});
});
Loading