@cohortapp/agent-sdk 2.9.0 → 2.10.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.
@@ -1,34 +1,56 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
 
4
- import {
5
- boardItemId,
6
- boardTitle,
7
- openBoardItem,
8
- closeBoardItem,
9
- } from "./board-mirror.mjs";
4
+ import { boardPriority, boardTitle, closeStageFor, closeBoardItem } from "./board-mirror.mjs";
10
5
 
11
6
  // ---------------------------------------------------------------------------
12
- // The board mirror exists because lib/org/board.mjs had exactly ONE caller —
13
- // the self-directed goal path — and nothing on the responder path touched it.
14
- // An agent could spend forty minutes on a DM ask and leave the board showing
15
- // whatever seed rows the workspace shipped with.
7
+ // This file used to open AND close a board row of its own through
8
+ // `board.create` + `board.claim` + `board.complete`. That was a second
9
+ // implementation of a lifecycle hq and this repo already share exactly one of
10
+ // (`lib/org/work-ledger.mjs` → `board.track` → hq `server/work/ledger.ts`), and
11
+ // the daemon called BOTH on the same item — two cards per ask on one board, one
12
+ // of them carrying no requester, no comments and no provenance. Worse,
13
+ // `board.create` gives a channel-less row ORG-WIDE visibility and the mirror
14
+ // sent no channel for Slack/email/voice, so a private ask's topic was published
15
+ // to the whole workspace.
16
16
  //
17
- // Its cardinal rule is that it FAILS OPEN: the work and the answer matter, the
18
- // row is bookkeeping. Most of these tests are about it staying out of the way.
17
+ // What survives is the CLOSE hook, because `closeObligation` is the funnel every
18
+ // terminal path goes through and only two of them reach the daemon's onClose.
19
+ // These tests pin that it goes through the SHARED ledger, that it never
20
+ // overstates, and that it still fails open.
19
21
  // ---------------------------------------------------------------------------
20
22
 
21
- const CFG = { base: "https://hq.example", token: "t" };
23
+ const CFG = { org: { cohort: { enabled: true, base: "https://hq.example", token: "t" } } };
22
24
  const okCfg = { loadOrgConfig: () => CFG };
23
25
 
24
- test("board item ids are derived from the obligation key, so re-entry is idempotent", () => {
25
- const a = boardItemId("cohort:dm:abc123:7");
26
- const b = boardItemId("cohort:dm:abc123:7");
27
- assert.equal(a, b, "the same obligation must map to the same row");
28
- assert.match(a, /^ob-/);
29
- assert.ok(!/[^a-zA-Z0-9_-]/.test(a.slice(3)), "id is transport-safe");
30
- assert.equal(boardItemId(null), null);
31
- });
26
+ /** A recording ledger seam. Answers the way `work-ledger.mjs` does. */
27
+ function ledger(over = {}) {
28
+ const seen = { calls: [] };
29
+ return {
30
+ seen,
31
+ deps: {
32
+ ...okCfg,
33
+ recordWorkStep: async (a) => {
34
+ seen.calls.push(a);
35
+ if (over.impl) return over.impl(a);
36
+ return { tracked: true, reason: "ok", taskId: "task_1", col: a.stage === "done" ? "done" : "blocked" };
37
+ },
38
+ },
39
+ };
40
+ }
41
+
42
+ const REC = {
43
+ key: "cohort:dm:C1:9",
44
+ state: "answered",
45
+ summary: "compiling the regulatory filing calendar",
46
+ item: {
47
+ id: "cohort-msg_1",
48
+ raw_ref: "cohort:messaging:msg_1:1",
49
+ service: "cohort",
50
+ channel_id: "chan_1",
51
+ content: "can you compile the regulatory filing calendar",
52
+ },
53
+ };
32
54
 
33
55
  test("the title is the topic the acknowledgement already speaks aloud", () => {
34
56
  assert.equal(
@@ -41,67 +63,103 @@ test("the title is the topic the acknowledgement already speaks aloud", () => {
41
63
  assert.ok(boardTitle({ summary: "x".repeat(400) }).length <= 120);
42
64
  });
43
65
 
44
- test("opens the row in `running`, which is what the product's On now reads", async () => {
45
- let sent = null;
46
- const r = await openBoardItem({
47
- rec: { key: "k1", summary: "drafting a reply to Karen", sender: "Karen" },
48
- agentRoot: "/x",
49
- deps: { ...okCfg, createItem: async (item) => { sent = item; return { ok: true }; } },
50
- });
66
+ test("the classifier's priority words are mapped onto hq's P-scale, which is all it accepts", () => {
67
+ // hq validates priority against z.enum(["P0".."P4"]); "critical" 400s the
68
+ // whole call, so the obligation vocabulary has to be translated here.
69
+ assert.equal(boardPriority("critical"), "P0");
70
+ assert.equal(boardPriority("high"), "P1");
71
+ assert.equal(boardPriority("normal"), "P2");
72
+ assert.equal(boardPriority("ignore"), "P3");
73
+ assert.equal(boardPriority("P1"), "P1");
74
+ // No mapping → omit, and let hq's own default (P2) answer.
75
+ assert.equal(boardPriority("whatever"), undefined);
76
+ assert.equal(boardPriority(undefined), undefined);
77
+ });
78
+
79
+ test("only `answered` closes the row as done — every other verdict blocks it", () => {
80
+ assert.equal(closeStageFor("answered"), "done");
81
+ assert.equal(closeStageFor(undefined), "done");
82
+ for (const bad of ["failed", "undeliverable", "stale", "anything-else"]) {
83
+ assert.equal(closeStageFor(bad), "failed", `${bad} must not read as delivered`);
84
+ }
85
+ });
86
+
87
+ test("the close goes through the SHARED ledger, not a second board writer", async () => {
88
+ const { seen, deps } = ledger();
89
+ const r = await closeBoardItem({ rec: REC, agentRoot: "/x", deps });
90
+
51
91
  assert.equal(r.mirrored, true);
52
- assert.equal(sent.status, "running");
53
- assert.equal(sent.col, "running", "a row parked in todo is invisible to the banner");
54
- assert.equal(sent.title, "Drafting a reply to Karen");
55
- assert.equal(sent.obligationKey, "k1");
56
- assert.match(sent.detail, /Karen/);
92
+ assert.equal(r.taskId, "task_1");
93
+ assert.equal(seen.calls.length, 1);
94
+ const call = seen.calls[0];
95
+ // The ledger identifies the ask from the ITEM (service + channel + message),
96
+ // and derives the dedupe key server-side — which is what makes this land on
97
+ // the SAME row the daemon's `accepted`/`working` steps opened, rather than a
98
+ // second card beside it.
99
+ assert.equal(call.item, REC.item);
100
+ assert.equal(call.stage, "done");
101
+ assert.equal(call.deferred, true);
102
+ assert.equal(call.cfg, CFG);
103
+ assert.match(call.note, /Answered in the conversation/);
57
104
  });
58
105
 
59
- test("a missing org credential is a no-op, not a throw", async () => {
60
- const r = await openBoardItem({
61
- rec: { key: "k1" },
62
- deps: { loadOrgConfig: () => ({}), createItem: async () => { throw new Error("must not be called"); } },
63
- });
64
- assert.equal(r.mirrored, false);
65
- assert.equal(r.reason, "no-org-credential");
106
+ test("a failed obligation lands the row blocked, and says why", async () => {
107
+ const { seen, deps } = ledger();
108
+ await closeBoardItem({ rec: { ...REC, state: "failed", closeNote: "exit 1" }, deps });
109
+ assert.equal(seen.calls[0].stage, "failed");
110
+ assert.match(seen.calls[0].note, /exit 1/);
111
+ assert.match(seen.calls[0].note, /blocked/);
66
112
  });
67
113
 
68
- test("a refusing board does not throw — the reply must still go out", async () => {
69
- const r = await openBoardItem({
70
- rec: { key: "k1", summary: "x" },
71
- deps: { ...okCfg, createItem: async () => ({ ok: false, error: { code: "FORBIDDEN", message: "no" } }) },
72
- });
73
- assert.equal(r.mirrored, false);
74
- assert.equal(r.reason, "refused");
114
+ test("an undeliverable obligation says so, and still does not read as delivered", async () => {
115
+ const { seen, deps } = ledger();
116
+ await closeBoardItem({ rec: { ...REC, state: "undeliverable" }, deps });
117
+ assert.equal(seen.calls[0].stage, "failed");
118
+ assert.match(seen.calls[0].note, /no way to deliver/);
75
119
  });
76
120
 
77
- test("a throwing transport does not throw — same reason", async () => {
78
- const r = await openBoardItem({
79
- rec: { key: "k1", summary: "x" },
80
- deps: { ...okCfg, createItem: async () => { throw new Error("socket hang up"); } },
121
+ test("an explicit outcome overrides the record's own state", async () => {
122
+ const { seen, deps } = ledger();
123
+ await closeBoardItem({ rec: REC, outcome: "failed", deps });
124
+ assert.equal(seen.calls[0].stage, "failed");
125
+ });
126
+
127
+ test("a non-Cohort obligation writes NO row — a private ask is not published org-wide", async () => {
128
+ // THE LEAK THIS REPLACED: `board.create` with no channelId produces a row
129
+ // every member of the org can read, titled after the ask. The daemon polls
130
+ // Slack, two Gmail accounts, telegram, whatsapp and voice. The shared gate
131
+ // refuses all of them (`not-a-cohort-channel`) — this test pins that the
132
+ // refusal reaches us as a benign no-op rather than being worked around.
133
+ const { seen, deps } = ledger({ impl: async () => ({ tracked: false, reason: "not-a-cohort-channel" }) });
134
+ const r = await closeBoardItem({
135
+ rec: { ...REC, item: { ...REC.item, service: "slack", channel_id: "D0123" } },
136
+ deps,
81
137
  });
82
138
  assert.equal(r.mirrored, false);
83
- assert.equal(r.reason, "error");
139
+ assert.equal(r.reason, "not-a-cohort-channel");
140
+ assert.equal(seen.calls.length, 1, "the decision is the ledger's, not a rule re-implemented here");
84
141
  });
85
142
 
86
- test("closing carries the obligation's own verdict, so a failure does not read as delivered", async () => {
87
- let sent = null;
143
+ test("an obligation with no item snapshot is a no-op, not a throw", async () => {
88
144
  const r = await closeBoardItem({
89
145
  rec: { key: "k1" },
90
- outcome: "failed",
91
- deps: { ...okCfg, completeItem: async (p) => { sent = p; return { ok: true }; } },
146
+ deps: { ...okCfg, recordWorkStep: async () => { throw new Error("must not be called"); } },
92
147
  });
93
- assert.equal(r.mirrored, true);
94
- assert.equal(sent.proof, "outcome:failed");
95
- assert.equal(sent.itemId, boardItemId("k1"));
148
+ assert.equal(r.mirrored, false);
149
+ assert.equal(r.reason, "no-item");
96
150
  });
97
151
 
98
- test("close is as fail-open as open", async () => {
99
- for (const deps of [
100
- { ...okCfg, completeItem: async () => ({ ok: false, error: { code: "GONE" } }) },
101
- { ...okCfg, completeItem: async () => { throw new Error("boom"); } },
102
- { loadOrgConfig: () => ({}), completeItem: async () => { throw new Error("nope"); } },
152
+ test("the close is fail-open: a refusal, a throw and a missing credential all resolve", async () => {
153
+ for (const [label, impl] of [
154
+ ["refused", async () => ({ tracked: false, reason: "FORBIDDEN_SCOPE: no" })],
155
+ ["threw", async () => { throw new Error("socket hang up"); }],
156
+ ["disabled", async () => ({ tracked: false, reason: "org-disabled" })],
103
157
  ]) {
104
- const r = await closeBoardItem({ rec: { key: "k1" }, outcome: "answered", deps });
105
- assert.equal(r.mirrored, false);
158
+ const r = await closeBoardItem({
159
+ rec: REC,
160
+ deps: { ...okCfg, recordWorkStep: impl },
161
+ });
162
+ assert.equal(r.mirrored, false, label);
163
+ assert.ok(r.reason, `${label} must report why`);
106
164
  }
107
165
  });