@meetquinn/animactl 0.1.31 → 0.1.32
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/server/memory/memory-coherence-scheduler.d.ts.map +1 -1
- package/dist/server/memory/memory-coherence-scheduler.js +23 -4
- package/dist/server/messages/contact-policy.service.js +6 -1
- package/dist/server/slack/workspace-directory.service.d.ts +2 -0
- package/dist/server/slack/workspace-directory.service.d.ts.map +1 -1
- package/dist/server/slack/workspace-directory.service.js +21 -5
- package/dist/server/tests/contact-policy.test.js +43 -0
- package/dist/server/tests/memory-coherence.test.js +45 -1
- package/dist/server/tests/workspace-directory.test.js +40 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"memory-coherence-scheduler.d.ts","sourceRoot":"","sources":["../../../server/memory/memory-coherence-scheduler.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAEhE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iCAAiC,CAAC;AAC7E,OAAO,KAAK,EAAE,SAAS,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAMjF,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,mCAAmC,CAAC;AAOzF,MAAM,WAAW,8BAA8B;IAC7C,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,IAAI,EAAE,wBAAwB,GAAG,OAAO,CAAC;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IAC1F,IAAI,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;CAC9B;AAED,MAAM,WAAW,4BAA4B;IAC3C,kCAAkC,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3E,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,oBAAoB,CAAC;IAC1D,gBAAgB,CAAC,EAAE,MAAM,OAAO,CAAC,YAAY,CAAC,CAAC;IAC/C,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;CACtE;AAED,qBAAa,wBAAwB;IACnC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAa;IACjC,OAAO,CAAC,QAAQ,CAAC,kCAAkC,CAAwC;IAC3F,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA4C;IAC1E,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAA8B;IAC/D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAoD;gBAEzE,IAAI,GAAE,4BAAiC;IAS7C,SAAS,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"memory-coherence-scheduler.d.ts","sourceRoot":"","sources":["../../../server/memory/memory-coherence-scheduler.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAEhE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iCAAiC,CAAC;AAC7E,OAAO,KAAK,EAAE,SAAS,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAMjF,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,mCAAmC,CAAC;AAOzF,MAAM,WAAW,8BAA8B;IAC7C,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,IAAI,EAAE,wBAAwB,GAAG,OAAO,CAAC;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IAC1F,IAAI,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;CAC9B;AAED,MAAM,WAAW,4BAA4B;IAC3C,kCAAkC,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3E,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,oBAAoB,CAAC;IAC1D,gBAAgB,CAAC,EAAE,MAAM,OAAO,CAAC,YAAY,CAAC,CAAC;IAC/C,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;CACtE;AAED,qBAAa,wBAAwB;IACnC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAa;IACjC,OAAO,CAAC,QAAQ,CAAC,kCAAkC,CAAwC;IAC3F,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA4C;IAC1E,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAA8B;IAC/D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAoD;gBAEzE,IAAI,GAAE,4BAAiC;IAS7C,SAAS,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;YAiCvC,0BAA0B;CAWzC;AAWD,wBAAsB,wCAAwC,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMhG;AAED,wBAAgB,8BAA8B,CAC5C,GAAG,EAAE,qBAAqB,GAAG,SAAS,GACrC,8BAA8B,CAShC;AAED,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,EAAE,EACrB,MAAM,EAAE,8BAA8B,GACrC,WAAW,EAAE,CAKf;AAsDD,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,EAAE,qBAAqB,EAAE,MAAM,GAAG,MAAM,CAI/F"}
|
|
@@ -33,10 +33,11 @@ export class MemoryCoherenceScheduler {
|
|
|
33
33
|
return;
|
|
34
34
|
const now = this.now();
|
|
35
35
|
// Due-slot computation is pure time math, so it runs before any file I/O.
|
|
36
|
-
// Note: with the 2-day catchup window ([0, -1]) every candidate
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
36
|
+
// Note: with the 2-day catchup window ([0, -1]) every candidate created
|
|
37
|
+
// before its most recent slot has one, so `due` is mostly non-empty; it is
|
|
38
|
+
// the enqueue-side settled-id dedupe (wake-queue `seen` markers) and the
|
|
39
|
+
// activity gate that make this loop a no-op on most ticks. Slots that
|
|
40
|
+
// predate the agent's creation are never due (see dueMemoryCoherenceSlot).
|
|
40
41
|
const due = candidates
|
|
41
42
|
.map((agent) => dueMemoryCoherenceSlot(agent, config, now, this.timezoneForAgent(agent, config.timezone)))
|
|
42
43
|
.filter((slot) => Boolean(slot))
|
|
@@ -108,9 +109,13 @@ export function memoryCoherenceAgents(agents, config) {
|
|
|
108
109
|
function dueMemoryCoherenceSlot(agent, config, now, timezone) {
|
|
109
110
|
const current = zonedDateTime(now, timezone);
|
|
110
111
|
const offsetMinutes = stableAgentOffsetMinutes(agent.id, config.windowDurationMinutes);
|
|
112
|
+
const createdAtMs = agentCreatedAtMs(agent, now);
|
|
111
113
|
const candidates = [0, -1]
|
|
112
114
|
.map((dayOffset) => slotForLocalDay(current.plus({ days: dayOffset }), config.windowStart, offsetMinutes, timezone))
|
|
113
115
|
.filter((slot) => slot <= current)
|
|
116
|
+
// A slot before the agent existed was never missed: no catch-up run, and no
|
|
117
|
+
// "delayed" outcome. The first pass is the first slot after creation.
|
|
118
|
+
.filter((slot) => createdAtMs === undefined || slot.toMillis() >= createdAtMs)
|
|
114
119
|
.sort((a, b) => b.toMillis() - a.toMillis());
|
|
115
120
|
const slot = candidates[0];
|
|
116
121
|
if (!slot)
|
|
@@ -123,6 +128,20 @@ function dueMemoryCoherenceSlot(agent, config, now, timezone) {
|
|
|
123
128
|
scheduledSlotLabel: `${labelTime} agent-local`,
|
|
124
129
|
};
|
|
125
130
|
}
|
|
131
|
+
// The agent schema backfills a missing `createdAt` with the parse time, so a
|
|
132
|
+
// legacy config that never persisted one reads as "created just now" on every
|
|
133
|
+
// tick. A createdAt this close to `now` is indistinguishable from that backfill
|
|
134
|
+
// and is ignored; a genuinely new agent is not a candidate this soon anyway
|
|
135
|
+
// (it needs a connected transport and a ledger message first).
|
|
136
|
+
const CREATED_AT_BACKFILL_MARGIN_MS = 60 * 1000;
|
|
137
|
+
function agentCreatedAtMs(agent, now) {
|
|
138
|
+
const createdAtMs = Date.parse(agent.createdAt ?? '');
|
|
139
|
+
if (!Number.isFinite(createdAtMs))
|
|
140
|
+
return undefined;
|
|
141
|
+
if (now.getTime() - createdAtMs < CREATED_AT_BACKFILL_MARGIN_MS)
|
|
142
|
+
return undefined;
|
|
143
|
+
return createdAtMs;
|
|
144
|
+
}
|
|
126
145
|
function slotForLocalDay(day, windowStart, offsetMinutes, timezone) {
|
|
127
146
|
return timeOnLocalDay(day, windowStart, timezone, 'memory coherence windowStart').plus({ minutes: offsetMinutes });
|
|
128
147
|
}
|
|
@@ -58,7 +58,12 @@ async function refusalReason(input) {
|
|
|
58
58
|
else if (input.channelId.startsWith('D') || input.channelId.startsWith('G')) {
|
|
59
59
|
// Raw D ids lack a counterpart; G ids can be private channels OR group DMs.
|
|
60
60
|
// A display-label lookup's best-effort fallback is not an authorization fact.
|
|
61
|
-
|
|
61
|
+
let conversation = await directory.getConversation(input.channelId);
|
|
62
|
+
if (conversation && !conversation.userId && input.channelId.startsWith('D')) {
|
|
63
|
+
// A cached D entry can be fresh yet lack its counterpart (e.g. written
|
|
64
|
+
// from a sparse conversations.open). Ask Slack once before failing closed.
|
|
65
|
+
conversation = (await directory.getConversationForCurrentBot(input.channelId)) ?? conversation;
|
|
66
|
+
}
|
|
62
67
|
if (!conversation)
|
|
63
68
|
return unresolved(input.channelId, 'the conversation (Slack did not return it; the ID may be wrong or not visible to this bot)');
|
|
64
69
|
if (conversation.isMpim) {
|
|
@@ -63,6 +63,8 @@ export declare class SlackWorkspaceDirectoryService {
|
|
|
63
63
|
private singleFlightCollectionFetch;
|
|
64
64
|
private upsertUser;
|
|
65
65
|
private upsertConversation;
|
|
66
|
+
/** Upsert that keeps fields a sparser response (e.g. conversations.open) lacks. */
|
|
67
|
+
private mergeConversation;
|
|
66
68
|
private readCache;
|
|
67
69
|
private updateCache;
|
|
68
70
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workspace-directory.service.d.ts","sourceRoot":"","sources":["../../../server/slack/workspace-directory.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAGhD,OAAO,EAEL,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,EAExB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,8BAA8B,CAAC;AACvE,OAAO,EAML,KAAK,qBAAqB,IAAI,wBAAwB,EACtD,KAAK,aAAa,IAAI,gBAAgB,EACvC,MAAM,mBAAmB,CAAC;AAE3B,MAAM,MAAM,qBAAqB,GAAG,0BAA0B,CAAC;AAC/D,MAAM,MAAM,aAAa,GAAG,kBAAkB,CAAC;AAE/C,MAAM,WAAW,4BAA4B;IAC3C,OAAO,CAAC,EAAE,wBAAwB,GAAG,MAAM,CAAC;IAC5C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,gBAAgB,CAAC;CACzB;AAqCD,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,gBAAgB,EAAE,QAAQ,SAAW,GAAG,aAAa,GAAG,SAAS,CA6B7G;AAED,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,wBAAwB,EACjC,QAAQ,SAAW,GAClB,qBAAqB,GAAG,SAAS,CAkBnC;AAED,qBAAa,8BAA8B;IAG7B,OAAO,CAAC,QAAQ,CAAC,KAAK;IAFlC,OAAO,CAAC,gBAAgB,CAAC,CAAkB;gBAEd,KAAK,EAAE;QAClC,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,SAAS,CAAC;QAClB,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB;IAEK,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC;IAY3D,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAS5D,wBAAwB,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,aAAa,CAAC;IAOrG,QAAQ,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC;IAUpC,iBAAiB,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAkBxD,kBAAkB,CAAC,IAAI,EAAE,aAAa,GAAG,gBAAgB,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM;IAa1F,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"workspace-directory.service.d.ts","sourceRoot":"","sources":["../../../server/slack/workspace-directory.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -171,13 +171,19 @@ export class SlackWorkspaceDirectoryService {
|
|
|
171
171
|
return normalized.displayName?.trim() || normalized.realName?.trim() || normalized.name?.trim() || fallback;
|
|
172
172
|
}
|
|
173
173
|
async openDm(userId) {
|
|
174
|
-
const body = await this.input.client.conversations.open({ users: userId });
|
|
174
|
+
const body = await this.input.client.conversations.open({ users: userId, return_im: true });
|
|
175
175
|
if (!body.channel?.id)
|
|
176
176
|
throw new Error(`Slack conversations.open did not return a DM channel for ${userId}`);
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
177
|
+
const opened = normalizeSlackConversationInfo(body.channel)
|
|
178
|
+
?? { id: body.channel.id, syncedAt: nowIso() };
|
|
179
|
+
// We opened a 1:1 DM with `userId`, so the counterpart is known even when
|
|
180
|
+
// Slack returns only `{ id }`. Caching an entry without it would make the
|
|
181
|
+
// do-not-contact check fail closed on this DM until the cache expires.
|
|
182
|
+
const conversation = opened.isMpim || userId.includes(',')
|
|
183
|
+
? opened
|
|
184
|
+
: { ...opened, isIm: true, userId: opened.userId ?? userId };
|
|
185
|
+
await this.mergeConversation(conversation);
|
|
186
|
+
return conversation;
|
|
181
187
|
}
|
|
182
188
|
async getConversation(channel) {
|
|
183
189
|
return this.readEntry({
|
|
@@ -616,6 +622,16 @@ export class SlackWorkspaceDirectoryService {
|
|
|
616
622
|
channels: upsertById(cache.channels, conversation),
|
|
617
623
|
}), teamId);
|
|
618
624
|
}
|
|
625
|
+
/** Upsert that keeps fields a sparser response (e.g. conversations.open) lacks. */
|
|
626
|
+
async mergeConversation(conversation, teamId = this.input.teamId) {
|
|
627
|
+
await this.updateCache((cache) => {
|
|
628
|
+
const existing = cache.channels.find((entry) => entry.id === conversation.id);
|
|
629
|
+
return {
|
|
630
|
+
...cache,
|
|
631
|
+
channels: upsertById(cache.channels, { ...existing, ...conversation }),
|
|
632
|
+
};
|
|
633
|
+
}, teamId);
|
|
634
|
+
}
|
|
619
635
|
async readCache(select, teamId = this.input.teamId) {
|
|
620
636
|
if (!teamId)
|
|
621
637
|
return undefined;
|
|
@@ -9,6 +9,9 @@ import { AgentService } from '../agents/agent.service.js';
|
|
|
9
9
|
import { assertSlackContactAllowed } from '../messages/contact-policy.service.js';
|
|
10
10
|
import { setCursorDeliveryEnabledForTests } from '../runtime/cursor-delivery.js';
|
|
11
11
|
import { createSlackWebClient } from '../slack/client.js';
|
|
12
|
+
import { SlackWorkspaceDirectoryService } from '../slack/workspace-directory.service.js';
|
|
13
|
+
import { getSlackWorkspaceDirectoryStore } from '../storage/schema/cache.js';
|
|
14
|
+
import { nowIso } from '../ids.js';
|
|
12
15
|
import { ServerConfig } from '../storage/schema/server.store.js';
|
|
13
16
|
import { ObservedConversationStore } from '../storage/schema/observed-conversation.store.js';
|
|
14
17
|
import { runAsk } from '../tools/ask.js';
|
|
@@ -183,6 +186,46 @@ test('raw DM lookup failure refuses; absent name uses ID alone', async () => fix
|
|
|
183
186
|
await writeFile(join(home, 'config.json'), JSON.stringify({ doNotContact: { TNONAME: [USER] } }));
|
|
184
187
|
await assert.rejects(check({ teamId: 'TNONAME', dmUserId: USER }), { message: refusal.replace('Jialin (U0AAAA)', 'U0AAAA') });
|
|
185
188
|
}));
|
|
189
|
+
test('fresh cached DM without a recipient triggers one live lookup before failing closed', async () => fixture(async ({ check, override, calls }) => {
|
|
190
|
+
const seed = (id) => getSlackWorkspaceDirectoryStore(TEAM).update((cache) => ({
|
|
191
|
+
...cache, teamId: TEAM, channels: [...cache.channels.filter((c) => c.id !== id), { id, syncedAt: nowIso() }],
|
|
192
|
+
}));
|
|
193
|
+
// Live lookup finds a listed recipient: still refused as a list match.
|
|
194
|
+
await seed('D123');
|
|
195
|
+
await assert.rejects(check({ channelId: 'D123' }), { message: refusal });
|
|
196
|
+
assert.equal(calls.filter((m) => m === 'conversations.info').length, 1);
|
|
197
|
+
// Live lookup finds someone else: allowed.
|
|
198
|
+
override.set('conversations.info', (data) => ({ ok: true, channel: { id: data.channel, is_im: true, user: 'UOTHER' } }));
|
|
199
|
+
await seed('DOTHER');
|
|
200
|
+
await check({ channelId: 'DOTHER' });
|
|
201
|
+
// Live lookup still has no recipient: fails closed as unverified.
|
|
202
|
+
override.set('conversations.info', (data) => ({ ok: true, channel: { id: data.channel, is_im: true } }));
|
|
203
|
+
await seed('DBLANK');
|
|
204
|
+
await assert.rejects(check({ channelId: 'DBLANK' }), (error) => {
|
|
205
|
+
assert.match(error.message, /Could not verify the DM recipient for DBLANK/);
|
|
206
|
+
assert.equal(error.kind, 'unverified');
|
|
207
|
+
return true;
|
|
208
|
+
});
|
|
209
|
+
// Live lookup errors: fails closed as unverified.
|
|
210
|
+
override.set('conversations.info', () => ({ ok: false, error: 'channel_not_found' }));
|
|
211
|
+
await seed('DERR');
|
|
212
|
+
await assert.rejects(check({ channelId: 'DERR' }), /Could not verify .* for DERR \(Slack: channel_not_found\)/);
|
|
213
|
+
}));
|
|
214
|
+
test('a DM opened via a sparse conversations.open is verifiable from cache without a live lookup', async () => fixture(async ({ check, override, calls }) => {
|
|
215
|
+
override.set('conversations.open', (data) => {
|
|
216
|
+
if (String(data.return_im) !== 'true')
|
|
217
|
+
return { ok: false, error: 'expected_return_im' };
|
|
218
|
+
return { ok: true, channel: { id: data.users === USER ? 'DLISTED' : 'DNEW' } };
|
|
219
|
+
});
|
|
220
|
+
override.set('conversations.info', () => ({ ok: false, error: 'should_not_be_called' }));
|
|
221
|
+
const directory = new SlackWorkspaceDirectoryService({ client: createSlackWebClient('xoxb-test'), teamId: TEAM });
|
|
222
|
+
const dm = await directory.openDm('UOWNER');
|
|
223
|
+
assert.equal(dm.userId, 'UOWNER');
|
|
224
|
+
await check({ channelId: 'DNEW', content: { text: 'hello, I am new here' } });
|
|
225
|
+
await directory.openDm(USER);
|
|
226
|
+
await assert.rejects(check({ channelId: 'DLISTED' }), { message: refusal });
|
|
227
|
+
assert.equal(calls.includes('conversations.info'), false);
|
|
228
|
+
}));
|
|
186
229
|
test('corrupt and unreadable config refuse with a safe actionable failure', async () => fixture(async ({ home, run, calls }) => {
|
|
187
230
|
for (const invalid of ['{"SECRET":', '{"doNotContact":{"T0123ABC":"SECRET"}}']) {
|
|
188
231
|
await writeFile(join(home, 'config.json'), invalid);
|
|
@@ -117,6 +117,50 @@ test('memory coherence scheduler scopes agents and respects active memory cap',
|
|
|
117
117
|
assert.equal(queues.itemsForAgent('nora').length, 0);
|
|
118
118
|
assert.equal(queues.itemsForAgent('tess').length, 0);
|
|
119
119
|
});
|
|
120
|
+
test('memory coherence scheduler never catches up a slot that predates the agent', async () => {
|
|
121
|
+
const queues = new TestMemoryQueues();
|
|
122
|
+
let now = new Date('2026-06-22T16:01:00.000Z');
|
|
123
|
+
const scheduler = new MemoryCoherenceScheduler({
|
|
124
|
+
hasMeaningfulActivitySinceLastPass: async () => true,
|
|
125
|
+
now: () => now,
|
|
126
|
+
queueForAgent: (agentId) => queues.queueForAgent(agentId),
|
|
127
|
+
readServerConfig: async () => ({
|
|
128
|
+
memoryCoherence: { enabled: true, maxConcurrent: 10, timezone: 'UTC', windowDurationMinutes: 60, windowStart: '05:00' },
|
|
129
|
+
}),
|
|
130
|
+
timezoneForAgent: () => 'UTC',
|
|
131
|
+
});
|
|
132
|
+
// Created at 15:28Z, after today's 05:xx slot (and yesterday's): nothing is due.
|
|
133
|
+
const fresh = agent('fresh', { createdAt: '2026-06-22T15:28:00.000Z' });
|
|
134
|
+
await scheduler.reconcile([fresh]);
|
|
135
|
+
assert.deepEqual(queues.enqueuedIds(), []);
|
|
136
|
+
// The next slot after creation runs normally.
|
|
137
|
+
now = new Date('2026-06-23T08:00:00.000Z');
|
|
138
|
+
await scheduler.reconcile([fresh]);
|
|
139
|
+
const enqueued = queues.enqueuedIds();
|
|
140
|
+
assert.equal(enqueued.length, 1);
|
|
141
|
+
assert.equal(enqueued[0]?.item.id, 'memory-coherence:fresh:2026-06-23');
|
|
142
|
+
assert.ok(enqueued[0].item.scheduledSlotAt >= fresh.createdAt);
|
|
143
|
+
});
|
|
144
|
+
test('memory coherence scheduler still catches up a missed slot after creation', async () => {
|
|
145
|
+
const queues = new TestMemoryQueues();
|
|
146
|
+
const scheduler = new MemoryCoherenceScheduler({
|
|
147
|
+
hasMeaningfulActivitySinceLastPass: async () => true,
|
|
148
|
+
now: () => new Date('2026-06-22T16:01:00.000Z'),
|
|
149
|
+
queueForAgent: (agentId) => queues.queueForAgent(agentId),
|
|
150
|
+
readServerConfig: async () => ({
|
|
151
|
+
memoryCoherence: { enabled: true, maxConcurrent: 10, timezone: 'UTC', windowDurationMinutes: 60, windowStart: '05:00' },
|
|
152
|
+
}),
|
|
153
|
+
timezoneForAgent: () => 'UTC',
|
|
154
|
+
});
|
|
155
|
+
await scheduler.reconcile([
|
|
156
|
+
// Created before today's slot: today's missed slot is a real catch-up.
|
|
157
|
+
agent('early', { createdAt: '2026-06-22T04:00:00.000Z' }),
|
|
158
|
+
// createdAt within a minute of now is indistinguishable from the schema's
|
|
159
|
+
// read-time backfill for legacy configs, so it does not suppress the slot.
|
|
160
|
+
agent('legacy', { createdAt: '2026-06-22T16:00:30.000Z' }),
|
|
161
|
+
]);
|
|
162
|
+
assert.deepEqual(queues.enqueuedIds().map((entry) => entry.item.id).sort(), ['memory-coherence:early:2026-06-22', 'memory-coherence:legacy:2026-06-22']);
|
|
163
|
+
});
|
|
120
164
|
test('memory coherence scheduler uses a stable per-agent offset', () => {
|
|
121
165
|
const first = stableAgentOffsetMinutes('iris', 120);
|
|
122
166
|
const second = stableAgentOffsetMinutes('iris', 120);
|
|
@@ -555,7 +599,7 @@ function messageRecord(messageId, timestamp) {
|
|
|
555
599
|
function agent(id, options = {}) {
|
|
556
600
|
const connected = options.connected ?? true;
|
|
557
601
|
return {
|
|
558
|
-
createdAt: '2026-01-01T00:00:00.000Z',
|
|
602
|
+
createdAt: options.createdAt ?? '2026-01-01T00:00:00.000Z',
|
|
559
603
|
enabled: options.enabled ?? true,
|
|
560
604
|
teamId: 'default',
|
|
561
605
|
feishu: {
|
|
@@ -645,3 +645,43 @@ async function waitForUser(teamId, userId, match, timeoutMs = 2000) {
|
|
|
645
645
|
return user !== undefined && match(user);
|
|
646
646
|
}, { description: `cache user ${userId} to match`, timeoutMs });
|
|
647
647
|
}
|
|
648
|
+
test('openDm caches the known counterpart and keeps richer cached fields', async () => {
|
|
649
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-wd-open-dm-'));
|
|
650
|
+
try {
|
|
651
|
+
await withAnimaHome(stateDir, async () => {
|
|
652
|
+
const teamId = 'T-open-dm';
|
|
653
|
+
await seedCache(teamId, {
|
|
654
|
+
channels: [{ id: 'D-known', name: 'kept-name', memberCount: 2, syncedAt: STALE_ISO }],
|
|
655
|
+
});
|
|
656
|
+
const openArgs = [];
|
|
657
|
+
const client = {
|
|
658
|
+
conversations: {
|
|
659
|
+
open: async (args) => {
|
|
660
|
+
openArgs.push(args);
|
|
661
|
+
// Slack's default conversations.open body: only the channel id.
|
|
662
|
+
return { channel: { id: args.users === 'U-owner' ? 'D-new' : 'D-known' }, ok: true };
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
const service = new SlackWorkspaceDirectoryService({ client, teamId });
|
|
667
|
+
const opened = await service.openDm('U-owner');
|
|
668
|
+
await service.openDm('U-other');
|
|
669
|
+
assert.equal(openArgs[0]?.return_im, true);
|
|
670
|
+
assert.deepEqual({ isIm: opened.isIm, userId: opened.userId }, { isIm: true, userId: 'U-owner' });
|
|
671
|
+
const channels = (await getSlackWorkspaceDirectoryStore(teamId).read()).channels;
|
|
672
|
+
const fresh = channels.find((entry) => entry.id === 'D-new');
|
|
673
|
+
assert.equal(fresh?.userId, 'U-owner');
|
|
674
|
+
assert.equal(fresh?.isIm, true);
|
|
675
|
+
const known = channels.find((entry) => entry.id === 'D-known');
|
|
676
|
+
assert.equal(known?.userId, 'U-other');
|
|
677
|
+
assert.equal(known?.name, 'kept-name', 'sparse open response must not drop cached fields');
|
|
678
|
+
assert.equal(known?.memberCount, 2);
|
|
679
|
+
assert.notEqual(known?.syncedAt, STALE_ISO);
|
|
680
|
+
// The cached entry now answers getConversation without a conversations.info call.
|
|
681
|
+
assert.equal((await service.getConversation('D-new'))?.userId, 'U-owner');
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
finally {
|
|
685
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
686
|
+
}
|
|
687
|
+
});
|