discoclaw 1.1.5 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/discord/message-coordinator.js +7 -38
- package/dist/discord/message-coordinator.test.js +5 -102
- package/dist/discord/message-history.js +21 -16
- package/dist/discord/message-history.test.js +46 -76
- package/dist/discord/thread-context.js +27 -61
- package/dist/discord/thread-context.test.js +5 -165
- package/package.json +1 -1
|
@@ -2633,7 +2633,6 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
2633
2633
|
let stopReactionRemoved = false;
|
|
2634
2634
|
// Declared before try so they remain accessible after the finally block closes.
|
|
2635
2635
|
let historySection = '';
|
|
2636
|
-
let historyAttachments = [];
|
|
2637
2636
|
let summarySection = '';
|
|
2638
2637
|
let existingSummaryText = null;
|
|
2639
2638
|
let existingSummaryUpdatedAt;
|
|
@@ -2705,7 +2704,6 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
2705
2704
|
try {
|
|
2706
2705
|
const historyResult = await fetchMessageHistory(msg.channel, msg.id, { budgetChars: params.messageHistoryBudget, fetchLimit: params.messageHistoryFetchLimit, maxAgeMs: params.messageHistoryMaxAgeMs, botDisplayName: params.botDisplayName });
|
|
2707
2706
|
historySection = historyResult.text;
|
|
2708
|
-
historyAttachments = historyResult.historyAttachments;
|
|
2709
2707
|
}
|
|
2710
2708
|
catch (err) {
|
|
2711
2709
|
params.log?.warn({ err }, 'discord:history fetch failed');
|
|
@@ -2937,7 +2935,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
2937
2935
|
hasChannelContext: Boolean(channelCtx.contextPath),
|
|
2938
2936
|
permissionTier: tools.permissionTier,
|
|
2939
2937
|
}, 'invoke:start');
|
|
2940
|
-
// Collect images across sources. Priority: direct > reply-ref
|
|
2938
|
+
// Collect images across sources. Priority: direct > reply-ref.
|
|
2941
2939
|
// Each source fills the remaining MAX_IMAGES_PER_INVOCATION budget.
|
|
2942
2940
|
let inputImages;
|
|
2943
2941
|
// 1. Direct message attachments (highest priority — full budget).
|
|
@@ -3021,41 +3019,12 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
3021
3019
|
catch (err) {
|
|
3022
3020
|
params.log?.warn({ err }, 'discord:youtube transcript fetch failed');
|
|
3023
3021
|
}
|
|
3024
|
-
//
|
|
3025
|
-
//
|
|
3026
|
-
//
|
|
3027
|
-
//
|
|
3028
|
-
//
|
|
3029
|
-
|
|
3030
|
-
if (!skipHistoryImages && historyAttachments.length > 0) {
|
|
3031
|
-
const currentCount = inputImages?.length ?? 0;
|
|
3032
|
-
const historyImageBudget = MAX_IMAGES_PER_INVOCATION - currentCount;
|
|
3033
|
-
if (historyImageBudget > 0) {
|
|
3034
|
-
try {
|
|
3035
|
-
// Deduplicate: exclude attachment URLs already processed from the direct message.
|
|
3036
|
-
const directUrls = new Set();
|
|
3037
|
-
if (msg.attachments) {
|
|
3038
|
-
for (const att of msg.attachments.values()) {
|
|
3039
|
-
directUrls.add(att.url);
|
|
3040
|
-
}
|
|
3041
|
-
}
|
|
3042
|
-
const deduped = historyAttachments.filter(a => !directUrls.has(a.url));
|
|
3043
|
-
if (deduped.length > 0) {
|
|
3044
|
-
const dlResult = await downloadMessageImages(deduped, historyImageBudget);
|
|
3045
|
-
if (dlResult.images.length > 0) {
|
|
3046
|
-
inputImages = [...(inputImages ?? []), ...dlResult.images];
|
|
3047
|
-
params.log?.info({ imageCount: dlResult.images.length }, 'discord:history images downloaded');
|
|
3048
|
-
}
|
|
3049
|
-
if (dlResult.errors.length > 0) {
|
|
3050
|
-
params.log?.warn({ errors: dlResult.errors }, 'discord:history image download errors');
|
|
3051
|
-
}
|
|
3052
|
-
}
|
|
3053
|
-
}
|
|
3054
|
-
catch (err) {
|
|
3055
|
-
params.log?.warn({ err }, 'discord:history image download failed');
|
|
3056
|
-
}
|
|
3057
|
-
}
|
|
3058
|
-
}
|
|
3022
|
+
// History images are intentionally NOT injected here. The text
|
|
3023
|
+
// history already notes `[attachment/embed]` when media was present,
|
|
3024
|
+
// and the reply-reference mechanism (source #2 above) handles cases
|
|
3025
|
+
// where the user explicitly wants the model to see an older image.
|
|
3026
|
+
// Injecting history images as raw content blocks caused the model to
|
|
3027
|
+
// analyze or act on stale images unprompted.
|
|
3059
3028
|
let currentPrompt = prompt;
|
|
3060
3029
|
let followUpDepth = 0;
|
|
3061
3030
|
let pendingFollowUp = null;
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* rule is injected alongside the live action inventory so the model trusts
|
|
4
4
|
* the per-turn inventory over generic product knowledge.
|
|
5
5
|
*
|
|
6
|
-
* Also tests image input precedence across direct
|
|
7
|
-
* history
|
|
6
|
+
* Also tests image input precedence across direct and reply-reference
|
|
7
|
+
* sources (history images are intentionally excluded).
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'node:fs/promises';
|
|
10
10
|
import os from 'node:os';
|
|
@@ -413,115 +413,18 @@ describe('image input precedence — direct > reply-ref > history', () => {
|
|
|
413
413
|
await handler(msg);
|
|
414
414
|
expect(runtime.images).toEqual([directImg, refImg]);
|
|
415
415
|
});
|
|
416
|
-
it('
|
|
417
|
-
|
|
418
|
-
const histImg = fakeImage('hist-1');
|
|
419
|
-
// No direct attachments → downloadMessageImages not called for direct.
|
|
420
|
-
mockReplyRef.mockResolvedValue({ section: '[User]: hi', images: [refImg] });
|
|
421
|
-
// History returns attachments.
|
|
416
|
+
it('history images are never injected into the prompt', async () => {
|
|
417
|
+
// History returns attachments, but they should NOT be downloaded.
|
|
422
418
|
const histAtt = fakeAttachment('hist.png');
|
|
423
419
|
mockFetchHistory.mockResolvedValue({ text: 'history text', historyAttachments: [histAtt] });
|
|
424
|
-
// downloadMessageImages called once for history attachments.
|
|
425
|
-
mockDownloadImages.mockResolvedValueOnce({ images: [histImg], errors: [] });
|
|
426
|
-
const runtime = makeImageCaptureRuntime();
|
|
427
|
-
const reply = makeReply();
|
|
428
|
-
const msg = makeGuildMessage(reply);
|
|
429
|
-
const params = makeParams(runtime, { messageHistoryBudget: 500 });
|
|
430
|
-
const queue = { run: vi.fn(async (_key, fn) => fn()) };
|
|
431
|
-
const handler = await makeHandler(params, queue);
|
|
432
|
-
await handler(msg);
|
|
433
|
-
expect(runtime.images).toEqual([refImg, histImg]);
|
|
434
|
-
});
|
|
435
|
-
it('history images are downloaded newest-first (preserving fetchMessageHistory order)', async () => {
|
|
436
|
-
const histImgA = fakeImage('hist-a');
|
|
437
|
-
const histImgB = fakeImage('hist-b');
|
|
438
|
-
// History returns attachments in newest-first order.
|
|
439
|
-
const attNew = fakeAttachment('new.png');
|
|
440
|
-
const attOld = fakeAttachment('old.png');
|
|
441
|
-
mockFetchHistory.mockResolvedValue({
|
|
442
|
-
text: 'history',
|
|
443
|
-
historyAttachments: [attNew, attOld],
|
|
444
|
-
});
|
|
445
|
-
// downloadMessageImages receives them in the same order and returns both.
|
|
446
|
-
mockDownloadImages.mockResolvedValueOnce({ images: [histImgA, histImgB], errors: [] });
|
|
447
|
-
const runtime = makeImageCaptureRuntime();
|
|
448
|
-
const reply = makeReply();
|
|
449
|
-
const msg = makeGuildMessage(reply);
|
|
450
|
-
const params = makeParams(runtime, { messageHistoryBudget: 500 });
|
|
451
|
-
const queue = { run: vi.fn(async (_key, fn) => fn()) };
|
|
452
|
-
const handler = await makeHandler(params, queue);
|
|
453
|
-
await handler(msg);
|
|
454
|
-
// Verify downloadMessageImages received attachments in newest-first order.
|
|
455
|
-
expect(mockDownloadImages).toHaveBeenCalledWith([attNew, attOld], expect.any(Number));
|
|
456
|
-
expect(runtime.images).toEqual([histImgA, histImgB]);
|
|
457
|
-
});
|
|
458
|
-
it('duplicate URLs between direct and history are only counted once', async () => {
|
|
459
|
-
const directImg = fakeImage('direct-shared');
|
|
460
|
-
const histImg = fakeImage('hist-unique');
|
|
461
|
-
const sharedUrl = 'https://cdn.discordapp.com/shared.png';
|
|
462
|
-
const directAtt = fakeAttachment('shared.png', sharedUrl);
|
|
463
|
-
const histAttShared = fakeAttachment('shared.png', sharedUrl);
|
|
464
|
-
const histAttUnique = fakeAttachment('unique.png');
|
|
465
|
-
// Direct download returns one image.
|
|
466
|
-
mockDownloadImages
|
|
467
|
-
.mockResolvedValueOnce({ images: [directImg], errors: [] })
|
|
468
|
-
// History download receives only the unique attachment (deduped).
|
|
469
|
-
.mockResolvedValueOnce({ images: [histImg], errors: [] });
|
|
470
|
-
mockFetchHistory.mockResolvedValue({
|
|
471
|
-
text: 'history',
|
|
472
|
-
historyAttachments: [histAttShared, histAttUnique],
|
|
473
|
-
});
|
|
474
|
-
const runtime = makeImageCaptureRuntime();
|
|
475
|
-
const reply = makeReply();
|
|
476
|
-
const attachments = new Map([['1', directAtt]]);
|
|
477
|
-
const msg = makeGuildMessage(reply, { attachments });
|
|
478
|
-
const params = makeParams(runtime, { messageHistoryBudget: 500 });
|
|
479
|
-
const queue = { run: vi.fn(async (_key, fn) => fn()) };
|
|
480
|
-
const handler = await makeHandler(params, queue);
|
|
481
|
-
await handler(msg);
|
|
482
|
-
// The second downloadMessageImages call should only receive the unique attachment.
|
|
483
|
-
expect(mockDownloadImages).toHaveBeenCalledTimes(2);
|
|
484
|
-
const historyCall = mockDownloadImages.mock.calls[1];
|
|
485
|
-
expect(historyCall[0]).toEqual([histAttUnique]);
|
|
486
|
-
expect(runtime.images).toEqual([directImg, histImg]);
|
|
487
|
-
});
|
|
488
|
-
it('history download is skipped when higher-priority sources exhaust the cap', async () => {
|
|
489
|
-
// Fill budget with direct images (MAX_IMAGES_PER_INVOCATION).
|
|
490
|
-
const directImages = Array.from({ length: MAX_IMAGES_PER_INVOCATION }, (_, i) => fakeImage(`direct-${i}`));
|
|
491
|
-
mockDownloadImages.mockResolvedValueOnce({ images: directImages, errors: [] });
|
|
492
|
-
// History has attachments, but budget should be exhausted.
|
|
493
|
-
const histAtt = fakeAttachment('hist.png');
|
|
494
|
-
mockFetchHistory.mockResolvedValue({
|
|
495
|
-
text: 'history',
|
|
496
|
-
historyAttachments: [histAtt],
|
|
497
|
-
});
|
|
498
|
-
const runtime = makeImageCaptureRuntime();
|
|
499
|
-
const reply = makeReply();
|
|
500
|
-
const directAtts = Array.from({ length: MAX_IMAGES_PER_INVOCATION }, (_, i) => fakeAttachment(`img-${i}.png`));
|
|
501
|
-
const attachments = new Map(directAtts.map((a, i) => [String(i), a]));
|
|
502
|
-
const msg = makeGuildMessage(reply, { attachments });
|
|
503
|
-
const params = makeParams(runtime, { messageHistoryBudget: 500 });
|
|
504
|
-
const queue = { run: vi.fn(async (_key, fn) => fn()) };
|
|
505
|
-
const handler = await makeHandler(params, queue);
|
|
506
|
-
await handler(msg);
|
|
507
|
-
// downloadMessageImages should be called only once (for direct), not for history.
|
|
508
|
-
expect(mockDownloadImages).toHaveBeenCalledTimes(1);
|
|
509
|
-
expect(runtime.images).toEqual(directImages);
|
|
510
|
-
});
|
|
511
|
-
it('history images are skipped for codex runtime to avoid session reset', async () => {
|
|
512
|
-
const histAtt = fakeAttachment('hist.png');
|
|
513
|
-
mockFetchHistory.mockResolvedValue({ text: 'history', historyAttachments: [histAtt] });
|
|
514
|
-
// Do NOT set mockResolvedValueOnce — download should never be called.
|
|
515
420
|
const runtime = makeImageCaptureRuntime();
|
|
516
|
-
// Override runtime id to 'codex'.
|
|
517
|
-
runtime.id = 'codex';
|
|
518
421
|
const reply = makeReply();
|
|
519
422
|
const msg = makeGuildMessage(reply);
|
|
520
423
|
const params = makeParams(runtime, { messageHistoryBudget: 500 });
|
|
521
424
|
const queue = { run: vi.fn(async (_key, fn) => fn()) };
|
|
522
425
|
const handler = await makeHandler(params, queue);
|
|
523
426
|
await handler(msg);
|
|
524
|
-
//
|
|
427
|
+
// downloadMessageImages should NOT be called for history attachments.
|
|
525
428
|
expect(mockDownloadImages).not.toHaveBeenCalled();
|
|
526
429
|
expect(runtime.images).toBeUndefined();
|
|
527
430
|
});
|
|
@@ -18,13 +18,13 @@ export function formatRelativeTime(deltaMs) {
|
|
|
18
18
|
const weeks = Math.floor(days / 7);
|
|
19
19
|
return `${weeks}w ago`;
|
|
20
20
|
}
|
|
21
|
-
const EMPTY_RESULT = { text: ''
|
|
21
|
+
const EMPTY_RESULT = { text: '' };
|
|
22
22
|
/**
|
|
23
23
|
* Fetch recent messages from a Discord channel and format them as conversation
|
|
24
24
|
* history suitable for prepending to a prompt.
|
|
25
25
|
*
|
|
26
|
-
* Returns text in chronological order
|
|
27
|
-
*
|
|
26
|
+
* Returns text in chronological order. Messages with attachments or embeds
|
|
27
|
+
* but no text content are represented as `[attachment]` / `[embed]`.
|
|
28
28
|
*/
|
|
29
29
|
export async function fetchMessageHistory(channel, beforeMessageId, opts) {
|
|
30
30
|
if (opts.budgetChars <= 0)
|
|
@@ -65,7 +65,23 @@ export async function fetchMessageHistory(channel, beforeMessageId, opts) {
|
|
|
65
65
|
for (let i = sorted.length - 1; i >= 0 && remaining > 0; i--) {
|
|
66
66
|
const m = sorted[i];
|
|
67
67
|
const author = m.author.bot ? (opts.botDisplayName ?? 'Discoclaw') : (m.author.displayName || m.author.username);
|
|
68
|
-
|
|
68
|
+
let content = String(m.content ?? '').trim();
|
|
69
|
+
if (!content) {
|
|
70
|
+
const atts = m.attachments;
|
|
71
|
+
const hasAttachments = atts && typeof atts.size === 'number'
|
|
72
|
+
&& atts.size > 0;
|
|
73
|
+
const embeds = m.embeds;
|
|
74
|
+
const hasEmbeds = Array.isArray(embeds) && embeds.length > 0;
|
|
75
|
+
if (hasAttachments) {
|
|
76
|
+
content = '[attachment]';
|
|
77
|
+
}
|
|
78
|
+
else if (hasEmbeds) {
|
|
79
|
+
content = '[embed]';
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
69
85
|
const ts = typeof m.createdTimestamp === 'number' ? m.createdTimestamp : 0;
|
|
70
86
|
const age = ts > 0 ? formatRelativeTime(now - ts) : '';
|
|
71
87
|
const tag = age ? `${author}, ${age}` : author;
|
|
@@ -88,17 +104,6 @@ export async function fetchMessageHistory(channel, beforeMessageId, opts) {
|
|
|
88
104
|
remaining -= full.length + 1; // +1 for newline separator
|
|
89
105
|
}
|
|
90
106
|
}
|
|
91
|
-
// Extract attachments newest-first for downstream image budget trimming.
|
|
92
|
-
const historyAttachments = [];
|
|
93
|
-
for (let i = sorted.length - 1; i >= 0; i--) {
|
|
94
|
-
const m = sorted[i];
|
|
95
|
-
const atts = m.attachments;
|
|
96
|
-
if (atts && typeof atts.values === 'function') {
|
|
97
|
-
for (const a of atts.values()) {
|
|
98
|
-
historyAttachments.push(a);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
107
|
const text = selected.length > 0 ? selected.join('\n') : '';
|
|
103
|
-
return { text
|
|
108
|
+
return { text };
|
|
104
109
|
}
|
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { fetchMessageHistory, formatRelativeTime } from './message-history.js';
|
|
3
3
|
/** Helper: create a fake Discord message. */
|
|
4
|
-
function fakeMsg(id, content, username, bot = false,
|
|
4
|
+
function fakeMsg(id, content, username, bot = false, opts) {
|
|
5
5
|
return {
|
|
6
6
|
id,
|
|
7
7
|
content,
|
|
8
8
|
author: { username, displayName: username, bot },
|
|
9
|
-
...(createdTimestamp !== undefined ? { createdTimestamp } : {}),
|
|
10
|
-
...(
|
|
11
|
-
? { attachments: new Map(
|
|
9
|
+
...(opts?.createdTimestamp !== undefined ? { createdTimestamp: opts.createdTimestamp } : {}),
|
|
10
|
+
...(opts?.attachmentCount
|
|
11
|
+
? { attachments: new Map(Array.from({ length: opts.attachmentCount }, (_, i) => [String(i), { url: `https://cdn.discordapp.com/${i}.png`, name: `${i}.png` }])) }
|
|
12
|
+
: {}),
|
|
13
|
+
...(opts?.embedCount
|
|
14
|
+
? { embeds: Array.from({ length: opts.embedCount }, () => ({ type: 'rich' })) }
|
|
12
15
|
: {}),
|
|
13
16
|
};
|
|
14
17
|
}
|
|
15
|
-
/** Shorthand for an image attachment. */
|
|
16
|
-
function imgAtt(name, url = `https://cdn.discordapp.com/${name}`) {
|
|
17
|
-
return { url, name, contentType: 'image/png', size: 1024 };
|
|
18
|
-
}
|
|
19
18
|
/** Helper: create a fake channel whose messages.fetch returns the given messages (newest-first). */
|
|
20
19
|
function fakeChannel(messages) {
|
|
21
20
|
return {
|
|
@@ -87,13 +86,11 @@ describe('fetchMessageHistory', () => {
|
|
|
87
86
|
};
|
|
88
87
|
const result = await fetchMessageHistory(ch, '1', { budgetChars: 3000 });
|
|
89
88
|
expect(result.text).toBe('');
|
|
90
|
-
expect(result.historyAttachments).toEqual([]);
|
|
91
89
|
});
|
|
92
90
|
it('returns empty result when no prior messages exist', async () => {
|
|
93
91
|
const ch = fakeChannel([]);
|
|
94
92
|
const result = await fetchMessageHistory(ch, '1', { budgetChars: 3000 });
|
|
95
93
|
expect(result.text).toBe('');
|
|
96
|
-
expect(result.historyAttachments).toEqual([]);
|
|
97
94
|
});
|
|
98
95
|
it('returns empty result when budget is 0', async () => {
|
|
99
96
|
const ch = fakeChannel([
|
|
@@ -101,7 +98,6 @@ describe('fetchMessageHistory', () => {
|
|
|
101
98
|
]);
|
|
102
99
|
const result = await fetchMessageHistory(ch, '2', { budgetChars: 0 });
|
|
103
100
|
expect(result.text).toBe('');
|
|
104
|
-
expect(result.historyAttachments).toEqual([]);
|
|
105
101
|
});
|
|
106
102
|
it('can fetch the latest messages and exclude specific message ids', async () => {
|
|
107
103
|
const fetch = vi.fn(async () => new Map([
|
|
@@ -119,85 +115,60 @@ describe('fetchMessageHistory', () => {
|
|
|
119
115
|
expect(fetch).toHaveBeenCalledWith({ limit: 11 });
|
|
120
116
|
expect(result.text).toBe('[User]: earlier context\n[User]: latest follow-up');
|
|
121
117
|
});
|
|
122
|
-
// ---
|
|
123
|
-
it('
|
|
124
|
-
const att1 = imgAtt('screenshot.png');
|
|
125
|
-
const att2 = imgAtt('diagram.png');
|
|
118
|
+
// --- Attachment / embed text marker tests ---
|
|
119
|
+
it('renders empty-content messages with attachments as [attachment]', async () => {
|
|
126
120
|
const ch = fakeChannel([
|
|
127
|
-
fakeMsg('
|
|
128
|
-
fakeMsg('
|
|
129
|
-
fakeMsg('1', 'and a diagram', 'User', false, [att2]),
|
|
121
|
+
fakeMsg('2', 'look at this', 'User'),
|
|
122
|
+
fakeMsg('1', '', 'User', false, { attachmentCount: 1 }),
|
|
130
123
|
]);
|
|
131
|
-
const result = await fetchMessageHistory(ch, '
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
expect(
|
|
135
|
-
expect(
|
|
124
|
+
const result = await fetchMessageHistory(ch, '3', { budgetChars: 5000 });
|
|
125
|
+
const lines = result.text.split('\n');
|
|
126
|
+
expect(lines).toHaveLength(2);
|
|
127
|
+
expect(lines[0]).toBe('[User]: [attachment]');
|
|
128
|
+
expect(lines[1]).toBe('[User]: look at this');
|
|
136
129
|
});
|
|
137
|
-
it('
|
|
138
|
-
const a1 = imgAtt('old.png');
|
|
139
|
-
const a2 = imgAtt('mid.png');
|
|
140
|
-
const a3 = imgAtt('new.png');
|
|
130
|
+
it('renders empty-content messages with embeds as [embed]', async () => {
|
|
141
131
|
const ch = fakeChannel([
|
|
142
|
-
fakeMsg('
|
|
143
|
-
fakeMsg('2', 'middle', 'User', false, [a2]),
|
|
144
|
-
fakeMsg('1', 'oldest', 'User', false, [a1]),
|
|
132
|
+
fakeMsg('1', '', 'User', false, { embedCount: 1 }),
|
|
145
133
|
]);
|
|
146
|
-
const result = await fetchMessageHistory(ch, '
|
|
147
|
-
expect(result.
|
|
134
|
+
const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000 });
|
|
135
|
+
expect(result.text).toBe('[User]: [embed]');
|
|
148
136
|
});
|
|
149
|
-
it('
|
|
150
|
-
const a1 = imgAtt('first.png');
|
|
151
|
-
const a2 = imgAtt('second.png');
|
|
137
|
+
it('prefers [attachment] over [embed] when both present', async () => {
|
|
152
138
|
const ch = fakeChannel([
|
|
153
|
-
fakeMsg('1', '
|
|
139
|
+
fakeMsg('1', '', 'User', false, { attachmentCount: 1, embedCount: 1 }),
|
|
154
140
|
]);
|
|
155
141
|
const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000 });
|
|
156
|
-
expect(result.
|
|
142
|
+
expect(result.text).toBe('[User]: [attachment]');
|
|
157
143
|
});
|
|
158
|
-
it('
|
|
144
|
+
it('skips empty-content messages with no attachments or embeds', async () => {
|
|
159
145
|
const ch = fakeChannel([
|
|
160
|
-
fakeMsg('2', '
|
|
161
|
-
fakeMsg('1', '
|
|
146
|
+
fakeMsg('2', 'visible', 'User'),
|
|
147
|
+
fakeMsg('1', '', 'User'),
|
|
162
148
|
]);
|
|
163
149
|
const result = await fetchMessageHistory(ch, '3', { budgetChars: 5000 });
|
|
164
|
-
expect(result.
|
|
150
|
+
expect(result.text).toBe('[User]: visible');
|
|
165
151
|
});
|
|
166
|
-
it('
|
|
167
|
-
const att = imgAtt('excluded.png');
|
|
152
|
+
it('keeps text content as-is when message also has attachments', async () => {
|
|
168
153
|
const ch = fakeChannel([
|
|
169
|
-
fakeMsg('
|
|
170
|
-
fakeMsg('1', 'exclude this', 'User', false, [att]),
|
|
154
|
+
fakeMsg('1', 'here is my screenshot', 'User', false, { attachmentCount: 2 }),
|
|
171
155
|
]);
|
|
172
|
-
const result = await fetchMessageHistory(ch, '
|
|
173
|
-
|
|
174
|
-
excludeMessageIds: ['1'],
|
|
175
|
-
});
|
|
176
|
-
expect(result.text).toBe('[User]: keep this');
|
|
177
|
-
expect(result.historyAttachments).toEqual([]);
|
|
156
|
+
const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000 });
|
|
157
|
+
expect(result.text).toBe('[User]: here is my screenshot');
|
|
178
158
|
});
|
|
179
|
-
it('
|
|
180
|
-
const oldAtt = imgAtt('old.png');
|
|
181
|
-
const newAtt = imgAtt('new.png');
|
|
159
|
+
it('does not include historyAttachments in result', async () => {
|
|
182
160
|
const ch = fakeChannel([
|
|
183
|
-
fakeMsg('
|
|
184
|
-
fakeMsg('1', 'old message', 'User', false, [oldAtt]),
|
|
161
|
+
fakeMsg('1', '', 'User', false, { attachmentCount: 1 }),
|
|
185
162
|
]);
|
|
186
|
-
const result = await fetchMessageHistory(ch, '
|
|
187
|
-
|
|
188
|
-
const lines = result.text.split('\n');
|
|
189
|
-
expect(lines[0]).toContain('old message');
|
|
190
|
-
expect(lines[1]).toContain('new message');
|
|
191
|
-
// Attachments: newest-first
|
|
192
|
-
expect(result.historyAttachments[0]).toBe(newAtt);
|
|
193
|
-
expect(result.historyAttachments[1]).toBe(oldAtt);
|
|
163
|
+
const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000 });
|
|
164
|
+
expect(result).not.toHaveProperty('historyAttachments');
|
|
194
165
|
});
|
|
195
166
|
// --- Temporal signal tests ---
|
|
196
167
|
it('includes relative timestamps when createdTimestamp is present', async () => {
|
|
197
168
|
const now = 1700000000000;
|
|
198
169
|
const ch = fakeChannel([
|
|
199
|
-
fakeMsg('2', 'recent msg', 'Alice', false,
|
|
200
|
-
fakeMsg('1', 'old msg', 'Bob', false,
|
|
170
|
+
fakeMsg('2', 'recent msg', 'Alice', false, { createdTimestamp: now - 5 * 60_000 }), // 5m ago
|
|
171
|
+
fakeMsg('1', 'old msg', 'Bob', false, { createdTimestamp: now - 3 * 86_400_000 }), // 3d ago
|
|
201
172
|
]);
|
|
202
173
|
const result = await fetchMessageHistory(ch, '3', { budgetChars: 5000, now });
|
|
203
174
|
const lines = result.text.split('\n');
|
|
@@ -207,7 +178,7 @@ describe('fetchMessageHistory', () => {
|
|
|
207
178
|
it('shows "just now" for messages under 60 seconds old', async () => {
|
|
208
179
|
const now = 1700000000000;
|
|
209
180
|
const ch = fakeChannel([
|
|
210
|
-
fakeMsg('1', 'fresh', 'User', false,
|
|
181
|
+
fakeMsg('1', 'fresh', 'User', false, { createdTimestamp: now - 10_000 }), // 10s ago
|
|
211
182
|
]);
|
|
212
183
|
const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000, now });
|
|
213
184
|
expect(result.text).toBe('[User, just now]: fresh');
|
|
@@ -222,7 +193,7 @@ describe('fetchMessageHistory', () => {
|
|
|
222
193
|
it('includes age label on bot messages too', async () => {
|
|
223
194
|
const now = 1700000000000;
|
|
224
195
|
const ch = fakeChannel([
|
|
225
|
-
fakeMsg('1', 'bot reply', 'Discoclaw', true,
|
|
196
|
+
fakeMsg('1', 'bot reply', 'Discoclaw', true, { createdTimestamp: now - 2 * 3600_000 }), // 2h ago
|
|
226
197
|
]);
|
|
227
198
|
const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000, now });
|
|
228
199
|
expect(result.text).toBe('[Discoclaw, 2h ago]: bot reply');
|
|
@@ -230,7 +201,7 @@ describe('fetchMessageHistory', () => {
|
|
|
230
201
|
it('shows hours correctly at boundary', async () => {
|
|
231
202
|
const now = 1700000000000;
|
|
232
203
|
const ch = fakeChannel([
|
|
233
|
-
fakeMsg('1', 'msg', 'User', false,
|
|
204
|
+
fakeMsg('1', 'msg', 'User', false, { createdTimestamp: now - 23 * 3600_000 }), // 23h ago
|
|
234
205
|
]);
|
|
235
206
|
const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000, now });
|
|
236
207
|
expect(result.text).toBe('[User, 23h ago]: msg');
|
|
@@ -238,9 +209,9 @@ describe('fetchMessageHistory', () => {
|
|
|
238
209
|
it('filters out messages older than maxAgeMs', async () => {
|
|
239
210
|
const now = 1700000000000;
|
|
240
211
|
const ch = fakeChannel([
|
|
241
|
-
fakeMsg('3', 'recent', 'User', false,
|
|
242
|
-
fakeMsg('2', 'stale', 'User', false,
|
|
243
|
-
fakeMsg('1', 'ancient', 'User', false,
|
|
212
|
+
fakeMsg('3', 'recent', 'User', false, { createdTimestamp: now - 60_000 }), // 1m ago
|
|
213
|
+
fakeMsg('2', 'stale', 'User', false, { createdTimestamp: now - 25 * 3600_000 }), // 25h ago
|
|
214
|
+
fakeMsg('1', 'ancient', 'User', false, { createdTimestamp: now - 72 * 3600_000 }), // 3d ago
|
|
244
215
|
]);
|
|
245
216
|
// maxAgeMs = 24h — only the 1m-ago message should survive
|
|
246
217
|
const result = await fetchMessageHistory(ch, '4', { budgetChars: 5000, now, maxAgeMs: 24 * 3600_000 });
|
|
@@ -251,16 +222,15 @@ describe('fetchMessageHistory', () => {
|
|
|
251
222
|
it('returns empty when all messages exceed maxAgeMs', async () => {
|
|
252
223
|
const now = 1700000000000;
|
|
253
224
|
const ch = fakeChannel([
|
|
254
|
-
fakeMsg('1', 'old', 'User', false,
|
|
225
|
+
fakeMsg('1', 'old', 'User', false, { createdTimestamp: now - 48 * 3600_000 }),
|
|
255
226
|
]);
|
|
256
227
|
const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000, now, maxAgeMs: 24 * 3600_000 });
|
|
257
228
|
expect(result.text).toBe('');
|
|
258
|
-
expect(result.historyAttachments).toEqual([]);
|
|
259
229
|
});
|
|
260
230
|
it('does not filter by age when maxAgeMs is 0', async () => {
|
|
261
231
|
const now = 1700000000000;
|
|
262
232
|
const ch = fakeChannel([
|
|
263
|
-
fakeMsg('1', 'ancient', 'User', false,
|
|
233
|
+
fakeMsg('1', 'ancient', 'User', false, { createdTimestamp: now - 100 * 86_400_000 }),
|
|
264
234
|
]);
|
|
265
235
|
const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000, now, maxAgeMs: 0 });
|
|
266
236
|
expect(result.text).toContain('ancient');
|
|
@@ -268,7 +238,7 @@ describe('fetchMessageHistory', () => {
|
|
|
268
238
|
it('shows weeks for messages older than 30 days', async () => {
|
|
269
239
|
const now = 1700000000000;
|
|
270
240
|
const ch = fakeChannel([
|
|
271
|
-
fakeMsg('1', 'ancient', 'User', false,
|
|
241
|
+
fakeMsg('1', 'ancient', 'User', false, { createdTimestamp: now - 45 * 86_400_000 }), // 45d ago
|
|
272
242
|
]);
|
|
273
243
|
const result = await fetchMessageHistory(ch, '2', { budgetChars: 5000, now });
|
|
274
244
|
expect(result.text).toBe('[User, 6w ago]: ancient');
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { downloadMessageImages } from './image-download.js';
|
|
2
1
|
import { countPinnedMessages, normalizePinnedMessages } from './pinned-message-utils.js';
|
|
3
2
|
const DEFAULT_BUDGET_CHARS = 3000;
|
|
4
3
|
const DEFAULT_RECENT_LIMIT = 10;
|
|
@@ -10,14 +9,6 @@ function hasMedia(m) {
|
|
|
10
9
|
const embLen = m.embeds && ('length' in m.embeds ? m.embeds.length : 0);
|
|
11
10
|
return (attSize ?? 0) > 0 || (embLen ?? 0) > 0;
|
|
12
11
|
}
|
|
13
|
-
function extractAttachmentLikes(attachments) {
|
|
14
|
-
if (!attachments)
|
|
15
|
-
return [];
|
|
16
|
-
if (typeof attachments.values === 'function') {
|
|
17
|
-
return [...attachments.values()];
|
|
18
|
-
}
|
|
19
|
-
return [];
|
|
20
|
-
}
|
|
21
12
|
function formatMessageLine(m, botName, suffix) {
|
|
22
13
|
const content = String(m.content ?? '').trim();
|
|
23
14
|
const author = m.author.bot
|
|
@@ -140,9 +131,7 @@ export async function resolveThreadContext(channel, currentMessageId, opts = {})
|
|
|
140
131
|
}
|
|
141
132
|
}
|
|
142
133
|
// 4. Recent thread messages (before the current command message)
|
|
143
|
-
|
|
144
|
-
let fetchedRecent = [];
|
|
145
|
-
if (recentLimit > 0 && (remaining > 50 || historyImageBudget > 0)) {
|
|
134
|
+
if (recentLimit > 0 && remaining > 50) {
|
|
146
135
|
try {
|
|
147
136
|
const messages = await channel.messages.fetch({
|
|
148
137
|
before: currentMessageId,
|
|
@@ -152,63 +141,40 @@ export async function resolveThreadContext(channel, currentMessageId, opts = {})
|
|
|
152
141
|
// Sort by snowflake ID (ascending = chronological).
|
|
153
142
|
const sorted = Array.from(messages.values())
|
|
154
143
|
.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
remaining = 0;
|
|
174
|
-
break;
|
|
175
|
-
}
|
|
176
|
-
else {
|
|
177
|
-
break;
|
|
178
|
-
}
|
|
144
|
+
const lines = [];
|
|
145
|
+
for (const m of sorted) {
|
|
146
|
+
// Deduplicate: skip the starter message if it appears in recent messages
|
|
147
|
+
if (m.id && seenMessageIds.has(m.id))
|
|
148
|
+
continue;
|
|
149
|
+
const line = formatMessageLine(m, botName);
|
|
150
|
+
if (!line)
|
|
151
|
+
continue;
|
|
152
|
+
if (line.length <= remaining) {
|
|
153
|
+
lines.push(line);
|
|
154
|
+
remaining -= line.length + 1;
|
|
155
|
+
if (m.id)
|
|
156
|
+
seenMessageIds.add(m.id);
|
|
157
|
+
}
|
|
158
|
+
else if (remaining > 50 && m.author.bot) {
|
|
159
|
+
lines.push(line.slice(0, remaining - 3) + '...');
|
|
160
|
+
remaining = 0;
|
|
161
|
+
break;
|
|
179
162
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
sections.push(...lines);
|
|
163
|
+
else {
|
|
164
|
+
break;
|
|
183
165
|
}
|
|
184
166
|
}
|
|
167
|
+
if (lines.length > 0) {
|
|
168
|
+
sections.push('Recent thread messages:');
|
|
169
|
+
sections.push(...lines);
|
|
170
|
+
}
|
|
185
171
|
}
|
|
186
172
|
}
|
|
187
173
|
catch (err) {
|
|
188
174
|
opts.log?.warn({ err }, 'thread-context: failed to fetch recent messages');
|
|
189
175
|
}
|
|
190
176
|
}
|
|
191
|
-
|
|
192
|
-
const images = [];
|
|
193
|
-
if (historyImageBudget > 0 && fetchedRecent.length > 0) {
|
|
194
|
-
const candidates = [];
|
|
195
|
-
for (let i = fetchedRecent.length - 1; i >= 0; i--) {
|
|
196
|
-
candidates.push(...extractAttachmentLikes(fetchedRecent[i].attachments));
|
|
197
|
-
}
|
|
198
|
-
if (candidates.length > 0) {
|
|
199
|
-
try {
|
|
200
|
-
const dlResult = await downloadMessageImages(candidates, historyImageBudget);
|
|
201
|
-
images.push(...dlResult.images);
|
|
202
|
-
if (dlResult.errors.length > 0) {
|
|
203
|
-
opts.log?.warn({ errors: dlResult.errors }, 'thread-context: image download errors');
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
catch (err) {
|
|
207
|
-
opts.log?.warn({ err }, 'thread-context: image download failed');
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
if (sections.length === 0 && images.length === 0)
|
|
177
|
+
if (sections.length === 0)
|
|
212
178
|
return null;
|
|
213
|
-
return { section: sections.join('\n')
|
|
179
|
+
return { section: sections.join('\n') };
|
|
214
180
|
}
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it, vi
|
|
2
|
-
import { downloadMessageImages } from './image-download.js';
|
|
3
|
-
vi.mock('./image-download.js', () => ({
|
|
4
|
-
downloadMessageImages: vi.fn(),
|
|
5
|
-
}));
|
|
6
|
-
const mockDownload = vi.mocked(downloadMessageImages);
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
2
|
import { resolveThreadContext } from './thread-context.js';
|
|
8
3
|
// ---------------------------------------------------------------------------
|
|
9
4
|
// Helpers
|
|
@@ -49,9 +44,6 @@ function fakeNonThread() {
|
|
|
49
44
|
// Tests
|
|
50
45
|
// ---------------------------------------------------------------------------
|
|
51
46
|
describe('resolveThreadContext', () => {
|
|
52
|
-
beforeEach(() => {
|
|
53
|
-
mockDownload.mockReset();
|
|
54
|
-
});
|
|
55
47
|
it('returns null for non-thread channels', async () => {
|
|
56
48
|
const result = await resolveThreadContext(fakeNonThread(), '100');
|
|
57
49
|
expect(result).toBeNull();
|
|
@@ -410,27 +402,17 @@ describe('resolveThreadContext', () => {
|
|
|
410
402
|
expect(aliceIdx).toBeLessThan(bobIdx);
|
|
411
403
|
expect(bobIdx).toBeLessThan(charlieIdx);
|
|
412
404
|
});
|
|
413
|
-
|
|
414
|
-
// Thread-history image tests
|
|
415
|
-
// ---------------------------------------------------------------------------
|
|
416
|
-
it('returns empty images array when historyImageBudget is not set', async () => {
|
|
405
|
+
it('does not include images field in result', async () => {
|
|
417
406
|
const ch = fakeThread({
|
|
418
407
|
name: 'no-img-thread',
|
|
419
408
|
messages: [fakeMsg('2', 'hello', 'Alice')],
|
|
420
409
|
});
|
|
421
410
|
const result = await resolveThreadContext(ch, '100');
|
|
422
411
|
expect(result).not.toBeNull();
|
|
423
|
-
expect(result
|
|
424
|
-
expect(mockDownload).not.toHaveBeenCalled();
|
|
412
|
+
expect(result).not.toHaveProperty('images');
|
|
425
413
|
});
|
|
426
|
-
it('supports
|
|
427
|
-
const
|
|
428
|
-
url: 'https://cdn.discordapp.com/img.png',
|
|
429
|
-
name: 'img.png',
|
|
430
|
-
contentType: 'image/png',
|
|
431
|
-
size: 1024,
|
|
432
|
-
};
|
|
433
|
-
const attachments = new Map([['1', att]]);
|
|
414
|
+
it('supports Map-based attachments for hasMedia detection', async () => {
|
|
415
|
+
const attachments = new Map([['1', { size: 1024 }]]);
|
|
434
416
|
const ch = fakeThread({
|
|
435
417
|
name: 'att-test',
|
|
436
418
|
messages: [fakeMsg('2', '', 'Alice', false, { attachments })],
|
|
@@ -439,146 +421,4 @@ describe('resolveThreadContext', () => {
|
|
|
439
421
|
expect(result).not.toBeNull();
|
|
440
422
|
expect(result.section).toContain('[Alice]: [attachment/embed]');
|
|
441
423
|
});
|
|
442
|
-
it('extracts and downloads images from thread history when historyImageBudget > 0', async () => {
|
|
443
|
-
const att = {
|
|
444
|
-
url: 'https://cdn.discordapp.com/img.png',
|
|
445
|
-
name: 'img.png',
|
|
446
|
-
contentType: 'image/png',
|
|
447
|
-
size: 1024,
|
|
448
|
-
};
|
|
449
|
-
const attachments = new Map([['1', att]]);
|
|
450
|
-
const fakeImage = { base64: 'abc', mediaType: 'image/png' };
|
|
451
|
-
mockDownload.mockResolvedValueOnce({ images: [fakeImage], errors: [] });
|
|
452
|
-
const ch = fakeThread({
|
|
453
|
-
name: 'img-thread',
|
|
454
|
-
messages: [fakeMsg('2', 'check this', 'Alice', false, { attachments })],
|
|
455
|
-
});
|
|
456
|
-
const result = await resolveThreadContext(ch, '100', { historyImageBudget: 5 });
|
|
457
|
-
expect(result).not.toBeNull();
|
|
458
|
-
expect(result.images).toEqual([fakeImage]);
|
|
459
|
-
expect(mockDownload).toHaveBeenCalledOnce();
|
|
460
|
-
expect(mockDownload).toHaveBeenCalledWith([att], 5);
|
|
461
|
-
});
|
|
462
|
-
it('passes all attachment types to downloadMessageImages for filtering', async () => {
|
|
463
|
-
const textAtt = {
|
|
464
|
-
url: 'https://cdn.discordapp.com/file.txt',
|
|
465
|
-
name: 'file.txt',
|
|
466
|
-
contentType: 'text/plain',
|
|
467
|
-
size: 100,
|
|
468
|
-
};
|
|
469
|
-
const imgAtt = {
|
|
470
|
-
url: 'https://cdn.discordapp.com/img.png',
|
|
471
|
-
name: 'img.png',
|
|
472
|
-
contentType: 'image/png',
|
|
473
|
-
size: 1024,
|
|
474
|
-
};
|
|
475
|
-
const attachments = new Map([['1', textAtt], ['2', imgAtt]]);
|
|
476
|
-
const fakeImage = { base64: 'abc', mediaType: 'image/png' };
|
|
477
|
-
mockDownload.mockResolvedValueOnce({ images: [fakeImage], errors: [] });
|
|
478
|
-
const ch = fakeThread({
|
|
479
|
-
name: 'mixed-att-thread',
|
|
480
|
-
messages: [fakeMsg('2', 'mixed', 'Alice', false, { attachments })],
|
|
481
|
-
});
|
|
482
|
-
const result = await resolveThreadContext(ch, '100', { historyImageBudget: 5 });
|
|
483
|
-
expect(result).not.toBeNull();
|
|
484
|
-
expect(result.images).toEqual([fakeImage]);
|
|
485
|
-
// Both attachments passed — downloadMessageImages handles filtering
|
|
486
|
-
expect(mockDownload).toHaveBeenCalledWith([textAtt, imgAtt], 5);
|
|
487
|
-
});
|
|
488
|
-
it('selects thread-history images newest-first', async () => {
|
|
489
|
-
const oldAtt = {
|
|
490
|
-
url: 'https://cdn.discordapp.com/old.png',
|
|
491
|
-
name: 'old.png',
|
|
492
|
-
contentType: 'image/png',
|
|
493
|
-
size: 1024,
|
|
494
|
-
};
|
|
495
|
-
const newAtt = {
|
|
496
|
-
url: 'https://cdn.discordapp.com/new.png',
|
|
497
|
-
name: 'new.png',
|
|
498
|
-
contentType: 'image/png',
|
|
499
|
-
size: 1024,
|
|
500
|
-
};
|
|
501
|
-
mockDownload.mockResolvedValueOnce({
|
|
502
|
-
images: [{ base64: 'new', mediaType: 'image/png' }],
|
|
503
|
-
errors: [],
|
|
504
|
-
});
|
|
505
|
-
const ch = fakeThread({
|
|
506
|
-
name: 'order-thread',
|
|
507
|
-
messages: [
|
|
508
|
-
fakeMsg('2', 'old msg', 'Alice', false, {
|
|
509
|
-
attachments: new Map([['1', oldAtt]]),
|
|
510
|
-
}),
|
|
511
|
-
fakeMsg('3', 'new msg', 'Bob', false, {
|
|
512
|
-
attachments: new Map([['2', newAtt]]),
|
|
513
|
-
}),
|
|
514
|
-
],
|
|
515
|
-
});
|
|
516
|
-
const result = await resolveThreadContext(ch, '100', { historyImageBudget: 1 });
|
|
517
|
-
expect(result).not.toBeNull();
|
|
518
|
-
// downloadMessageImages receives new attachment first (newest-first)
|
|
519
|
-
expect(mockDownload).toHaveBeenCalledWith([newAtt, oldAtt], 1);
|
|
520
|
-
});
|
|
521
|
-
it('handles partial image download failures gracefully', async () => {
|
|
522
|
-
const att1 = {
|
|
523
|
-
url: 'https://cdn.discordapp.com/good.png',
|
|
524
|
-
name: 'good.png',
|
|
525
|
-
contentType: 'image/png',
|
|
526
|
-
size: 1024,
|
|
527
|
-
};
|
|
528
|
-
const att2 = {
|
|
529
|
-
url: 'https://cdn.discordapp.com/bad.png',
|
|
530
|
-
name: 'bad.png',
|
|
531
|
-
contentType: 'image/png',
|
|
532
|
-
size: 1024,
|
|
533
|
-
};
|
|
534
|
-
const attachments = new Map([['1', att1], ['2', att2]]);
|
|
535
|
-
const fakeImage = { base64: 'abc', mediaType: 'image/png' };
|
|
536
|
-
mockDownload.mockResolvedValueOnce({
|
|
537
|
-
images: [fakeImage],
|
|
538
|
-
errors: ['bad.png: download failed'],
|
|
539
|
-
});
|
|
540
|
-
const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
541
|
-
const ch = fakeThread({
|
|
542
|
-
name: 'partial-fail-thread',
|
|
543
|
-
messages: [fakeMsg('2', 'images here', 'Alice', false, { attachments })],
|
|
544
|
-
});
|
|
545
|
-
const result = await resolveThreadContext(ch, '100', { historyImageBudget: 5, log });
|
|
546
|
-
expect(result).not.toBeNull();
|
|
547
|
-
expect(result.images).toEqual([fakeImage]);
|
|
548
|
-
expect(result.section).toContain('[Alice]: images here');
|
|
549
|
-
expect(log.warn).toHaveBeenCalledWith({ errors: ['bad.png: download failed'] }, 'thread-context: image download errors');
|
|
550
|
-
});
|
|
551
|
-
it('handles complete image download failure gracefully', async () => {
|
|
552
|
-
const att = {
|
|
553
|
-
url: 'https://cdn.discordapp.com/img.png',
|
|
554
|
-
name: 'img.png',
|
|
555
|
-
contentType: 'image/png',
|
|
556
|
-
size: 1024,
|
|
557
|
-
};
|
|
558
|
-
const attachments = new Map([['1', att]]);
|
|
559
|
-
mockDownload.mockRejectedValueOnce(new Error('network error'));
|
|
560
|
-
const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
561
|
-
const ch = fakeThread({
|
|
562
|
-
name: 'crash-thread',
|
|
563
|
-
messages: [fakeMsg('2', 'oops', 'Alice', false, { attachments })],
|
|
564
|
-
});
|
|
565
|
-
const result = await resolveThreadContext(ch, '100', { historyImageBudget: 5, log });
|
|
566
|
-
expect(result).not.toBeNull();
|
|
567
|
-
expect(result.images).toEqual([]);
|
|
568
|
-
expect(result.section).toContain('[Alice]: oops');
|
|
569
|
-
expect(log.warn).toHaveBeenCalled();
|
|
570
|
-
});
|
|
571
|
-
it('skips image download when messages have no attachments', async () => {
|
|
572
|
-
const ch = fakeThread({
|
|
573
|
-
name: 'text-only-thread',
|
|
574
|
-
messages: [
|
|
575
|
-
fakeMsg('2', 'just text', 'Alice'),
|
|
576
|
-
fakeMsg('3', 'more text', 'Bob'),
|
|
577
|
-
],
|
|
578
|
-
});
|
|
579
|
-
const result = await resolveThreadContext(ch, '100', { historyImageBudget: 5 });
|
|
580
|
-
expect(result).not.toBeNull();
|
|
581
|
-
expect(result.images).toEqual([]);
|
|
582
|
-
expect(mockDownload).not.toHaveBeenCalled();
|
|
583
|
-
});
|
|
584
424
|
});
|