Compare commits

...
Author SHA1 Message Date
dev-driver 364a46c2fd Merge remote-tracking branch 'origin/main' into feat/named-people-gateway 2026-10-24 04:52:44 +00:00
dev-driver ab083aee7e test(gateway): park the question as a real post does, so the guard is shown
The parked question now carries its human-required episode's posted receipt,
as a real Slack post records it, and the replies come after the post. Without
it the resolver already gave up on main, so the test passed there and showed
nothing. Now another person's reply fails on main (it resumes the question)
and passes here, and a new case pins the other side: the person it's parked
on still resumes it from Slack.
2026-10-24 04:28:57 +00:00
dev-driver 303ed582a0 feat(gateway): name the person who wrote from Slack
A Slack message, answer or reaction lands on its row with a summary that names
the registered person who actually wrote (`<address> via Slack: …`) instead of
one fixed title. The rows' tags are unchanged.
2026-10-24 04:13:38 +00:00
dev-driver 9a57abbc9a fix(gateway): a Slack reply from someone else doesn't resume another person's question
A reply that comes through the gateway keeps its sender's identity. When a
different registered person replies in Slack to a question parked on someone
else, their reply still reaches the agent as their own message, and the
question stays parked on its person: the Slack resolver no longer resumes it.
A typed reply to a direct ask was already checked against its recipient, and a
different person's button click is still ignored.

An action taken at the terminal is the primary user's and keeps its existing
authority and recording; nothing changes there.
2026-10-24 04:13:38 +00:00
6 changed files with 106 additions and 15 deletions

No files matched your search

@@ -224,7 +224,8 @@ export class InboundRouter {
this.botLimiter = new BotLimiter(deps.now);
}
private summaryOf(ev: SlackEvent, transfer?: InboundFileResult | null, correlationQitemId?: string): { summary: string; body: string } {
/** The row's summary names the person who actually wrote (their registered address); its tags stay as they were. */
private summaryOf(ev: SlackEvent, sender: string, transfer?: InboundFileResult | null, correlationQitemId?: string): { summary: string; body: string } {
const text = String(ev.text ?? "");
const meta = `slack channel=${ev.channel} user=${ev.user} ts=${ev.ts}`;
// OPR.0.5.6.2 — attachments ride the row BODY by LOCAL path (Slack owns
@@ -253,7 +254,7 @@ export class InboundRouter {
const firstFileName = transfer?.stored[0]?.name ?? transfer?.failed[0]?.name;
const headline = text.trim() ? text : firstFileName ? `[file] ${firstFileName}` : text;
return {
summary: `${ev.recoveredAfterGap ? "[Recovered after gap] " : ""}Founder via Slack: ${headline.slice(0, 90)}`,
summary: `${ev.recoveredAfterGap ? "[Recovered after gap] " : ""}${sender} via Slack: ${headline.slice(0, 90)}`,
body: `${sections.filter((s) => s.length > 0).join("\n\n")}\n\n---\nSource: ${meta}${correlationQitemId ? `\nIn reply to: ${correlationQitemId}` : ""}\nRouted by openrig slack-inbound. Default destination per config; re-route via queue as needed.`,
};
}
@@ -326,7 +327,7 @@ export class InboundRouter {
if (!isCurrent()) return { landed: false, reason: "inactive" };
// S10 — deterministic route (thread map) when wired; static destination otherwise.
const route = this.deps.resolveRoute?.(ev) ?? { destination: this.deps.destination };
const { summary, body } = this.summaryOf(ev, transfer, route.correlationQitemId);
const { summary, body } = this.summaryOf(ev, who.source, transfer, route.correlationQitemId);
// A person's top-level message (not a reply in a thread) becomes the root its answer lands under.
const mintsRoot = Boolean(this.deps.mintThread && ev.channel && (!ev.thread_ts || ev.thread_ts === ev.ts));
const correlated = route.correlationQitemId ? this.deps.queue.getQitem?.(route.correlationQitemId) : null;
@@ -540,7 +541,7 @@ export class InboundRouter {
tags: [...route.tags ?? ["founder-slack", "inbound"], "human-answer"],
inReplyTo: qitemId,
humanAnswer: true,
summary: `Founder via Slack: answered ${lines.length === 1 ? "1 question" : `${lines.length} questions`}`,
summary: `${who.source} via Slack: answered ${lines.length === 1 ? "1 question" : `${lines.length} questions`}`,
body: `${lines.join("\n")}\n\n---\nAnswers (question id → option id): ${JSON.stringify(recorded.answers)}\nIn reply to: ${qitemId} (its humanAnswers field holds the same)\nRouted by openrig slack-inbound (button click).`,
});
resolution = await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: lines.join("; ") });
@@ -611,7 +612,7 @@ export class InboundRouter {
priority: "routine",
tags: [...route.tags ?? ["founder-slack", "inbound"], "human-reaction"],
inReplyTo: ask,
summary: `Founder via Slack: reacted :${ev.reaction}: to ${ask}`,
summary: `${who.source} via Slack: reacted :${ev.reaction}: to ${ask}`,
body: [
`${who.source} reacted :${ev.reaction}: to your ask ${ask} in Slack.`,
"",
@@ -97,6 +97,9 @@ export function makeHumanReplyResolver(
const source = queueRepo.getById(input.qitemId);
if (source?.humanIntent === "update") return "not-applicable";
if (!contract) return "not-applicable";
// A different person's reply to a question parked on someone else already reached the agent as their own message
// (the inbound row, linked to the question). It doesn't resume the question, which stays parked on its person.
if (source?.state === "blocked" && queueRepo.askedSomeoneElse(input.actorSession, source.blockedOn)) return "not-applicable";
const uncertainDecision = `${input.decision}\n\n---\nOpenRig timing context (Slack reply ${JSON.stringify([input.actorSession, input.messageTs ?? null])}): This reply resumed the question, but its ordering against the posted question could not be confirmed. Resuming is not approval; read the person's words in context.`;
// An acknowledgement retry must not resolve again. Match this reply's durable
// context, not an older episode's generic resolved marker.
@@ -1960,6 +1960,16 @@ export class QueueRepository {
return registry.ok && resolveRegisteredHumanAddress(destination, registry.entities) !== null;
}
/** The asked person, when a different registered person is acting on what was asked of them; else null. An agent, the
* asked person under another spelling, and an ask to no registered person aren't someone else. */
askedSomeoneElse(actorSession: string, askedSession: string | null | undefined): string | null {
const registry = this.loadHumanRegistryFn();
if (!registry.ok) return null;
const asked = resolveRegisteredHumanAddress(askedSession, registry.entities);
const actor = resolveRegisteredHumanAddress(actorSession, registry.entities);
return asked && actor && actor !== asked ? asked : null;
}
private validateReplyTo(replyTo: string, humanIntent: QueueCreateInput["humanIntent"]): void {
if (humanIntent !== "update") {
throw new QueueRepositoryError("reply_to_requires_update", "replyTo is accepted only with humanIntent update; a decision keeps its own thread so its reply stays unambiguous.");
@@ -29,7 +29,7 @@ it("keeps complete short and long human briefs durable while bounding summaries
expect(result.disposition).toBe("accepted");
const row = repo.getById(result.qitemId!)!;
expect(row.body).toBe(`${text}\n\n---\nSource: slack channel=C-FIXTURE user=U-FIXTURE ts=${event.ts}\nRouted by openrig slack-inbound. Default destination per config; re-route via queue as needed.`);
expect(row.summary).toBe(`Founder via Slack: ${text.slice(0, 90)}`);
expect(row.summary).toBe(`human-fixture@external via Slack: ${text.slice(0, 90)}`);
expect(seen.load().has(`${event.channel}:${event.ts}`)).toBe(true);
expect((await router.route(event)).disposition).toBe("ignored");
}
+9 -9
View File
@@ -54,7 +54,7 @@ function harness(opts: { admitted?: boolean; failCreates?: number; newerRoot?: s
seen: new SeenStore("/s.jsonl", fs, clock),
deadLetter,
destination: "operator-agent@kernel",
resolveSender: () => opts.admitted === false ? { admitted: false, teaching: "not registered" } : { admitted: true, source: "founder@humans" },
resolveSender: () => opts.admitted === false ? { admitted: false, teaching: "not registered" } : { admitted: true, source: "ana@humans" },
resolveRoute: makeThreadRouteResolver({ map: threadMap, unroutedDestination: "orch-lead@team", log: (m: string) => logs.push(m) }),
resolveHumanReply: async (input: { qitemId: string }) => { resolved.push(input.qitemId); return "resolved"; },
log: (m: string) => logs.push(m),
@@ -79,10 +79,10 @@ describe("#899 reactions on an ask", () => {
expect(await deliver(h, reaction())).toEqual({ status: "accepted", reason: "reaction" });
expect(h.rows).toHaveLength(1);
const row = h.rows[0]!;
expect(row).toMatchObject({ source: "founder@humans", destination: "asker@rig" });
expect(row).toMatchObject({ source: "ana@humans", destination: "asker@rig" });
expect(row.tags).toContain("human-reaction");
expect(row.summary).toBe("Founder via Slack: reacted :white_check_mark: to qitem-ask-1");
expect(row.body).toContain("founder@humans reacted :white_check_mark: to your ask qitem-ask-1");
expect(row.summary).toBe("ana@humans via Slack: reacted :white_check_mark: to qitem-ask-1");
expect(row.body).toContain("ana@humans reacted :white_check_mark: to your ask qitem-ask-1");
expect(row.body).toContain("doesn't answer or close the ask");
// The seat decides what the reaction means; nothing resolves the ask for it.
expect(h.resolved).toEqual([]);
@@ -94,29 +94,29 @@ describe("#899 reactions on an ask", () => {
expect(await deliver(h, reaction())).toEqual({ status: "ignored", reason: "dup" });
await deliver(h, reaction({ reaction: "eyes", event_ts: "402.0" }));
expect(h.rows.map((row) => row.summary)).toEqual([
"Founder via Slack: reacted :white_check_mark: to qitem-ask-1",
"Founder via Slack: reacted :eyes: to qitem-ask-1",
"ana@humans via Slack: reacted :white_check_mark: to qitem-ask-1",
"ana@humans via Slack: reacted :eyes: to qitem-ask-1",
]);
});
it("on an older root of the same ask still reach its seat", async () => {
const h = harness({ newerRoot: "450.0" });
expect(await deliver(h, reaction())).toEqual({ status: "accepted", reason: "reaction" });
expect(h.rows[0]).toMatchObject({ destination: "asker@rig", summary: "Founder via Slack: reacted :white_check_mark: to qitem-ask-1" });
expect(h.rows[0]).toMatchObject({ destination: "asker@rig", summary: "ana@humans via Slack: reacted :white_check_mark: to qitem-ask-1" });
});
it("on a reply part OpenRig posted for a long ask reach the seat that asked, as on the ask itself", async () => {
const h = harness({ newerRoot: "400.0" });
expect(await deliver(h, reaction({ item: { type: "message", channel: "C1", ts: "400.5" } }))).toEqual({ status: "accepted", reason: "reaction" });
expect(h.rows).toHaveLength(1);
expect(h.rows[0]).toMatchObject({ destination: "asker@rig", summary: "Founder via Slack: reacted :white_check_mark: to qitem-ask-1" });
expect(h.rows[0]).toMatchObject({ destination: "asker@rig", summary: "ana@humans via Slack: reacted :white_check_mark: to qitem-ask-1" });
});
it("on an ask posted into another ask's thread reach that ask's own seat, not the thread owner's", async () => {
const h = harness();
expect(await deliver(h, reaction({ item: { type: "message", channel: "C1", ts: "400.7" } }))).toEqual({ status: "accepted", reason: "reaction" });
expect(h.rows).toHaveLength(1);
expect(h.rows[0]).toMatchObject({ destination: "other@rig", summary: "Founder via Slack: reacted :white_check_mark: to qitem-ask-2" });
expect(h.rows[0]).toMatchObject({ destination: "other@rig", summary: "ana@humans via Slack: reacted :white_check_mark: to qitem-ask-2" });
});
it("are ignored on any message that isn't an ask, with no row for anyone", async () => {
@@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
import { makeHumanReplyResolver } from "../src/domain/gateway/slack/slack-subsystem.js";
import type { HumanFragment, LoadResult } from "../src/domain/gateway/human-registry.js";
// A reply that comes through the gateway keeps its sender's identity: a different registered person's Slack reply to a
// question parked on someone else reaches the agent as their own message and doesn't resume the question. An action
// at the terminal is the primary user's and keeps its existing authority.
const person = (entityId: string): HumanFragment => ({
entityId, class: "human", displayName: entityId, address: `${entityId}@external`,
connectorBindings: [{ kind: "slack", connectorRef: "main", secretsRef: `vault://slack/${entityId}`, role: "primary", handle: `U${entityId.toUpperCase()}` }],
prefs: { deliveryClass: "B", availability: "available" },
});
const ANA = "human-ana@external";
const MIKE = "human-mike@external";
describe("a Slack reply from someone other than the person a question is parked on", () => {
let db: Database.Database;
let repo: QueueRepository;
let contract: MissionControlWriteContract;
beforeEach(() => {
db = createDb();
migrate(db, ALL_MIGRATIONS);
const registry: LoadResult = { ok: true, entities: [person("human-ana"), person("human-mike")] };
const bus = new EventBus(db);
repo = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => registry });
contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
});
afterEach(() => db.close());
/** A question parked on Ana and posted to her in Slack: the human-required episode with its posted receipt, as a real
* post records it, so a reply after the post is one the resolver would act on. */
const parkOnAna = async () => {
const row = await repo.create({ sourceSession: "orch-lead@r", destinationSession: "dev@r", body: "work", nudge: false });
repo.update({ qitemId: row.qitemId, actorSession: "dev@r", state: "blocked", blockedOn: "human-ana@host", summary: "Parked",
evidenceRef: "/proof/parked.md", transitionNote: "parked on Ana" });
const episode = repo.listTransitions(row.qitemId).filter((t) => t.ownerNotificationKind === "human-required").at(-1)!;
repo.update({ qitemId: row.qitemId, actorSession: "openrig-slack",
transitionNote: `slack-owner-notification-posted notification_key=${row.qitemId}:${episode.transitionId} kind=human-required thread_ts=1.1 message_ts=1.5` });
return row;
};
const slackReply = (qitemId: string, actorSession: string) =>
makeHumanReplyResolver(repo, contract)({ qitemId, actorSession, decision: "go ahead", messageTs: "2.2" });
it("doesn't resume the question: it stays parked on its person and nothing is written to it", async () => {
const parked = await parkOnAna();
const before = repo.listTransitions(parked.qitemId).length;
expect(await slackReply(parked.qitemId, MIKE)).toBe("not-applicable");
expect(repo.getById(parked.qitemId)).toMatchObject({ state: "blocked", blockedOn: "human-ana@host" });
expect(repo.listTransitions(parked.qitemId)).toHaveLength(before);
});
it("the person it's parked on still resumes it from Slack", async () => {
const parked = await parkOnAna();
expect(await slackReply(parked.qitemId, ANA)).toBe("resolved");
expect(repo.getById(parked.qitemId)?.state).toBe("in-progress");
expect(repo.listTransitions(parked.qitemId).filter((t) => t.ownerNotificationKind === "human-decision-resolved").map((t) => t.actorSession))
.toEqual([ANA]);
});
it("an action at the terminal keeps its existing authority over the same question", async () => {
const parked = await parkOnAna();
const result = await contract.act({ verb: "resolve", qitemId: parked.qitemId, actorSession: MIKE, decision: "go ahead", notify: false });
expect(result).not.toHaveProperty("correspondence");
expect(repo.getById(parked.qitemId)?.state).toBe("in-progress");
expect(repo.listTransitions(parked.qitemId).filter((t) => t.ownerNotificationKind === "human-decision-resolved").map((t) => t.actorSession))
.toEqual([MIKE]);
});
});