@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.
@@ -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;YAgCvC,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;AAoCD,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,EAAE,qBAAqB,EAAE,MAAM,GAAG,MAAM,CAI/F"}
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 always has
37
- // a most-recent slot, so `due` is only empty when there are no candidates;
38
- // it is the enqueue-side settled-id dedupe (wake-queue `seen` markers) and
39
- // the activity gate that make this loop a no-op on most ticks.
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
- const conversation = await directory.getConversation(input.channelId);
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":"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"}
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 conversation = normalizeSlackConversationInfo(body.channel);
178
- if (conversation)
179
- await this.upsertConversation(conversation);
180
- return conversation ?? { id: body.channel.id, syncedAt: nowIso() };
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetquinn/animactl",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "license": "Apache-2.0",
5
5
  "private": false,
6
6
  "description": "Operator CLI for installing and running Anima.",