@cohortapp/agent-sdk 2.6.2 → 2.7.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.6.2",
3
+ "version": "2.7.0",
4
4
  "description": "Cohort Agent SDK \u2014 autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,169 @@
1
+ /**
2
+ * responder.test.mjs — conversation history behind holding messages / quick replies.
3
+ * Run: node --test scripts/daemon/responder.test.mjs
4
+ *
5
+ * `loadConversationHistory` used to build its candidate directories as
6
+ * `memory/interactions/slack/<id>` unconditionally, and its writer
7
+ * (`logInteraction`) early-returns for any service that is not Slack. A Cohort
8
+ * DM therefore had nothing written and nothing to read, so every holding
9
+ * message for Cohort was generated from the current message alone. A bare "?"
10
+ * produced "What's on your mind?" — indistinguishable from amnesia, and the
11
+ * CEO read it as exactly that (2026-08-13, dm-h001-a016).
12
+ *
13
+ * Cohort has an authoritative `messaging.history`. These tests pin that the
14
+ * cohort branch reads it, orders it oldest-first, and NEVER throws — the
15
+ * holding path must degrade to "no history" rather than take the send down.
16
+ */
17
+ "use strict";
18
+
19
+ import { test } from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ import { loadConversationHistory } from "./responder.mjs";
26
+
27
+ const cohortItem = {
28
+ sender: "Dana Okafor",
29
+ service: "cohort",
30
+ channel: "dm-h001-a016",
31
+ channel_id: "cmql1802601iihhzpf774vwbk",
32
+ content: "?",
33
+ };
34
+
35
+ // Newest-first, the way messaging.history actually returns it.
36
+ const HISTORY = [
37
+ { id: "m3", createdAt: "2026-08-13T05:47:51Z", authorName: "Dana Okafor", body: "with that in mind, fix these issues you just noted please" },
38
+ { id: "m2", createdAt: "2026-08-12T13:52:53Z", authorName: "Isla Roselli", body: "Both are merged. #19 went in as 94f227c4." },
39
+ { id: "m1", createdAt: "2026-08-12T13:47:30Z", authorName: "Dana Okafor", body: "why haven't you merged? please go ahead" },
40
+ ];
41
+
42
+ test("cohort: reads messaging.history and renders it oldest-first", async () => {
43
+ const seen = [];
44
+ const text = await loadConversationHistory(cohortItem, {
45
+ fetchHistory: async (params, o) => {
46
+ seen.push({ params, hasCfg: Boolean(o && o.cfg) });
47
+ return HISTORY;
48
+ },
49
+ loadOrgConfig: () => ({ org: { cohort: { enabled: true, base: "https://x", token: "t" } } }),
50
+ });
51
+
52
+ assert.ok(text, "expected a transcript, got null");
53
+ assert.match(text, /Dana Okafor: why haven't you merged/);
54
+ assert.match(text, /Isla Roselli: Both are merged/);
55
+ assert.match(text, /Dana Okafor: with that in mind/);
56
+
57
+ // Oldest-first: the model reads a conversation, not a reversed one.
58
+ assert.ok(
59
+ text.indexOf("why haven't you merged") < text.indexOf("with that in mind"),
60
+ "history must be rendered oldest-first",
61
+ );
62
+
63
+ // The channel it asks for is the item's channel_id, and cfg is ALWAYS passed:
64
+ // fetchHistory() fails open to [] when cfg is omitted, which is silently
65
+ // indistinguishable from an empty channel. That is the bug this file exists for.
66
+ assert.equal(seen.length, 1);
67
+ assert.equal(seen[0].params.channel, "cmql1802601iihhzpf774vwbk");
68
+ assert.equal(seen[0].hasCfg, true, "cfg must be passed or fetchHistory returns [] silently");
69
+ });
70
+
71
+ test("cohort: an empty channel is null, not an empty transcript", async () => {
72
+ const text = await loadConversationHistory(cohortItem, {
73
+ fetchHistory: async () => [],
74
+ loadOrgConfig: () => ({}),
75
+ });
76
+ assert.equal(text, null);
77
+ });
78
+
79
+ test("cohort: a failing history call degrades to null and never throws", async () => {
80
+ const text = await loadConversationHistory(cohortItem, {
81
+ fetchHistory: async () => { throw new Error("cohort unreachable"); },
82
+ loadOrgConfig: () => ({}),
83
+ });
84
+ assert.equal(text, null);
85
+ });
86
+
87
+ test("cohort: the current message is not fed back as its own history", async () => {
88
+ const text = await loadConversationHistory(
89
+ { ...cohortItem, message_id: "m3" },
90
+ {
91
+ fetchHistory: async () => HISTORY,
92
+ loadOrgConfig: () => ({}),
93
+ },
94
+ );
95
+ assert.ok(text);
96
+ assert.doesNotMatch(text, /with that in mind/, "the inbound message itself must be excluded");
97
+ });
98
+
99
+ test("slack: still reads the local interaction mirror", async () => {
100
+ const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
101
+ const dir = join(root, "memory", "interactions", "slack", "D099N1JGKRQ");
102
+ mkdirSync(dir, { recursive: true });
103
+ writeFileSync(
104
+ join(dir, "2026-08-13.jsonl"),
105
+ [
106
+ JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "Dana Okafor", content: "ship it" }),
107
+ JSON.stringify({ ts: "2026-08-13T05:01:00Z", from: "agent", content: "shipped" }),
108
+ ].join("\n"),
109
+ );
110
+
111
+ const text = await loadConversationHistory(
112
+ { sender: "Dana Okafor", service: "slack", channel: "D099N1JGKRQ", content: "?" },
113
+ { agentRepoDir: root },
114
+ );
115
+
116
+ assert.ok(text, "expected the slack mirror to still be read");
117
+ assert.match(text, /Dana Okafor: ship it/);
118
+ assert.match(text, /agent: shipped/);
119
+ });
120
+
121
+ test("cohort: falls back to the local mirror when the server is unreachable", async () => {
122
+ // Isla's original returned null outright for Cohort when messaging.history
123
+ // failed. Falling through to the mirror means a transient API blip degrades
124
+ // to cached context instead of to none — the mirror is a poor source of
125
+ // truth but a perfectly good backstop.
126
+ const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
127
+ const dir = join(root, "memory", "interactions", "cohort", "chan-1");
128
+ mkdirSync(dir, { recursive: true });
129
+ writeFileSync(
130
+ join(dir, "2026-08-13.jsonl"),
131
+ JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "Dana Okafor", content: "cached line" }),
132
+ );
133
+
134
+ const text = await loadConversationHistory(
135
+ { sender: "Dana Okafor", service: "cohort", channel: "chan-1", content: "?" },
136
+ {
137
+ agentRepoDir: root,
138
+ loadOrgConfig: () => ({}),
139
+ fetchHistory: async () => { throw new Error("network down"); },
140
+ },
141
+ );
142
+
143
+ assert.ok(text, "a server failure must not erase locally cached context");
144
+ assert.match(text, /Dana Okafor: cached line/);
145
+ });
146
+
147
+ test("cohort: server history wins over the local mirror", async () => {
148
+ const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
149
+ const dir = join(root, "memory", "interactions", "cohort", "chan-1");
150
+ mkdirSync(dir, { recursive: true });
151
+ writeFileSync(
152
+ join(dir, "2026-08-13.jsonl"),
153
+ JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "stale", content: "outdated mirror line" }),
154
+ );
155
+
156
+ const text = await loadConversationHistory(
157
+ { sender: "Dana Okafor", service: "cohort", channel: "chan-1", content: "?" },
158
+ {
159
+ agentRepoDir: root,
160
+ loadOrgConfig: () => ({}),
161
+ fetchHistory: async () => ([
162
+ { id: "m2", authorName: "Dana Okafor", body: "authoritative line", createdAt: "2026-08-13T06:00:00Z" },
163
+ ]),
164
+ },
165
+ );
166
+
167
+ assert.match(text, /authoritative line/, "the server is the source of truth");
168
+ assert.doesNotMatch(text, /outdated mirror line/, "the stale mirror must not be mixed in");
169
+ });
@@ -523,23 +523,105 @@ function loadUserProfile(sender) {
523
523
  // Load recent conversation history for context
524
524
  // ---------------------------------------------------------------------------
525
525
 
526
+ /** Cohort's message ids as this item carries them, so history can drop itself. */
527
+ function inboundMessageIds(item) {
528
+ const ids = new Set();
529
+ const add = (v) => { if (v) ids.add(String(v)); };
530
+ add(item.message_id);
531
+ // Inbox filenames prefix the service: "cohort-cmsr45u91…" → "cmsr45u91…".
532
+ if (item.id) { add(item.id); add(String(item.id).replace(/^[a-z]+-/, "")); }
533
+ // raw_ref is "cohort:dm:<messageId>:<seq>".
534
+ const m = typeof item.raw_ref === "string" ? item.raw_ref.match(/^cohort:[^:]+:([^:]+)/) : null;
535
+ if (m) add(m[1]);
536
+ return ids;
537
+ }
538
+
539
+ const COHORT_HISTORY_LIMIT = 12;
540
+ const COHORT_HISTORY_CHARS = 400;
541
+
542
+ /**
543
+ * Cohort history straight from the server, via the same `messaging.history` the
544
+ * replies are posted to.
545
+ *
546
+ * The local mirror below is a cache the agent populates itself, so it carries
547
+ * context only if the agent has already been talking AND logging correctly.
548
+ * When the write half broke (logInteraction early-returned for every non-Slack
549
+ * service), the read half silently returned nothing, memory/interactions/ was
550
+ * never even created, and holding messages were composed from the inbound
551
+ * message alone — a bare "?" came back as "What's on your mind?" mid-thread.
552
+ *
553
+ * Asking the authority that stores the message removes that coupling: a fresh
554
+ * agent, or one whose mirror was wiped, still answers with full context.
555
+ *
556
+ * Fails open to null on every path. No amount of history is worth failing a send.
557
+ */
558
+ async function loadCohortHistory(item, deps = {}) {
559
+ const channel = item.channel_id || item.channel || "";
560
+ if (!channel) return null;
561
+
562
+ try {
563
+ const fetchHistory = deps.fetchHistory
564
+ || (await import("../../lib/org/messaging.mjs")).fetchHistory;
565
+ const loadOrgConfig = deps.loadOrgConfig
566
+ || (await import("../../lib/org/client.mjs")).loadOrgConfig;
567
+ const agentRoot = deps.agentRepoDir || process.env.AGENT_ROOT || process.env.AGENT_DIR || AGENT_REPO_DIR;
568
+
569
+ // cfg is mandatory. configFromAgent() reads base+token ONLY from the object
570
+ // handed in — it never loads config/org.yaml — so omitting it returns a
571
+ // fail-open [] that looks exactly like an empty channel.
572
+ const messages = await fetchHistory(
573
+ { channel, limit: COHORT_HISTORY_LIMIT + 1 },
574
+ { cfg: loadOrgConfig(agentRoot), agentRoot },
575
+ );
576
+ if (!Array.isArray(messages) || messages.length === 0) return null;
577
+
578
+ const self = inboundMessageIds(item);
579
+ const rows = messages
580
+ .filter((m) => m && !self.has(String(m.id)))
581
+ .slice(0, COHORT_HISTORY_LIMIT)
582
+ .reverse() // messaging.history is newest-first; a conversation reads the other way
583
+ .map((m) => {
584
+ const from = m.authorName || m.author?.name || m.authorId || "unknown";
585
+ const text = String(m.body ?? m.content ?? "").trim();
586
+ return `[${m.createdAt || ""}] ${from}: ${text.substring(0, COHORT_HISTORY_CHARS)}`;
587
+ });
588
+
589
+ return rows.length > 0 ? rows.join("\n") : null;
590
+ } catch (err) {
591
+ console.warn(`[responder] cohort history unavailable for ${item.sender}: ${err.message}`);
592
+ return null;
593
+ }
594
+ }
595
+
526
596
  /**
527
597
  * The reader half of `logInteraction`. It looked ONLY under
528
598
  * `memory/interactions/slack/…`, so even once a Cohort exchange was written it
529
599
  * would not have been found. Both halves are now keyed on the item's service.
600
+ *
601
+ * Cohort asks the server first (see loadCohortHistory) and falls back to the
602
+ * local mirror, so a transient API failure degrades to cached context rather
603
+ * than to none.
530
604
  */
531
- function loadConversationHistory(item) {
605
+ export async function loadConversationHistory(item, deps = {}) {
532
606
  if (!item || !item.sender || !item.service) return null;
533
607
 
534
608
  const service = String(item.service);
609
+
610
+ if (service === "cohort") {
611
+ const remote = await loadCohortHistory(item, deps);
612
+ if (remote) return remote;
613
+ }
614
+ // Test seam: the mirror's location is a parameter, so a test can point at a
615
+ // tmpdir instead of mutating process.env for the whole worker.
616
+ const repoDir = deps.agentRepoDir || AGENT_REPO_DIR;
535
617
  const senderSlug = item.sender.replace(/\s+/g, "-").toLowerCase();
536
618
  const channelId = service === "slack"
537
619
  ? resolveSlackChannel(item)
538
620
  : (item.channel_id || item.channel || null);
539
621
 
540
622
  const candidateDirs = [];
541
- if (channelId) candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", service, String(channelId)));
542
- candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", service, `dm-${senderSlug}`));
623
+ if (channelId) candidateDirs.push(join(repoDir, "memory", "interactions", service, String(channelId)));
624
+ candidateDirs.push(join(repoDir, "memory", "interactions", service, `dm-${senderSlug}`));
543
625
 
544
626
  const entries = [];
545
627
  for (const dir of candidateDirs) {
@@ -621,7 +703,7 @@ Keep responses focused — 1-4 sentences for simple items, up to a short paragra
621
703
  Match the sender's tone and urgency level.
622
704
  ${profile ? `\nSender profile:\n${profile}` : ""}`;
623
705
 
624
- const conversationHistory = loadConversationHistory(item);
706
+ const conversationHistory = await loadConversationHistory(item);
625
707
 
626
708
  const userContent = [
627
709
  `From: ${item.sender} (${item.sender_privilege || "unknown"})`,